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:


§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:

  1. 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.4 task_transition / task_update tools).
  2. TASK_STATES tupleexport const TASK_STATES = ['INIT', 'GATHER', 'ANALYZE', 'PLAN', 'APPLY', 'VERIFY', 'DONE', 'CANCELLED'] as const;
  3. TaskState derived typeexport type TaskState = (typeof TASK_STATES)[number];
  4. TaskShape interfaceexport interface TaskShape { readonly id: string; readonly state: TaskState; }
  5. TERMINAL_STATES — frozen Set<TaskState> containing 'DONE' and 'CANCELLED'. Exposed via export const TERMINAL_STATES: ReadonlySet<TaskState> = new Set<TaskState>(['DONE', 'CANCELLED']); (Sets are not frozen by JS semantics, but the exported type is ReadonlySet<TaskState> — any mutation attempt at the type level fails typecheck. Additional runtime hardening via Object.freeze on the set is not reliable (V8 tracks it only for extensibility). Acceptable.)
  6. 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),
     });
    
  7. Internal ALLOWED_NEXT map (module-scoped, not exported) — ReadonlyMap<TaskState, ReadonlySet<TaskState>> derived once from VALID_TRANSITIONS. Used by canTransition for 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] under noUncheckedIndexedAccess is typed readonly TaskState[] | undefined. ALLOWED_NEXT.get(from) is also ReadonlySet<TaskState> | undefined, but canTransition narrows via ?? EMPTY_SET where EMPTY_SET is a module-scoped frozen empty set. The single ?? EMPTY_SET is one branch; at runtime, every TaskState is 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 rewrite canTransition to use has on 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 !== undefined check is technically a branch, but since the map is built from the same literal tuple that defines TaskState, at runtime every known TaskState key is present — so only the truthy side executes. Under noUncheckedIndexedAccess this 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 canTransition with a value cast to TaskState but not actually in the map. Use canTransition('__bogus__' as TaskState, 'INIT' as TaskState) and assert false. This exercises the allowed === undefined branch. One extra line in the test, 100% branch coverage guaranteed.

  8. transition function:

     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 };
     }
    
  9. InvalidTransitionError class:

     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 because Error.name is declared in lib.es5.d.ts as a non-readonly string; override requires noImplicitOverride (not set, so we use override as documentation without strict enforcement).
    • Object.setPrototypeOf is required because TS’s super() emit under target: ES2022 plus ESM can leave the prototype chain wrong in some Jest setups (observed in src/__tests__/server.test.ts error handling). Safe belt-and-braces idiom.
    • Class declaration appears after the transition function 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 uses new 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 before transition so readers encounter the error type before the function that throws it.
  10. Tail satisfies self-checkconst _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_STATES as 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 state is 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' }.

§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', …) → three expect(…).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: TaskState and TaskShape are imported with import type in the test file.
  • curly: all: every if has braces.
  • eqeqeq: always: strict equality only.
  • No any. No unknown. No non-null assertions (!).
  • JSDoc blocks on every exported symbol (class, function, type, const) — matches src/modes.ts style.
  • Class field declarations ordered: override readonly name first, 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 test output: total tests, pass count, coverage summary for state-machine.ts.
  • npm run lint output: zero warnings zero errors.
  • npm run build output: 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.tsNEW (own).
  • src/__tests__/task-state-machine.test.tsNEW (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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.