Files
gstack/test/tasks-prior.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

261 lines
18 KiB
TypeScript

/**
* G2 (#1791): a rerun of a plan review reconciles the last task list instead
* of writing a second, conflicting one. Bug on main: /autoplan's aggregator
* keeps only the newest run_id per phase, so run 1's open task vanishes when
* run 2 lists something else, and a rerun that closes every task (an empty
* list) leaves run 1's stale tasks showing. These tests run the real rendered
* aggregator block against fixture lists.
*/
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 { generateTasksSectionAggregate, generateTasksSectionEmit } from '../scripts/resolvers/tasks-section';
import type { TemplateContext } from '../scripts/resolvers/types';
import { addRows, priorSet, readTaskFiles, supersede, SupersedeRefused } from '../lib/tasks-prior';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-tasks');
const BRANCH = 'feature/x';
function world() {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'tasks-prior-'));
const repo = path.join(home, 'repo');
const dir = path.join(home, '.gstack', 'projects', 'demo');
fs.mkdirSync(repo); fs.mkdirSync(dir, { recursive: true });
const git = (...a: string[]) => spawnSync('git', a, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
git('init', '-q', '-b', BRANCH);
git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'seed');
const commit = git('rev-parse', 'HEAD').stdout.trim();
const bin = path.join(home, '.claude', 'skills', 'gstack', 'bin');
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(path.join(bin, 'gstack-paths'), `#!/bin/sh\necho ${path.join(home, '.gstack')}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'gstack-slug'), '#!/bin/sh\n[ "$2" = SLUG ] && echo demo || echo feature-x\n', { mode: 0o755 });
return { home, repo, dir, commit };
}
const row = (run: string, id: string, title: string, commit: string, extra: Record<string, unknown> = {}) => JSON.stringify({ phase: 'eng-review', run_id: run, branch: BRANCH, commit, id, priority: 'P1', component: 'demo', files: ['a.ts'], effort_human: '~1h', effort_cc: '~5min', title, source_finding: 'f', ...extra });
function aggregate(w: ReturnType<typeof world>): string {
const block = /```bash\n([\s\S]*?)\n```/.exec(generateTasksSectionAggregate({} as TemplateContext))![1]!;
const r = spawnSync('bash', ['-c', `${block}\nprintf '%s' "$AGGREGATED_TASKS"`], { cwd: w.repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, HOME: w.home } });
return r.stdout;
}
const tasks = (cmd: string[], repo: string) => {
const home = path.dirname(repo), state = path.join(home, '.gstack');
return spawnSync('bun', [BIN, ...cmd], { cwd: repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state } });
};
describe('the bug on main: the aggregator keeps only the newest run', () => {
test('run 2 listing other work hides run 1\'s open task; an empty rerun shows run 1\'s stale task', () => {
const w = world();
fs.writeFileSync(path.join(w.dir, 'tasks-eng-review-20261031-100000.jsonl'), row('R1', 'T1', 'add retry', w.commit) + '\n');
fs.writeFileSync(path.join(w.dir, 'tasks-eng-review-20261031-110000.jsonl'), row('R2', 'T1', 'add metrics', w.commit) + '\n');
expect(aggregate(w)).toContain('add metrics');
expect(aggregate(w)).not.toContain('add retry');
fs.writeFileSync(path.join(w.dir, 'tasks-eng-review-20261031-110000.jsonl'), '');
expect(aggregate(w)).toContain('add retry');
});
});
describe('gstack-tasks prior and supersede', () => {
test('prior finds this branch\'s newest earlier list by row fields, across legacy file names', () => {
const w = world();
fs.writeFileSync(path.join(w.dir, 'tasks-eng-review-20261031-100000.jsonl'), [row('R1', 'T1', 'old', w.commit), row('R1', 'T2', 'other branch', w.commit, { branch: 'main' })].join('\n') + '\n');
fs.writeFileSync(path.join(w.dir, 'tasks-eng-review-feature-x-20261031-110000.jsonl'), row('R2', 'T1', 'add retry', w.commit) + '\nnot json\n');
fs.writeFileSync(path.join(w.dir, 'tasks-ceo-review-feature-x-20261031-120000.jsonl'), JSON.stringify({ phase: 'ceo-review', run_id: 'R9', branch: BRANCH, id: 'T1' }) + '\n');
const r = tasks(['prior', '--phase', 'eng-review', '--branch', BRANCH, '--dir', w.dir], w.repo);
expect(r.stdout.split('\n')[0]).toBe(`PRIOR_TASKS: ${path.join(w.dir, 'tasks-eng-review-feature-x-20261031-110000.jsonl')} run=R2 tasks=1`);
expect(r.stdout).toContain('"title":"add retry"');
expect(r.stderr).toContain('PRIOR_TASKS_MALFORMED: ');
expect(tasks(['prior', '--phase', 'eng-review', '--branch', 'nope', '--dir', w.dir], w.repo).stdout.trim()).toBe('PRIOR_TASKS: none');
expect(tasks(['prior', '--phase', 'eng-review', '--branch', BRANCH, '--dir', path.join(w.home, 'missing')], w.repo).stdout.trim()).toBe('PRIOR_TASKS: none');
expect(tasks(['prior', '--phase', 'eng', '--branch', BRANCH, '--dir', w.dir], w.repo).status).toBe(2);
});
test('a carried task survives the rerun and a zero-open rerun clears the aggregate', () => {
const w = world();
const first = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(first, [row('R1', 'T1', 'add retry', w.commit), row('R1', 'T2', 'rename flag', w.commit)].join('\n') + '\n');
const second = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-110000.jsonl');
fs.writeFileSync(second, [row('R2', 'T1', 'add retry', w.commit, { carried_from: 'R1/T1' }), row('R2', 'T2', 'add metrics', w.commit)].join('\n') + '\n');
const s = tasks(['supersede', '--phase', 'eng-review', '--branch', BRANCH, '--dir', w.dir, '--keep', second], w.repo);
expect(s.stdout).toBe(`SUPERSEDED: ${first}\n`);
expect(fs.existsSync(path.join(w.dir, 'superseded-tasks-eng-review-feature-x-20261031-100000.jsonl'))).toBe(true);
const agg = aggregate(w);
expect(agg).toContain('add retry');
expect(agg).toContain('add metrics');
expect(agg).not.toContain('rename flag');
const third = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-120000.jsonl');
fs.writeFileSync(third, '');
expect(tasks(['supersede', '--phase', 'eng-review', '--branch', BRANCH, '--dir', w.dir, '--keep', third], w.repo).stdout).toBe(`SUPERSEDED: ${second}\n`);
expect(aggregate(w)).not.toContain('add retry');
});
test('another phase, a mixed-branch legacy file and a newer run stay live; a bad --keep moves nothing', () => {
const w = world();
const ceo = path.join(w.dir, 'tasks-ceo-review-feature-x-20261031-090000.jsonl');
fs.writeFileSync(ceo, JSON.stringify({ phase: 'ceo-review', run_id: 'R0', branch: BRANCH, id: 'T1' }) + '\n');
const mixed = path.join(w.dir, 'tasks-eng-review-20261031-080000.jsonl');
fs.writeFileSync(mixed, [row('R0', 'T1', 'a', w.commit), row('R0', 'T2', 'b', w.commit, { branch: 'main' })].join('\n') + '\n');
const keep = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(keep, row('R2', 'T1', 'x', w.commit) + '\n');
const newer = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-110000.jsonl');
fs.writeFileSync(newer, row('R3', 'T1', 'y', w.commit) + '\n');
const r = supersede(w.dir, 'eng-review', BRANCH, keep);
expect(r.moved).toEqual([]);
expect(r.skipped.map(s => s.reason).sort()).toEqual(['holds another branch', 'newer than the kept list']);
expect(fs.existsSync(ceo)).toBe(true);
fs.writeFileSync(keep, row('R2', 'T1', 'x', w.commit, { branch: 'main' }) + '\n');
expect(() => supersede(w.dir, 'eng-review', BRANCH, keep)).toThrow(SupersedeRefused);
const refused = tasks(['supersede', '--phase', 'eng-review', '--branch', BRANCH, '--dir', w.dir, '--keep', keep], w.repo);
expect(refused.status).toBe(3);
expect(refused.stderr).toContain('(TASKS_SUPERSEDE_REFUSED)');
expect(fs.existsSync(mixed) && fs.existsSync(newer)).toBe(true);
});
test('an interrupted supersede is finished by the next call', () => {
const w = world();
const old = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(old, row('R1', 'T1', 'a', w.commit) + '\n');
fs.writeFileSync(path.join(w.dir, '.tasks-supersede-eng-review.pending'), JSON.stringify([[old, path.join(w.dir, 'superseded-' + path.basename(old))]]));
expect(priorSet(readTaskFiles(w.dir, 'eng-review'), 'eng-review', BRANCH)?.run_id).toBe('R1');
expect(tasks(['prior', '--phase', 'eng-review', '--branch', BRANCH, '--dir', w.dir], w.repo).stdout.trim()).toBe('PRIOR_TASKS: none');
expect(fs.existsSync(path.join(w.dir, '.tasks-supersede-eng-review.pending'))).toBe(false);
});
});
describe('gstack-tasks add', () => {
const R1 = '20261031T100000Z-1';
const rowsFile = (w: ReturnType<typeof world>) => {
fs.mkdirSync(path.join(w.repo, '.gstack', 'tmp'), { recursive: true });
return path.join(w.repo, '.gstack', 'tmp', 'tasks-eng-review-rows.jsonl');
};
const lists = (w: ReturnType<typeof world>) => fs.readdirSync(w.dir).filter(n => /^tasks-eng-review-.*\.jsonl$/.test(n));
test('a rerun carries one task, closes the other, and the aggregate shows only the reconciled list', () => {
const w = world();
const prior = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(prior, row(R1, 'T1', 'add retry', w.commit) + '\n' + row(R1, 'T2', 'add metrics', w.commit) + '\n');
expect(tasks(['prior', '--phase', 'eng-review', '--dir', w.dir], w.repo).stdout).toMatch(new RegExp(`RECONCILE: .*tasks-eng-review-rows\\.jsonl.*"carried_from": "${R1}/<id>".*"prior": "${R1}/<id>", "disposition": "done"`));
const title = 'retry "quoted"\nwith a \\ backslash and $(id)';
const task = (id: string, t: string, extra = {}) => JSON.stringify({ id, priority: 'P1', component: 'demo', title: t, source_finding: 'f', effort_human: '~1h', effort_cc: '~5min', files: ['a.ts'], ...extra });
const rows = rowsFile(w);
fs.writeFileSync(rows, [task('T1', title, { carried_from: `${R1}/T1` }), JSON.stringify({ prior: `${R1}/T2`, disposition: 'done', reason: w.commit }), task('T2', 'validate the rate limit')].join('\n') + '\n');
const add = tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo);
expect(add.status).toBe(0);
const list = /^ADDED: tasks=2 dispositions=1 (.+)$/m.exec(add.stdout)![1]!;
expect(path.basename(list)).toMatch(/^tasks-eng-review-feature-x-\d{8}-\d{6}\.jsonl$/);
expect(add.stdout).toContain(`SUPERSEDED: ${prior}`);
const written = fs.readFileSync(list, 'utf8').trim().split('\n').map(l => JSON.parse(l));
expect(written[0]).toMatchObject({ phase: 'eng-review', branch: BRANCH, commit: w.commit, id: 'T1', title, files: ['a.ts'], carried_from: `${R1}/T1` });
expect(written[0].run_id > R1).toBe(true);
expect(JSON.parse(fs.readFileSync(path.join(w.dir, 'task-dispositions-eng-review-feature-x.jsonl'), 'utf8'))).toMatchObject({ prior: `${R1}/T2`, disposition: 'done', reason: w.commit, branch: BRANCH });
expect(fs.existsSync(rows)).toBe(false);
expect(fs.readFileSync(path.join(w.repo, '.git', 'info', 'exclude'), 'utf8')).toContain('/.gstack/tmp/');
const agg = aggregate(w);
expect(agg).toContain('with a');
expect(agg).not.toContain('add metrics');
});
test('with no rows file and no earlier list, add writes an empty list (ran, no findings)', () => {
const w = world();
const add = tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo);
expect(add.stdout).toMatch(/^ADDED: tasks=0 dispositions=0 /m);
expect(lists(w)).toHaveLength(1);
expect(fs.readFileSync(path.join(w.dir, lists(w)[0]!), 'utf8')).toBe('');
});
test('add refuses while an earlier task is neither carried nor closed, and both lists stay as they were', () => {
const w = world();
const prior = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(prior, row(R1, 'T1', 'add retry', w.commit) + '\n' + row(R1, 'T2', 'add metrics', w.commit) + '\n');
fs.writeFileSync(rowsFile(w), JSON.stringify({ prior: `${R1}/T1`, disposition: 'done', reason: w.commit }) + '\n');
const add = tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo);
expect(add.status).toBe(3);
expect(add.stderr).toContain(`TASKS_SUPERSEDE_REFUSED`);
expect(add.stderr).toContain(`undecided: ${R1}/T2;`);
expect(lists(w)).toEqual([path.basename(prior)]);
expect(fs.existsSync(rowsFile(w))).toBe(true);
fs.rmSync(rowsFile(w));
expect(tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo).stderr).toContain(`undecided: ${R1}/T1, ${R1}/T2;`);
});
test('a dropped disposition is recorded with its reason and retires the prior list', () => {
const w = world();
const prior = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(prior, row(R1, 'T1', 'add retry', w.commit) + '\n');
fs.writeFileSync(rowsFile(w), JSON.stringify({ prior: `${R1}/T1`, disposition: 'dropped', reason: 'out of scope' }) + '\n');
const add = tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo);
expect(add.stdout).toMatch(/^ADDED: tasks=0 dispositions=1 /m);
expect(JSON.parse(fs.readFileSync(path.join(w.dir, 'task-dispositions-eng-review-feature-x.jsonl'), 'utf8'))).toMatchObject({ prior: `${R1}/T1`, disposition: 'dropped', reason: 'out of scope' });
expect(fs.existsSync(path.join(w.dir, 'superseded-' + path.basename(prior)))).toBe(true);
expect(aggregate(w)).not.toContain('add retry');
});
test('prior deletes a rows file an unfinished run left, so add never ingests stale rows', () => {
const w = world();
fs.writeFileSync(rowsFile(w), JSON.stringify({ id: 'T9', priority: 'P1', component: 'c', title: 'stale', source_finding: 's', effort_human: 'h', effort_cc: 'c', files: [] }) + '\n');
expect(tasks(['prior', '--phase', 'eng-review', '--dir', w.dir], w.repo).stdout).toContain('PRIOR_TASKS: none');
expect(fs.existsSync(rowsFile(w))).toBe(false);
expect(tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo).stdout).toMatch(/^ADDED: tasks=0 dispositions=0 /m);
});
test('from a subdirectory, add excludes .gstack/tmp in the repository\'s own git dir and reads the root rows file', () => {
const w = world();
const sub = path.join(w.repo, 'a', 'b');
fs.mkdirSync(sub, { recursive: true });
fs.writeFileSync(rowsFile(w), JSON.stringify({ id: 'T1', priority: 'P1', component: 'c', title: 'from sub', source_finding: 's', effort_human: 'h', effort_cc: 'c', files: [] }) + '\n');
const add = spawnSync('bun', [BIN, 'add', '--phase', 'eng-review', '--dir', path.relative(sub, w.dir)], { cwd: sub, encoding: 'utf8', timeout: 30_000, env: { ...process.env, HOME: w.home, GSTACK_HOME: path.join(w.home, '.gstack'), GSTACK_STATE_ROOT: path.join(w.home, '.gstack') } });
expect(add.stdout).toMatch(/^ADDED: tasks=1 dispositions=0 /m);
expect(fs.readFileSync(path.join(w.repo, '.git', 'info', 'exclude'), 'utf8')).toContain('/.gstack/tmp/');
expect(fs.existsSync(path.join(w.home, '.git'))).toBe(false);
});
test('on a detached HEAD the rows carry an empty branch, as the /autoplan aggregator reads it', () => {
const w = world();
spawnSync('git', ['checkout', '-q', '--detach'], { cwd: w.repo, timeout: 10_000 });
fs.writeFileSync(rowsFile(w), JSON.stringify({ id: 'T1', priority: 'P1', component: 'c', title: 'detached task', source_finding: 's', effort_human: 'h', effort_cc: 'c', files: [] }) + '\n');
expect(tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo).status).toBe(0);
expect(aggregate(w)).toContain('detached task');
});
test('a second add in the same second refuses and keeps its rows file', () => {
const w = world();
const t0 = new Date('2026-10-31T10:00:00Z');
addRows(w.dir, 'eng-review', BRANCH, 'feature-x', w.commit, path.join(w.home, 'none.jsonl'), t0);
const rows = path.join(w.home, 'rows.jsonl');
fs.writeFileSync(rows, JSON.stringify({ id: 'T1', priority: 'P1', component: 'c', title: 't', source_finding: 's', effort_human: 'h', effort_cc: 'c', files: [] }) + '\n');
expect(() => addRows(w.dir, 'eng-review', BRANCH, 'feature-x', w.commit, rows, t0)).toThrow(SupersedeRefused);
expect(fs.existsSync(rows)).toBe(true);
});
test('add refuses an invalid rows file and writes nothing', () => {
const w = world();
const prior = path.join(w.dir, 'tasks-eng-review-feature-x-20261031-100000.jsonl');
fs.writeFileSync(prior, row(R1, 'T1', 'add retry', w.commit) + '\n');
for (const bad of ['{"id":"T1"}', '{"id":"T1","priority":"P1","component":"c","title":"t","source_finding":"s","effort_human":"h","effort_cc":"c","files":[1]}', '{"prior":"T2","disposition":"done","reason":"x"}', '{"prior":"R/T2","disposition":"maybe","reason":"x"}', 'not json']) {
fs.writeFileSync(rowsFile(w), bad + '\n');
const r = tasks(['add', '--phase', 'eng-review', '--dir', w.dir], w.repo);
expect(r.status).toBe(3);
expect(r.stderr).toContain('TASKS_SUPERSEDE_REFUSED');
}
expect(lists(w)).toEqual([path.basename(prior)]);
});
});
describe('the rendered emit block', () => {
test('every plan review reads the prior list and writes through gstack-tasks with no free text on a command line', () => {
for (const [skill, phase] of [['plan-eng-review', 'eng-review'], ['plan-ceo-review', 'ceo-review'], ['plan-design-review', 'design-review'], ['plan-devex-review', 'devex-review']] as const) {
const out = generateTasksSectionEmit({ skillName: skill } as TemplateContext, [phase]);
expect(out).toContain(`gstack-tasks prior --phase ${phase}`);
expect(out).toContain(`.gstack/tmp/tasks-${phase}-rows.jsonl`);
expect(out).toContain(`\`\`\`bash\n~/.claude/skills/gstack/bin/gstack-tasks add --phase ${phase}\n\`\`\``);
expect(out).toContain('"carried_from":"<run_id>/<id>"');
expect(out.match(/```bash/g)).toHaveLength(1);
}
});
});