Files
gstack/test/office-hours-probe.test.ts
Garry Tanandcapy-ai[bot] 8695938e74 v1.92.15.0 feat: plan reviews check facts and keep one task list; /office-hours hands its wireframe to design review (#340, #513, #973, #1048, #1791) (#3775)
* 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>
2026-11-01 06:11:17 +00:00

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');
});
});