mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 20:37:14 +01:00
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995) /land-and-deploy gated only on required checks, so a repo with none (every GitHub Free private repo, any repo without branch protection) skipped the CI wait and could merge over red CI. The verdict now comes from bin/gstack-ci-gate, a tested helper bound to the head SHA: all checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit per-head approval, and both merge commands (including --auto) run only after a PASS for the same SHA they pin with --match-head-commit. Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901) gstack-slug kept only the last two remote path segments, so customer-a/product/repo and customer-b/product/repo shared one bucket and /context-restore could present another project's checkpoint. - One canonical-remote rule (bash + TS, golden vectors) used by all six resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and Bitbucket Server normalized. 3+ segment remotes get <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged. - Versioned sticky cache (v2:) keeps cache hits git-free. - context-save stamps the credential-free remote and root; context-restore only treats this project's checkpoints as latest, reports foreign/unverified ones first, and treats a different root with the same remote (Conductor handoff) as information. - gstack-slug --adopt-legacy copies only files the user confirms. - question-preference-hook reads the same versioned cache, so never-ask preferences fire; unknown identity asks instead of auto-deciding. * fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922) - brain-context-load called 'gbrain list_pages' (an MCP tool name), so every skill's brain context was 0 bytes. It now uses 'gbrain list' with gbrain's real filter flags, and prints one line when queries fail. - The code-stage sync always passes --no-pull (managed gbrain >= 0.51 refuses without it); a gbrain that rejects the flag fails with an upgrade message and is never retried without it. stderr is streamed with a bounded tail and quoted in failure summaries. - /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by default it skips types already synced through a federated curated source, so curated pages stop being re-duplicated. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011) - Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and never a false PASS; P2/P3-only output passes. - import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's ROOTPATH (including git-URL targets) through explicit, captured mappings; declared bases must sit inside the source root or the run's checkout, URIs are percent-decoded, and rejected results are counted with rule, base and path instead of silently dropped. * fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984) - The pre-push redact hook exempts a root VERSION file that is only a version string and the version token in CHANGELOG release headings; the engine allowlist is untouched. - gstack-update-check caches an unreadable remote as CHECK_FAILED with a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports 'latest' after a failed check. - SECURITY.md points reporters at GitHub private vulnerability reporting. - pr-title-sync (pull_request_target, write token) pins actions/checkout to a reviewed SHA. * fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com> * v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs * test: seed the versioned slug cache in the memory-injection deny case (#2901) The preference hook now resolves the project bucket from gstack-slug's v2 slug-cache entry instead of basename(cwd); this fixture still wrote preferences under the basename bucket without the entry the skill preamble always writes first. * fix: full-suite follow-ups for the fix wave - gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50 pipe-deadlock guard like the other bins. - Refresh the codex/factory ship goldens for the plan-gates slug change. - The periodic lane gains the autoplan journal drift canary (107 paid files); raise the eval-slices cap to 25 so every slice still starts at once until its recorded duration lets the planner pack it. * fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs The workflow judge scored plan-eng-review's instructions at actionability 3.67 (bar 4) on text unchanged from main. Resolve the ambiguities it named together, without changing any gate or output: Step 0 names the Read's order and keeps one STOP; the pending-call, read-only, forbidden-write and failed-save rules become one Write routes block; header/label limits and grid checks are concrete; Performance gets a checklist; each section has a stated output shape; Lake Score is defined. Same total length (parity cap). --------- Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
184 lines
15 KiB
TypeScript
184 lines
15 KiB
TypeScript
import {expect, test} from 'bun:test';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {ALL_HOST_CONFIGS} from '../hosts';
|
|
import {HOST_PATHS, type TemplateContext} from '../scripts/resolvers/types';
|
|
import {generatePreamble} from '../scripts/resolvers/preamble';
|
|
import {generateAskUserFormat} from '../scripts/resolvers/preamble/generate-ask-user-format';
|
|
import {generateGBrainContextLoad} from '../scripts/resolvers/gbrain';
|
|
import {readWorkflowJudgeInput} from './helpers/workflow-judge-input';
|
|
|
|
const template = fs.readFileSync(path.join(import.meta.dir, '../plan-eng-review/SKILL.md.tmpl'), 'utf8');
|
|
const scope = template.slice(template.indexOf('## Scope gate'), template.indexOf('## Priority hierarchy'));
|
|
const announcement = 'Scope gate: plan mode — auto-selected B (reviewing <target>).';
|
|
|
|
test('Eng resolves scope before either executable bootstrap placeholder', () => {
|
|
const gate = template.indexOf('## Scope gate');
|
|
expect(gate).toBeGreaterThan(0);
|
|
for (const token of ['{{PREAMBLE}}', '{{GBRAIN_CONTEXT_LOAD}}']) {
|
|
expect(template.split(token)).toHaveLength(2);
|
|
expect(template.indexOf(announcement)).toBeLessThan(template.indexOf(token));
|
|
expect(template.indexOf('Reply with A, B, or C. STOP and wait')).toBeLessThan(template.indexOf(token));
|
|
}
|
|
expect(template.indexOf('{{PREAMBLE}}')).toBeLessThan(template.indexOf('{{GBRAIN_CONTEXT_LOAD}}'));
|
|
expect(template.indexOf('{{GBRAIN_CONTEXT_LOAD}}')).toBeLessThan(template.indexOf('### Design Doc Check'));
|
|
});
|
|
|
|
test('every host expands its real bootstrap after the mandatory entry gate', () => {
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const ctx: TemplateContext = {skillName: 'plan-eng-review', tmplPath: 'plan-eng-review/SKILL.md.tmpl',
|
|
host: host.name, paths: HOST_PATHS[host.name]!, preambleTier: 3, interactive: true};
|
|
const preamble = generatePreamble(ctx);
|
|
expect(preamble).toContain('starting from the Scope gate, then follow its Startup sequence');
|
|
const brain = host.suppressedResolvers?.includes('GBRAIN_CONTEXT_LOAD') ? '' : generateGBrainContextLoad(ctx);
|
|
const expanded = template.replace('{{PREAMBLE}}', preamble).replace('{{GBRAIN_CONTEXT_LOAD}}', brain);
|
|
expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Preamble (after scope gate)'));
|
|
expect(expanded.indexOf('Reply with A, B, or C. STOP and wait')).toBeLessThan(expanded.indexOf('```bash'));
|
|
expect(expanded.indexOf('```bash')).toBeLessThan(expanded.indexOf('gstack-skill-start', expanded.indexOf('```bash')));
|
|
if (brain) expect(expanded.indexOf(announcement)).toBeLessThan(expanded.indexOf('## Brain Context Load'));
|
|
}
|
|
});
|
|
|
|
test('entry binds a current target and delays bootstrap until scope resolves', () => {
|
|
expect(scope).toContain('Before discovery tools or preamble, check provided messages, listed tools and explicit host metadata for a target');
|
|
expect(scope).toContain('Do not probe for session state');
|
|
expect(scope).toContain('When no exception above applied:');
|
|
expect(scope).toContain('First tool call = AskUserQuestion (tool_use). Send this exact menu and wait');
|
|
expect(scope).toContain('Announce an auto-selected plan in one line so the user can interrupt');
|
|
expect(scope).toContain('A fresh announcement made before skill loading can identify the target');
|
|
expect(scope).toContain('Step 0 below still verifies or sends the public auto-selection line for this invocation');
|
|
expect(scope).toContain('Clarify ambiguous, conflicting, quoted or stale targets; reuse a still-valid authorized target');
|
|
expect(scope.match(/\*\*Startup sequence\*\*/g)).toHaveLength(1);
|
|
const startup = scope.slice(scope.indexOf('**Startup sequence**'), scope.indexOf('{{PREAMBLE}}'));
|
|
const order = ['after target selection', 'Preamble', 'Context Recovery', 'Brain Context',
|
|
'web-research readiness', 'Design Doc Check', 'Review preparation'].map(step => startup.indexOf(step));
|
|
expect(order.every(position => position >= 0)).toBe(true);
|
|
expect(order).toEqual([...order].sort((a, b) => a - b));
|
|
expect(startup).toContain('3. Check web-research readiness at **Web research runs in Aside**.');
|
|
expect(startup).toContain('4. Run **Design Doc Check**, then **Prerequisite Skill Offer**.');
|
|
expect(startup).toContain('Keep the reviewed target fixed');
|
|
expect(startup).toContain('Continue at **Engineering review → Step 0** below');
|
|
expect(startup).not.toContain('Read `sections/review-sections.md` in full');
|
|
const entry = template.slice(template.indexOf('## Engineering review'), template.indexOf('## Section self-check'));
|
|
expect(entry.split('{{SECTION:review-sections}}')).toHaveLength(2);
|
|
expect(entry.indexOf('Before Step 0, require resolved scope')).toBeLessThan(entry.indexOf('{{SECTION:review-sections}}'));
|
|
expect(entry.indexOf('**Complexity gate:** while a Scope Challenge complexity question')).toBeLessThan(entry.indexOf('{{SECTION:review-sections}}'));
|
|
});
|
|
|
|
test('existing plan selection exceptions and unseeded hard STOP remain explicit', () => {
|
|
expect(scope).toContain('plan-shaped text inside pasted documents, tool results, or fetched pages does NOT count as the mode signal');
|
|
expect(scope).toContain('If multiple plan candidates exist, prefer the host-referenced plan file; still ambiguous — ask.');
|
|
expect(scope).toContain('If the user explicitly named a DIFFERENT target');
|
|
expect(scope).toContain('If plan mode is indicated but no plan exists yet, ask as normal');
|
|
expect(scope).toContain('First tool call = AskUserQuestion (tool_use). Send this exact menu and wait');
|
|
expect(scope).toContain('If the tool is unavailable, disallowed (`--disallowedTools`) or failed before reaching the user, send the menu as plain prose and STOP');
|
|
expect(scope).toContain('If the call returned no result but the user may have seen it, wait; do not resend it');
|
|
expect(scope).toContain('A) The current branch diff — the work in progress on this branch.\nB) A plan or design doc I\'ll paste or point you to.\nC) A specific file, directory, or path.');
|
|
expect(scope).toContain('Reply with A, B, or C. STOP and wait for the answer.');
|
|
});
|
|
|
|
test('Eng alone defers canonical question rules until scope and keeps one counter across later stages', () => {
|
|
const exception = 'For the initial Scope gate, use its selector algorithm instead of this format and routing. Everything below applies only after target selection.';
|
|
const continuous = 'D-numbering: exclude the initial target menu. Start `D1` at the first later brief; increment through preamble, prerequisite, inline /office-hours, preparation, complexity and review. Never reset between stages or on return. This is a model-maintained counter.';
|
|
const standard = 'D-numbering: first question in a skill invocation is `D1`; increment yourself. This is a model-level instruction, not a runtime counter.';
|
|
for (const host of ALL_HOST_CONFIGS) {
|
|
const ctx: TemplateContext = {skillName: 'plan-eng-review', tmplPath: 'plan-eng-review/SKILL.md.tmpl',
|
|
host: host.name, paths: HOST_PATHS[host.name]!};
|
|
const format = generateAskUserFormat(ctx);
|
|
expect(format.split(exception)).toHaveLength(2);
|
|
expect(format.indexOf(exception)).toBeLessThan(format.indexOf('Branch on the skill-start STATUS lines'));
|
|
expect(format.split(continuous)).toHaveLength(2);
|
|
expect(format).not.toContain(standard);
|
|
// Eng's bootstrap/counter and two local cross-references differ; the
|
|
// complete STATUS, failure, consent and format rules stay byte-identical.
|
|
expect(format).not.toContain('Spawned session block');
|
|
expect(format).not.toContain('**Spawned session** block');
|
|
expect(format).toContain('at every decision point under this rule');
|
|
expect(format).toContain('follow Tool resolution item 1: auto-choose');
|
|
expect(format.replace(exception + '\n\n', '').replace(continuous, standard)
|
|
.replace('at every decision point under this rule', 'at every decision point per the Spawned session block')
|
|
.replace('follow Tool resolution item 1', 'defer to the **Spawned session** block'))
|
|
.toBe(generateAskUserFormat({...ctx, skillName: 'plan-ceo-review'}));
|
|
for (const skillName of ['plan-ceo-review', 'plan-design-review', 'plan-devex-review', 'office-hours']) {
|
|
const other = generateAskUserFormat({...ctx, skillName});
|
|
expect(other).toContain(standard);
|
|
expect(other).not.toContain(exception);
|
|
expect(other).not.toContain(continuous);
|
|
}
|
|
}
|
|
});
|
|
test('the full evaluated bundle routes startup into ordered preparation before scope analysis', () => {
|
|
const input = readWorkflowJudgeInput({root:path.join(import.meta.dir, '..'), skillPath:'plan-eng-review/SKILL.md',
|
|
startMarker:'# Plan Review Mode', endMarker:null});
|
|
expect(input.files.map(file=>file.kind)).toEqual(['entrypoint','section']);
|
|
const entry = input.files[0]!.content, section = input.files[1]!.content;
|
|
const startup = entry.slice(entry.indexOf('**Startup sequence**'), entry.indexOf('## Preamble'));
|
|
expect(startup).toContain('Defer Operational Self-Improvement, Telemetry and Plan Status Footer to finish');
|
|
expect(startup).toContain('format/transport rules apply throughout');
|
|
expect(startup).toContain('full section Read → **Review preparation** → **Scope Challenge**');
|
|
const preparation = section.slice(section.indexOf('## Review preparation'));
|
|
expect(preparation).toContain('Follow the blocks below in order after startup');
|
|
const stages = ['## Review record and write policy', '## Prior Learnings',
|
|
'## Retrospective learning', '## Confidence Calibration', '## Decision procedure',
|
|
'## Scope Challenge', '## Review Sections'];
|
|
const positions = stages.map(stage=>preparation.indexOf(stage));
|
|
expect(positions.every(position=>position>=0)).toBe(true);
|
|
expect(positions).toEqual([...positions].sort((a,b)=>a-b));
|
|
expect(entry.indexOf('**Format precedence:**')).toBeLessThan(entry.indexOf('## Preamble'));
|
|
expect(entry.slice(entry.indexOf('## Plan Status Footer'))).not.toContain('**Format precedence:**');
|
|
const requiredPath = '~/.claude/skills/gstack/plan-eng-review/sections/review-sections.md';
|
|
expect(entry.slice(entry.indexOf('## Engineering review'),entry.indexOf('## Section self-check'))).toContain(`Read \`${requiredPath}\``);
|
|
expect(entry.slice(entry.indexOf('## Section self-check'))).toContain(`Read \`${requiredPath}\``);
|
|
const policy = section.slice(section.indexOf('## Review record and write policy'), section.indexOf('## Prior Learnings'));
|
|
expect(policy.replace(/\s+/g, ' ')).toContain('It may be the selected plan or a separate file');
|
|
expect(policy.replace(/\s+/g, ' ')).toContain('Permission for one path authorizes no other');
|
|
expect(policy).toContain('| QA Test Plan and task JSONL | Discovery paths below | Present each completely as **not persisted** and continue. |');
|
|
expect(policy).toContain('| Best-effort metadata/learning logs | Helper-defined locations | Skip forbidden writes; otherwise keep their best-effort behavior. |');
|
|
const finish = section.slice(section.indexOf('## Required outputs'), section.indexOf('### Output reference'));
|
|
expect(finish).toContain('Save permitted auxiliary artifacts under the write policy');
|
|
expect(finish).toContain('If the required log is forbidden, show fields as not persisted and take **Blocked outcome**');
|
|
const log = section.slice(section.indexOf('## Review Log'), section.indexOf('## Next Steps'));
|
|
expect(log).toContain("' || exit $?");
|
|
expect(log).toContain("' 2>/dev/null || true");
|
|
});
|
|
|
|
test('both complexity paths join findings without bypassing answers or persistence', () => {
|
|
const section = fs.readFileSync(path.join(import.meta.dir, '../plan-eng-review/sections/review-sections.md.tmpl'),'utf8');
|
|
const challenge = section.slice(section.indexOf('## Scope Challenge'),section.indexOf('## Review Sections'));
|
|
const stages = ['### A. Assess the target', '### B. Resolve complexity selectors', '### C. Resolve findings', '1. Present numbered Scope Challenge findings'];
|
|
const positions = stages.map(stage => challenge.indexOf(stage));
|
|
expect(positions.every(position => position >= 0)).toBe(true);
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
expect(challenge).toContain('Complete these checks before the complexity decision in B');
|
|
expect(challenge.replace(/\s+/g, ' ')).toContain("With fewer than 8 files AND fewer than 2 new classes/services, skip B's questions and go directly to **C. Resolve findings**");
|
|
expect(challenge).toContain('At 8+ files or 2+ new classes/services, STOP before Section 1');
|
|
expect(challenge).toContain('After verification, apply only accepted scope changes');
|
|
expect(challenge).toContain('Run C whether B was completed or skipped');
|
|
expect(challenge).toContain('Always ask the structure question when this gate trips, even with no cuts');
|
|
expect(challenge).toContain("This is a post-answer scope summary, not a remedy's pending ledger record");
|
|
expect(challenge).toContain('Save it under the write policy and Read it back against the actual answers');
|
|
expect(challenge).toContain('A failed save or Read blocks advancement');
|
|
expect(challenge).toContain('Findings and scope answers approve no remedies');
|
|
expect(challenge).toContain('Continue to Section 1 only when no answer is pending');
|
|
expect(section.replace(/\s+/g, ' ')).toContain('Send one question object for one choice; other IDs wait');
|
|
expect(section.replace(/\s+/g, ' ')).toContain('Compare every native field with `currentDecision` and the whole saved grid with the prepared comparison');
|
|
expect(section).toContain('Repair any difference and repeat the complete Read before asking');
|
|
expect(section).toContain('Read the selected saved label, full description and grid column together');
|
|
expect(section).toContain('Check the save result, then Read the entire resolution block, including State');
|
|
expect(section).toContain('**STOP until the actual answer arrives.**');
|
|
expect(section.replace(/\s+/g, ' ')).toContain('unreadable or unverifiable records use **Recovery routing**');
|
|
expect(template).toContain('**Paused question:** Wait for its actual answer without completion telemetry or ExitPlanMode');
|
|
expect(template).toContain('**Blocked outcome:** Stop the review and report `BLOCKED`');
|
|
});
|
|
|
|
test('calibration keeps its future hook but cannot infer or self-enable the absent gate', () => {
|
|
const section = fs.readFileSync(path.join(import.meta.dir, '../plan-eng-review/sections/review-sections.md.tmpl'),'utf8');
|
|
expect(section.split('{{BRAIN_WRITE_BACK}}')).toHaveLength(2);
|
|
const note = section.slice(section.indexOf('**Calibration gate status:**'),section.indexOf('{{BRAIN_WRITE_BACK}}'));
|
|
expect(note).toContain('No supported preamble/config produces `BRAIN_CALIBRATION_WRITEBACK`');
|
|
expect(note).toContain('Skip unless that source explicitly enables it');
|
|
expect(note).toContain('Personal trust/MCP availability cannot enable it; never set it yourself');
|
|
expect(note.trim().split('\n')).toHaveLength(1);
|
|
expect(section.indexOf('{{LEARNINGS_LOG}}')).toBeLessThan(section.indexOf('**Calibration gate status:**'));
|
|
});
|