0005 — The D8 result-vocabulary re-code and its legacy read mapping
Context
Before P1b's D8 vocabulary re-code, a task's result target.kind used
"prompt" for a prepared command/agent-LLM result and one shared
"command" for every native (shell/script) result, which conflated two
materially different execution shapes under one label and mislabeled an
LLM-routed dispatch as a literal "prompt". D8 renamed the written
vocabulary; the harder problem was that task_history is a durable,
already-populated table — existing rows on disk still carry the OLD labels,
and readTaskHistory has to keep returning a coherent shape for both
generations of rows forever.
Decision
Moved verbatim from src/tasks/run/task-result.ts's module header:
TaskRunResult— the shape every dispatch arm returns — plus the small cluster of helpers that build one directly:preparedResultTarget(the D8 result-vocabulary projection of a freshly prepared execution),finishDisabledTask(the disabled-task short-circuit), andexitCodeForStatus(the OS-scheduler exit-code mapping).RunTaskOptions— the public options bagrunTask(),load-task.ts, and every dispatch arm read from — lives here too, alongside the other public-surface types the compat shim (src/tasks/runner.ts) re-exports.D8 (§5.3, §6 F-2):
preparedResultTarget's prepared-command arm now returns{kind:"command", engine}(formerly{kind:"prompt", engine}); its native arm now returns the bare{kind:"shell"}/{kind:"script"}(formerly one shared{kind:"command"}) — the arm-specificcmdis added byrun-native-task.tsonce it has actually built the argv, mirroring the pre-P1b shape where the bare disabled-task projection never carriedcmdeither.
And from src/tasks/run/task-history.ts's module header (the WRITE half —
the READ half's mapping rule is kept in the code itself, not moved; see
below):
The
task_historyread/write boundary:appendHistory(write) andreadTaskHistory/taskHistoryRowToResult(read).D8 (spec §5.3, §6 F-2) result-vocabulary re-code, implemented entirely at this read/write boundary. WRITE: every row
appendHistorywrites now carriestargetVocab: 2in its metadata, and the new target_kind strings ("command" for a prepared command/agent-LLM result, "shell", "script", "workflow" unchanged).
Consequences
- Every NEW row written after D8 is unambiguous —
targetVocab: 2plus the newtarget_kindstrings mean a reader never has to guess which generation a row belongs to. - Every OLD row (written before D8, no
targetVocabmarker at all) must keep reading correctly forever — this is not a migration that runs once and finishes; there is no "convert task_history in place" step, because the table is an append-only historical log, not authoring state. The read-side mapping rule is therefore a PERMANENT part of the codebase (docs/plans/specs/p4-deletions-closeout.mdrow B-51: "it reads old rows forever. Deleting it is a review-blocking violation."). - The exact legacy mapping — kept in
src/tasks/run/task-history.tsitself as a short invariant comment, not summarized here, because a maintainer readingtaskHistoryRowToResultneeds it right there — is: a legacy row (notargetVocabmarker) maps"prompt"→{kind:"command", engine},"command"→{kind:"shell"},"workflow"unchanged, and anything else (including the new vocabulary's own strings written WITHOUT a marker, which no production writer ever does) →"unknown". The P0-pinned null fallbacks survive: a workflow row'sreffalls back to"", the command/prompt arm'senginefalls back tonull. SAFE_TASK_ATTEMPT_ERROR_CODES(src/tasks/run/attempt-lifecycle.ts) is a related but separate allowlist — it decides which error CODES are safe to surface verbatim in adetail.errorcolumn, not which result-kind strings are legal. Do not conflate the two when reading either file.- Removed 2026-08-28 (review finding against this phase's own close-out
spec,
docs/plans/specs/p4-deletions-closeout.md):finishDisabledTaskandpreparedResultTarget, quoted above as part of the moved essay, are no longer insrc/tasks/run/task-result.ts.finishDisabledTask's only caller —run-task.ts'sshouldSkipUnactivatedTask— was deleted by the SAME phase's P4-N6 (task source v4 has no document-levelenabledto skip at fire time), which leftfinishDisabledTaskfully dead;preparedResultTargetwas in turnfinishDisabledTask's only caller, so it went dead with it. Deleting them also retired the"disabled"TaskRunStatusmemberfinishDisabledTaskwas the sole producer of, and the now-unreachableresult.status === "disabled"branch insrc/commands/tasks/tasks.ts. This does not touch the D8 vocabulary or the legacy read mapping this ADR is about — every OTHER dispatch arm still builds its ownTaskRunResult.targetper the Decision above, andtask-history.ts's read side is unchanged. - Superseded 2026-09-01: the "PERMANENT part of the codebase" ruling
above (row B-51) is overturned.
task_historyis DB-owned data, and the legacy->current remap is deterministic and total, so it belongs in a one-time schema migration, not three permanently-recurring read-side branches (the mapping had been independently re-implemented atsrc/tasks/run/task-history.ts,src/commands/health/improve-metrics.ts'sisAgentTaskHistoryRow, andsrc/commands/health/windows.ts). State migration025-task-history-vocabulary-backfill(src/core/state/migrations.ts) now rewrites every legacy row'starget_kind(and stampstargetVocab: 2) the first time a pre-migration state.db is opened by this or a later release; all three read sites were deleted and now readtarget_kinddirectly in the current vocabulary.
Provenance
- Source:
src/tasks/run/task-result.tsmodule header;src/tasks/run/task-history.tsmodule header (WRITE half only — the READ half's mapping rule stays in the code as a permanent invariant). - Spec:
docs/plans/specs/p1b-model-extraction.md§5.3, §6 F-2. - Extracted: P4 (
docs/plans/specs/p4-deletions-closeout.md§4.2), 2026-08-27.