This document owns what must remain true in the Ralph control plane and artifact model.
Related docs:
- Architecture for module layout
- Provenance for trust-chain details
- Verifier for verifier and stop semantics
- Boundaries for explicit non-goals
These paths are stable parts of the product contract:
- objective text:
ralphCodex.prdPath, default.ralph/prd.md - progress log:
ralphCodex.progressPath, default.ralph/progress.md - task graph:
ralphCodex.ralphTaskFilePath, default.ralph/tasks.json - doctrine pack:
.ralph/doctrine/ - runtime state:
.ralph/state.json, mirrored to VS CodeworkspaceState - generated prompts:
.ralph/prompts/ - CLI transcripts and last messages:
.ralph/runs/ - per-iteration artifacts:
.ralph/artifacts/iteration-###/ - run-level provenance bundles:
.ralph/artifacts/runs/<provenance-id>/ - extension log:
.ralph/logs/extension.log
resetRuntimeState() may remove generated runtime state and artifacts, but it must preserve the durable PRD, progress log, task file, and doctrine pack.
Fresh workspace initialization creates a compact doctrine skeleton under .ralph/doctrine/:
project-profile.mdinvariants.mdboundaries.mdworkflows.mdagents.mddecisions.mdrisks.mdopen-questions.mdevidence-index.json
Initialization is non-destructive: existing doctrine files are never overwritten, and a partially existing doctrine folder is completed by creating only missing files. .ralph/prd.md remains the initialization guard for active Ralph workspaces.
Established workspaces adopt the same skeleton through the explicit Ralphdex: Initialize Doctrine Pack command. That command requires the existing durable Ralph files (.ralph/prd.md, .ralph/tasks.json, .ralph/progress.md), preserves existing doctrine Markdown files, creates only missing doctrine Markdown files, and repairs evidence-index.json only when it is missing or structurally invalid. This doctrine repair path is separate from fresh bootstrap and separate from runtime cleanup/reset flows.
The protected doctrine files are .ralph/doctrine/invariants.md, .ralph/doctrine/boundaries.md, and .ralph/doctrine/agents.md. Ralph scaffolds these files and validates required headings plus the minimal evidence-index.json shape. Providers must not rewrite protected doctrine during normal task execution. They may emit bounded doctrineUpdates proposals in the completion report, which Ralph validates and persists as reviewable artifacts only. There is still no semantic doctrine auto-updater, approval workflow, auto-apply behavior, or autonomous doctrine rewriting.
Doctrine does not replace the active Ralph state files. .ralph/prd.md remains the product objective, .ralph/tasks.json remains the executable task graph, and .ralph/progress.md remains the progress log. Doctrine validation health is warning-level preflight/status context unless a future tranche explicitly defines stronger gates.
tasks.json is explicit, flat, and versioned:
{
"version": 2,
"tasks": [
{
"id": "T1",
"title": "Top-level task",
"status": "in_progress",
"acceptance": ["All child tasks are complete", "Validation passes"],
"constraints": ["Do not change the task file schema"],
"context": ["src/ralph/taskFile.ts", "src/ralph/types.ts"]
},
{
"id": "T1.1",
"title": "Child task",
"status": "todo",
"parentId": "T1",
"dependsOn": ["T1"],
"tier": "simple"
}
]
}Required rules:
- Persisted output must be version
2. - Use
parentIdfor parent-child relationships. - Use
dependsOnfor prerequisites. - Keep the file flat and inspectable.
- Task selection stays deterministic: first actionable
in_progress, then first actionabletodo. - Parent-versus-descendant completion must stay monotonic: a task may be
doneonly when every explicit descendant is alsodone. - If a parent still has unfinished descendants, reopen the parent or complete/block the descendants explicitly; do not hide remaining work by leaving the parent
done. - Do not reintroduce implicit subtask inference as the main task model.
remainingSubtasksand backlog logic must use explicit descendants and dependencies, not task-id prefix guesses.
Legacy normalization is allowed for simple older task files, but persisted output should still end as version 2.
Task claims are a separate, file-backed coordination surface:
- claim records live in a version
1JSON file with an append-onlyclaimsarray - active ownership for a task is the canonical latest active claim for that
taskId - acquisition must not overwrite an existing canonical holder; it returns a contested result instead
- acquisition writes the new active claim, rereads the file, and only succeeds if that reread still shows the same canonical holder
- release is idempotent and only marks the canonical active claim held by the requesting agent as
released - stale claims are detectable from
claimedAtplus a configurable TTL, but Ralph must not silently reassign or release them outside a bounded recovery path - preflight and
Show Statusmust surface claim-graph state separately from task-graph drift, including contested active claims, stale active claims, and canonical claims whoseprovenanceIddiffers from the current iteration provenance Prepare PromptandOpen Codex IDEmust not acquire durable active claims; only the CLI execution path may hold a blocking task claim because it also owns reconciliation and release- when CLI selection encounters legacy active claims held by Ralph with an
-ide-provenance id, it must release those non-blocking handoff claims and replace them with a fresh CLI claim so abandoned IDE handoffs cannot strand later selection - operator stale-claim recovery is explicit:
Resolve Stale Task Claimmay mark only the canonical stale active claim asstale, must recordresolvedAt,resolvedBy, andresolutionReason, and must return that task to the normal CLI selection pool instead of silently reassigning it - watchdog stale-claim recovery is also explicit: the watchdog role may mark only a canonical stale active claim as
stale, must persist the same recovery fields plus the watchdog identity, and must stay limited to durable evidence surfaced through preflight and iteration history instead of speculative reassignment - status wording must keep the lifecycle explicit: CLI iterations own blocking claim acquire/release, IDE prompt preparation does not, and stale canonical claims are recoverable only through the operator command or the dedicated watchdog reconciliation path rather than manual
claims.jsonedits
All tasks.json mutation paths must acquire tasks.lock (a sibling file in the same directory) before reading, modifying, and writing the task file. This includes task-status reconciliation, task-graph replenishment, and any other code that produces a new tasks.json.
Lock mechanics:
- The lock file is
<dir>/tasks.lockwhere<dir>is the directory containingtasks.json. - Acquisition uses an exclusive
wxopen, which atomically fails if the file already exists. - The lock is held only for the duration of the read–modify–write cycle; it is not a long-lived lease.
- Maximum hold duration is bounded by the operation itself. The default retry budget is
lockRetryCount × lockRetryDelayMs(default10 × 25 ms = 250 ms). Any caller that needs a longer window must pass explicit options. - On timeout,
withTaskFileLockreturns{ outcome: 'lock_timeout', lockPath, attempts }without throwing. The caller is responsible for surfacing this as a preflight failure. - The lock file is always removed in a
finallyblock, so normal exits and in-process exceptions both clean up correctly. - An abrupt process termination (SIGKILL, power loss) will leave the lock file on disk. Operators must remove a stale
tasks.lockmanually before the next iteration can proceed. Ralph preflight should detect an unexpectedly old lock file and surface it as a warning so operators know to intervene.
All state.json mutation paths must acquire state.lock (a sibling file in the same directory) before writing. Every call to saveState in stateManager.ts must hold this lock for the duration of its write.
Lock mechanics:
- The lock file is
<dir>/state.lockwhere<dir>is the directory containingstate.json. - Acquisition uses an exclusive
wxopen, which atomically fails if the file already exists. - The lock is held only for the duration of the write cycle; it is not a long-lived lease.
- Maximum hold duration is bounded by the write operation itself. The default retry budget is
lockRetryCount × lockRetryDelayMs(default120 × 250 ms = 30 s). Any caller that needs a longer window must pass explicit options. - On timeout,
withStateLockreturns{ outcome: 'lock_timeout', lockPath, attempts }without throwing.saveStateconverts a timeout result to a thrown error so callers fail fast rather than silently skipping persistence. - The lock file is always removed in a
finallyblock, so normal exits and in-process exceptions both clean up correctly. - An abrupt process termination (SIGKILL, power loss) will leave the lock file on disk. Operators must remove a stale
state.lockmanually if subsequentsaveStatecalls time out. - Preflight must detect an unexpectedly old
state.lockfile and surface it as a warning so operators know to intervene, matching the same check performed fortasks.lock.
saveState and allocateIteration have different concurrency safety properties:
saveStateserializes individual writes: two concurrent callers cannot corrupt the file, but each writes the snapshot supplied by its caller. If they hold different snapshots, the last writer wins — no corruption, but the final value is the last-written snapshot.allocateIterationis safe for concurrent callers: it re-reads livestate.jsonfrom disk inside the lock before computing and writing the updated counter. Two concurrent allocations will always produce distinct iteration numbers.
Use allocateIteration for any counter that must be unique across concurrent callers. Use saveState only when the caller already holds a serialized, canonical view of the state — the normal Ralph loop runs one iteration at a time, so this is safe.
recordIteration updates nextIteration directly as result.iteration + 1 via saveState (without re-reading disk) because the loop model serializes iterations at the orchestration level. If the loop ever permits concurrent iterations, those paths must switch to allocateIteration instead.
saveState writes to both state.json on disk and VS Code workspaceState (Memento) within the same lock acquisition:
- The Memento write happens after the file write, inside the same
withStateLockcallback. - If the process crashes between the two writes, disk and Memento can diverge.
loadStatereads disk first and falls back to Memento only whenstate.jsonis absent or unparseable.allocateIterationreads disk inside the lock, falling back to Memento only when the disk read fails.state.jsonis the canonical source of truth; Memento is a secondary fallback for recovery when the file is absent.- Operators who reset state manually (by deleting
state.json) should be aware that a stale Memento value may surface as the loaded state until a newsaveStatecall overwrites it.
nextIteration must be allocated atomically before any artifact paths are constructed for a new iteration. The allocation is performed by stateManager.allocateIteration, which:
- Acquires
state.lock. - Reads the live
state.jsonfrom disk (not a cached snapshot). - Captures the current
nextIterationas the allocated number. - Increments
nextIterationby 1 and writes the minimal update back tostate.json. - Releases the lock and returns the allocated number.
All iteration artifact paths (resolveIterationArtifactPaths, resolvePreflightArtifactPaths, etc.) must receive the value returned by allocateIteration, not the pre-lock snapshot value. This guarantees that two concurrent agents can never receive the same iteration number even if they read the workspace snapshot at the same instant.
Before CLI execution starts, preflight must run and remain deterministic.
It must detect:
- duplicate ids
- orphaned parents
- invalid dependencies
- dependency cycles
- done parents with unfinished descendants
- impossible done-with-incomplete-dependencies states
- likely schema drift such as
dependenciesinstead ofdependsOn
Task diagnostics should preserve lightweight source metadata from the raw task file so messages can cite array index plus line/column when feasible.
Severe preflight findings must block CLI execution before codex exec starts.
Task-ledger drift is one of those blocking findings. When a persisted parent is done while any descendant remains todo, in_progress, or blocked, Ralph must treat the backlog as inconsistent rather than exhausted. In that state, status and preflight surfaces should keep the drift explicit with messages like No task selected because task-ledger drift blocks safe selection: ... and Task-ledger drift: ... so operators repair the ledger instead of assuming Ralph needs new work.
Drift and repair evidence surfaces in:
report.diagnostics(and thepreflight-report.jsonartifact): task-graph errors use codecompleted_parent_with_incomplete_descendants; auto-repairs use codeauto_corrected_parent_referencewith severitywarning.renderPreflightReportoutput (thepreflight-summary.mdartifact and the preflight section of every prompt): each diagnostic is rendered as- <severity> [<code>]: <message>under the Task graph section. Operators see repair warnings before a task is selected and drift errors that block selection.- Loop continuation (
decideLoopContinuation):completed_parent_with_incomplete_descendantsandledger_drifterror diagnostics are passed aspreflightDiagnosticsand prevent automatic backlog replenishment — the loop stops withno_actionable_taskrather than continuing into areplenish-backlogprompt. Show Statuscommand output: the Task graph summary line counts errors and warnings by severity, distinguishing drift errors from clean or repaired states.
A clean backlog exhaustion (all tasks done, no drift) produces a ready: true report with no error or drift diagnostics and a summary of Preflight ready: No task selected.. This is visually and structurally distinct from a drift-blocked state (ready: false, summary beginning No task selected because task-ledger drift blocks safe selection: ...).
Every preflight run executes checkStaleState in-process (no LLM, no external process) and appends its results to the preflight report under the Agent Health section. The check is mechanical-only — it detects stale signals and surfaces them as warnings; it does not take recovery actions.
checkStaleState detects four stale-state signals:
- Stale
state.lock: ifstate.lockis older than the configurable threshold (ralphCodex.staleLockThresholdMinutes, default 5 min), emit astale_state_lockwarning with the file age and an instruction to remove it manually if no iteration is in progress. - Stale
tasks.lock: same pattern fortasks.lock— emit astale_tasks_lockwarning if older than the threshold. - Active claim with no matching iteration result: if an active claim in
claims.jsonhas aclaimedAtolder than the stale TTL and no matchingiteration-result.json(sameprovenanceId, or sametaskIdfor the same agent) exists after the claim time, emit astale_active_claim_no_resultwarning per claim with agentId, taskId, and age. - Active claim with no recent matching state run: if an active claim is past the TTL with no matching finished run or iteration record in
state.jsonafter the claim time, emit astale_active_claim_agent_offlinewarning indicating the agent may be offline.
Agent Health diagnostics appear in:
- The
preflight-report.jsonartifact under theagentHealthcategory. - The preflight summary rendered in
preflight-summary.mdunder a dedicated Agent Health section. - The
Show Statuscommand output, which includes the Agent Health summary line alongside Task graph, Claim graph, and other sections.
Recovery actions remain intentionally out of scope for checkStaleState itself. It only surfaces the stale signals. Claim recovery may then happen through Resolve Stale Task Claim or the dedicated watchdog reconciliation path, while lock-file removal still requires manual operator intervention.
The Ralph loop phases are fixed:
inspectselect taskgenerate promptexecutecollect resultverifyclassify outcomepersist statedecide whether to continue
The control plane stays deterministic:
- prompt kinds are
bootstrap,iteration,replenish-backlog,fix-failure,continue-progress, andhuman-review-handoff - when the durable backlog is exhausted and the task ledger is internally consistent, Ralph may run a dedicated replenishment prompt that updates
.ralph/tasks.json; it must still leave the task file explicit, flat, and version 2 - when the task ledger is inconsistent, replenish-backlog context must preserve that distinction and direct the operator or model to repair
.ralph/tasks.jsonbefore adding new tasks - during normal CLI task execution, Ralph reconciles the model's structured completion report locally; the model does not directly persist
.ralph/tasks.jsonor.ralph/progress.md - prompt generation may differ by
cliExecversusideHandoff, but the underlying loop model must not change - the loop coordinates one selected task and one Codex execution at a time; multi-agent orchestration acceptance criteria were satisfied on 2026-03-17 (see docs/multi-agent-readiness.md), but coordinating multiple concurrent agents remains an operator concern outside the built-in loop
- prompt context stays compact; no raw transcript dumping and no full-repo enumeration
- execution must bind to persisted artifacts before launch
- machine-readable results must keep selected task, execution status, verification status, classification, stop reason, timestamps, and artifact references
- machine-readable results must also record completion-report reconciliation status and warnings when that contract applies
- status surfaces must remain readable without forcing users to inspect raw JSON
Selected-task completion is not the same as backlog completion. Summaries and status surfaces must keep remaining backlog explicit.
Each iteration directory is predictable and should include the artifacts that apply to that path:
preflight-report.jsonpreflight-summary.mdprompt.mdprompt-evidence.jsonexecution-plan.jsoncli-invocation.jsonfor CLI runscompletion-report.jsonfor CLI runssummary.mdexecution-summary.jsonverifier-summary.jsontask-remediation.jsonwhen repeated-stop remediation is emitted for that iteration- planning-gate stop evidence when pre-execution readiness blocks or decomposes the selected task
iteration-result.jsonwhen an iteration result exists
Run-level provenance bundles are first-class artifacts, not optional debugging leftovers. Each bundle should include:
provenance-bundle.jsonsummary.md- copied preflight, prompt, evidence, and plan surfaces
- explicit model-claim versus verifier-evidence references when execution occurred, including the unverified
completion-report.jsonpath plusexecution-summary.json,verifier-summary.json, anditeration-result.json provenance-failure.jsonplusprovenance-failure-summary.mdwhen launch integrity blocks execution
The canonical artifact registry at .ralph/artifacts/index.json is an additive index, never the source of truth for the artifacts themselves. Latest-pointer files remain authoritative for backward compatibility. Each registry entry stores artifact metadata (type, root-relative POSIX path, createdAt, runId, taskId, agentId, agentRole, provider, retentionClass, pinned, optional related cross-references) and is keyed by path so re-registration upserts rather than duplicates. Registry writes are lock-guarded (index.json.lock) so parallel agents cannot lose each other's entries. Cleanup must reconcile the registry against disk (reconcileArtifactRegistry) so entries for deleted artifacts are dropped; resetRuntimeState removes the whole artifacts directory, registry included.
"Clean Up Old Run Artifacts" must be explainable and auditable (issue #72). Its destructive selection is computed once by a shared deletion-plan helper, so the dry-run preview (previewRuntimeArtifactCleanup) reports exactly what the apply path (cleanupRuntimeArtifacts) will delete — the two cannot drift. Every applied cleanup writes a durable cleanup-manifest.json/.md recording deleted/retained/protected artifacts, deleted logs, latest-pointer repairs, and registry reconciliation; the manifest is written after deletion and is never itself pruned. Cleanup also repairs latest-pointer surfaces (repairLatestArtifactSurfaces) so navigational evidence stays valid, while preserving durable state, the PRD/progress/task files, and the latest evidence pointers.
PRD ↔ backlog reconciliation (issue #71) is proposal-only: it detects drift between .ralph/prd.md and .ralph/tasks.json and writes a reviewable prd-reconciliation.json/.md artifact, but it must never mutate .ralph/tasks.json. The checks are conservative so a healthy workspace produces zero findings; it deliberately does not flag done tasks the PRD acknowledges as closed (that needs prose understanding the deterministic engine does not attempt).
Archived PRD horizons are historical context only: stale_prd_task_reference and orphan_active_task traceability both evaluate against the live PRD scope, so archive-only task ids or title tokens cannot suppress findings for active backlog tasks.
The operator trust timeline (issue #73) is a read-only projection of durable state — the pre-run execution intent preview is derived from the effective config + selected task, and the post-run timeline/auto-remediation audit is folded from the typed event journal (#68). It must never mutate state and is surfaced only through the React dashboard, not a string-rendered UI path.
Stable latest pointers are part of the operator interface and must stay current:
latest-summary.mdandlatest-result.jsonlatest-preflight-report.jsonandlatest-preflight-summary.mdlatest-prompt.mdandlatest-prompt-evidence.jsonlatest-execution-plan.jsonandlatest-cli-invocation.jsonlatest-remediation.jsonwhen repeated-stop remediation exists for the latest applicable iterationlatest-provenance-bundle.jsonandlatest-provenance-summary.mdlatest-provenance-failure.jsonwhen a blocked integrity artifact exists
Command behavior depends on those stable entry points:
Open Latest Ralph Summarypreferslatest-summary.md- when a latest summary Markdown surface is manually deleted, Ralph should deterministically recreate it from the surviving latest JSON artifact before treating it as absent
Open Latest Provenance Bundlepreferslatest-provenance-summary.mdOpen Latest Prompt Evidenceopenslatest-prompt-evidence.jsonOpen Latest CLI Transcriptprefers the transcript path referenced bylatest-cli-invocation.jsonand falls back to the newest last-message artifactShow Statusshould surface the newest remediation summary fromlatest-remediation.jsonwhen repeated-stop guidance exists, even if the latest iteration state is staleReveal Latest Provenance Bundle Directoryreveals the newest run-bundle directory
Run-bundle cleanup stays deterministic and file-based:
- keep the newest bundle plus the newest
Nbundles configured by retention - never delete a bundle still referenced by any latest pointer
- allow
0to disable automatic cleanup
Generated non-provenance artifact cleanup also stays deterministic and file-based:
ralphCodex.generatedArtifactRetentionCountbounds.ralph/prompts/,.ralph/runs/, and.ralph/artifacts/iteration-###/- cleanup keeps the newest
Ngenerated prompts, run artifact pairs, and iteration directories by parsed iteration number - cleanup resolves each generated-artifact category independently: keep the newest
Nentries first, then union in any protected references, and report retained entries in newest-first iteration order - when retention and protection conflict, protection only adds the referenced older entries; it does not evict or reorder the newer
Nentries already retained by iteration precedence - cleanup summaries and logs must also report which retained entries were added only because of protected references, so precedence conflicts remain inspectable without re-deriving the set difference by hand
- cleanup only treats the following records as protected roots for generated artifacts:
.ralph/state.json:lastPromptPath,lastRun.promptPath,lastRun.transcriptPath,lastRun.lastMessagePath,lastIteration.artifactDir,lastIteration.promptPath,lastIteration.execution.transcriptPath,lastIteration.execution.lastMessagePath, and the same prompt, transcript, last-message, and iteration-directory fields within everyrunHistory[]anditerationHistory[]entry- those direct state path references stay protected even when an older raw state record omits the matching run
iteration; iteration-directory protection is only derived when an iteration number is present - when persisted state omits
lastIterationoriterationHistory[], cleanup derives the equivalent protected iteration-directory, prompt, transcript, and last-message references from the storedlastRunorrunHistory[]iteration numbers - stable latest-pointer JSON artifacts:
latest-result.json,latest-preflight-report.json,latest-prompt-evidence.json,latest-execution-plan.json,latest-cli-invocation.json,latest-provenance-bundle.json, andlatest-provenance-failure.json - stable latest-summary surfaces:
latest-summary.md,latest-preflight-summary.md, andlatest-provenance-summary.mdcan each protect only the implied iteration directory when their persisted heading, iteration line, or artifact-path lines still point at an older retained iteration latest-result.jsoncan independently protect an older iteration directory, prompt file, and transcript/last-message pair through its persistedartifactDir,summaryPath,promptPath,promptArtifactPath,transcriptPath, andlastMessagePathlatest-preflight-report.jsoncan independently protect an older iteration directory through its persistedartifactDir,reportPath, andsummaryPath; it does not protect prompt or run artifacts by itselflatest-execution-plan.jsoncan independently protect an older iteration directory and prompt file through its persistedartifactDir,promptPath,promptArtifactPath, andexecutionPlanPath;latest-cli-invocation.jsoncan independently protect an older iteration directory plus its transcript/last-message pair throughpromptArtifactPath,cliInvocationPath,transcriptPath, andlastMessagePathlatest-prompt-evidence.jsonprotects the prompt file and iteration directory implied by its persistedkindplusiteration; it does not protect transcript or last-message files by itself- manual maintenance cleanup may prune older generated prompts, runs, and iteration directories more aggressively than on-write retention, but it must still preserve the current state roots (
lastPromptPath,lastRun.*,lastIteration.*), the stable latest-pointer artifacts, and the stable latest summary surfaces latest-provenance-bundle.jsonandlatest-provenance-failure.jsonprotect only the referenced iteration directory through their persisted iteration-scoped artifact paths, including provenance-failure JSON and summary paths; they do not protect prompt files in.ralph/prompts/or transcript/last-message pairs in.ralph/runs/- within those latest-pointer JSON artifacts, only the prompt, transcript/last-message, iteration-directory, preflight, summary, execution-plan, CLI-invocation, iteration-result, and provenance-failure path fields count as protected references
- cleanup runs after Ralph persists prompt or iteration provenance so prompt-only and executed paths converge on the same retention rule
- allow
0to disable automatic cleanup
Git handling is detection/reporting only. Do not add branch orchestration, worktree orchestration, or destructive git behavior as part of the control plane.
This section defines the canonical shape and field-presence rules enforced by normalizeTask in src/ralph/taskFile.ts. Every newly created RalphTask — whether parsed from tasks.json, converted from a RalphSuggestedChildTask, or synthesized by any other producer — passes through normalization before it enters the in-memory task graph.
The canonical RalphTask interface lives in src/ralph/types.ts. The SUPPORTED_TASK_FIELDS set and normalization functions live in src/ralph/taskFile.ts. The shared producer-facing normalization pipeline lives in src/ralph/taskNormalization.ts, and the shared persistence helpers used by command handlers, pipeline scaffolding, decomposition/remediation, and wizard writes live in src/ralph/taskCreation.ts.
normalizeNewTask in src/ralph/taskNormalization.ts is the single entry point that all task producers should use when creating new tasks. It applies alias mapping (rationale → notes, suggestedValidationCommand → validation), structured-dependency flattening ({ taskId }[] → string[]), null → undefined coercion, default status injection, field-name auto-correction, optional parent augmentation for derive-if-possible fields, and canonical normalization via normalizeTask. Producers that previously built raw task objects should call normalizeNewTask instead to guarantee consistent coercion and field preservation.
Operator-facing expectation: generated tasks should emerge from any supported producer path with the richest canonical shape that Ralph can prove at creation time, not an artificially reduced id/title/status subset. When a producer knows notes, validation, acceptance, constraints, context, tier, or derives dependsOn/mode from a parent or scaffold context, those fields should survive into persisted tasks.json through the shared pipeline. Missing fields are still canonical only when the upstream producer genuinely lacked that information, when the field is in the leave-absent category below, or when a minimal producer such as the pipeline-root scaffold intentionally has only a narrower source payload.
Task producers should create executable backlog items, not vague epics. PRD generation, wizard fallback drafts, and task seeding should prefer atomic tasks with acceptance criteria, validation guidance when knowable, constraints, and sequencing dependencies where order matters. The task readiness gate remains the execution-time safety net; generated-task warnings are advisory unless a future generation-review mode explicitly makes them blocking.
New tasks enter the system through one of these paths. Every path terminates in normalizeTask (directly or via normalizeNewTask), which enforces the rules below.
| Producer | Entry point | Notes |
|---|---|---|
Manual edit of tasks.json |
parseTaskFileText → normalizeTask |
All fields come from the file author. The parser adds a source location for diagnostic reporting. |
| Task decomposition | buildDecompositionProposal → taskCreation.applySuggestedChildTasksToFile → taskFile.applySuggestedChildTasks → normalizeNewTask |
Child IDs follow ${parentId}.${index}. dependsOn, validation, mode, tier, and acceptance may be derived from the parent via normalizeNewTask augmentation. |
| Remediation (reframe / mark_blocked) | remediationSuggestedChildTasks → taskCreation.applySuggestedChildTasksToFile → taskFile.applySuggestedChildTasks → normalizeNewTask |
Creates a single .1 child scoped to the remediation action. |
| Planning gate pre-execution decomposition | planningPass.parsePlanningResponse (suggestedChildTasks) → taskCreation.applySuggestedChildTasksToFile → taskFile.applySuggestedChildTasks → normalizeNewTask |
Optional (`taskReadinessGate=auto |
| Pipeline root | buildPipelineRootTask → taskCreation.appendNormalizedTasksToFile → normalizeNewTask → parseTaskFile → normalizeTask |
Intentionally minimal source payload: only id, title, and notes. Status defaults to 'todo', and other optional fields remain absent because the scaffold path does not know them yet. |
| Pipeline children | buildPipelineChildTasks → taskCreation.applySuggestedChildTasksToFile → taskFile.applySuggestedChildTasks → normalizeNewTask |
Children are derived from PRD sections with sequential dependencies. validation: null becomes undefined via normalizeNewTask. |
| PRD generation / backlog seeding / bootstrap append | generateProjectDraft, seedTasksFromRequest, or command-local drafts → appendNormalizedTasksToFile → normalizeNewTask |
Initialize Workspace and New Project append through the shared persistence helper, and both Ralphdex: Add Task and Ralphdex: Seed Tasks from Feature Request seed flat backlog tasks from a high-level request through the same normalization boundary. Seeding writes durable request/response evidence under .ralph/artifacts/task-seeding/, but persisted tasks still enter tasks.json only as flat version-2 task objects through the shared append pipeline. Rich producer fields survive AI generation, seeding, and fallback/bootstrap paths. |
| PRD wizard confirm-write | writePrdWizardDraft → replaceTasksFileWithNormalizedTasks → normalizeNewTask |
Reviewed wizard tasks replace the target tasks.json through the same shared normalization/persistence boundary used by append flows. The wizard write path is intentionally narrow: it writes only .ralph/prd.md and .ralph/tasks.json, never unrelated workspace settings. |
For paths that go through taskCreation.applySuggestedChildTasksToFile, children are normalized at creation time via normalizeNewTask (which handles alias mapping, dependency flattening, parent augmentation, and canonical coercion). The subsequent write-then-read cycle re-normalizes through parseTaskFile for consistency.
Planning providers may use provider-native skills/tools and repository guidance (AGENTS.md, workflows, invariants), but any readiness/decomposition decision that affects execution must be persisted in Ralph-owned artifacts (task-plan.json, optional plan.md, iteration result, and planning-gate evidence). Provider-internal state is never a durable source of truth.
This shared invariant is deliberate for UI and workflow surfaces: operator-visible commands should describe generated tasks as "complete when the source is complete" rather than implying that sparse generated tasks are the normal or preferred result. Sparse output is valid only for producer paths whose source data is truly sparse.
Epic or feature task seeding is intentionally not a hierarchy-construction path. Regardless of whether the request comes from the command palette, dashboard, or sidebar, the seeding helper may remap duplicate IDs and rewrite seeded intra-batch dependencies accordingly, but it must still persist only flat top-level version-2 tasks. Parent/child structure remains an explicit later step through decomposition or other bounded child-task producers, not an implicit side effect of seeding.
These three fields must be present and valid on every task. Normalization throws if any is missing or has the wrong type.
| Field | Type | Validation |
|---|---|---|
id |
string |
Must be a non-empty string. Trimmed of leading/trailing whitespace. |
title |
string |
Must be a non-empty string. Trimmed of leading/trailing whitespace. |
status |
RalphTaskStatus |
Must be one of 'todo', 'in_progress', 'blocked', 'done'. |
Optional fields follow one of three presence behaviors:
- preserve-source: kept exactly as the producer supplied it (after normalization coercion). The producer is the sole authority; the system never synthesizes or overrides this value.
- derive-if-possible: when the producer does not supply a value, a parent or context-aware path may derive one. The derived value is still subject to normalization coercion. During source parsing (reading
tasks.json), no derivation occurs — the field survives only if the file author wrote it. Derivation happens exclusively in producer code paths like decomposition and pipeline construction. - leave-absent: omitted from the normalized task unless the producer explicitly supplies a value. No automatic derivation, regardless of producer path.
| Field | Type | Category | Coercion Rules |
|---|---|---|---|
parentId |
string? |
preserve-source | Trimmed. Returns undefined if empty or whitespace-only. |
dependsOn |
string[]? |
derive-if-possible | Each entry trimmed, empties filtered, deduplicated via Set. Returns undefined if result array is empty. Decomposition derives sequential and inherited dependencies. |
notes |
string? |
derive-if-possible | Trimmed. Returns undefined if empty or whitespace-only. Decomposition maps rationale → notes. |
validation |
string? |
derive-if-possible | Trimmed. Returns undefined if empty or whitespace-only. Decomposition inherits parent's validation. null from suggested children becomes undefined. |
blocker |
string? |
leave-absent | Trimmed. Returns undefined if empty or whitespace-only. |
priority |
RalphTaskPriority? |
leave-absent | Must be 'low', 'normal', or 'high'. Returns undefined if invalid or absent. Task selection treats absent as 'normal' for ordering, but the stored value stays undefined. |
mode |
RalphTaskMode? |
derive-if-possible | Must be 'default' or 'documentation'. Returns undefined if invalid. Decomposition inherits parent's mode. Runtime treats absent as 'default'. |
tier |
RalphTaskTier? |
derive-if-possible | Must be 'simple', 'medium', or 'complex'. Returns undefined if invalid. Decomposition inherits parent's tier when present. When absent, runtime heuristic scoring determines complexity. |
acceptance |
string[]? |
derive-if-possible | Each entry trimmed, empties filtered. Returns undefined if result array is empty. Decomposition derives acceptance from parent when possible. |
constraints |
string[]? |
leave-absent | Each entry trimmed, empties filtered. Returns undefined if result array is empty. |
context |
string[]? |
leave-absent | Each entry trimmed, empties filtered. Returns undefined if result array is empty. |
writeRiskLabels |
string[]? |
leave-absent | Each entry trimmed, empties filtered. Returns undefined if result array is empty. Write-risk labels for fan-out wave safety validation. Tasks sharing a label cannot run in the same wave. |
lastVerifierResult |
'passed' | 'failed' | 'skipped'? |
leave-absent | Set by reconciliation to record the last verifier outcome. Returns undefined if value is not a recognized enum member. |
lastReconciliationWarning |
string? |
leave-absent | Trimmed. Returns undefined if empty or whitespace-only. Set by reconciliation when a conflict warning snippet exists. |
source |
RalphTaskSourceLocation? |
preserve-source | Injected by the parser for diagnostic line/column reporting. Not persisted to disk; stripped during serialization. Source location in JSON file content is ignored; the parser always determines location. |
When a task is read from tasks.json (source parsing), normalizeTask applies coercion but never invents field values. A field that is absent in the file stays absent after normalization. The derive-if-possible category only activates through explicit producer code in decomposition, remediation, or pipeline construction — not through normalizeTask itself.
This means:
- acceptance, validation, and tier written by a human in
tasks.jsonsurvive normalization exactly as authored (after coercion). If the human omits them, they remainundefined. - constraints and context are leave-absent: they survive only when a producer (human or code) explicitly sets them. Decomposition preserves these from
RalphSuggestedChildTaskwhen supplied but never synthesizes them. - mode and tier are inherited from the parent during decomposition but left absent when parsing a manually authored
tasks.jsonthat omits them. - generated tasks should therefore usually carry the richest known subset of
notes,validation,acceptance,constraints,context,tier,dependsOn, andmode; absence is canonical only when the source path did not know the value or the field is intentionally leave-absent.
- String coercion: optional string fields that contain only whitespace or are empty after trimming become
undefined, not empty strings. - Array coercion: optional array fields filter out non-string entries and entries that are empty after trimming. If the resulting array is empty, the field becomes
undefined, not[]. - Dependency deduplication:
dependsOnpasses throughSetafter trimming, so duplicate task IDs are silently collapsed. - Enum rejection:
priority,mode, andtiersilently becomeundefinedwhen the supplied value is not a recognized enum member. They do not throw. - Unknown-field drop: only fields in
SUPPORTED_TASK_FIELDSsurvive normalization. Any field not in that set is silently discarded. The supported set is:id,title,status,parentId,dependsOn,notes,validation,blocker,priority,mode,tier,acceptance,constraints,context,writeRiskLabels,lastVerifierResult,lastReconciliationWarning. Thesourcefield is handled separately (parser-injected, never persisted). - Auto-correction: before normalization, commonly misspelled field names are auto-corrected with a diagnostic warning. The correction is applied before validation so the corrected field name enters normalization normally. See the auto-correction reference below.
The LIKELY_TASK_FIELD_MISTAKES map in src/ralph/taskFile.ts corrects these misspellings. Correction only applies when the target field is not already present on the task object.
| Misspelled name | Corrected to |
|---|---|
dependencies, dependency, dependson, depends_on |
dependsOn |
acceptancecriteria, acceptance_criteria, donecriteria, done_criteria |
acceptance |
guardrails, guard_rails |
constraints |
files, relevantfiles, relevant_files |
context |
type, taskmode, task_mode, tasktype, task_type |
mode |
Field name comparison is case-insensitive and ignores non-alphanumeric characters (via normalizedFieldKey).
stringifyTaskFile in src/ralph/taskFile.ts controls how the in-memory task graph is written back to tasks.json:
- The
sourcefield is stripped before serialization. It is diagnostic-only and never appears in the persisted file. - Fields whose value is
undefinedare omitted from the JSON output (standardJSON.stringifybehavior). This means optional fields that normalization set toundefineddo not appear asnullor empty in the file. - The output is deterministic:
JSON.stringify(obj, null, 2)with a trailing newline. - The
mutationCountfield on the task file object is included only when present (non-undefined).
The RalphSuggestedChildTask interface in src/ralph/types.ts is the shape that decomposition, remediation, and pipeline code use to propose new child tasks before they are converted to persisted RalphTask entries.
| Field | Type | Required | Mapping to RalphTask |
|---|---|---|---|
id |
string |
yes | Direct. |
title |
string |
yes | Direct. |
parentId |
string |
yes | Direct. |
dependsOn |
RalphSuggestedTaskDependency[] |
yes | Flattened to string[] via .map(d => d.taskId). |
validation |
string | null |
yes | null becomes undefined. |
rationale |
string |
yes | Maps to notes. |
acceptance |
string[]? |
no | Preserved if supplied. |
constraints |
string[]? |
no | Preserved if supplied. |
context |
string[]? |
no | Preserved if supplied. |
tier |
RalphTaskTier? |
no | Preserved if supplied. |
RalphSuggestedTaskDependency carries a taskId (string) and a reason ('blocks_sequence' or 'inherits_parent_dependency'). Only taskId is persisted; the reason is used for proposal diagnostics.
When applySuggestedChildTasks converts a RalphSuggestedChildTask into a persisted RalphTask:
| Aspect | Rule |
|---|---|
status |
Always forced to 'todo' regardless of any suggested value. |
parentId |
Taken directly from the suggested child's parentId. |
dependsOn |
Extracted from RalphSuggestedTaskDependency[].taskId. |
validation |
null from the suggestion becomes undefined in the persisted task. |
notes |
Mapped from the suggestion's rationale field. |
mode |
Inherited from the parent task's mode, not from the suggestion. |
acceptance |
Preserved from the suggestion if supplied. |
constraints |
Preserved from the suggestion if supplied. |
context |
Preserved from the suggestion if supplied. |
tier |
Preserved from the suggestion if supplied. |
| Parent status | If the parent was done or todo, it is promoted to 'in_progress'. |
Parent dependsOn |
Updated to include all new child IDs (deduplicated). |