mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +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>
160 lines
10 KiB
TypeScript
160 lines
10 KiB
TypeScript
/**
|
|
* Plan-review task lists across reruns (G2, #1791).
|
|
*
|
|
* Each plan review writes `tasks-<phase>-<branch>-<ts>.jsonl` (older runs:
|
|
* `tasks-<phase>-<ts>.jsonl`) in the project's state directory, and
|
|
* /autoplan's aggregator keeps the newest run_id per phase. A rerun used to
|
|
* write a fresh list blind, so run 1's open tasks vanished from the aggregate.
|
|
*
|
|
* prior the newest earlier list for this phase and branch (selected
|
|
* by each row's branch field and run_id, never by file name),
|
|
* so the rerun reconciles it: carry, done or dropped
|
|
* supersede after the rerun's list validates, rename the reconciled
|
|
* earlier lists for this phase and branch to `superseded-…`,
|
|
* out of the aggregator's `tasks-<phase>-*.jsonl` glob; another
|
|
* phase, another branch's rows, a newer run and an empty legacy
|
|
* file are left alone. A pending marker written before the
|
|
* renames lets the next call finish an interrupted supersede.
|
|
* add the rerun's list: the agent writes task and disposition rows
|
|
* to the rows file with its file-write tool (free text never
|
|
* rides a command line); add validates and stamps them, writes
|
|
* the list, then supersedes.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
export const TASK_PHASES = ['ceo-review', 'design-review', 'eng-review', 'devex-review'] as const;
|
|
export type TaskPhase = (typeof TASK_PHASES)[number];
|
|
export type TaskRow = Record<string, unknown> & { phase?: string; run_id?: string; branch?: string; id?: string };
|
|
export interface TaskFile { file: string; rows: TaskRow[]; malformed: number[] }
|
|
export interface PriorSet { file: string; run_id: string; rows: TaskRow[] }
|
|
|
|
const TS_SUFFIX_RE = /-\d{8}-\d{6}\.jsonl$/;
|
|
const marker = (dir: string, phase: string) => path.join(dir, `.tasks-supersede-${phase}.pending`);
|
|
|
|
export function readTaskFiles(dir: string, phase: TaskPhase): TaskFile[] {
|
|
if (!fs.existsSync(dir)) return [];
|
|
return fs.readdirSync(dir).filter(n => n.startsWith(`tasks-${phase}-`) && n.endsWith('.jsonl')).sort().map(name => {
|
|
const file = path.join(dir, name);
|
|
const rows: TaskRow[] = [], malformed: number[] = [];
|
|
fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
|
|
if (!line.trim()) return;
|
|
try { const v = JSON.parse(line); if (v && typeof v === 'object' && !Array.isArray(v)) rows.push(v); else malformed.push(i + 1); }
|
|
catch { malformed.push(i + 1); }
|
|
});
|
|
return { file, rows, malformed };
|
|
});
|
|
}
|
|
|
|
/** The newest earlier task set for this phase and branch: the rows of the highest run_id, wherever they live. */
|
|
export function priorSet(files: readonly TaskFile[], phase: TaskPhase, branch: string): PriorSet | undefined {
|
|
let best: PriorSet | undefined;
|
|
for (const f of files) {
|
|
for (const row of f.rows) {
|
|
if (row.phase !== phase || row.branch !== branch || typeof row.run_id !== 'string') continue;
|
|
if (!best || row.run_id > best.run_id) best = { file: f.file, run_id: row.run_id, rows: [] };
|
|
}
|
|
}
|
|
if (best) for (const f of files) best.rows.push(...f.rows.filter(r => r.phase === phase && r.branch === branch && r.run_id === best!.run_id));
|
|
return best;
|
|
}
|
|
|
|
export function finishPending(dir: string, phase: TaskPhase): string[] {
|
|
const m = marker(dir, phase);
|
|
if (!fs.existsSync(m)) return [];
|
|
const moves = JSON.parse(fs.readFileSync(m, 'utf8')) as Array<[string, string]>;
|
|
const done: string[] = [];
|
|
for (const [from, to] of moves) if (fs.existsSync(from)) { fs.renameSync(from, to); done.push(from); }
|
|
fs.rmSync(m);
|
|
return done;
|
|
}
|
|
|
|
export class SupersedeRefused extends Error {}
|
|
|
|
/** Where a plan review writes this run's rows with its file-write tool: one file per phase in the repository's git-excluded \`.gstack/tmp\`, so free text never rides a command line. */
|
|
export const rowsFileName = (phase: TaskPhase) => `tasks-${phase}-rows.jsonl`;
|
|
|
|
export const TASK_FIELDS = ['id', 'priority', 'component', 'title', 'source_finding', 'effort_human', 'effort_cc'] as const;
|
|
const PRIOR_RE = /^[^/\s]+\/[^/\s]+$/;
|
|
|
|
/**
|
|
* Validate the rows file (tasks, and dispositions of earlier tasks this run
|
|
* closed), create this run's list `tasks-<phase>-<branch slug>-<ts>.jsonl`
|
|
* (empty when there are no tasks: "ran, no findings"), stamp each task with
|
|
* this phase, run, branch and commit, append dispositions to
|
|
* `task-dispositions-<phase>-<branch slug>.jsonl`, remove the rows file and
|
|
* supersede this branch's earlier lists. Nothing is written when a row is invalid.
|
|
*/
|
|
export function addRows(dir: string, phase: TaskPhase, branch: string, branchSlug: string, commit: string, rows: string, now = new Date()): { file: string; tasks: number; dispositions: number; superseded: string[] } {
|
|
const iso = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
const runId = `${iso}-${process.pid}`;
|
|
const tasks: TaskRow[] = [], dispositions: Record<string, string>[] = [];
|
|
const lines = fs.existsSync(rows) ? fs.readFileSync(rows, 'utf8').split('\n') : [];
|
|
lines.forEach((line, i) => {
|
|
if (!line.trim()) return;
|
|
const at = `${rows}:${i + 1}`;
|
|
let v: Record<string, unknown>;
|
|
try { v = JSON.parse(line); } catch { throw new SupersedeRefused(`${at} is not JSON`); }
|
|
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new SupersedeRefused(`${at} is not a JSON object`);
|
|
if ('disposition' in v) {
|
|
if (!(v.disposition === 'done' || v.disposition === 'dropped') || typeof v.prior !== 'string' || !PRIOR_RE.test(v.prior) || typeof v.reason !== 'string' || !v.reason) throw new SupersedeRefused(`${at} needs prior <run_id>/<id>, disposition done or dropped, and a reason`);
|
|
dispositions.push({ phase, run_id: runId, branch, prior: v.prior, disposition: v.disposition, reason: v.reason });
|
|
return;
|
|
}
|
|
const missing = TASK_FIELDS.filter(k => typeof v[k] !== 'string' || !v[k]);
|
|
if (missing.length) throw new SupersedeRefused(`${at} is missing ${missing.join(', ')}`);
|
|
if (!Array.isArray(v.files) || v.files.some(f => typeof f !== 'string')) throw new SupersedeRefused(`${at} files must be an array of strings`);
|
|
if (v.carried_from !== undefined && (typeof v.carried_from !== 'string' || !PRIOR_RE.test(v.carried_from))) throw new SupersedeRefused(`${at} carried_from must be <run_id>/<id>`);
|
|
tasks.push({ phase, run_id: runId, branch, commit, ...Object.fromEntries(TASK_FIELDS.map(k => [k, v[k]])), files: v.files, ...(v.carried_from ? { carried_from: v.carried_from } : {}) });
|
|
});
|
|
const prior = fs.existsSync(dir) ? priorSet(readTaskFiles(dir, phase), phase, branch) : undefined;
|
|
const decided = new Set([...tasks.map(t => t.carried_from), ...dispositions.map(d => d.prior)]);
|
|
const open = (prior?.rows ?? []).map(r => `${prior!.run_id}/${String(r.id)}`).filter(id => !decided.has(id));
|
|
if (open.length) throw new SupersedeRefused(`${rows} leaves earlier tasks undecided: ${open.join(', ')}; carry each (carried_from) or close it (a done or dropped disposition)`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const slug = branchSlug.replace(/[^A-Za-z0-9._-]/g, '-') || 'unknown';
|
|
const file = path.join(dir, `tasks-${phase}-${slug}-${iso.slice(0, 8)}-${iso.slice(9, 15)}.jsonl`);
|
|
try { fs.writeFileSync(file, tasks.map(t => JSON.stringify(t) + '\n').join(''), { flag: 'wx' }); } catch (e) {
|
|
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
|
throw new SupersedeRefused(`${file} already exists (a run in the same second); rerun add`);
|
|
}
|
|
if (dispositions.length) fs.appendFileSync(path.join(dir, `task-dispositions-${phase}-${slug}.jsonl`), dispositions.map(d => JSON.stringify(d) + '\n').join(''));
|
|
fs.rmSync(rows, { force: true });
|
|
const { moved } = supersede(dir, phase, branch, file);
|
|
return { file, tasks: tasks.length, dispositions: dispositions.length, superseded: moved };
|
|
}
|
|
|
|
export function supersede(dir: string, phase: TaskPhase, branch: string, keep: string): { moved: string[]; skipped: Array<{ file: string; reason: string }> } {
|
|
finishPending(dir, phase);
|
|
const keepPath = path.resolve(keep);
|
|
if (path.dirname(keepPath) !== path.resolve(dir) || !path.basename(keepPath).startsWith(`tasks-${phase}-`)) throw new SupersedeRefused(`--keep must be a tasks-${phase}-*.jsonl file in ${dir}`);
|
|
const files = readTaskFiles(dir, phase);
|
|
const kept = files.find(f => f.file === keepPath);
|
|
if (!kept) throw new SupersedeRefused(`${keep} does not exist`);
|
|
if (kept.malformed.length) throw new SupersedeRefused(`${keep} has malformed lines ${kept.malformed.join(',')}`);
|
|
const stray = kept.rows.find(r => r.phase !== phase || r.branch !== branch);
|
|
if (stray) throw new SupersedeRefused(`${keep} holds a row for phase ${String(stray.phase)} branch ${String(stray.branch)}`);
|
|
const keepRun = kept.rows.map(r => String(r.run_id ?? '')).sort().at(-1);
|
|
const keepMtime = fs.statSync(keepPath).mtimeMs;
|
|
const keepPrefix = path.basename(keepPath).replace(TS_SUFFIX_RE, '');
|
|
const moves: Array<[string, string]> = [];
|
|
const skipped: Array<{ file: string; reason: string }> = [];
|
|
for (const f of files) {
|
|
if (f.file === keepPath) continue;
|
|
const skip = (reason: string) => skipped.push({ file: f.file, reason });
|
|
if (f.malformed.length) { skip('malformed lines'); continue; }
|
|
if (!f.rows.length) {
|
|
if (path.basename(f.file).replace(TS_SUFFIX_RE, '') !== keepPrefix || keepPrefix === `tasks-${phase}`) { skip('empty file of unknown branch'); continue; }
|
|
if (fs.statSync(f.file).mtimeMs > keepMtime) { skip('newer than the kept list'); continue; }
|
|
} else {
|
|
if (f.rows.some(r => r.branch !== branch)) { skip('holds another branch'); continue; }
|
|
const run = f.rows.map(r => String(r.run_id ?? '')).sort().at(-1)!;
|
|
if (keepRun ? run > keepRun : fs.statSync(f.file).mtimeMs > keepMtime) { skip('newer than the kept list'); continue; }
|
|
}
|
|
moves.push([f.file, path.join(dir, `superseded-${path.basename(f.file)}`)]);
|
|
}
|
|
if (!moves.length) return { moved: [], skipped };
|
|
fs.writeFileSync(marker(dir, phase), JSON.stringify(moves));
|
|
return { moved: finishPending(dir, phase), skipped };
|
|
}
|