#!/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
[--branch ] [--dir ] [--json]
* gstack-tasks add --phase [--branch ] [--dir ]
* gstack-tasks supersede --phase --keep [--branch ] [--dir ] [--json]
*
* --dir defaults to /projects/, --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 [--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
validate the rows file, write this run's list (empty: ran, no findings) and its
dispositions to task-dispositions-
-.jsonl, then supersede this branch's earlier lists
supersede --phase --keep [--json] move this branch's reconciled earlier lists out of the aggregate
Every command takes [--dir ] (default /projects/) and [--branch ] (default the current git branch).
Rows file /.gstack/tmp/tasks--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": "/"
disposition {"prior": "/", "disposition": "done" | "dropped", "reason": ""}
prior prints \`PRIOR_TASKS: none\` or \`PRIOR_TASKS: run= tasks=\`, one JSON row per task, then a RECONCILE: line;
\`PRIOR_TASKS_MALFORMED: :\` goes to stderr for a line that is not a JSON object (skipped, never edited).
supersede renames each earlier tasks--*.jsonl whose rows are all this branch and not newer than --keep to
superseded- and prints \`SUPERSEDED: \` or \`KEPT: ()\`; --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}/". Shipped: a disposition row {"prior": "${prior.run_id}/", "disposition": "done", "reason": ""}. 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 ');
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));