P0.3.1 — Step 3 Packet
Execution plan for src/domains/tasks/state-machine.ts + src/__tests__/task-state-machine.test.ts. Gates Step 4 (Implement). Binding on file skeleton, test matrix, commit plan, and coverage posture.
Upstream:
- Audit:
docs/audits/p0-3-1-task-state-machine-audit.md·06d36a78 - Contract:
docs/contracts/p0-3-1-task-state-machine-contract.md·c69780ca - Canonical:
docs/colibri-system.md§6.3 ·docs/3-world/execution/task-pipeline.md·docs/5-time/task.md
§1. File plan
Two files created, zero files edited. No package.json delta, no config delta.
| Path | LOC estimate | Role |
|---|---|---|
src/domains/tasks/state-machine.ts |
~110 | Production module |
src/__tests__/task-state-machine.test.ts |
~320 | Test suite |
New directory: src/domains/tasks/. No README.md — the module’s JSDoc header self-documents; a one-line README would duplicate without adding clarity.
§2. Production module skeleton (src/domains/tasks/state-machine.ts)
Ordered top-to-bottom:
- File-level JSDoc — purpose, canonical references (
colibri-system.md§6.3,3-world/execution/task-pipeline.md,5-time/task.md), purity statement, consumer list (P0.3.2 repository, P0.3.4task_transition/task_updatetools). TASK_STATEStuple —export const TASK_STATES = ['INIT', 'GATHER', 'ANALYZE', 'PLAN', 'APPLY', 'VERIFY', 'DONE', 'CANCELLED'] as const;TaskStatederived type —export type TaskState = (typeof TASK_STATES)[number];TaskShapeinterface —export interface TaskShape { readonly id: string; readonly state: TaskState; }TERMINAL_STATES— frozenSet<TaskState>containing'DONE'and'CANCELLED'. Exposed viaexport const TERMINAL_STATES: ReadonlySet<TaskState> = new Set<TaskState>(['DONE', 'CANCELLED']);(Sets are not frozen by JS semantics, but the exported type isReadonlySet<TaskState>— any mutation attempt at the type level fails typecheck. Additional runtime hardening viaObject.freezeon the set is not reliable (V8 tracks it only for extensibility). Acceptable.)-
VALID_TRANSITIONS— exhaustive record,Object.freezed:export const VALID_TRANSITIONS: Readonly<Record<TaskState, readonly TaskState[]>> = Object.freeze({ INIT: Object.freeze(['GATHER', 'CANCELLED'] as const), GATHER: Object.freeze(['ANALYZE', 'CANCELLED'] as const), ANALYZE: Object.freeze(['PLAN', 'CANCELLED'] as const), PLAN: Object.freeze(['APPLY', 'CANCELLED'] as const), APPLY: Object.freeze(['VERIFY', 'CANCELLED'] as const), VERIFY: Object.freeze(['DONE', 'GATHER', 'CANCELLED'] as const), DONE: Object.freeze([] as const), CANCELLED: Object.freeze([] as const), }); -
Internal
ALLOWED_NEXTmap (module-scoped, not exported) —ReadonlyMap<TaskState, ReadonlySet<TaskState>>derived once fromVALID_TRANSITIONS. Used bycanTransitionfor a branch-coverage-safe lookup:const ALLOWED_NEXT: ReadonlyMap<TaskState, ReadonlySet<TaskState>> = new Map( TASK_STATES.map((from) => [from, new Set<TaskState>(VALID_TRANSITIONS[from])]), );Rationale:
VALID_TRANSITIONS[from]undernoUncheckedIndexedAccessis typedreadonly TaskState[] | undefined.ALLOWED_NEXT.get(from)is alsoReadonlySet<TaskState> | undefined, butcanTransitionnarrows via?? EMPTY_SETwhereEMPTY_SETis a module-scoped frozen empty set. The single?? EMPTY_SETis one branch; at runtime, everyTaskStateis present, so only the truthy side fires — the falsy side is hit exactly twice in the test by constructing a task with a runtime-only invalid state (see §5 for the test that covers this edge) is overkill. Instead, we rewritecanTransitionto usehason a freshly-got set that is known to exist because we build it from the literal tuple:export function canTransition(from: TaskState, to: TaskState): boolean { const allowed = ALLOWED_NEXT.get(from); return allowed !== undefined && allowed.has(to); }The
allowed !== undefinedcheck is technically a branch, but since the map is built from the same literal tuple that definesTaskState, at runtime every knownTaskStatekey is present — so only the truthy side executes. UndernoUncheckedIndexedAccessthis is unavoidable; Istanbul will count both sides only if it sees distinct executions of both. Empirical check: running all 64 pairs of(from, to)combinations (from the exhaustive test) only exercises the truthy side.Resolution for 100% branch coverage: add one test that deliberately calls
canTransitionwith a value cast toTaskStatebut not actually in the map. UsecanTransition('__bogus__' as TaskState, 'INIT' as TaskState)and assertfalse. This exercises theallowed === undefinedbranch. One extra line in the test, 100% branch coverage guaranteed. -
transitionfunction:export function transition<T extends TaskShape>(task: T, to: TaskState): T { if (!canTransition(task.state, to)) { throw new InvalidTransitionError({ from: task.state, to, taskId: task.id }); } return { ...task, state: to }; } -
InvalidTransitionErrorclass:export class InvalidTransitionError extends Error { override readonly name = 'InvalidTransitionError'; readonly from: TaskState; readonly to: TaskState; readonly taskId: string; constructor(args: { from: TaskState; to: TaskState; taskId: string }) { super(`Invalid task transition for task ${args.taskId}: ${args.from} → ${args.to}`); this.from = args.from; this.to = args.to; this.taskId = args.taskId; // Restore prototype chain for ts-jest ESM (Error subclass quirk). Object.setPrototypeOf(this, InvalidTransitionError.prototype); } }Notes:
override readonly name = 'InvalidTransitionError';under TS strict is fine becauseError.nameis declared inlib.es5.d.tsas a non-readonly string;overriderequiresnoImplicitOverride(not set, so we useoverrideas documentation without strict enforcement).Object.setPrototypeOfis required because TS’ssuper()emit undertarget: ES2022plus ESM can leave the prototype chain wrong in some Jest setups (observed insrc/__tests__/server.test.tserror handling). Safe belt-and-braces idiom.- Class declaration appears after the
transitionfunction in the file so the function body can reference it via hoisting. Class declarations are hoisted for type but not for value; the function body usesnew InvalidTransitionError(...)— since that body is only invoked at call time, the class-declaration order does not matter for runtime. For readability, we place the class beforetransitionso readers encounter the error type before the function that throws it.
- Tail
satisfiesself-check —const _STATE_COUNT: 8 = TASK_STATES.length;(compile-only). Catches accidental insertion/removal. The constant is unused at runtime.
§2.1 Module-scoped constants ordering (final)
Final file structure (top to bottom):
File JSDoc
TASK_STATES
TaskState
TaskShape
TERMINAL_STATES
VALID_TRANSITIONS
ALLOWED_NEXT (private)
InvalidTransitionError
canTransition
transition
const _STATE_COUNT: 8 = ... (compile-time self-check)
No circular refs. All symbols declared before use.
§3. Test file skeleton (src/__tests__/task-state-machine.test.ts)
/**
* Tests for `src/domains/tasks/state-machine.ts` — β Task Pipeline FSM.
* Pure-logic module; no fixtures, no mocks, no env.
*/
import {
InvalidTransitionError,
TASK_STATES,
TERMINAL_STATES,
VALID_TRANSITIONS,
canTransition,
transition,
type TaskShape,
type TaskState,
} from '../domains/tasks/state-machine.js';
describe('TASK_STATES', () => { /* §3.1 */ });
describe('TERMINAL_STATES', () => { /* §3.2 */ });
describe('VALID_TRANSITIONS', () => { /* §3.3 */ });
describe('canTransition', () => { /* §3.4 + §3.5 */ });
describe('transition', () => { /* §3.6 */ });
describe('InvalidTransitionError', () => { /* §3.7 */ });
describe('terminal exits', () => { /* §3.8 */ });
§3.1 TASK_STATES
it('is an 8-tuple in canonical order', …)→ assert array shallow-equal to the 8 literal strings in order.it('is a superset {INIT, GATHER, ANALYZE, PLAN, APPLY, VERIFY, DONE, CANCELLED}', …)→ set-equality.
§3.2 TERMINAL_STATES
it('has exactly {DONE, CANCELLED}', …)→ size === 2; contains both.
§3.3 VALID_TRANSITIONS
it('has a key for every TaskState', …)→ keys of record =TASK_STATESas set.it('DONE has no outgoing edges', …)→VALID_TRANSITIONS.DONE.length === 0.it('CANCELLED has no outgoing edges', …)→VALID_TRANSITIONS.CANCELLED.length === 0.it('total edge count is 13', …)→ sum of array lengths = 13.
§3.4 canTransition — valid edges (happy path)
Table-driven test using describe.each or it.each:
const VALID_EDGES: ReadonlyArray<[TaskState, TaskState]> = [
['INIT', 'GATHER'],
['INIT', 'CANCELLED'],
['GATHER', 'ANALYZE'],
['GATHER', 'CANCELLED'],
['ANALYZE', 'PLAN'],
['ANALYZE', 'CANCELLED'],
['PLAN', 'APPLY'],
['PLAN', 'CANCELLED'],
['APPLY', 'VERIFY'],
['APPLY', 'CANCELLED'],
['VERIFY', 'DONE'],
['VERIFY', 'GATHER'],
['VERIFY', 'CANCELLED'],
];
it.each(VALID_EDGES)('canTransition(%s, %s) === true', (from, to) => {
expect(canTransition(from, to)).toBe(true);
});
13 assertions.
§3.5 canTransition — invalid edges (exhaustive)
it('returns false for every ordered pair not in VALID_TRANSITIONS', () => {
const validSet = new Set(VALID_EDGES.map(([a, b]) => `${a}->${b}`));
for (const from of TASK_STATES) {
for (const to of TASK_STATES) {
const key = `${from}->${to}`;
if (validSet.has(key)) continue;
expect(canTransition(from, to)).toBe(false);
}
}
});
Covers the 51 invalid pairs (64 total − 13 valid).
Plus one dedicated test for the allowed === undefined branch:
it('returns false when from is not a known TaskState (defensive)', () => {
// Simulates a corrupt SQLite row with an unknown state string cast into the type.
expect(canTransition('__bogus__' as TaskState, 'INIT')).toBe(false);
});
This single assertion drives the allowed === undefined branch to 100%.
§3.6 transition
Covered scenarios:
- Valid transition returns updated task (13 cases, via
it.each(VALID_EDGES)):transition({ id: 't1', state: from }, to)returns{ id: 't1', state: to }.- Original task’s
stateis unchanged (reference assertion). - Returned object is NOT
===the input.
- Preserves extra fields on the caller’s type (1 case):
transition({ id: 't1', state: 'INIT', writer: 'claude', priority: 1 }, 'GATHER')returns{ id: 't1', state: 'GATHER', writer: 'claude', priority: 1 }.
- Throws on every invalid transition (iterate 51 pairs):
- Assert
throws InvalidTransitionError. - Assert error fields match
{ from, to, taskId: 't1' }.
- Assert
§3.7 InvalidTransitionError
it('is an Error subclass', …)→instanceof Error.it('has name "InvalidTransitionError"', …)→err.name === 'InvalidTransitionError'.it('exposes from/to/taskId as readonly fields', …)→ threeexpect(…).toBe(…)assertions.it('formats message as "Invalid task transition for task <id>: <from> → <to>"', …)→ substring matches for each of'Invalid task transition','task t1','INIT','APPLY','→'.it('can be constructed directly for test purposes', …)→new InvalidTransitionError({ from: 'INIT', to: 'DONE', taskId: 'x' })works and all fields populate.
§3.8 Terminal exits (redundant with §3.5 but explicit)
The spec’s acceptance criterion calls out terminals specifically:
describe('DONE is terminal', () => {
it.each(TASK_STATES)('transition(DONE -> %s) throws', (to) => {
expect(() => transition({ id: 't-done', state: 'DONE' }, to)).toThrow(InvalidTransitionError);
});
});
describe('CANCELLED is terminal', () => {
it.each(TASK_STATES)('transition(CANCELLED -> %s) throws', (to) => {
expect(() => transition({ id: 't-cancelled', state: 'CANCELLED' }, to)).toThrow(InvalidTransitionError);
});
});
16 assertions (8 DONE + 8 CANCELLED).
§4. Coverage plan
Target: 100% stmt / 100% branch / 100% func / 100% line on src/domains/tasks/state-machine.ts.
Branch inventory:
| # | Branch | Covered by |
|—|——–|————|
| B1 | canTransition: allowed !== undefined (truthy) | §3.4 (13 valid pairs) |
| B2 | canTransition: allowed !== undefined (falsy) | §3.5 defensive cast test |
| B3 | canTransition: allowed.has(to) (truthy) | §3.4 (13 valid pairs) |
| B4 | canTransition: allowed.has(to) (falsy) | §3.5 (51 invalid pairs) |
| B5 | transition: !canTransition(...) (truthy → throw) | §3.6 invalid-path iteration (51 calls) |
| B6 | transition: !canTransition(...) (falsy → return) | §3.6 valid-path iteration (13 calls) |
Function inventory:
| # | Function | Covered by |
|—|———-|————|
| F1 | canTransition | §3.4 + §3.5 + §3.6 |
| F2 | transition | §3.6 |
| F3 | InvalidTransitionError.constructor | §3.6 + §3.7 |
Statement/line inventory: every statement in the module is exercised by at least one of B1–B6, F1–F3, and the module-scoped literal initializers (executed on import).
Untested by design: none. There are no istanbul ignore comments in this module.
§5. Lint/style plan
@typescript-eslint/consistent-type-imports:TaskStateandTaskShapeare imported withimport typein the test file.curly: all: everyifhas braces.eqeqeq: always: strict equality only.- No
any. Nounknown. No non-null assertions (!). - JSDoc blocks on every exported symbol (class, function, type, const) — matches
src/modes.tsstyle. - Class field declarations ordered:
override readonly namefirst, then other readonly fields, then constructor. Matches TS class-member-ordering convention.
§6. Commit plan
The packet is committed now as Step 3. Subsequent commits:
| Step | Commit message | Files |
|---|---|---|
| 4 | feat(p0-3-1-task-state-machine): β task pipeline FSM + tests |
src/domains/tasks/state-machine.ts, src/__tests__/task-state-machine.test.ts |
| 5 | verify(p0-3-1-task-state-machine): test evidence |
docs/verification/p0-3-1-task-state-machine-verification.md |
Only two further commits. No squash. No amend.
§7. Verify-stage commands (Step 5)
npm ci
npm run lint
npm test
npm run build
Each must exit 0. The verification doc records:
git log --oneline origin/main..HEAD→ the full chain commits.git diff --stat origin/main..HEAD→ line delta.npm testoutput: total tests, pass count, coverage summary forstate-machine.ts.npm run lintoutput: zero warnings zero errors.npm run buildoutput: zero errors.cat coverage/lcov-report/domains/tasks/state-machine.ts.html→ extracted coverage percentages (manual copy of the numbers into the doc; no screenshots).
§8. Parallel-lock compliance reaffirmation
Files touched by this packet:
src/domains/tasks/state-machine.ts— NEW (own).src/__tests__/task-state-machine.test.ts— NEW (own).docs/audits/p0-3-1-task-state-machine-audit.md— committed in Step 1.docs/contracts/p0-3-1-task-state-machine-contract.md— committed in Step 2.docs/packets/p0-3-1-task-state-machine-packet.md— this file (Step 3).docs/verification/p0-3-1-task-state-machine-verification.md— Step 5.
Files NOT touched (confirmed by worktree scan):
src/server.ts(P0.2.3 sibling owns)src/db/*(P0.3.2 will own the tasks table)src/config.ts,src/modes.ts,src/__tests__/*.test.ts(other than the new file)src/domains/skills/*(P0.6.1 sibling owns)src/domains/trail/*/src/domains/thought/*(P0.7.1 sibling owns)package.json,package-lock.json,jest.config.ts,tsconfig.json,.eslintrc.json- All Obsidian vault mirror paths
No imports from P0.2.3 / P0.6.1 / P0.7.1 paths (they don’t exist in baseline 3ebbe419).
§9. Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
branch === 100% fails due to the allowed !== undefined branch |
Low | Medium | The defensive canTransition('__bogus__' as TaskState, 'INIT') test forces the falsy side. If Istanbul still flags it, fall back to VALID_TRANSITIONS[from].includes(to) with a narrowing cast inside — see §2 alternate. |
| ts-jest ESM error-subclass prototype breakage | Low | Low | Object.setPrototypeOf(this, InvalidTransitionError.prototype) in constructor (§2 point 9). |
.js import specifier mismatch |
Low | High (build fails) | Test file uses from '../domains/tasks/state-machine.js' per jest moduleNameMapper. Verified pattern from src/__tests__/modes.test.ts. |
Unused-variable lint warning on _STATE_COUNT |
Medium | Low | Prefix with underscore — ESLint’s no-unused-vars rule for TS defaults to allowing _-prefixed names. Alternative: inline satisfies against the tuple type. |
Non-deterministic test order with it.each |
Very low | Low | Jest defaults to stable iteration order; no randomization used. |
§10. Gate
This packet is Sigma-pre-approved per the task prompt (“Sigma pre-approved packet — no mid-task gating”). Proceed directly from Step 3 → Step 4 on commit.
Ready to implement.