#!/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));