Files
msd-core/src/quick-batch-command-router.cts
Tom Boucher 515191f07d feat(#3677): quick-batch hardening and acceptance (#4240)
* 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>
2026-09-03 09:47:22 -04:00

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,
};