* chore(#3677): checkpoint design artifacts (gitignored, dev-only) * test(#3677): add failing regression test for the crash-window duplicate-dispatch gap (RED) Independently re-traces resume-mode.md/planner-wave.md/worktree-dispatch.md/ merge-wave.md and src/quick-batch.cts's resumeBatch (lines 894-899) and confirms the prior research pass's Open Question 1: a coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) leaves BATCH.json at "pending" with no STATE.md row yet (only written in Step 9), so --resume's eligibility re-derivation would dispatch a second executor into a new worktree for the same item, orphaning the first. This test asserts worktree-dispatch.md's Step 6 excludes an item whose SUMMARY.md already exists from the spawn set, mirroring planner-wave.md's existing PLAN.md-existence check one layer earlier. Fails against the current worktree-dispatch.md, which has no such guard. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the full trace and fix-location rationale. * fix(#3677): guard worktree-dispatch.md against re-dispatching an already-executed item (GREEN) worktree-dispatch.md's Step 6 re-derives eligibility every dispatch round via the same quick-batch resume call resume-mode.md uses, but had no check for "did this item already finish executing" the way planner-wave.md already checks "did this item already get planned" (PLAN.md existence) before re-planning. A coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) left the item eligible for a second dispatch on --resume, orphaning the first worktree's real, already- committed work and silently losing it once the second executor's SUMMARY.md write clobbered the first at the same item_dir path. Adds a SUMMARY.md-existence exclusion before spawn-plan is computed, symmetric to planner-wave.md's PLAN.md check. The excluded item is not lost: merge-wave.md's own mergeable-wave criterion (status=pending, SUMMARY.md on disk, not yet merged) already picks it up independently of this eligible/spawn list. Workflow-prose-only fix — touches no already-merged/reviewed .cts module. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the fix-location rationale (why not resumeBatch itself). * test(#3677): add real-git coverage for worktree-ownership tampering, scope drift, and submodules Closes the three coverage gaps identified in 40-design.md §2/§3 (#3677, epic #3344 Phase 5's own AC bullets: "arbitrary-worktree ownership attempts", "scope drift", "submodules"): - Arbitrary-worktree ownership tampering: a manifest entry naming a non-agent branch is silently dropped at normalization before any git subprocess runs; a manifest entry naming a plausible agent-branch that was never actually created by this repo's own worktree.create (a genuinely foreign repo/branch) is blocked via base_mismatch. Both leave the foreign location and repoRoot's HEAD provably untouched. - Advisory scope drift: a committed path outside declared files_modified still merges successfully (advisory, never blocking) while surfacing a scope_out_of_declared warning naming the drifted path; an exact declared-scope match produces zero warnings (boundary case). - Real .gitmodules submodule integration: a repo containing a real local git submodule merges cleanly through executeWorktreeWaveCleanupPlan for an unrelated plan; a real gitlink pointer bump (declared) merges cleanly with the superproject tree reflecting the new pinned commit; an undeclared bump is advisory-only and surfaces a scope warning naming vendor/sub, same as any other undeclared modification. No src/*.cts changes — all three gaps were coverage-only; the underlying primitives already behaved correctly (independently verified against real git subprocess output before writing each assertion). * docs(#3677): document how to diagnose a preserved quick-batch worktree Extends the one-sentence "worktree is preserved (never deleted)" mention into a concrete diagnosis procedure: where the preserved directory is, how to read the executor's real commits/diff against the plan's declared files_modified, how to read the item's own SUMMARY.md independent of merge outcome, how to manually merge-and-clean-up or discard, and how to re-run --resume afterward. Also documents that a SUMMARY.md-written-but-still- pending item (the crash-window case fixed in this same PR) needs no manual intervention — --resume routes it straight to the merge step. * chore(#3677): checkpoint final acceptance-evidence mapping (gitignored, dev-only) * fix(#3677): make crash-window duplicate-dispatch guard behaviorally provable and durably recoverable Orthogonal review (Spec finding): the crash-window regression test added earlier this phase only asserted readStep('worktree-dispatch.md') + regex matches against the markdown prose — proving the DOCUMENTATION says the right thing, never that the runtime condition (pending status + on-disk SUMMARY.md + absent STATE row) is actually handled correctly. #3677's own "Alternatives considered" explicitly rejects "document recovery without fault injection" for exactly this reason. Extracts the filtering decision into a pure, independently testable function, filterAlreadyExecuted(eligibleIds, executedIds) in src/quick-batch-dispatch.cts, wired to a new `quick-batch filter-executed` CLI verb (src/quick-batch-command-router.cts) — the same pure-decision- then-CLI-wired pattern computeSpawnPlan/computeMergeOrder already establish. worktree-dispatch.md now calls this verb explicitly instead of only describing the decision in prose. A genuine fixture-based test in tests/quick-batch.test.cjs constructs a REAL BATCH.json (createBatch), writes a REAL SUMMARY.md on disk at the item's real item_dir, calls the REAL resumeBatch, and proves both that resumeBatch alone still reports the item eligible AND that filterAlreadyExecuted (fed a real filesystem check) correctly excludes it. The prior prose-assertion tests are kept — they now prove the workflow markdown is correctly WIRED to the verb — but are no longer the only proof. Self-discovered defect while building that fixture (fixed inline, not deferred): tracing merge-wave.md against /gsd:quick's own prior art (QUICK_WORKTREE_MANIFEST=$(mktemp ...), quick.md:415) showed $QUICK_BATCH_WORKTREE_MANIFEST is a fresh PER-PROCESS temp file. A resumed coordinator correctly does not re-dispatch an already-executed item (this fix), but nothing durably recorded that item's worktree_path/branch/base either — Step 7 in the resumed process would have had no data to build its cleanup-wave entry from. Adds dispatched_worktree/dispatched_branch/ dispatched_base to QuickBatchItem (src/quick-batch.cts) — deliberately NOT a reuse of the pre-existing `worktree` field, whose loadBatch validation requires the path to exist on disk (verified empirically: reusing it made the batch permanently unloadable the moment a legitimately-merged worktree was removed). worktree-dispatch.md persists the triple once a worktree is created; merge-wave.md falls back to it when the ephemeral manifest lacks an entry, clears it after a successful merge, and fails closed rather than guessing if no record exists anywhere. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.1 and §9.3 for the full trace, empirical verification notes, and rejected alternatives (reusing `worktree` directly). * test(#3677): prove the arbitrary-worktree-ownership boundary against two real sibling worktrees Orthogonal review (Security finding): the two existing ownership-tampering tests didn't test ownership — one was trivially rejected by WORKTREE_AGENT_BRANCH_RE's shape check before any git call (proves branch- NAME filtering, not ownership), the other pointed at a wholly separate, never-linked foreign repo, so merge-base failed immediately because the branch didn't exist as a ref at all. Neither exercised the real scenario: a manifest entry whose worktree_path/branch are swapped to point at a DIFFERENT, GENUINELY-REGISTERED sibling worktree of the SAME repoRoot, with a branch name passing the shape check and a base in allowed_bases. Investigated executeWorktreeWaveCleanupPlan (src/worktree-safety.cts) directly: this is NOT a reachable gap. Git enforces branch-per-worktree uniqueness, so a swapped-in entry.branch can only match worktree_path's ACTUAL checked-out branch if it names that sibling's own real, uniquely- generated branch name — which manifest tampering confined to one batch's own record has no way to know (branch names are agent-<quick_id>[-<timestamp>]-shaped, and quick_id allocation is collision-checked GLOBALLY across every existing quick task and batch, not merely within one batch). Adds a stronger test that empirically proves this: two REAL, concurrently- alive sibling worktrees of the same repo (both via real `git worktree add`, both WORKTREE_AGENT_BRANCH_RE-passing, both sharing one merge-base), with worktree_path/branch swapped between them in both directions. Both attempts are blocked via branch_mismatch; both real worktrees, their branches, and one sibling's real uncommitted-to-main commit survive completely untouched. Supplements (does not replace) the original two tests, which still prove distinct, real boundaries. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.2 for the full trace, including the one explicitly-documented (not fixed) trust boundary this investigation surfaced: the primitive defends against fabricated data, not a caller bug that misattributes a real-but-wrong item's own triple to a different item. * chore(#3677): checkpoint design-doc addendum for review pass 2 findings (gitignored, dev-only) * docs(#3677): add changeset for PR 4240 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
342 lines
17 KiB
TypeScript
342 lines
17 KiB
TypeScript
/**
|
|
* Quick-Batch command router — CLI subcommand dispatcher for
|
|
* `gsd-tools quick-batch` (#3676, Phase 4 of epic #3344 / ADR-1239).
|
|
*
|
|
* Quick-batch is a first-party, always-on command family (like `/gsd:quick`,
|
|
* which has no capability-registry entry), so this router is wired directly
|
|
* into `HOST_COMMAND_ROUTERS` (`gsd-core/bin/gsd-tools.cjs`) — NOT the opt-in
|
|
* capability-registry/`activationKey` path `graphify` uses. Shape follows
|
|
* `graphify-command-router.cts` (thin `routeHubCommandFamily` wrapper), which
|
|
* gives the Hub's `makeUnknownCommand` handling for free on an unknown
|
|
* subcommand (test-matrix row 47).
|
|
*
|
|
* Verbs wrap `src/quick-batch.cts` (durable manifest read/write — reused
|
|
* as-is) and `src/quick-batch-dispatch.cts` (pure decision logic, #3676's
|
|
* own new module). This router performs NO decision logic of its own beyond
|
|
* argument shaping — every behavioral rule lives in one of those two
|
|
* modules, per the design doc's "Do the simplest thing" law.
|
|
*
|
|
* Test seam: pass `_quickBatch`/`_quickBatchDispatch` in the options object
|
|
* to inject recording mocks instead of the real modules — same `_`-prefix
|
|
* convention `graphify-command-router.cts` uses.
|
|
*
|
|
* ADR-457 build-at-publish: compiled by tsc to
|
|
* gsd-core/bin/lib/quick-batch-command-router.cjs.
|
|
*/
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import quickBatch = require('./quick-batch.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import quickBatchDispatch = require('./quick-batch-dispatch.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import io = require('./io.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import commandRoutingHub = require('./command-routing-hub.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
|
|
import { safeJsonParse } from './security.cjs';
|
|
|
|
const { output, ERROR_REASON } = io;
|
|
const { makeInvalidArgs } = commandRoutingHub;
|
|
const { routeHubCommandFamily } = cjsCommandRouterAdapter;
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface QuickBatchModule {
|
|
parseTaskList(text: string): unknown;
|
|
parseTaskListFromFile(cwd: string, filePath: string): unknown;
|
|
createBatch(cwd: string, items: unknown[], options?: Record<string, unknown>): unknown;
|
|
loadBatch(cwd: string, batchId: string): unknown;
|
|
resumeBatch(cwd: string, batchId: string, options?: Record<string, unknown>): unknown;
|
|
completeQuickItem(cwd: string, batchId: string, quickId: string, fields: Record<string, unknown>): unknown;
|
|
updateBatchItems(cwd: string, batchId: string, updates: unknown[]): unknown;
|
|
}
|
|
|
|
interface QuickBatchDispatchModule {
|
|
parseQuickBatchArgs(args: string[]): unknown;
|
|
computeEffectiveConcurrency(input: Record<string, unknown>): number;
|
|
computeMergeOrder(waveOrder: string[], readyIds: Set<string>): string[];
|
|
computeSpawnPlan(input: Record<string, unknown>): unknown;
|
|
routeVerificationOutcome(status: string): unknown;
|
|
routeMergeOutcome(outcome: Record<string, unknown>): unknown;
|
|
buildCleanupManifestEntry(input: Record<string, unknown>): unknown;
|
|
filterAlreadyExecuted(eligibleIds: string[], executedIds: string[]): unknown;
|
|
}
|
|
|
|
interface RouteQuickBatchCommandOptions {
|
|
args: string[];
|
|
cwd: string;
|
|
raw: boolean;
|
|
error: (message: string, reason?: string) => void;
|
|
_quickBatch?: QuickBatchModule;
|
|
_quickBatchDispatch?: QuickBatchDispatchModule;
|
|
}
|
|
|
|
// ─── Small arg-parsing helpers (local — no new shared convention needed) ────
|
|
|
|
/** `--flag value` lookup; undefined when the flag is absent. */
|
|
function argValue(args: string[], flag: string): string | undefined {
|
|
const idx = args.indexOf(flag);
|
|
if (idx === -1) return undefined;
|
|
return args[idx + 1];
|
|
}
|
|
|
|
function parseJsonArg<T>(raw: string | undefined, label: string): { ok: true; value: T } | { ok: false; reason: string } {
|
|
if (raw === undefined) {
|
|
return { ok: false, reason: `${label} requires a JSON value` };
|
|
}
|
|
const parsed = safeJsonParse(raw, { maxLength: 1048576, label });
|
|
if (!parsed.ok) {
|
|
return { ok: false, reason: `${label} is not valid JSON: ${parsed.error ?? 'unknown parse error'}` };
|
|
}
|
|
return { ok: true, value: parsed.value as T };
|
|
}
|
|
|
|
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
|
|
function routeQuickBatchCommand({ args, cwd, raw, error, _quickBatch, _quickBatchDispatch }: RouteQuickBatchCommandOptions): void {
|
|
const qb: QuickBatchModule = _quickBatch ?? (quickBatch as unknown as QuickBatchModule);
|
|
const dispatch: QuickBatchDispatchModule = _quickBatchDispatch ?? (quickBatchDispatch as unknown as QuickBatchDispatchModule);
|
|
|
|
/** Forward a `Result<T>` from either module straight to output()/error(). */
|
|
function emit(result: unknown): void {
|
|
if (result && typeof result === 'object' && 'ok' in result) {
|
|
const r = result as { ok: boolean; reason?: string; value?: unknown };
|
|
if (!r.ok) {
|
|
error(r.reason ?? 'quick-batch command failed', ERROR_REASON.USAGE);
|
|
return;
|
|
}
|
|
output(r.value, raw);
|
|
return;
|
|
}
|
|
output(result, raw);
|
|
}
|
|
|
|
routeHubCommandFamily({
|
|
family: 'quick-batch',
|
|
args,
|
|
subcommands: [
|
|
'create',
|
|
'update',
|
|
'resume',
|
|
'complete',
|
|
'effective-concurrency',
|
|
'merge-eligible',
|
|
'spawn-plan',
|
|
'filter-executed',
|
|
'verification-routing',
|
|
'merge-routing',
|
|
'cleanup-entry',
|
|
'parse-args',
|
|
],
|
|
handlers: {
|
|
// `quick-batch create --file <path> [--base-revision <sha>] [--options <json>]`
|
|
create: () => {
|
|
const filePath = argValue(args, '--file');
|
|
if (!filePath) {
|
|
return makeInvalidArgs('--file', 'Usage: gsd-tools quick-batch create --file <path> [--base-revision <sha>] [--options <json>]', ERROR_REASON.USAGE);
|
|
}
|
|
const parsed = qb.parseTaskListFromFile(cwd, filePath) as { ok: boolean; reason?: string; value?: Array<{ description: string }> };
|
|
if (!parsed.ok) {
|
|
return makeInvalidArgs('--file', parsed.reason ?? 'unable to parse task list', ERROR_REASON.USAGE);
|
|
}
|
|
const baseRevision = argValue(args, '--base-revision');
|
|
const optionsRaw = argValue(args, '--options');
|
|
let batchOptions: Record<string, unknown> | undefined;
|
|
if (optionsRaw !== undefined) {
|
|
const optResult = parseJsonArg<Record<string, unknown>>(optionsRaw, '--options');
|
|
if (!optResult.ok) return makeInvalidArgs('--options', optResult.reason, ERROR_REASON.USAGE);
|
|
batchOptions = optResult.value;
|
|
}
|
|
const items = (parsed.value ?? []).map((it) => ({ description: it.description }));
|
|
emit(qb.createBatch(cwd, items, { baseRevision, batchOptions }));
|
|
},
|
|
// `quick-batch update --batch <id> --updates <json>`
|
|
update: () => {
|
|
const batchId = argValue(args, '--batch');
|
|
if (!batchId) {
|
|
return makeInvalidArgs('--batch', 'Usage: gsd-tools quick-batch update --batch <id> --updates <json>', ERROR_REASON.USAGE);
|
|
}
|
|
const updatesResult = parseJsonArg<unknown[]>(argValue(args, '--updates'), '--updates');
|
|
if (!updatesResult.ok) return makeInvalidArgs('--updates', updatesResult.reason, ERROR_REASON.USAGE);
|
|
emit(qb.updateBatchItems(cwd, batchId, updatesResult.value));
|
|
},
|
|
// `quick-batch resume --batch <id> [--current-base-revision <sha>]`
|
|
resume: () => {
|
|
const batchId = argValue(args, '--batch');
|
|
if (!batchId) {
|
|
return makeInvalidArgs('--batch', 'Usage: gsd-tools quick-batch resume --batch <id> [--current-base-revision <sha>]', ERROR_REASON.USAGE);
|
|
}
|
|
const currentBaseRevision = argValue(args, '--current-base-revision');
|
|
emit(qb.resumeBatch(cwd, batchId, currentBaseRevision !== undefined ? { currentBaseRevision } : {}));
|
|
},
|
|
// `quick-batch complete --batch <id> --quick-id <id> --description <t> --date <d> --commit <sha> [--directory <dir>]`
|
|
complete: () => {
|
|
const batchId = argValue(args, '--batch');
|
|
const quickId = argValue(args, '--quick-id');
|
|
const description = argValue(args, '--description');
|
|
const date = argValue(args, '--date');
|
|
const commit = argValue(args, '--commit');
|
|
if (!batchId || !quickId || !description || !date || !commit) {
|
|
return makeInvalidArgs(
|
|
'--batch/--quick-id/--description/--date/--commit',
|
|
'Usage: gsd-tools quick-batch complete --batch <id> --quick-id <id> --description <t> --date <d> --commit <sha> [--directory <dir>]',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
const directory = argValue(args, '--directory');
|
|
emit(qb.completeQuickItem(cwd, batchId, quickId, { description, date, commit, directory }));
|
|
},
|
|
// `quick-batch effective-concurrency --jobs <auto|N> --task-count <N> --capacity <N> --isolation <str> [--mutating]`
|
|
'effective-concurrency': () => {
|
|
const jobsRaw = argValue(args, '--jobs');
|
|
const taskCount = Number(argValue(args, '--task-count'));
|
|
const capacity = Number(argValue(args, '--capacity'));
|
|
const isolation = argValue(args, '--isolation') ?? '';
|
|
if (jobsRaw === undefined || !Number.isFinite(taskCount) || !Number.isFinite(capacity)) {
|
|
return makeInvalidArgs(
|
|
'--jobs/--task-count/--capacity',
|
|
'Usage: gsd-tools quick-batch effective-concurrency --jobs <auto|N> --task-count <N> --capacity <N> --isolation <str> [--mutating]',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
const jobs: 'auto' | number = jobsRaw === 'auto' ? 'auto' : Number(jobsRaw);
|
|
const mutating = args.includes('--mutating');
|
|
output({
|
|
concurrency: dispatch.computeEffectiveConcurrency({ jobs, taskCount, capacity, isolation, mutating }),
|
|
}, raw);
|
|
},
|
|
// `quick-batch merge-eligible --wave-order <json-array> --ready <json-array>`
|
|
'merge-eligible': () => {
|
|
const waveOrderResult = parseJsonArg<string[]>(argValue(args, '--wave-order'), '--wave-order');
|
|
if (!waveOrderResult.ok) return makeInvalidArgs('--wave-order', waveOrderResult.reason, ERROR_REASON.USAGE);
|
|
const readyResult = parseJsonArg<string[]>(argValue(args, '--ready'), '--ready');
|
|
if (!readyResult.ok) return makeInvalidArgs('--ready', readyResult.reason, ERROR_REASON.USAGE);
|
|
output({
|
|
mergeable: dispatch.computeMergeOrder(waveOrderResult.value, new Set(readyResult.value)),
|
|
}, raw);
|
|
},
|
|
// `quick-batch spawn-plan --eligible <json-array> --capacity <N> --in-flight <N> [--refused <json-array>]`
|
|
'spawn-plan': () => {
|
|
const eligibleResult = parseJsonArg<string[]>(argValue(args, '--eligible'), '--eligible');
|
|
if (!eligibleResult.ok) return makeInvalidArgs('--eligible', eligibleResult.reason, ERROR_REASON.USAGE);
|
|
const capacity = Number(argValue(args, '--capacity'));
|
|
const currentInFlight = Number(argValue(args, '--in-flight'));
|
|
if (!Number.isFinite(capacity) || !Number.isFinite(currentInFlight)) {
|
|
return makeInvalidArgs(
|
|
'--capacity/--in-flight',
|
|
'Usage: gsd-tools quick-batch spawn-plan --eligible <json-array> --capacity <N> --in-flight <N> [--refused <json-array>]',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
let refused: string[] = [];
|
|
const refusedRaw = argValue(args, '--refused');
|
|
if (refusedRaw !== undefined) {
|
|
const refusedResult = parseJsonArg<string[]>(refusedRaw, '--refused');
|
|
if (!refusedResult.ok) return makeInvalidArgs('--refused', refusedResult.reason, ERROR_REASON.USAGE);
|
|
refused = refusedResult.value;
|
|
}
|
|
output(dispatch.computeSpawnPlan({ eligibleIds: eligibleResult.value, capacity, currentInFlight, refused }), raw);
|
|
},
|
|
// `quick-batch filter-executed --eligible <json-array> --executed <json-array>`
|
|
// Crash-window duplicate-dispatch guard (#3677): splits this round's
|
|
// eligible ids into `spawnEligible` (safe to dispatch) and
|
|
// `alreadyExecuted` (SUMMARY.md already exists on disk — caller
|
|
// determines this via its own filesystem check; NEVER re-dispatch
|
|
// these — merge-wave.md's own on-disk criterion picks them up).
|
|
'filter-executed': () => {
|
|
const eligibleResult = parseJsonArg<string[]>(argValue(args, '--eligible'), '--eligible');
|
|
if (!eligibleResult.ok) return makeInvalidArgs('--eligible', eligibleResult.reason, ERROR_REASON.USAGE);
|
|
const executedResult = parseJsonArg<string[]>(argValue(args, '--executed'), '--executed');
|
|
if (!executedResult.ok) return makeInvalidArgs('--executed', executedResult.reason, ERROR_REASON.USAGE);
|
|
output(dispatch.filterAlreadyExecuted(eligibleResult.value, executedResult.value), raw);
|
|
},
|
|
// `quick-batch verification-routing --status <passed|gaps_found|human_needed>`
|
|
'verification-routing': () => {
|
|
const status = argValue(args, '--status');
|
|
if (status !== 'passed' && status !== 'gaps_found' && status !== 'human_needed') {
|
|
return makeInvalidArgs(
|
|
'--status',
|
|
'Usage: gsd-tools quick-batch verification-routing --status <passed|gaps_found|human_needed>',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
output(dispatch.routeVerificationOutcome(status), raw);
|
|
},
|
|
// `quick-batch merge-routing --kind <merged|merge_failed|scope_violation> [--detail <text>]`
|
|
'merge-routing': () => {
|
|
const kind = argValue(args, '--kind');
|
|
if (kind !== 'merged' && kind !== 'merge_failed' && kind !== 'scope_violation') {
|
|
return makeInvalidArgs(
|
|
'--kind',
|
|
'Usage: gsd-tools quick-batch merge-routing --kind <merged|merge_failed|scope_violation> [--detail <text>]',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
const detail = argValue(args, '--detail');
|
|
output(dispatch.routeMergeOutcome(detail !== undefined ? { kind, detail } : { kind }), raw);
|
|
},
|
|
// `quick-batch cleanup-entry --agent-id <id|null> --worktree-path <p> --branch <b> --expected-base <b> [--allowed-bases <json-array>] --plan-content <text>`
|
|
'cleanup-entry': () => {
|
|
const agentIdRaw = argValue(args, '--agent-id');
|
|
const worktreePath = argValue(args, '--worktree-path');
|
|
const branch = argValue(args, '--branch');
|
|
const expectedBase = argValue(args, '--expected-base');
|
|
const planContent = argValue(args, '--plan-content');
|
|
if (!worktreePath || !branch || !expectedBase || planContent === undefined) {
|
|
return makeInvalidArgs(
|
|
'--worktree-path/--branch/--expected-base/--plan-content',
|
|
'Usage: gsd-tools quick-batch cleanup-entry --worktree-path <p> --branch <b> --expected-base <b> --plan-content <text> [--agent-id <id>] [--allowed-bases <json-array>]',
|
|
ERROR_REASON.USAGE,
|
|
);
|
|
}
|
|
let allowedBases: string[] | undefined;
|
|
const allowedBasesRaw = argValue(args, '--allowed-bases');
|
|
if (allowedBasesRaw !== undefined) {
|
|
const allowedResult = parseJsonArg<string[]>(allowedBasesRaw, '--allowed-bases');
|
|
if (!allowedResult.ok) return makeInvalidArgs('--allowed-bases', allowedResult.reason, ERROR_REASON.USAGE);
|
|
allowedBases = allowedResult.value;
|
|
}
|
|
output(dispatch.buildCleanupManifestEntry({
|
|
agentId: agentIdRaw ?? null,
|
|
worktreePath,
|
|
branch,
|
|
expectedBase,
|
|
allowedBases,
|
|
planContent,
|
|
}), raw);
|
|
},
|
|
// `quick-batch parse-args --text "<raw $ARGUMENTS string>"` (preferred —
|
|
// callers pass the ENTIRE, still-quoted $ARGUMENTS as ONE argv element;
|
|
// this handler does the whitespace split itself, in Node, so shell
|
|
// pathname expansion (globbing) on attacker-influenced task text never
|
|
// happens before this parser sees it — quoting `"$ARGUMENTS"` at the
|
|
// call site is what closes that off; splitting it here is what keeps
|
|
// the caller from having to word-split it unsafely beforehand).
|
|
// `quick-batch parse-args -- <already-tokenized args>` (legacy/direct
|
|
// form — still supported for a caller that already has a real argv
|
|
// array with no shell splitting involved, e.g. a test harness).
|
|
'parse-args': () => {
|
|
const textArg = argValue(args, '--text');
|
|
if (textArg !== undefined) {
|
|
const rawArgs = textArg.trim().length === 0 ? [] : textArg.trim().split(/\s+/);
|
|
emit(dispatch.parseQuickBatchArgs(rawArgs));
|
|
return;
|
|
}
|
|
const sepIdx = args.indexOf('--');
|
|
const rawArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1);
|
|
emit(dispatch.parseQuickBatchArgs(rawArgs));
|
|
},
|
|
},
|
|
unknownMessage: (subcommand: string, available: string[]) =>
|
|
`Unknown quick-batch subcommand. Available: ${available.join(', ')}`,
|
|
error,
|
|
cwd,
|
|
raw,
|
|
});
|
|
}
|
|
|
|
export = {
|
|
routeQuickBatchCommand,
|
|
};
|