mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +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>
109 lines
7.0 KiB
TypeScript
Executable File
109 lines
7.0 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-tasks — a plan review's task list, and its reconciliation across reruns (G2, #1791).
|
|
*
|
|
* gstack-tasks prior --phase <p> [--branch <b>] [--dir <d>] [--json]
|
|
* gstack-tasks add --phase <p> [--branch <b>] [--dir <d>]
|
|
* gstack-tasks supersede --phase <p> --keep <list> [--branch <b>] [--dir <d>] [--json]
|
|
*
|
|
* --dir defaults to <state root>/projects/<gstack-slug SLUG>, --branch to the current git branch.
|
|
* Logic: lib/tasks-prior.ts. Codes: lib/result-codes-plan.ts (TASKS_SUPERSEDE_REFUSED).
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
|
import { resultLine } from '../lib/result-codes';
|
|
import { runScriptSync } from '../lib/script-invocation';
|
|
import { resolveStateRoot } from '../lib/state-root';
|
|
import { SupersedeRefused, TASK_PHASES, addRows, finishPending, priorSet, readTaskFiles, rowsFileName, supersede, type TaskPhase } from '../lib/tasks-prior';
|
|
|
|
const HELP = `gstack-tasks — a plan review's task list across reruns
|
|
|
|
prior --phase <p> [--json] the newest earlier list for this phase and branch, and how to reconcile it;
|
|
deletes any rows file an unfinished run left
|
|
add --phase <p> validate the rows file, write this run's list (empty: ran, no findings) and its
|
|
dispositions to task-dispositions-<p>-<branch>.jsonl, then supersede this branch's earlier lists
|
|
supersede --phase <p> --keep <list> [--json] move this branch's reconciled earlier lists out of the aggregate
|
|
|
|
Every command takes [--dir <d>] (default <state root>/projects/<slug>) and [--branch <b>] (default the current git branch).
|
|
Rows file <repo>/.gstack/tmp/tasks-<p>-rows.jsonl, one JSON object per line, written with a file-write tool
|
|
(free text never goes on a command line; a missing file means zero rows):
|
|
task {"id","priority","component","title","source_finding","effort_human","effort_cc"} strings,
|
|
"files" an array of strings, optional "carried_from": "<run_id>/<id>"
|
|
disposition {"prior": "<run_id>/<id>", "disposition": "done" | "dropped", "reason": "<commit or why>"}
|
|
prior prints \`PRIOR_TASKS: none\` or \`PRIOR_TASKS: <file> run=<run_id> tasks=<n>\`, one JSON row per task, then a RECONCILE: line;
|
|
\`PRIOR_TASKS_MALFORMED: <file>:<line>\` goes to stderr for a line that is not a JSON object (skipped, never edited).
|
|
supersede renames each earlier tasks-<p>-*.jsonl whose rows are all this branch and not newer than --keep to
|
|
superseded-<name> and prints \`SUPERSEDED: <file>\` or \`KEPT: <file> (<reason>)\`; --keep must validate first.
|
|
${exitTableHelp()}`;
|
|
|
|
function flag(args: string[], name: string): string | undefined {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
function usage(message: string): number {
|
|
process.stderr.write(`gstack-tasks: ${message}\n${HELP}\n`);
|
|
return EXIT.usage;
|
|
}
|
|
const git = (...args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
|
|
function slug(name: 'SLUG' | 'BRANCH'): string {
|
|
const r = runScriptSync(path.join(import.meta.dir, 'gstack-slug'), ['--get', name], { encoding: 'utf8', timeout: 10_000 });
|
|
return (r.status === 0 && r.stdout.trim()) || 'unknown';
|
|
}
|
|
|
|
/** The repository's git-excluded .gstack/tmp, where a host's file-write tool works without a permission card; the tasks directory outside a repository. */
|
|
function rowsDir(fallback: string): string {
|
|
const top = git('rev-parse', '--show-toplevel');
|
|
if (!top) return fallback;
|
|
const exclude = git('rev-parse', '--git-path', 'info/exclude');
|
|
if (exclude) {
|
|
const file = path.resolve(exclude);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
if (!(fs.existsSync(file) && fs.readFileSync(file, 'utf8').split('\n').includes('/.gstack/tmp/'))) fs.appendFileSync(file, '/.gstack/tmp/\n');
|
|
}
|
|
return path.join(top, '.gstack', 'tmp');
|
|
}
|
|
|
|
function main(argv: string[]): number {
|
|
const [cmd, ...rest] = argv;
|
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
|
if (!['prior', 'add', 'supersede'].includes(cmd)) return usage(`unknown command ${cmd}`);
|
|
const phase = flag(rest, '--phase') as TaskPhase | undefined;
|
|
if (!phase || !TASK_PHASES.includes(phase)) return usage(`--phase must be one of ${TASK_PHASES.join(', ')}`);
|
|
const dir = path.resolve(flag(rest, '--dir') ?? path.join(resolveStateRoot(), 'projects', slug('SLUG')));
|
|
const branch = flag(rest, '--branch') ?? (git('rev-parse', '--git-dir') ? git('branch', '--show-current') : 'unknown');
|
|
const json = rest.includes('--json');
|
|
try {
|
|
if (cmd === 'add') {
|
|
const r = addRows(dir, phase, branch, flag(rest, '--branch') ?? slug('BRANCH'), git('rev-parse', 'HEAD') || 'unknown', path.join(rowsDir(dir), rowsFileName(phase)));
|
|
process.stdout.write([`ADDED: tasks=${r.tasks} dispositions=${r.dispositions} ${r.file}`, ...r.superseded.map(f => `SUPERSEDED: ${f}`)].join('\n') + '\n');
|
|
return EXIT.ok;
|
|
}
|
|
if (cmd === 'prior') {
|
|
finishPending(dir, phase);
|
|
fs.rmSync(path.join(rowsDir(dir), rowsFileName(phase)), { force: true });
|
|
const files = readTaskFiles(dir, phase);
|
|
for (const f of files) for (const n of f.malformed) process.stderr.write(`PRIOR_TASKS_MALFORMED: ${f.file}:${n}\n`);
|
|
const prior = priorSet(files, phase, branch);
|
|
if (json) { process.stdout.write(JSON.stringify(prior ?? { file: null, run_id: null, rows: [] }) + '\n'); return EXIT.ok; }
|
|
if (!prior) { process.stdout.write('PRIOR_TASKS: none\n'); return EXIT.ok; }
|
|
process.stdout.write([`PRIOR_TASKS: ${prior.file} run=${prior.run_id} tasks=${prior.rows.length}`, ...prior.rows.map(r => JSON.stringify(r)),
|
|
`RECONCILE: decide each task above in this run's rows file (.gstack/tmp/tasks-${phase}-rows.jsonl). Still needed: list it again with "carried_from": "${prior.run_id}/<id>". Shipped: a disposition row {"prior": "${prior.run_id}/<id>", "disposition": "done", "reason": "<commit>"}. Not wanted: the same with "dropped" and why. gstack-tasks add then retires this list.`].join('\n') + '\n');
|
|
return EXIT.ok;
|
|
}
|
|
const keep = flag(rest, '--keep');
|
|
if (!keep) return usage('supersede needs --keep <the list this run wrote>');
|
|
const r = supersede(dir, phase, branch, keep);
|
|
if (json) process.stdout.write(JSON.stringify(r) + '\n');
|
|
else process.stdout.write([...r.moved.map(f => `SUPERSEDED: ${f}`), ...r.skipped.map(s => `KEPT: ${s.file} (${s.reason})`)].join('\n') + (r.moved.length || r.skipped.length ? '\n' : ''));
|
|
return EXIT.ok;
|
|
} catch (e) {
|
|
if (!(e instanceof SupersedeRefused)) throw e;
|
|
process.stderr.write(resultLine(`gstack-tasks ${cmd}`, 'TASKS_SUPERSEDE_REFUSED', e.message) + '\n');
|
|
return EXIT.refused;
|
|
}
|
|
}
|
|
|
|
process.exitCode = main(process.argv.slice(2));
|