mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
* G1: gstack-plan-reality check opens every file:line receipt (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: office-hours spec reviewer checks paths and flags through a bin-enforced probe (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan-review reruns reconcile the last task list (gstack-tasks prior/supersede) (#1791) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan reviews write task lists through gstack-tasks start/add/close The emit block shrinks to three calls: prior (now defaulting --dir and --branch, and printing how to reconcile), start (creates the empty branch-named list) and add (one validated row per task). close records a done or dropped prior task. The plan-review result codes move to lib/result-codes-plan.ts so lib/result-codes.ts stays under the module-size ratchet; generated plan-review sections shrink, so their size pins go down. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * F2: office-hours saves the sketch beside the design doc; /plan-design-review reads it Step 5 copies sketch.png/html to <PROJECT_DIR>/{user}-{branch}-sketch-{datetime}.* and writes a Wireframe: line (state-root relative) or Wireframe: none (<reason>). The design review section finds the design doc before Pass 1 and opens the wireframe, text-only when the file is missing. G4's signals line rides along in the same generated office-hours files; tests in planning-wave-prose. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G3: plan reviews tie storage to queries, not file count (#1048) One bullet in each of plan-eng-review and plan-ceo-review: fields queried, constrained or updated on their own get explicit columns or tables; JSON suits a document read and written whole. Written fresh; the reporter's patch is not ported. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * evals: GSTA-228 planning-wave cases with branch, main and no-gstack arms planted facts (G1, behavior), data model (G3, behavior), task-list rerun (G2, rule) and closing signals (G4, behavior). GSTACK_WAVE_ARM picks the arm; only the branch arm asserts the preregistered bar, every arm writes a score row. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GSTA-228 planning wave fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: the probe takes subcommand words for fixed-subcommand CLIs The planted-facts eval's one branch miss: the reviewer asked to probe "git log", the probe refused it, and git's top-level help cannot show a log flag, so the fabricated --since-commit stayed "unverified". probe --help <command> [<subcommand> [<subcommand>]] now runs <cli> <sub> --help for git, gh, docker, cargo, go, npm and uv (never node, bun, python3, make, yarn, pnpm or a gstack bin, where the word could name a script), in an empty temporary directory with pagers set to cat, falling back to -h when --help has no usage line (a minimized system's missing man page). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: task text goes through a rows file, never a command line The free suite's generated-bash lint (INV-3) refused the add command's <title>/<finding> placeholders: free text reaches a command only as a file the agent writes with its file-write tool. start now also creates an empty rows file in the repository's git-excluded .gstack/tmp and records the run; the agent writes task and disposition rows there; add validates every row, stamps phase, run, branch and commit, appends tasks to the list and dispositions to task-dispositions-<phase>-<branch>.jsonl, then supersedes the branch's earlier lists. close is gone (dispositions are rows). The new planning-wave cases get measured periodic walls in scripts/paid-test-durations.json so the periodic census still packs into 40 slices, the schema case declares the preamble sources its eng-template owners need, and the paid file count is 114. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: fold start into add so each plan review emits one bash fence The rows file lives at .gstack/tmp/tasks-<phase>-rows.jsonl; add writes this run's list (empty when there are no rows), appends dispositions, deletes the rows file and supersedes the branch's earlier lists. Lowers 27 size pins. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2 tests: give gstack-tasks a private HOME and state root CI shard 13 home containment caught gstack-slug writing ~/.gstack/slug-cache entries from the tasks-prior fixtures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: gstack-tasks has no start command Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: tighten the probe and the rows file after ship review The probe now accepts only each CLI's listed built-in subcommands (no uv run, docker run, go test, npm exec or gh extensions), refuses gstack bins that do not handle --help and never retries them with -h, and runs with a throwaway HOME and an environment without keys. gstack-tasks resolves the slug through runScriptSync (Windows) and prior deletes a rows file an unfinished run left. Adds tests for the dropped disposition and the stale rows file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: add refuses while an earlier task is neither carried nor closed Ship review (state matrix): a rerun that skipped RECONCILE silently retired open tasks. add now names each undecided <run_id>/<id> and writes nothing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: fixes from the ship fix-check and Step 11 voices Probe: find the repository root without running git, drop relative and in-repository PATH entries for lookup and for the child, keep HOME and toolchain locations (rustup, mise, asdf) but no keys, drop cargo fmt/clippy (external cargo-* programs), restore planted env in the test. gstack-tasks: resolve the exclude path against the cwd (subdirectories), resolve --dir, stamp an empty branch on a detached HEAD like the aggregator, name the file from --branch, and refuse a same-second second list instead of appending. Receipts: line 0, reversed ranges, dangling links and paths through files are unresolved instead of crashing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: probe limits and the reconcile refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: probe subcommand counts per CLI (ship docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
133 lines
7.9 KiB
TypeScript
133 lines
7.9 KiB
TypeScript
/**
|
|
* G1 (#973): the office-hours spec reviewer may check a cited flag with
|
|
* `gstack-office-hours-review probe --help <command>`. The bin enforces the
|
|
* allowlist; the prompt confines Read/Grep to the repository and names the
|
|
* probe instead of forbidding every check.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { PROBE_TIMEOUT_MS, repoRootOf, resolveProbe, runProbe } from '../lib/office-hours-probe';
|
|
import { renderOfficeHoursReviewerPrompt } from '../lib/office-hours-review';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-office-hours-review');
|
|
|
|
function tool(dir: string, name: string, body: string): string {
|
|
const p = path.join(dir, name);
|
|
fs.writeFileSync(p, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
|
return p;
|
|
}
|
|
|
|
describe('resolveProbe', () => {
|
|
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-bin-'));
|
|
const gstackBin = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-gstack-'));
|
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-repo-'));
|
|
tool(bin, 'git', 'echo usage: git');
|
|
tool(bin, 'curl', 'echo nope');
|
|
tool(gstackBin, 'gstack-thing', '[ "$1" = --help ] && echo usage: gstack-thing');
|
|
tool(gstackBin, 'gstack-login', 'echo logged in');
|
|
tool(repo, 'jq', 'echo repo jq');
|
|
|
|
test('a listed CLI on PATH and a gstack bin resolve', () => {
|
|
expect(resolveProbe('git', { pathEnv: bin, gstackBin, repoRoot: repo })).toEqual({ exe: path.join(bin, 'git') });
|
|
expect(resolveProbe('gstack-thing', { pathEnv: bin, gstackBin, repoRoot: repo })).toEqual({ exe: path.join(gstackBin, 'gstack-thing') });
|
|
});
|
|
|
|
test('unlisted names, paths, shell syntax and repository copies are refused', () => {
|
|
for (const command of ['curl', './jq', '/usr/bin/git', 'git;rm', '$(id)', 'gstack-missing']) {
|
|
expect('refused' in resolveProbe(command, { pathEnv: bin, gstackBin, repoRoot: repo })).toBe(true);
|
|
}
|
|
expect(resolveProbe('jq', { pathEnv: repo, gstackBin, repoRoot: repo })).toEqual({ refused: 'jq is not on PATH' });
|
|
fs.symlinkSync(path.join(repo, 'jq'), path.join(bin, 'jq'));
|
|
expect(resolveProbe('jq', { pathEnv: bin, gstackBin, repoRoot: repo })).toEqual({ refused: 'jq resolves inside the repository' });
|
|
expect(resolveProbe('rg', { pathEnv: bin, gstackBin })).toEqual({ refused: 'rg is not on PATH' });
|
|
});
|
|
|
|
test('fixed-name subcommands are allowed only for subcommand CLIs, as plain lowercase words', () => {
|
|
expect(resolveProbe('git', { pathEnv: bin, gstackBin, repoRoot: repo }, ['log'])).toEqual({ exe: path.join(bin, 'git') });
|
|
tool(bin, 'node', 'echo node');
|
|
for (const [command, words] of [['node', ['script']], ['gstack-thing', ['prior']], ['git', ['-c']], ['git', ['../x']], ['git', ['Log']], ['git', ['a', 'b', 'c']]] as const) {
|
|
expect('refused' in resolveProbe(command, { pathEnv: bin, gstackBin, repoRoot: repo }, words)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('a subcommand word must be a listed built-in of that CLI, never one that runs a named program, image or extension', () => {
|
|
for (const c of ['gh', 'docker', 'uv', 'go', 'npm']) tool(bin, c, 'echo usage');
|
|
const o = { pathEnv: bin, gstackBin, repoRoot: repo };
|
|
for (const [command, w] of [['gh', ['pr', 'create']], ['git', ['worktree']], ['docker', ['compose', 'up']], ['uv', ['pip', 'install']], ['go', ['mod', 'tidy']]] as const) {
|
|
expect(resolveProbe(command, o, w)).toEqual({ exe: path.join(bin, command) });
|
|
}
|
|
for (const [command, w] of [['uv', ['run', 'probetarget']], ['uv', ['tool', 'run']], ['docker', ['run', 'img']], ['docker', ['container', 'run']], ['go', ['test', 'pkg']], ['go', ['run', 'x']], ['cargo', ['fmt']], ['cargo', ['clippy']], ['npm', ['exec', 'x']], ['npm', ['run', 'x']], ['gh', ['myext']], ['git', ['log', 'x']]] as const) {
|
|
expect('refused' in resolveProbe(command, o, w)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('a gstack bin that does not handle --help is refused', () => {
|
|
expect(resolveProbe('gstack-login', { pathEnv: bin, gstackBin })).toEqual({ refused: 'gstack-login does not handle --help' });
|
|
});
|
|
|
|
test('the probe sees no keys or tokens, keeps HOME, drops in-repository PATH entries, and never retries a gstack bin with -h', () => {
|
|
const saved = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, GH_TOKEN: process.env.GH_TOKEN };
|
|
process.env.OPENAI_API_KEY = 'planted'; process.env.GH_TOKEN = 'planted';
|
|
try {
|
|
tool(repo, 'man', 'echo repo man');
|
|
const peek = tool(bin, 'peek', 'echo "key=${OPENAI_API_KEY:-none} gh=${GH_TOKEN:-none} home=$HOME path=$PATH args=$*"');
|
|
const out = runProbe(peek, [], { pathEnv: [repo, 'rel/bin', bin].join(path.delimiter), repoRoot: repo }).output.trim();
|
|
expect(out).toBe(`key=none gh=none home=${process.env.HOME} path=${bin} args=-h`);
|
|
expect(resolveProbe('man' as string, { pathEnv: repo, gstackBin, repoRoot: repo })).toHaveProperty('refused');
|
|
} finally {
|
|
for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
}
|
|
const g = tool(gstackBin, 'gstack-quiet', 'echo "args=$*" # --help');
|
|
expect(runProbe(g).output.trim()).toBe('args=--help');
|
|
});
|
|
|
|
test('repoRootOf finds the enclosing repository without running git', () => {
|
|
fs.mkdirSync(path.join(repo, '.git', 'x'), { recursive: true });
|
|
fs.mkdirSync(path.join(repo, 'a', 'b'), { recursive: true });
|
|
expect(repoRootOf(path.join(repo, 'a', 'b'))).toBe(repo);
|
|
expect(repoRootOf(bin)).toBeUndefined();
|
|
});
|
|
|
|
test('runProbe passes only --help, closes stdin and stops a hanging command', () => {
|
|
const echo = tool(bin, 'args', 'echo "usage: args=$*"; read x && echo "stdin=$x"');
|
|
expect(runProbe(echo).output).toBe('usage: args=--help\n');
|
|
const hang = tool(bin, 'hang', 'sleep 30');
|
|
const t0 = Date.now();
|
|
expect(runProbe(hang).timedOut).toBe(true);
|
|
expect(Date.now() - t0).toBeLessThan(PROBE_TIMEOUT_MS + 5_000);
|
|
}, 20_000);
|
|
|
|
test('subcommands come before --help, help without a usage line falls back to -h, and the probe runs in an empty directory', () => {
|
|
const sub = tool(bin, 'sub', '[ "$2" = --help ] && exit 1; echo "args=$* files=$(ls -A | wc -l) pager=$PAGER"');
|
|
expect(runProbe(sub, ['log']).output.trim()).toBe('args=log -h files=0 pager=cat');
|
|
});
|
|
});
|
|
|
|
describe('gstack-office-hours-review probe', () => {
|
|
test('a refused command exits 3 with OFFICE_HOURS_PROBE_REFUSED; a listed one prints its help', () => {
|
|
const refused = spawnSync('bun', [BIN, 'probe', '--help', 'curl'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 });
|
|
expect(refused.status).toBe(3);
|
|
expect(refused.stderr).toContain('(OFFICE_HOURS_PROBE_REFUSED)');
|
|
const git = spawnSync('bun', [BIN, 'probe', '--help', 'git'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 });
|
|
expect(git.stdout).toMatch(/^PROBE: git exit=\d+/);
|
|
expect(spawnSync('bun', [BIN, 'probe', '--help', 'git', 'log'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout).toMatch(/^PROBE: git log exit=\d+/);
|
|
expect(spawnSync('bun', [BIN, 'probe', '--help', 'node', 'script'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).status).toBe(3);
|
|
expect(spawnSync('bun', [BIN, 'probe', 'git'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).status).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('reviewer prompt', () => {
|
|
test('names the probe and repository-only reads, keeps Edit forbidden, and makes wrong facts blocking', () => {
|
|
const prompt = renderOfficeHoursReviewerPrompt({ document: '/tmp/design.md', verdictPath: '/tmp/review/round-1.json' });
|
|
expect(prompt).toContain('Read, Grep and Glob (on files inside the repository checkout, never elsewhere)');
|
|
expect(prompt).toContain('`Probe:` command');
|
|
expect(prompt).toContain('instead of running code');
|
|
expect(prompt).toContain('A wrong path, flag or number is blocking.');
|
|
expect(prompt).toContain('Never use Edit');
|
|
});
|
|
});
|