@noir-ai/workflow 1.0.0-beta.1 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -6,10 +6,36 @@ type Phase = (typeof PHASES)[number];
6
6
  declare const STATES: readonly ["draft", "clarifying", "specified", "planned", "executing", "verifying", "done", "blocked", "abandoned"];
7
7
  type WorkflowState = (typeof STATES)[number];
8
8
  type Mode = 'full' | 'quick';
9
- interface GateResult {
9
+ /**
10
+ * Task classification — drives the soft, escapable gate predicates (slice P:
11
+ * the PRD recommendation at the spec gate). Mirrors the user-facing
12
+ * `prd.mandatoryFor` enum in @noir-ai/core's `NoirConfigSchema` (declared
13
+ * LOCALLY here so workflow has no core-cycle concern; the two literals stay in
14
+ * sync by tests). `undefined` (the default for legacy tasks) ⇒ no soft gate
15
+ * fires — additive, fully backward-compatible.
16
+ */
17
+ declare const TASK_CLASSES: readonly ["feature", "epic", "enhancement", "bugfix", "spike", "quick-task", "refactor"];
18
+ type TaskClass = (typeof TASK_CLASSES)[number];
19
+ /**
20
+ * Input shape for {@link recordGate} — what CALLERS pass. Omits `at` (the
21
+ * recorder stamps it from `Date.now()` so the audit reflects when the gate
22
+ * actually fired, not when the caller constructed the object). Split out of
23
+ * {@link GateResult} (debt-batch A / W3): callers used to pass a throwaway
24
+ * `at: 0` that recordGate overrode — this shape makes the override implicit.
25
+ */
26
+ interface GateResultInput {
10
27
  phase: Phase;
11
28
  decision: 'approved' | 'forced' | 'skipped';
12
29
  reason?: string;
30
+ }
31
+ /**
32
+ * A recorded gate decision — the AUTHORITATIVE shape stored in the
33
+ * `audit:<taskId>` KV (the SOT per S4 spec §5 / §11 OQ-5) and surfaced via
34
+ * `task.history` (a derived view the engine regenerates from the audit KV).
35
+ * `at` is always present here; callers that want to RECORD a gate pass
36
+ * {@link GateResultInput} (no `at`) to {@link recordGate}.
37
+ */
38
+ interface GateResult extends GateResultInput {
13
39
  at: number;
14
40
  }
15
41
  interface TaskState {
@@ -19,12 +45,43 @@ interface TaskState {
19
45
  state: WorkflowState;
20
46
  phase: Phase;
21
47
  mode: Mode;
48
+ /**
49
+ * DERIVED view of the gate audit for this task, regenerated from the
50
+ * authoritative `audit:<taskId>` KV (S4 spec §11 OQ-5) by the engine on
51
+ * every write and every status read. Kept on the TaskState so consumers
52
+ * (CLI `task status`, the daemon `workflow_status` tool, MCP clients) can
53
+ * read the gate history from the persisted TaskState without a second KV
54
+ * lookup. Never edited directly outside the engine — derive via
55
+ * {@link readGateHistory}.
56
+ */
22
57
  history: GateResult[];
58
+ /** Task classification (slice P). `undefined` ⇒ no soft PRD gate fires. */
59
+ taskClass?: TaskClass;
23
60
  jumpEntry?: Phase;
24
61
  /** Reason captured by `setBlocked` (admin escape; set directly, not via FSM). */
25
62
  blockReason?: string;
26
63
  updatedAt: number;
27
64
  }
65
+ /**
66
+ * The gate-config slice the engine consumes (debt-batch A / P4). Mirrors the
67
+ * user-facing `prd:` block from @noir-ai/core's `NoirConfigSchema` — declared
68
+ * locally (with a `readonly` array) so workflow has no core-cycle concern; the
69
+ * daemon / CLI bridges NoirConfig → this shape at construction time. Every
70
+ * field has a sane default, so an unspecified `gateConfig` (the legacy
71
+ * constructor call) resolves to "feature/epic trigger the PRD recommendation".
72
+ */
73
+ interface WorkflowGateConfig {
74
+ /**
75
+ * PRD soft-gate config. `mandatoryFor` lists the task classes for which a
76
+ * missing PRD artifact (at the moment of entering the spec phase, in full
77
+ * mode) is surfaced as an observable, escapable recommendation on the spec
78
+ * gate. The advance still proceeds — `--force <reason>` is the explicit
79
+ * override path; quick mode + unlisted classes skip the check entirely.
80
+ */
81
+ prd: {
82
+ mandatoryFor: readonly TaskClass[];
83
+ };
84
+ }
28
85
 
29
86
  /**
30
87
  * Write intake artifact to .noir/intake/<taskId>.md
@@ -35,6 +92,13 @@ declare function writeIntake(root: string, taskId: string, content: string): voi
35
92
  * Creates markdown with frontmatter and body
36
93
  */
37
94
  declare function writeSpec(root: string, taskId: string, slug: string, body: string): void;
95
+ /**
96
+ * Write PRD artifact to .noir/prd/<taskId>-<slug>.md
97
+ * Pre-SDD product document; the spec @imports it (prdRef). No FSM change.
98
+ */
99
+ declare function writePrd(root: string, taskId: string, slug: string, body: string): void;
100
+ /** Read a PRD artifact, or null if absent. */
101
+ declare function readPrd(root: string, taskId: string, slug: string): string | null;
38
102
  /**
39
103
  * Write plan artifact to .noir/plans/<taskId>-<slug>.md
40
104
  */
@@ -63,7 +127,10 @@ interface AdvanceOpts {
63
127
  * Pass a gate without satisfying its criteria. Requires a non-empty `reason`
64
128
  * (validated here, in the engine — `recordGate` is policy-free). The landing
65
129
  * gate, if any, is recorded with `decision: 'forced'`. Mutually exclusive
66
- * with {@link skip}.
130
+ * with {@link skip}. This is ALSO the explicit-override path for the soft
131
+ * PRD recommendation (debt-batch A / P4): supplying `--force <reason>` at the
132
+ * spec gate of a mandatoryFor task with no PRD records `forced` with the
133
+ * user's reason instead of the recommendation note.
67
134
  */
68
135
  force?: {
69
136
  reason?: string;
@@ -72,7 +139,9 @@ interface AdvanceOpts {
72
139
  * Jump directly to a phase, bypassing the FSM (the escape hatch for blocked /
73
140
  * out-of-order resumption). The landing is recorded as `jumpEntry` on the
74
141
  * TaskState; a landing gate-state (specified/planned/done) still records its
75
- * gate so the observable-checkpoint invariant holds.
142
+ * gate so the observable-checkpoint invariant holds. A no-op jump (target
143
+ * equal to the current phase) returns the task unchanged without re-recording
144
+ * any gate (W3 guard — the prior behavior double-stamped the audit).
76
145
  */
77
146
  to?: Phase;
78
147
  /**
@@ -94,7 +163,8 @@ interface AdvanceOpts {
94
163
  * Policy that did not belong in T1/T2 lives here:
95
164
  * • `--force` requires a non-empty reason (validated before any gate write),
96
165
  * • `blocked` / `abandoned` have no incoming FSM edges and are set directly,
97
- * • `opts.to` jumps past FSM edges and is recorded via `jumpEntry`.
166
+ * • `opts.to` jumps past FSM edges and is recorded via `jumpEntry`,
167
+ * • (P4) the soft PRD recommendation at the spec gate for mandatoryFor tasks.
98
168
  *
99
169
  * Modes (Full/Quick) and cross-session resume are T5; MCP tools are T6 — the
100
170
  * engine stays mode-agnostic here and only stores `mode` on the TaskState.
@@ -109,6 +179,7 @@ declare class WorkflowEngine {
109
179
  */
110
180
  readonly root: string;
111
181
  private readonly projectId;
182
+ private readonly gateConfig;
112
183
  constructor(store: Store,
113
184
  /**
114
185
  * Project root — the engine is constructed with it so modes (quickPath
@@ -116,13 +187,26 @@ declare class WorkflowEngine {
116
187
  * (checkpoint/audit export) are self-contained. Public so the modes module
117
188
  * can pass it to {@link ArtifactWriter}.
118
189
  */
119
- root: string, projectId: ProjectId);
190
+ root: string, projectId: ProjectId,
191
+ /**
192
+ * Gate-config slice (debt-batch A / P4). Optional — the legacy 3-arg call
193
+ * shape (every existing consumer) resolves to {@link DEFAULT_GATE_CONFIG}
194
+ * (PRD recommendation fires for feature/epic). The daemon / CLI bridge
195
+ * passes the resolved `prd.mandatoryFor` from NoirConfig so user overrides
196
+ * take effect; tests pass an explicit shape to pin behavior.
197
+ */
198
+ gateConfig?: WorkflowGateConfig);
120
199
  /**
121
200
  * Create a new task at draft/intake, persist it, and point `workflow:active`
122
201
  * at it. Re-starting an existing taskId overwrites it (intentional — the KV is
123
202
  * the source of truth, not a journal).
203
+ *
204
+ * `taskClass` (debt-batch A / P4) is optional and additive — legacy callers
205
+ * (and existing tests) omit it; the soft PRD gate then never fires for the
206
+ * task (consistent with the "additive, no-op when absent" rule). New callers
207
+ * that want the recommendation pass `'feature'` / `'epic'` / etc.
124
208
  */
125
- startTask(taskId: string, slug: string, mode: Mode): Promise<TaskState>;
209
+ startTask(taskId: string, slug: string, mode: Mode, taskClass?: TaskClass): Promise<TaskState>;
126
210
  /**
127
211
  * Advance `taskId` to its next phase, or jump with `opts.to`.
128
212
  *
@@ -131,8 +215,25 @@ declare class WorkflowEngine {
131
215
  * reason) when `opts.force` is supplied, or `skipped` when `opts.skip` is
132
216
  * supplied (quick mode). `force` and `skip` are mutually exclusive. Jumps
133
217
  * bypass the FSM and additionally stamp `jumpEntry`.
218
+ *
219
+ * Single source of truth (W1): the gate is written ONCE to the audit KV via
220
+ * {@link recordGate}, and `task.history` is RE-DERIVED from that KV. No
221
+ * second write, no second timestamp — the S4 sub-ms drift is gone.
134
222
  */
135
223
  advance(taskId: string, opts?: AdvanceOpts): Promise<TaskState>;
224
+ /**
225
+ * Compute the soft PRD-recommendation message (P4), or `null` when the
226
+ * recommendation does NOT apply. The recommendation applies when ALL of:
227
+ * • the gate landing now is the spec gate (entering `specified`), AND
228
+ * • the task is in full mode (quick mode skips — quickPath writes a stub), AND
229
+ * • the task has a `taskClass` listed in `gateConfig.prd.mandatoryFor`, AND
230
+ * • no PRD artifact exists at `.noir/prd/<id>-<slug>.md` (readPrd), AND
231
+ * • the user did NOT supply --force (force is the explicit-override path).
232
+ *
233
+ * Returns the observable note (audited on the spec gate's `reason`) so a
234
+ * downstream consumer (CLI status, workflow_status MCP tool) can surface it.
235
+ */
236
+ private prdRecommendation;
136
237
  /** Read the persisted TaskState, or null if the task is unknown. */
137
238
  status(taskId: string): TaskState | null;
138
239
  /**
@@ -143,14 +244,27 @@ declare class WorkflowEngine {
143
244
  */
144
245
  activeTaskId(): string | null;
145
246
  /**
146
- * Re-flush the current state to KV. In T4 every advance already persists, so
147
- * this is the explicit "mark a checkpoint" hook (bumps `updatedAt`); T5
148
- * deepens it to flush artifacts + audit export for cross-session resume.
247
+ * Re-flush the current state to KV + flush the gate audit export to
248
+ * `.noir/audit/<taskId>.json`. W2 (debt-batch A): the prior implementation
249
+ * only bumped `updatedAt`, which every advance already does vestigial.
250
+ * Cross-session resume (`resumeTask`) reads `workflow:<id>` straight from the
251
+ * KV and consumes nothing from this method; the S4 ledger noted the write was
252
+ * dead. The fix is to WIRE the checkpoint to a real cross-tool artifact
253
+ * flush: the audit JSON on disk (the S4 spec §11 OQ-5 "export to
254
+ * `.noir/audit/<taskId>.json`" that {@link writeAuditExport} already
255
+ * implemented but nothing called). The MCP `checkpoint { action:'save' }`
256
+ * tool stays the public surface; its save now leaves a human-inspectable
257
+ * audit JSON alongside the KV.
149
258
  */
150
259
  checkpoint(taskId: string): Promise<void>;
151
260
  /**
152
261
  * Set state directly to `blocked` (no FSM edge — the admin escape). The reason
153
262
  * is captured on the TaskState for surfacing in `noir.workflow_status`.
263
+ *
264
+ * W3: a SUPPLIED reason must be non-empty after trimming (mirrors the
265
+ * `--force` policy). `setBlocked(id)` with no reason stays valid — it clears
266
+ * no field and just flips state. A whitespace-only reason is rejected as
267
+ * malformed (consistent with `--force`'s whitespace rejection).
154
268
  */
155
269
  setBlocked(taskId: string, reason?: string): Promise<TaskState>;
156
270
  /** Set state directly to `abandoned` (terminal; no FSM edge). */
@@ -176,17 +290,36 @@ declare function gateFor(phase: Phase): WorkflowState | null;
176
290
  /**
177
291
  * Append a gate decision to the task's audit log in the store KV.
178
292
  *
179
- * The audit lives at `audit:<taskId>` as a `GateResult[]` and is the source of
180
- * truth for every gate outcome (the `.noir/audit/` export is a later helper).
293
+ * The audit lives at `audit:<taskId>` as a `GateResult[]` and is the
294
+ * AUTHORITATIVE record for every gate outcome (S4 spec §5 / §11 OQ-5 "audit
295
+ * in store KV as source of truth + export to `.noir/audit/<taskId>.json`").
296
+ * The {@link TaskState.history} field is a DERIVED view, regenerated from this
297
+ * KV by the engine — never edited directly. This collapses the S4 dual source
298
+ * of truth (debt-batch A / W1): one write, one timestamp, one read-back.
299
+ *
181
300
  * This is the "quiet observable checkpoint": an `approved`, `forced`, or
182
301
  * `skipped` decision is always recorded — never silently dropped — and `forced`
183
302
  * carries a `reason`.
184
303
  *
185
304
  * `at` is stamped here (`Date.now()`) rather than trusted from the caller, so
186
- * the audit reflects when the gate actually fired. The write is append-only:
305
+ * the audit reflects when the gate actually fired (single timestamp the W3
306
+ * sub-ms drift between the engine's history stamp and the audit KV stamp is
307
+ * gone, because there is no longer a second stamp). The write is append-only:
187
308
  * prior entries are read and preserved (never overwritten).
188
309
  */
189
- declare function recordGate(store: Store, taskId: string, result: GateResult): void;
310
+ declare function recordGate(store: Store, taskId: string, result: GateResultInput): GateResult;
311
+ /**
312
+ * Read the authoritative gate audit for `taskId` from the store KV. Returns an
313
+ * empty array when no gate has fired yet (a fresh task) — never `null`, so
314
+ * callers can spread/index without a separate undefined check.
315
+ *
316
+ * The engine uses this to regenerate {@link TaskState.history} (the derived
317
+ * view) on every advance and every status read; downstream consumers
318
+ * (`buildWorkflowStatus`, the CLI `task status`) read `task.history` and so get
319
+ * the derived-from-KV value indirectly — the audit KV remains the SOT they all
320
+ * ultimately derive from.
321
+ */
322
+ declare function readGateHistory(store: Store, taskId: string): GateResult[];
190
323
 
191
324
  /**
192
325
  * Default spec body written by {@link runQuick}. Quick mode is "discipline
@@ -237,4 +370,4 @@ declare function applyTransition(from: WorkflowState, to: WorkflowState): Workfl
237
370
  declare function stateForPhase(p: Phase): WorkflowState;
238
371
  declare function nextPhase(state: WorkflowState): Phase | null;
239
372
 
240
- export { type AdvanceOpts, type GateResult, type Mode, PHASES, type Phase, QUICK_SPEC_STUB, type QuickOpts, STATES, type TaskState, WorkflowEngine, type WorkflowState, applyTransition, canTransition, gateFor, nextPhase, recordGate, resumeTask, runQuick, stateForPhase, writeAuditExport, writeChangelogStub, writeDecisionStub, writeIntake, writePlan, writeSpec, writeTask };
373
+ export { type AdvanceOpts, type GateResult, type GateResultInput, type Mode, PHASES, type Phase, QUICK_SPEC_STUB, type QuickOpts, STATES, TASK_CLASSES, type TaskClass, type TaskState, WorkflowEngine, type WorkflowGateConfig, type WorkflowState, applyTransition, canTransition, gateFor, nextPhase, readGateHistory, readPrd, recordGate, resumeTask, runQuick, stateForPhase, writeAuditExport, writeChangelogStub, writeDecisionStub, writeIntake, writePlan, writePrd, writeSpec, writeTask };
package/dist/index.js CHANGED
@@ -20,6 +20,23 @@ slug: ${slug}
20
20
  ${body}`;
21
21
  writeFileSync(file, content, "utf-8");
22
22
  }
23
+ function writePrd(root, taskId, slug, body) {
24
+ const dir = paths.prdDir(root);
25
+ const file = paths.prdFile(root, taskId, slug);
26
+ mkdirSync(dir, { recursive: true });
27
+ const content = `---
28
+ taskId: ${taskId}
29
+ slug: ${slug}
30
+ ---
31
+
32
+ ${body}`;
33
+ writeFileSync(file, content, "utf-8");
34
+ }
35
+ function readPrd(root, taskId, slug) {
36
+ const file = paths.prdFile(root, taskId, slug);
37
+ if (!existsSync(file)) return null;
38
+ return readFileSync(file, "utf-8");
39
+ }
23
40
  function writePlan(root, taskId, slug, body) {
24
41
  const dir = paths.plansDir(root);
25
42
  const file = paths.planFile(root, taskId, slug);
@@ -97,7 +114,12 @@ function gateFor(phase) {
97
114
  function recordGate(store, taskId, result) {
98
115
  const key = `audit:${taskId}`;
99
116
  const prior = store.getState(key) ?? [];
100
- store.setState(key, [...prior, { ...result, at: Date.now() }]);
117
+ const recorded = { ...result, at: Date.now() };
118
+ store.setState(key, [...prior, recorded]);
119
+ return recorded;
120
+ }
121
+ function readGateHistory(store, taskId) {
122
+ return store.getState(`audit:${taskId}`) ?? [];
101
123
  }
102
124
 
103
125
  // src/types.ts
@@ -121,6 +143,15 @@ var STATES = [
121
143
  "blocked",
122
144
  "abandoned"
123
145
  ];
146
+ var TASK_CLASSES = [
147
+ "feature",
148
+ "epic",
149
+ "enhancement",
150
+ "bugfix",
151
+ "spike",
152
+ "quick-task",
153
+ "refactor"
154
+ ];
124
155
 
125
156
  // src/state-machine.ts
126
157
  var TRANSITIONS = {
@@ -177,6 +208,9 @@ function nextPhase(state) {
177
208
  // src/engine.ts
178
209
  var ACTIVE_KEY = "workflow:active";
179
210
  var GATE_PHASES = ["spec", "plan", "verify"];
211
+ var DEFAULT_GATE_CONFIG = {
212
+ prd: { mandatoryFor: ["feature", "epic"] }
213
+ };
180
214
  function gatePhaseForState(state) {
181
215
  for (const p of GATE_PHASES) {
182
216
  if (gateFor(p) === state) return p;
@@ -184,20 +218,27 @@ function gatePhaseForState(state) {
184
218
  return null;
185
219
  }
186
220
  var WorkflowEngine = class {
187
- constructor(store, root, projectId) {
221
+ constructor(store, root, projectId, gateConfig) {
188
222
  this.store = store;
189
223
  this.root = root;
190
224
  this.projectId = projectId;
225
+ this.gateConfig = gateConfig ?? DEFAULT_GATE_CONFIG;
191
226
  }
192
227
  store;
193
228
  root;
194
229
  projectId;
230
+ gateConfig;
195
231
  /**
196
232
  * Create a new task at draft/intake, persist it, and point `workflow:active`
197
233
  * at it. Re-starting an existing taskId overwrites it (intentional — the KV is
198
234
  * the source of truth, not a journal).
235
+ *
236
+ * `taskClass` (debt-batch A / P4) is optional and additive — legacy callers
237
+ * (and existing tests) omit it; the soft PRD gate then never fires for the
238
+ * task (consistent with the "additive, no-op when absent" rule). New callers
239
+ * that want the recommendation pass `'feature'` / `'epic'` / etc.
199
240
  */
200
- async startTask(taskId, slug, mode) {
241
+ async startTask(taskId, slug, mode, taskClass) {
201
242
  const task = {
202
243
  taskId,
203
244
  slug,
@@ -206,6 +247,7 @@ var WorkflowEngine = class {
206
247
  phase: "intake",
207
248
  mode,
208
249
  history: [],
250
+ ...taskClass !== void 0 ? { taskClass } : {},
209
251
  updatedAt: Date.now()
210
252
  };
211
253
  this.persist(task);
@@ -220,6 +262,10 @@ var WorkflowEngine = class {
220
262
  * reason) when `opts.force` is supplied, or `skipped` when `opts.skip` is
221
263
  * supplied (quick mode). `force` and `skip` are mutually exclusive. Jumps
222
264
  * bypass the FSM and additionally stamp `jumpEntry`.
265
+ *
266
+ * Single source of truth (W1): the gate is written ONCE to the audit KV via
267
+ * {@link recordGate}, and `task.history` is RE-DERIVED from that KV. No
268
+ * second write, no second timestamp — the S4 sub-ms drift is gone.
223
269
  */
224
270
  async advance(taskId, opts) {
225
271
  const task = this.requireTask(taskId);
@@ -231,6 +277,9 @@ var WorkflowEngine = class {
231
277
  }
232
278
  const jump = opts?.to !== void 0;
233
279
  const targetPhase = jump ? opts?.to : this.nextPhaseOf(task);
280
+ if (jump && targetPhase === task.phase) {
281
+ return task;
282
+ }
234
283
  const targetState = stateForPhase(targetPhase);
235
284
  if (!jump) {
236
285
  applyTransition(task.state, targetState);
@@ -238,15 +287,17 @@ var WorkflowEngine = class {
238
287
  const gatePhase = gatePhaseForState(targetState);
239
288
  if (gatePhase !== null) {
240
289
  const decision = opts?.force ? "forced" : opts?.skip ? "skipped" : "approved";
241
- const gate = {
290
+ const prdHint = this.prdRecommendation(task, gatePhase, opts);
291
+ const input = {
242
292
  phase: gatePhase,
243
293
  decision,
244
- // exactOptionalPropertyTypes is false; spread reason only when forced.
245
- ...opts?.force ? { reason: opts.force.reason } : {},
246
- at: Date.now()
294
+ // exactOptionalPropertyTypes is false; spread reason only when present.
295
+ // Force-path wins over the soft hint (a user who forces is explicitly
296
+ // accepting the recommendation; their reason is the override signal).
297
+ ...opts?.force ? { reason: opts.force.reason } : prdHint !== null ? { reason: prdHint } : {}
247
298
  };
248
- recordGate(this.store, taskId, gate);
249
- task.history.push(gate);
299
+ recordGate(this.store, taskId, input);
300
+ task.history = readGateHistory(this.store, taskId);
250
301
  }
251
302
  task.state = targetState;
252
303
  task.phase = targetPhase;
@@ -255,9 +306,34 @@ var WorkflowEngine = class {
255
306
  this.persist(task);
256
307
  return task;
257
308
  }
309
+ /**
310
+ * Compute the soft PRD-recommendation message (P4), or `null` when the
311
+ * recommendation does NOT apply. The recommendation applies when ALL of:
312
+ * • the gate landing now is the spec gate (entering `specified`), AND
313
+ * • the task is in full mode (quick mode skips — quickPath writes a stub), AND
314
+ * • the task has a `taskClass` listed in `gateConfig.prd.mandatoryFor`, AND
315
+ * • no PRD artifact exists at `.noir/prd/<id>-<slug>.md` (readPrd), AND
316
+ * • the user did NOT supply --force (force is the explicit-override path).
317
+ *
318
+ * Returns the observable note (audited on the spec gate's `reason`) so a
319
+ * downstream consumer (CLI status, workflow_status MCP tool) can surface it.
320
+ */
321
+ prdRecommendation(task, gatePhase, opts) {
322
+ if (gatePhase !== "spec") return null;
323
+ if (task.mode === "quick") return null;
324
+ const taskClass = task.taskClass;
325
+ if (taskClass === void 0) return null;
326
+ if (!this.gateConfig.prd.mandatoryFor.includes(taskClass)) return null;
327
+ if (opts?.force) return null;
328
+ if (readPrd(this.root, task.taskId, task.slug) !== null) return null;
329
+ return `PRD recommended for ${taskClass} \u2014 provide one (noir-prd) or --force <reason> to skip`;
330
+ }
258
331
  /** Read the persisted TaskState, or null if the task is unknown. */
259
332
  status(taskId) {
260
- return this.store.getState(workflowKey(taskId));
333
+ const task = this.store.getState(workflowKey(taskId));
334
+ if (!task) return null;
335
+ task.history = readGateHistory(this.store, taskId);
336
+ return task;
261
337
  }
262
338
  /**
263
339
  * The taskId of the most-recently-started task (`workflow:active` in the
@@ -269,24 +345,44 @@ var WorkflowEngine = class {
269
345
  return this.store.getState(ACTIVE_KEY);
270
346
  }
271
347
  /**
272
- * Re-flush the current state to KV. In T4 every advance already persists, so
273
- * this is the explicit "mark a checkpoint" hook (bumps `updatedAt`); T5
274
- * deepens it to flush artifacts + audit export for cross-session resume.
348
+ * Re-flush the current state to KV + flush the gate audit export to
349
+ * `.noir/audit/<taskId>.json`. W2 (debt-batch A): the prior implementation
350
+ * only bumped `updatedAt`, which every advance already does vestigial.
351
+ * Cross-session resume (`resumeTask`) reads `workflow:<id>` straight from the
352
+ * KV and consumes nothing from this method; the S4 ledger noted the write was
353
+ * dead. The fix is to WIRE the checkpoint to a real cross-tool artifact
354
+ * flush: the audit JSON on disk (the S4 spec §11 OQ-5 "export to
355
+ * `.noir/audit/<taskId>.json`" that {@link writeAuditExport} already
356
+ * implemented but nothing called). The MCP `checkpoint { action:'save' }`
357
+ * tool stays the public surface; its save now leaves a human-inspectable
358
+ * audit JSON alongside the KV.
275
359
  */
276
360
  async checkpoint(taskId) {
277
361
  const task = this.store.getState(workflowKey(taskId));
278
362
  if (!task) throw new Error(`Unknown task: ${taskId}`);
279
363
  task.updatedAt = Date.now();
280
364
  this.persist(task);
365
+ writeAuditExport(this.root, taskId, readGateHistory(this.store, taskId));
281
366
  }
282
367
  /**
283
368
  * Set state directly to `blocked` (no FSM edge — the admin escape). The reason
284
369
  * is captured on the TaskState for surfacing in `noir.workflow_status`.
370
+ *
371
+ * W3: a SUPPLIED reason must be non-empty after trimming (mirrors the
372
+ * `--force` policy). `setBlocked(id)` with no reason stays valid — it clears
373
+ * no field and just flips state. A whitespace-only reason is rejected as
374
+ * malformed (consistent with `--force`'s whitespace rejection).
285
375
  */
286
376
  async setBlocked(taskId, reason) {
287
377
  const task = this.requireTask(taskId);
378
+ if (reason !== void 0) {
379
+ const trimmed = reason.trim();
380
+ if (trimmed.length === 0) {
381
+ throw new Error("setBlocked reason must be non-empty (or omitted to leave unset)");
382
+ }
383
+ task.blockReason = trimmed;
384
+ }
288
385
  task.state = "blocked";
289
- if (reason) task.blockReason = reason;
290
386
  task.updatedAt = Date.now();
291
387
  this.persist(task);
292
388
  return task;
@@ -343,11 +439,14 @@ export {
343
439
  PHASES,
344
440
  QUICK_SPEC_STUB,
345
441
  STATES,
442
+ TASK_CLASSES,
346
443
  WorkflowEngine,
347
444
  applyTransition,
348
445
  canTransition,
349
446
  gateFor,
350
447
  nextPhase,
448
+ readGateHistory,
449
+ readPrd,
351
450
  recordGate,
352
451
  resumeTask,
353
452
  runQuick,
@@ -357,6 +456,7 @@ export {
357
456
  writeDecisionStub,
358
457
  writeIntake,
359
458
  writePlan,
459
+ writePrd,
360
460
  writeSpec,
361
461
  writeTask
362
462
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/artifacts.ts","../src/gates.ts","../src/types.ts","../src/state-machine.ts","../src/engine.ts","../src/modes.ts"],"sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { paths } from '@noir-ai/core';\nimport type { GateResult } from './types.js';\n\n/**\n * Write intake artifact to .noir/intake/<taskId>.md\n */\nexport function writeIntake(root: string, taskId: string, content: string): void {\n const dir = join(root, '.noir', 'intake');\n const file = join(dir, `${taskId}.md`);\n\n mkdirSync(dir, { recursive: true });\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write spec artifact to .noir/specs/<taskId>-<slug>.md\n * Creates markdown with frontmatter and body\n */\nexport function writeSpec(root: string, taskId: string, slug: string, body: string): void {\n const dir = paths.specsDir(root);\n const file = paths.specFile(root, taskId, slug);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\nslug: ${slug}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write plan artifact to .noir/plans/<taskId>-<slug>.md\n */\nexport function writePlan(root: string, taskId: string, slug: string, body: string): void {\n const dir = paths.plansDir(root);\n const file = paths.planFile(root, taskId, slug);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\nslug: ${slug}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write task artifact to .noir/tasks/<taskId>-<taskName>.md\n */\nexport function writeTask(root: string, taskId: string, taskName: string, body: string): void {\n const dir = paths.tasksDir(root);\n const file = paths.taskFile(root, taskId, taskName);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\ntask: ${taskName}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write decision stub to .noir/decisions/<n>.md\n */\nexport function writeDecisionStub(root: string, n: number, title: string): void {\n const dir = paths.decisionsDir(root);\n const file = paths.decisionFile(root, n);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `# ${title}\n\n*Decision record ${n}*\n\n<!-- Status: pending -->\n`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write changelog stub entry to .noir/CHANGELOG.md\n */\nexport function writeChangelogStub(root: string, entry: string): void {\n const file = join(root, '.noir', 'CHANGELOG.md');\n\n // Create directory if it doesn't exist\n mkdirSync(join(root, '.noir'), { recursive: true });\n\n let content: string;\n if (existsSync(file)) {\n // Append: read existing content and add the new entry on its own line,\n // preserving the header and all prior entries.\n const existing = readFileSync(file, 'utf-8');\n const prefix = existing.endsWith('\\n') ? existing : `${existing}\\n`;\n content = `${prefix}${entry}\\n`;\n } else {\n content = `# Changelog\n\n${entry}\n`;\n }\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write audit export to .noir/audit/<taskId>.json\n * Exports GateResult array as JSON\n */\nexport function writeAuditExport(root: string, taskId: string, results: GateResult[]): void {\n const dir = paths.auditDir(root);\n const file = paths.auditFile(root, taskId);\n\n mkdirSync(dir, { recursive: true });\n writeFileSync(file, JSON.stringify(results, null, 2), 'utf-8');\n}\n","import type { Store } from '@noir-ai/store';\nimport type { GateResult, Phase, WorkflowState } from './types.js';\n\n/**\n * The workflow state a phase's gate guards entry into (Noir §9.1 observable\n * checkpoint). A gate fires when its phase completes:\n * spec-gate → entering `specified`\n * plan-gate → entering `planned`\n * verify-gate → entering `done`\n *\n * Phases without a gate (intake / clarify / execute / document) return `null`.\n * This is distinct from {@link stateForPhase}: `stateForPhase('verify')` is the\n * in-progress `verifying`, while `gateFor('verify')` is the state the verify\n * gate admits you into (`done`).\n */\nexport function gateFor(phase: Phase): WorkflowState | null {\n switch (phase) {\n case 'spec':\n return 'specified';\n case 'plan':\n return 'planned';\n case 'verify':\n return 'done';\n default:\n return null;\n }\n}\n\n/**\n * Append a gate decision to the task's audit log in the store KV.\n *\n * The audit lives at `audit:<taskId>` as a `GateResult[]` and is the source of\n * truth for every gate outcome (the `.noir/audit/` export is a later helper).\n * This is the \"quiet observable checkpoint\": an `approved`, `forced`, or\n * `skipped` decision is always recorded — never silently dropped — and `forced`\n * carries a `reason`.\n *\n * `at` is stamped here (`Date.now()`) rather than trusted from the caller, so\n * the audit reflects when the gate actually fired. The write is append-only:\n * prior entries are read and preserved (never overwritten).\n */\nexport function recordGate(store: Store, taskId: string, result: GateResult): void {\n const key = `audit:${taskId}`;\n const prior = store.getState<GateResult[]>(key) ?? [];\n store.setState<GateResult[]>(key, [...prior, { ...result, at: Date.now() }]);\n}\n","import type { ProjectId } from '@noir-ai/core';\n\nexport const PHASES = [\n 'intake',\n 'clarify',\n 'spec',\n 'plan',\n 'execute',\n 'verify',\n 'document',\n] as const;\nexport type Phase = (typeof PHASES)[number];\n\nexport const STATES = [\n 'draft',\n 'clarifying',\n 'specified',\n 'planned',\n 'executing',\n 'verifying',\n 'done',\n 'blocked',\n 'abandoned',\n] as const;\nexport type WorkflowState = (typeof STATES)[number];\n\nexport type Mode = 'full' | 'quick';\n\nexport interface GateResult {\n phase: Phase;\n decision: 'approved' | 'forced' | 'skipped';\n reason?: string;\n at: number;\n}\n\nexport interface TaskState {\n taskId: string;\n slug: string;\n projectId: ProjectId;\n state: WorkflowState;\n phase: Phase;\n mode: Mode;\n history: GateResult[]; // gate decisions (audit in-process view)\n jumpEntry?: Phase; // recorded if a jump-to-phase happened\n /** Reason captured by `setBlocked` (admin escape; set directly, not via FSM). */\n blockReason?: string;\n updatedAt: number;\n}\n","import type { Phase, WorkflowState } from './types.js';\n\n// Re-export the phase/state vocabularies so consumers can import the entire FSM\n// surface from one module (the test imports PHASES/STATES from here).\nexport { PHASES, STATES } from './types.js';\n\n// Legal state transitions (happy path + terminal). Gates (spec/plan/verify) are\n// modeled as the transition INTO specified/planned/done — the engine records a\n// GateResult at that point (see gates.ts).\nconst TRANSITIONS: Record<WorkflowState, WorkflowState[]> = {\n draft: ['clarifying'],\n clarifying: ['specified'],\n specified: ['planned'],\n planned: ['executing'],\n executing: ['verifying'],\n verifying: ['done'],\n done: [],\n blocked: ['draft', 'clarifying', 'specified', 'planned', 'executing', 'verifying'],\n abandoned: [],\n};\n\nexport function canTransition(from: WorkflowState, to: WorkflowState): boolean {\n return (TRANSITIONS[from] ?? []).includes(to);\n}\n\nexport function applyTransition(from: WorkflowState, to: WorkflowState): WorkflowState {\n if (!canTransition(from, to)) {\n const hint =\n to === 'planned'\n ? ' (the spec gate must be passed first)'\n : to === 'executing'\n ? ' (the plan gate must be passed first)'\n : to === 'done'\n ? ' (the verify gate must be passed first)'\n : '';\n throw new Error(`Illegal transition ${from} → ${to}${hint}`);\n }\n return to;\n}\n\n// Phase <-> state mapping for the engine.\nexport function stateForPhase(p: Phase): WorkflowState {\n switch (p) {\n case 'intake':\n return 'draft';\n case 'clarify':\n return 'clarifying';\n case 'spec':\n return 'specified';\n case 'plan':\n return 'planned';\n case 'execute':\n return 'executing';\n case 'verify':\n return 'verifying';\n case 'document':\n return 'done';\n }\n}\n\nexport function nextPhase(state: WorkflowState): Phase | null {\n const map: Partial<Record<WorkflowState, Phase>> = {\n draft: 'clarify',\n clarifying: 'spec',\n specified: 'plan',\n planned: 'execute',\n executing: 'verify',\n verifying: 'document',\n };\n return map[state] ?? null;\n}\n","import type { ProjectId } from '@noir-ai/core';\nimport type { Store } from '@noir-ai/store';\nimport { gateFor, recordGate } from './gates.js';\nimport { applyTransition, nextPhase, stateForPhase } from './state-machine.js';\nimport type { GateResult, Mode, Phase, TaskState, WorkflowState } from './types.js';\n\n/**\n * Store KV layout (single source of truth for machine state):\n * workflow:active → taskId of the most recently started task\n * workflow:<taskId> → TaskState (JSON)\n * Audit decisions live at `audit:<taskId>` (see {@link recordGate}).\n */\nconst ACTIVE_KEY = 'workflow:active';\nconst GATE_PHASES = ['spec', 'plan', 'verify'] as const satisfies ReadonlyArray<Phase>;\n\n/** Options for {@link WorkflowEngine.advance}. */\nexport interface AdvanceOpts {\n /**\n * Pass a gate without satisfying its criteria. Requires a non-empty `reason`\n * (validated here, in the engine — `recordGate` is policy-free). The landing\n * gate, if any, is recorded with `decision: 'forced'`. Mutually exclusive\n * with {@link skip}.\n */\n force?: { reason?: string };\n /**\n * Jump directly to a phase, bypassing the FSM (the escape hatch for blocked /\n * out-of-order resumption). The landing is recorded as `jumpEntry` on the\n * TaskState; a landing gate-state (specified/planned/done) still records its\n * gate so the observable-checkpoint invariant holds.\n */\n to?: Phase;\n /**\n * Quick-mode: record the landing gate (if any) as `decision: 'skipped'`\n * instead of `approved`. The gate is still RECORDED — never silently dropped\n * (Noir §9.1 observable-checkpoint invariant) — only the decision changes.\n * Mutually exclusive with {@link force}.\n */\n skip?: true;\n}\n\n/**\n * The phase whose gate admits entry into `state` (the inverse of {@link gateFor}).\n *\n * The verify gate fires on entering `done` — but `done` is `stateForPhase('document')`,\n * so the target *phase* of that transition is `document`, not `verify`. The gate\n * must therefore be looked up from the target *state*, not the target phase.\n */\nfunction gatePhaseForState(state: WorkflowState): Phase | null {\n for (const p of GATE_PHASES) {\n if (gateFor(p) === state) return p;\n }\n return null;\n}\n\n/**\n * WorkflowEngine — drives an SDD task through its lifecycle.\n *\n * The engine is a thin orchestrator over three T1–T3 primitives:\n * • the hand-rolled FSM ({@link applyTransition}) for legal forward moves,\n * • {@link recordGate} for observable checkpoint audit, and\n * • the store KV for persisted {@link TaskState}.\n *\n * Policy that did not belong in T1/T2 lives here:\n * • `--force` requires a non-empty reason (validated before any gate write),\n * • `blocked` / `abandoned` have no incoming FSM edges and are set directly,\n * • `opts.to` jumps past FSM edges and is recorded via `jumpEntry`.\n *\n * Modes (Full/Quick) and cross-session resume are T5; MCP tools are T6 — the\n * engine stays mode-agnostic here and only stores `mode` on the TaskState.\n */\nexport class WorkflowEngine {\n constructor(\n private readonly store: Store,\n /**\n * Project root — the engine is constructed with it so modes (quickPath\n * writes a spec stub via {@link writeSpec}) and future artifact flushes\n * (checkpoint/audit export) are self-contained. Public so the modes module\n * can pass it to {@link ArtifactWriter}.\n */\n readonly root: string,\n private readonly projectId: ProjectId,\n ) {}\n\n /**\n * Create a new task at draft/intake, persist it, and point `workflow:active`\n * at it. Re-starting an existing taskId overwrites it (intentional — the KV is\n * the source of truth, not a journal).\n */\n async startTask(taskId: string, slug: string, mode: Mode): Promise<TaskState> {\n const task: TaskState = {\n taskId,\n slug,\n projectId: this.projectId,\n state: 'draft',\n phase: 'intake',\n mode,\n history: [],\n updatedAt: Date.now(),\n };\n this.persist(task);\n this.store.setState<string>(ACTIVE_KEY, taskId);\n return task;\n }\n\n /**\n * Advance `taskId` to its next phase, or jump with `opts.to`.\n *\n * At a gate-landing state (entering `specified` / `planned` / `done`) a\n * {@link GateResult} is recorded — `approved` by default, `forced` (with the\n * reason) when `opts.force` is supplied, or `skipped` when `opts.skip` is\n * supplied (quick mode). `force` and `skip` are mutually exclusive. Jumps\n * bypass the FSM and additionally stamp `jumpEntry`.\n */\n async advance(taskId: string, opts?: AdvanceOpts): Promise<TaskState> {\n const task = this.requireTask(taskId);\n\n // Policy: --force and skip are mutually exclusive gate decisions (a gate\n // can't be both forced AND skipped). Validated BEFORE any gate write so a\n // bad combination never leaves a partial audit trail behind.\n if (opts?.force && opts?.skip) {\n throw new Error('cannot combine --force and skip');\n }\n // Policy: --force requires a non-empty reason.\n if (opts?.force && !opts.force.reason?.trim()) {\n throw new Error('--force requires a reason');\n }\n\n const jump = opts?.to !== undefined;\n const targetPhase: Phase = jump ? (opts?.to as Phase) : this.nextPhaseOf(task);\n\n const targetState = stateForPhase(targetPhase);\n if (!jump) {\n // applyTransition surfaces the FSM's gate hint on illegal moves.\n applyTransition(task.state, targetState);\n }\n\n // Observable checkpoint: entering specified/planned/done always records a\n // gate — looked up from the target STATE (see gatePhaseForState).\n const gatePhase = gatePhaseForState(targetState);\n if (gatePhase !== null) {\n const decision = opts?.force ? 'forced' : opts?.skip ? 'skipped' : 'approved';\n const gate: GateResult = {\n phase: gatePhase,\n decision,\n // exactOptionalPropertyTypes is false; spread reason only when forced.\n ...(opts?.force ? { reason: opts.force.reason } : {}),\n at: Date.now(),\n };\n recordGate(this.store, taskId, gate);\n task.history.push(gate);\n }\n\n task.state = targetState;\n task.phase = targetPhase;\n if (jump) task.jumpEntry = targetPhase;\n task.updatedAt = Date.now();\n\n this.persist(task);\n return task;\n }\n\n /** Read the persisted TaskState, or null if the task is unknown. */\n status(taskId: string): TaskState | null {\n return this.store.getState<TaskState>(workflowKey(taskId));\n }\n\n /**\n * The taskId of the most-recently-started task (`workflow:active` in the\n * store KV), or `null` when no task has been started yet. Lets the MCP\n * `workflow_status` / `checkpoint` tools omit `taskId` and operate on the\n * active task.\n */\n activeTaskId(): string | null {\n return this.store.getState<string>(ACTIVE_KEY);\n }\n\n /**\n * Re-flush the current state to KV. In T4 every advance already persists, so\n * this is the explicit \"mark a checkpoint\" hook (bumps `updatedAt`); T5\n * deepens it to flush artifacts + audit export for cross-session resume.\n */\n async checkpoint(taskId: string): Promise<void> {\n const task = this.store.getState<TaskState>(workflowKey(taskId));\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n task.updatedAt = Date.now();\n this.persist(task);\n }\n\n /**\n * Set state directly to `blocked` (no FSM edge — the admin escape). The reason\n * is captured on the TaskState for surfacing in `noir.workflow_status`.\n */\n async setBlocked(taskId: string, reason?: string): Promise<TaskState> {\n const task = this.requireTask(taskId);\n task.state = 'blocked';\n if (reason) task.blockReason = reason;\n task.updatedAt = Date.now();\n this.persist(task);\n return task;\n }\n\n /** Set state directly to `abandoned` (terminal; no FSM edge). */\n async abandon(taskId: string): Promise<TaskState> {\n const task = this.requireTask(taskId);\n task.state = 'abandoned';\n task.updatedAt = Date.now();\n this.persist(task);\n return task;\n }\n\n private nextPhaseOf(task: TaskState): Phase {\n const next = nextPhase(task.state);\n if (next === null) {\n throw new Error(`No next phase from state ${task.state}`);\n }\n return next;\n }\n\n private requireTask(taskId: string): TaskState {\n const task = this.store.getState<TaskState>(workflowKey(taskId));\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n return task;\n }\n\n private persist(task: TaskState): void {\n this.store.setState<TaskState>(workflowKey(task.taskId), task);\n }\n}\n\nfunction workflowKey(taskId: string): string {\n return `workflow:${taskId}`;\n}\n","import type { Store } from '@noir-ai/store';\nimport { writeSpec } from './artifacts.js';\nimport type { WorkflowEngine } from './engine.js';\nimport type { TaskState, WorkflowState } from './types.js';\n\n/**\n * Default spec body written by {@link runQuick}. Quick mode is \"discipline\n * lite\": the spec is stubbed (the spec + plan gates are skipped), but a real\n * spec file still lands on disk so later phases (and humans) have something to\n * read. Exported so callers/tests can assert on the canonical stub text.\n */\nexport const QUICK_SPEC_STUB = '<quick-mode stub spec>';\n\n/** Options for {@link runQuick}. */\nexport interface QuickOpts {\n /**\n * Override the stub spec body (defaults to {@link QUICK_SPEC_STUB}). Useful\n * when a quick task already has a one-line description worth persisting.\n */\n specBody?: string;\n}\n\n/**\n * Quick mode — the fast path for small / spike tasks.\n *\n * Writes a stub spec to `.noir/specs/<taskId>-<slug>.md` (via\n * {@link writeSpec}) and fast-forwards the task from `draft` to `executing`,\n * recording the spec and plan gates as `decision: 'skipped'`. The verify gate\n * is intentionally LEFT alone — it still fires as `approved` when the task\n * later reaches `done`, providing the one real checkpoint (discipline lite).\n *\n * The task must already be started (`engine.startTask(..., 'quick')`); this\n * function reads the slug from the persisted TaskState. Skipped gates are\n * RECORDED (Noir §9.1 observable-checkpoint invariant), never silently dropped\n * — the audit KV and `history` both carry the `skipped` entries.\n */\nexport async function runQuick(\n engine: WorkflowEngine,\n taskId: string,\n opts?: QuickOpts,\n): Promise<TaskState> {\n const task = engine.status(taskId);\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n\n // Flush the stub spec first so the artifact exists before any gate fires.\n writeSpec(engine.root, taskId, task.slug, opts?.specBody ?? QUICK_SPEC_STUB);\n\n // draft → clarifying → specified (spec gate SKIPPED) → planned (plan gate\n // SKIPPED) → executing. Verify is left for the normal flow (runs as approved).\n await engine.advance(taskId); // draft → clarifying (no gate)\n await engine.advance(taskId, { skip: true }); // → specified (spec gate skipped)\n await engine.advance(taskId, { skip: true }); // → planned (plan gate skipped)\n return engine.advance(taskId); // → executing (no gate) — land here\n}\n\n/** Terminal states with nothing left to resume. `blocked` is NOT terminal. */\nconst TERMINAL_STATES: ReadonlySet<WorkflowState> = new Set<WorkflowState>(['done', 'abandoned']);\n\n/**\n * Reconstruct the in-flight TaskState across a session break.\n *\n * Reads `workflow:active` from the store KV → the most-recently-started\n * taskId (T4 semantics; v1 is one-task-per-project) → the persisted\n * `workflow:<taskId>` TaskState. Returns `null` when there is no active task,\n * the task record is missing, or the active task is terminal (`done` /\n * `abandoned` — nothing to resume). A `blocked` task IS resumable.\n *\n * Uses only the public {@link Store} API, so a freshly-`openStore`'d handle\n * against the same on-disk DB is sufficient (no live engine required).\n */\nexport async function resumeTask(store: Store): Promise<TaskState | null> {\n const activeId = store.getState<string>('workflow:active');\n if (!activeId) return null;\n\n const task = store.getState<TaskState>(`workflow:${activeId}`);\n if (!task) return null;\n if (TERMINAL_STATES.has(task.state)) return null;\n\n return task;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/artifacts.ts","../src/gates.ts","../src/types.ts","../src/state-machine.ts","../src/engine.ts","../src/modes.ts"],"sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { paths } from '@noir-ai/core';\nimport type { GateResult } from './types.js';\n\n/**\n * Write intake artifact to .noir/intake/<taskId>.md\n */\nexport function writeIntake(root: string, taskId: string, content: string): void {\n const dir = join(root, '.noir', 'intake');\n const file = join(dir, `${taskId}.md`);\n\n mkdirSync(dir, { recursive: true });\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write spec artifact to .noir/specs/<taskId>-<slug>.md\n * Creates markdown with frontmatter and body\n */\nexport function writeSpec(root: string, taskId: string, slug: string, body: string): void {\n const dir = paths.specsDir(root);\n const file = paths.specFile(root, taskId, slug);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\nslug: ${slug}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write PRD artifact to .noir/prd/<taskId>-<slug>.md\n * Pre-SDD product document; the spec @imports it (prdRef). No FSM change.\n */\nexport function writePrd(root: string, taskId: string, slug: string, body: string): void {\n const dir = paths.prdDir(root);\n const file = paths.prdFile(root, taskId, slug);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\nslug: ${slug}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/** Read a PRD artifact, or null if absent. */\nexport function readPrd(root: string, taskId: string, slug: string): string | null {\n const file = paths.prdFile(root, taskId, slug);\n if (!existsSync(file)) return null;\n return readFileSync(file, 'utf-8');\n}\n\n/**\n * Write plan artifact to .noir/plans/<taskId>-<slug>.md\n */\nexport function writePlan(root: string, taskId: string, slug: string, body: string): void {\n const dir = paths.plansDir(root);\n const file = paths.planFile(root, taskId, slug);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\nslug: ${slug}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write task artifact to .noir/tasks/<taskId>-<taskName>.md\n */\nexport function writeTask(root: string, taskId: string, taskName: string, body: string): void {\n const dir = paths.tasksDir(root);\n const file = paths.taskFile(root, taskId, taskName);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `---\ntaskId: ${taskId}\ntask: ${taskName}\n---\n\n${body}`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write decision stub to .noir/decisions/<n>.md\n */\nexport function writeDecisionStub(root: string, n: number, title: string): void {\n const dir = paths.decisionsDir(root);\n const file = paths.decisionFile(root, n);\n\n mkdirSync(dir, { recursive: true });\n\n const content = `# ${title}\n\n*Decision record ${n}*\n\n<!-- Status: pending -->\n`;\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write changelog stub entry to .noir/CHANGELOG.md\n */\nexport function writeChangelogStub(root: string, entry: string): void {\n const file = join(root, '.noir', 'CHANGELOG.md');\n\n // Create directory if it doesn't exist\n mkdirSync(join(root, '.noir'), { recursive: true });\n\n let content: string;\n if (existsSync(file)) {\n // Append: read existing content and add the new entry on its own line,\n // preserving the header and all prior entries.\n const existing = readFileSync(file, 'utf-8');\n const prefix = existing.endsWith('\\n') ? existing : `${existing}\\n`;\n content = `${prefix}${entry}\\n`;\n } else {\n content = `# Changelog\n\n${entry}\n`;\n }\n\n writeFileSync(file, content, 'utf-8');\n}\n\n/**\n * Write audit export to .noir/audit/<taskId>.json\n * Exports GateResult array as JSON\n */\nexport function writeAuditExport(root: string, taskId: string, results: GateResult[]): void {\n const dir = paths.auditDir(root);\n const file = paths.auditFile(root, taskId);\n\n mkdirSync(dir, { recursive: true });\n writeFileSync(file, JSON.stringify(results, null, 2), 'utf-8');\n}\n","import type { Store } from '@noir-ai/store';\nimport type { GateResult, GateResultInput, Phase, WorkflowState } from './types.js';\n\n/**\n * The workflow state a phase's gate guards entry into (Noir §9.1 observable\n * checkpoint). A gate fires when its phase completes:\n * spec-gate → entering `specified`\n * plan-gate → entering `planned`\n * verify-gate → entering `done`\n *\n * Phases without a gate (intake / clarify / execute / document) return `null`.\n * This is distinct from {@link stateForPhase}: `stateForPhase('verify')` is the\n * in-progress `verifying`, while `gateFor('verify')` is the state the verify\n * gate admits you into (`done`).\n */\nexport function gateFor(phase: Phase): WorkflowState | null {\n switch (phase) {\n case 'spec':\n return 'specified';\n case 'plan':\n return 'planned';\n case 'verify':\n return 'done';\n default:\n return null;\n }\n}\n\n/**\n * Append a gate decision to the task's audit log in the store KV.\n *\n * The audit lives at `audit:<taskId>` as a `GateResult[]` and is the\n * AUTHORITATIVE record for every gate outcome (S4 spec §5 / §11 OQ-5 — \"audit\n * in store KV as source of truth + export to `.noir/audit/<taskId>.json`\").\n * The {@link TaskState.history} field is a DERIVED view, regenerated from this\n * KV by the engine — never edited directly. This collapses the S4 dual source\n * of truth (debt-batch A / W1): one write, one timestamp, one read-back.\n *\n * This is the \"quiet observable checkpoint\": an `approved`, `forced`, or\n * `skipped` decision is always recorded — never silently dropped — and `forced`\n * carries a `reason`.\n *\n * `at` is stamped here (`Date.now()`) rather than trusted from the caller, so\n * the audit reflects when the gate actually fired (single timestamp — the W3\n * sub-ms drift between the engine's history stamp and the audit KV stamp is\n * gone, because there is no longer a second stamp). The write is append-only:\n * prior entries are read and preserved (never overwritten).\n */\nexport function recordGate(store: Store, taskId: string, result: GateResultInput): GateResult {\n const key = `audit:${taskId}`;\n const prior = store.getState<GateResult[]>(key) ?? [];\n const recorded: GateResult = { ...result, at: Date.now() };\n store.setState<GateResult[]>(key, [...prior, recorded]);\n return recorded;\n}\n\n/**\n * Read the authoritative gate audit for `taskId` from the store KV. Returns an\n * empty array when no gate has fired yet (a fresh task) — never `null`, so\n * callers can spread/index without a separate undefined check.\n *\n * The engine uses this to regenerate {@link TaskState.history} (the derived\n * view) on every advance and every status read; downstream consumers\n * (`buildWorkflowStatus`, the CLI `task status`) read `task.history` and so get\n * the derived-from-KV value indirectly — the audit KV remains the SOT they all\n * ultimately derive from.\n */\nexport function readGateHistory(store: Store, taskId: string): GateResult[] {\n return store.getState<GateResult[]>(`audit:${taskId}`) ?? [];\n}\n","import type { ProjectId } from '@noir-ai/core';\n\nexport const PHASES = [\n 'intake',\n 'clarify',\n 'spec',\n 'plan',\n 'execute',\n 'verify',\n 'document',\n] as const;\nexport type Phase = (typeof PHASES)[number];\n\nexport const STATES = [\n 'draft',\n 'clarifying',\n 'specified',\n 'planned',\n 'executing',\n 'verifying',\n 'done',\n 'blocked',\n 'abandoned',\n] as const;\nexport type WorkflowState = (typeof STATES)[number];\n\nexport type Mode = 'full' | 'quick';\n\n/**\n * Task classification — drives the soft, escapable gate predicates (slice P:\n * the PRD recommendation at the spec gate). Mirrors the user-facing\n * `prd.mandatoryFor` enum in @noir-ai/core's `NoirConfigSchema` (declared\n * LOCALLY here so workflow has no core-cycle concern; the two literals stay in\n * sync by tests). `undefined` (the default for legacy tasks) ⇒ no soft gate\n * fires — additive, fully backward-compatible.\n */\nexport const TASK_CLASSES = [\n 'feature',\n 'epic',\n 'enhancement',\n 'bugfix',\n 'spike',\n 'quick-task',\n 'refactor',\n] as const;\nexport type TaskClass = (typeof TASK_CLASSES)[number];\n\n/**\n * Input shape for {@link recordGate} — what CALLERS pass. Omits `at` (the\n * recorder stamps it from `Date.now()` so the audit reflects when the gate\n * actually fired, not when the caller constructed the object). Split out of\n * {@link GateResult} (debt-batch A / W3): callers used to pass a throwaway\n * `at: 0` that recordGate overrode — this shape makes the override implicit.\n */\nexport interface GateResultInput {\n phase: Phase;\n decision: 'approved' | 'forced' | 'skipped';\n reason?: string;\n}\n\n/**\n * A recorded gate decision — the AUTHORITATIVE shape stored in the\n * `audit:<taskId>` KV (the SOT per S4 spec §5 / §11 OQ-5) and surfaced via\n * `task.history` (a derived view the engine regenerates from the audit KV).\n * `at` is always present here; callers that want to RECORD a gate pass\n * {@link GateResultInput} (no `at`) to {@link recordGate}.\n */\nexport interface GateResult extends GateResultInput {\n at: number;\n}\n\nexport interface TaskState {\n taskId: string;\n slug: string;\n projectId: ProjectId;\n state: WorkflowState;\n phase: Phase;\n mode: Mode;\n /**\n * DERIVED view of the gate audit for this task, regenerated from the\n * authoritative `audit:<taskId>` KV (S4 spec §11 OQ-5) by the engine on\n * every write and every status read. Kept on the TaskState so consumers\n * (CLI `task status`, the daemon `workflow_status` tool, MCP clients) can\n * read the gate history from the persisted TaskState without a second KV\n * lookup. Never edited directly outside the engine — derive via\n * {@link readGateHistory}.\n */\n history: GateResult[];\n /** Task classification (slice P). `undefined` ⇒ no soft PRD gate fires. */\n taskClass?: TaskClass;\n jumpEntry?: Phase; // recorded if a jump-to-phase happened\n /** Reason captured by `setBlocked` (admin escape; set directly, not via FSM). */\n blockReason?: string;\n updatedAt: number;\n}\n\n/**\n * The gate-config slice the engine consumes (debt-batch A / P4). Mirrors the\n * user-facing `prd:` block from @noir-ai/core's `NoirConfigSchema` — declared\n * locally (with a `readonly` array) so workflow has no core-cycle concern; the\n * daemon / CLI bridges NoirConfig → this shape at construction time. Every\n * field has a sane default, so an unspecified `gateConfig` (the legacy\n * constructor call) resolves to \"feature/epic trigger the PRD recommendation\".\n */\nexport interface WorkflowGateConfig {\n /**\n * PRD soft-gate config. `mandatoryFor` lists the task classes for which a\n * missing PRD artifact (at the moment of entering the spec phase, in full\n * mode) is surfaced as an observable, escapable recommendation on the spec\n * gate. The advance still proceeds — `--force <reason>` is the explicit\n * override path; quick mode + unlisted classes skip the check entirely.\n */\n prd: {\n mandatoryFor: readonly TaskClass[];\n };\n}\n","import type { Phase, WorkflowState } from './types.js';\n\n// Re-export the phase/state vocabularies so consumers can import the entire FSM\n// surface from one module (the test imports PHASES/STATES from here).\nexport { PHASES, STATES } from './types.js';\n\n// Legal state transitions (happy path + terminal). Gates (spec/plan/verify) are\n// modeled as the transition INTO specified/planned/done — the engine records a\n// GateResult at that point (see gates.ts).\nconst TRANSITIONS: Record<WorkflowState, WorkflowState[]> = {\n draft: ['clarifying'],\n clarifying: ['specified'],\n specified: ['planned'],\n planned: ['executing'],\n executing: ['verifying'],\n verifying: ['done'],\n done: [],\n blocked: ['draft', 'clarifying', 'specified', 'planned', 'executing', 'verifying'],\n abandoned: [],\n};\n\nexport function canTransition(from: WorkflowState, to: WorkflowState): boolean {\n return (TRANSITIONS[from] ?? []).includes(to);\n}\n\nexport function applyTransition(from: WorkflowState, to: WorkflowState): WorkflowState {\n if (!canTransition(from, to)) {\n const hint =\n to === 'planned'\n ? ' (the spec gate must be passed first)'\n : to === 'executing'\n ? ' (the plan gate must be passed first)'\n : to === 'done'\n ? ' (the verify gate must be passed first)'\n : '';\n throw new Error(`Illegal transition ${from} → ${to}${hint}`);\n }\n return to;\n}\n\n// Phase <-> state mapping for the engine.\nexport function stateForPhase(p: Phase): WorkflowState {\n switch (p) {\n case 'intake':\n return 'draft';\n case 'clarify':\n return 'clarifying';\n case 'spec':\n return 'specified';\n case 'plan':\n return 'planned';\n case 'execute':\n return 'executing';\n case 'verify':\n return 'verifying';\n case 'document':\n return 'done';\n }\n}\n\nexport function nextPhase(state: WorkflowState): Phase | null {\n const map: Partial<Record<WorkflowState, Phase>> = {\n draft: 'clarify',\n clarifying: 'spec',\n specified: 'plan',\n planned: 'execute',\n executing: 'verify',\n verifying: 'document',\n };\n return map[state] ?? null;\n}\n","import type { ProjectId } from '@noir-ai/core';\nimport type { Store } from '@noir-ai/store';\nimport { readPrd, writeAuditExport } from './artifacts.js';\nimport { gateFor, readGateHistory, recordGate } from './gates.js';\nimport { applyTransition, nextPhase, stateForPhase } from './state-machine.js';\nimport type {\n GateResult,\n GateResultInput,\n Mode,\n Phase,\n TaskClass,\n TaskState,\n WorkflowGateConfig,\n WorkflowState,\n} from './types.js';\n\n/**\n * Store KV layout. The TaskState lives at `workflow:<taskId>`; the gate audit\n * lives at `audit:<taskId>` and is the AUTHORITATIVE record for every gate\n * outcome (S4 spec §11 OQ-5). `task.history` on the TaskState is a DERIVED view\n * the engine regenerates from the audit KV (debt-batch A / W1 collapse) so\n * there is a single write, single timestamp, single read-back — no drift.\n */\nconst ACTIVE_KEY = 'workflow:active';\nconst GATE_PHASES = ['spec', 'plan', 'verify'] as const satisfies ReadonlyArray<Phase>;\n\n/**\n * Default gate config — used when the engine is constructed without an explicit\n * {@link WorkflowGateConfig} (the legacy 3-arg call shape every existing\n * consumer uses). Mirrors @noir-ai/core's `prd.mandatoryFor` default so the\n * soft PRD recommendation fires for feature/epic tasks out of the box.\n */\nconst DEFAULT_GATE_CONFIG: WorkflowGateConfig = {\n prd: { mandatoryFor: ['feature', 'epic'] },\n};\n\n/** Options for {@link WorkflowEngine.advance}. */\nexport interface AdvanceOpts {\n /**\n * Pass a gate without satisfying its criteria. Requires a non-empty `reason`\n * (validated here, in the engine — `recordGate` is policy-free). The landing\n * gate, if any, is recorded with `decision: 'forced'`. Mutually exclusive\n * with {@link skip}. This is ALSO the explicit-override path for the soft\n * PRD recommendation (debt-batch A / P4): supplying `--force <reason>` at the\n * spec gate of a mandatoryFor task with no PRD records `forced` with the\n * user's reason instead of the recommendation note.\n */\n force?: { reason?: string };\n /**\n * Jump directly to a phase, bypassing the FSM (the escape hatch for blocked /\n * out-of-order resumption). The landing is recorded as `jumpEntry` on the\n * TaskState; a landing gate-state (specified/planned/done) still records its\n * gate so the observable-checkpoint invariant holds. A no-op jump (target\n * equal to the current phase) returns the task unchanged without re-recording\n * any gate (W3 guard — the prior behavior double-stamped the audit).\n */\n to?: Phase;\n /**\n * Quick-mode: record the landing gate (if any) as `decision: 'skipped'`\n * instead of `approved`. The gate is still RECORDED — never silently dropped\n * (Noir §9.1 observable-checkpoint invariant) — only the decision changes.\n * Mutually exclusive with {@link force}.\n */\n skip?: true;\n}\n\n/**\n * The phase whose gate admits entry into `state` (the inverse of {@link gateFor}).\n *\n * The verify gate fires on entering `done` — but `done` is `stateForPhase('document')`,\n * so the target *phase* of that transition is `document`, not `verify`. The gate\n * must therefore be looked up from the target *state*, not the target phase.\n */\nfunction gatePhaseForState(state: WorkflowState): Phase | null {\n for (const p of GATE_PHASES) {\n if (gateFor(p) === state) return p;\n }\n return null;\n}\n\n/**\n * WorkflowEngine — drives an SDD task through its lifecycle.\n *\n * The engine is a thin orchestrator over three T1–T3 primitives:\n * • the hand-rolled FSM ({@link applyTransition}) for legal forward moves,\n * • {@link recordGate} for observable checkpoint audit, and\n * • the store KV for persisted {@link TaskState}.\n *\n * Policy that did not belong in T1/T2 lives here:\n * • `--force` requires a non-empty reason (validated before any gate write),\n * • `blocked` / `abandoned` have no incoming FSM edges and are set directly,\n * • `opts.to` jumps past FSM edges and is recorded via `jumpEntry`,\n * • (P4) the soft PRD recommendation at the spec gate for mandatoryFor tasks.\n *\n * Modes (Full/Quick) and cross-session resume are T5; MCP tools are T6 — the\n * engine stays mode-agnostic here and only stores `mode` on the TaskState.\n */\nexport class WorkflowEngine {\n private readonly gateConfig: WorkflowGateConfig;\n\n constructor(\n private readonly store: Store,\n /**\n * Project root — the engine is constructed with it so modes (quickPath\n * writes a spec stub via {@link writeSpec}) and future artifact flushes\n * (checkpoint/audit export) are self-contained. Public so the modes module\n * can pass it to {@link ArtifactWriter}.\n */\n readonly root: string,\n private readonly projectId: ProjectId,\n /**\n * Gate-config slice (debt-batch A / P4). Optional — the legacy 3-arg call\n * shape (every existing consumer) resolves to {@link DEFAULT_GATE_CONFIG}\n * (PRD recommendation fires for feature/epic). The daemon / CLI bridge\n * passes the resolved `prd.mandatoryFor` from NoirConfig so user overrides\n * take effect; tests pass an explicit shape to pin behavior.\n */\n gateConfig?: WorkflowGateConfig,\n ) {\n this.gateConfig = gateConfig ?? DEFAULT_GATE_CONFIG;\n }\n\n /**\n * Create a new task at draft/intake, persist it, and point `workflow:active`\n * at it. Re-starting an existing taskId overwrites it (intentional — the KV is\n * the source of truth, not a journal).\n *\n * `taskClass` (debt-batch A / P4) is optional and additive — legacy callers\n * (and existing tests) omit it; the soft PRD gate then never fires for the\n * task (consistent with the \"additive, no-op when absent\" rule). New callers\n * that want the recommendation pass `'feature'` / `'epic'` / etc.\n */\n async startTask(\n taskId: string,\n slug: string,\n mode: Mode,\n taskClass?: TaskClass,\n ): Promise<TaskState> {\n const task: TaskState = {\n taskId,\n slug,\n projectId: this.projectId,\n state: 'draft',\n phase: 'intake',\n mode,\n history: [],\n ...(taskClass !== undefined ? { taskClass } : {}),\n updatedAt: Date.now(),\n };\n this.persist(task);\n this.store.setState<string>(ACTIVE_KEY, taskId);\n return task;\n }\n\n /**\n * Advance `taskId` to its next phase, or jump with `opts.to`.\n *\n * At a gate-landing state (entering `specified` / `planned` / `done`) a\n * {@link GateResult} is recorded — `approved` by default, `forced` (with the\n * reason) when `opts.force` is supplied, or `skipped` when `opts.skip` is\n * supplied (quick mode). `force` and `skip` are mutually exclusive. Jumps\n * bypass the FSM and additionally stamp `jumpEntry`.\n *\n * Single source of truth (W1): the gate is written ONCE to the audit KV via\n * {@link recordGate}, and `task.history` is RE-DERIVED from that KV. No\n * second write, no second timestamp — the S4 sub-ms drift is gone.\n */\n async advance(taskId: string, opts?: AdvanceOpts): Promise<TaskState> {\n const task = this.requireTask(taskId);\n\n // Policy: --force and skip are mutually exclusive gate decisions (a gate\n // can't be both forced AND skipped). Validated BEFORE any gate write so a\n // bad combination never leaves a partial audit trail behind.\n if (opts?.force && opts?.skip) {\n throw new Error('cannot combine --force and skip');\n }\n // Policy: --force requires a non-empty reason.\n if (opts?.force && !opts.force.reason?.trim()) {\n throw new Error('--force requires a reason');\n }\n\n const jump = opts?.to !== undefined;\n const targetPhase: Phase = jump ? (opts?.to as Phase) : this.nextPhaseOf(task);\n\n // W3 guard: a jump to the CURRENT phase is a no-op. Previously the engine\n // re-stamped the audit (the landing gate fired again), producing a spurious\n // duplicate entry. Return the task unchanged — no gate, no state change.\n if (jump && targetPhase === task.phase) {\n return task;\n }\n\n const targetState = stateForPhase(targetPhase);\n if (!jump) {\n // applyTransition surfaces the FSM's gate hint on illegal moves.\n applyTransition(task.state, targetState);\n }\n\n // Observable checkpoint: entering specified/planned/done always records a\n // gate — looked up from the target STATE (see gatePhaseForState).\n const gatePhase = gatePhaseForState(targetState);\n if (gatePhase !== null) {\n const decision = opts?.force ? 'forced' : opts?.skip ? 'skipped' : 'approved';\n // P4 soft PRD recommendation: when entering `specified` (the spec gate),\n // the task is mandatoryFor-eligible, no PRD artifact exists, and the user\n // did NOT supply --force, fold a recommendation note into the recorded\n // gate's `reason`. The advance STILL PROCEEDS — this is the \"quiet\n // observable nudge\" doctrine (§9.1): never a hard block, never silently\n // dropped. Quick-mode + unlisted taskClasses skip the check entirely;\n // --force records `forced` with the user's reason (the explicit override).\n const prdHint = this.prdRecommendation(task, gatePhase, opts);\n const input: GateResultInput = {\n phase: gatePhase,\n decision,\n // exactOptionalPropertyTypes is false; spread reason only when present.\n // Force-path wins over the soft hint (a user who forces is explicitly\n // accepting the recommendation; their reason is the override signal).\n ...(opts?.force\n ? { reason: opts.force.reason }\n : prdHint !== null\n ? { reason: prdHint }\n : {}),\n };\n // W1: record ONCE to the authoritative audit KV; derive history from it.\n recordGate(this.store, taskId, input);\n task.history = readGateHistory(this.store, taskId);\n }\n\n task.state = targetState;\n task.phase = targetPhase;\n if (jump) task.jumpEntry = targetPhase;\n task.updatedAt = Date.now();\n\n this.persist(task);\n return task;\n }\n\n /**\n * Compute the soft PRD-recommendation message (P4), or `null` when the\n * recommendation does NOT apply. The recommendation applies when ALL of:\n * • the gate landing now is the spec gate (entering `specified`), AND\n * • the task is in full mode (quick mode skips — quickPath writes a stub), AND\n * • the task has a `taskClass` listed in `gateConfig.prd.mandatoryFor`, AND\n * • no PRD artifact exists at `.noir/prd/<id>-<slug>.md` (readPrd), AND\n * • the user did NOT supply --force (force is the explicit-override path).\n *\n * Returns the observable note (audited on the spec gate's `reason`) so a\n * downstream consumer (CLI status, workflow_status MCP tool) can surface it.\n */\n private prdRecommendation(task: TaskState, gatePhase: Phase, opts?: AdvanceOpts): string | null {\n if (gatePhase !== 'spec') return null;\n if (task.mode === 'quick') return null;\n const taskClass = task.taskClass;\n if (taskClass === undefined) return null;\n if (!this.gateConfig.prd.mandatoryFor.includes(taskClass)) return null;\n if (opts?.force) return null; // explicit override — user's reason wins\n if (readPrd(this.root, task.taskId, task.slug) !== null) return null;\n return `PRD recommended for ${taskClass} — provide one (noir-prd) or --force <reason> to skip`;\n }\n\n /** Read the persisted TaskState, or null if the task is unknown. */\n status(taskId: string): TaskState | null {\n const task = this.store.getState<TaskState>(workflowKey(taskId));\n if (!task) return null;\n // W1: re-derive history from the authoritative audit KV on every read, so\n // consumers see any externally-mutated audit (e.g. a manual KV write or a\n // future audit-import path) without waiting for the next advance.\n task.history = readGateHistory(this.store, taskId);\n return task;\n }\n\n /**\n * The taskId of the most-recently-started task (`workflow:active` in the\n * store KV), or `null` when no task has been started yet. Lets the MCP\n * `workflow_status` / `checkpoint` tools omit `taskId` and operate on the\n * active task.\n */\n activeTaskId(): string | null {\n return this.store.getState<string>(ACTIVE_KEY);\n }\n\n /**\n * Re-flush the current state to KV + flush the gate audit export to\n * `.noir/audit/<taskId>.json`. W2 (debt-batch A): the prior implementation\n * only bumped `updatedAt`, which every advance already does — vestigial.\n * Cross-session resume (`resumeTask`) reads `workflow:<id>` straight from the\n * KV and consumes nothing from this method; the S4 ledger noted the write was\n * dead. The fix is to WIRE the checkpoint to a real cross-tool artifact\n * flush: the audit JSON on disk (the S4 spec §11 OQ-5 \"export to\n * `.noir/audit/<taskId>.json`\" that {@link writeAuditExport} already\n * implemented but nothing called). The MCP `checkpoint { action:'save' }`\n * tool stays the public surface; its save now leaves a human-inspectable\n * audit JSON alongside the KV.\n */\n async checkpoint(taskId: string): Promise<void> {\n const task = this.store.getState<TaskState>(workflowKey(taskId));\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n task.updatedAt = Date.now();\n this.persist(task);\n // Flush the authoritative audit KV to disk for cross-tool inspection. The\n // audit is read straight from the KV (the SOT) so the JSON matches what\n // `status()` would report as `task.history`.\n writeAuditExport(this.root, taskId, readGateHistory(this.store, taskId));\n }\n\n /**\n * Set state directly to `blocked` (no FSM edge — the admin escape). The reason\n * is captured on the TaskState for surfacing in `noir.workflow_status`.\n *\n * W3: a SUPPLIED reason must be non-empty after trimming (mirrors the\n * `--force` policy). `setBlocked(id)` with no reason stays valid — it clears\n * no field and just flips state. A whitespace-only reason is rejected as\n * malformed (consistent with `--force`'s whitespace rejection).\n */\n async setBlocked(taskId: string, reason?: string): Promise<TaskState> {\n const task = this.requireTask(taskId);\n if (reason !== undefined) {\n const trimmed = reason.trim();\n if (trimmed.length === 0) {\n throw new Error('setBlocked reason must be non-empty (or omitted to leave unset)');\n }\n task.blockReason = trimmed;\n }\n task.state = 'blocked';\n task.updatedAt = Date.now();\n this.persist(task);\n return task;\n }\n\n /** Set state directly to `abandoned` (terminal; no FSM edge). */\n async abandon(taskId: string): Promise<TaskState> {\n const task = this.requireTask(taskId);\n task.state = 'abandoned';\n task.updatedAt = Date.now();\n this.persist(task);\n return task;\n }\n\n private nextPhaseOf(task: TaskState): Phase {\n const next = nextPhase(task.state);\n if (next === null) {\n throw new Error(`No next phase from state ${task.state}`);\n }\n return next;\n }\n\n private requireTask(taskId: string): TaskState {\n const task = this.store.getState<TaskState>(workflowKey(taskId));\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n return task;\n }\n\n private persist(task: TaskState): void {\n this.store.setState<TaskState>(workflowKey(task.taskId), task);\n }\n}\n\nfunction workflowKey(taskId: string): string {\n return `workflow:${taskId}`;\n}\n","import type { Store } from '@noir-ai/store';\nimport { writeSpec } from './artifacts.js';\nimport type { WorkflowEngine } from './engine.js';\nimport type { TaskState, WorkflowState } from './types.js';\n\n/**\n * Default spec body written by {@link runQuick}. Quick mode is \"discipline\n * lite\": the spec is stubbed (the spec + plan gates are skipped), but a real\n * spec file still lands on disk so later phases (and humans) have something to\n * read. Exported so callers/tests can assert on the canonical stub text.\n */\nexport const QUICK_SPEC_STUB = '<quick-mode stub spec>';\n\n/** Options for {@link runQuick}. */\nexport interface QuickOpts {\n /**\n * Override the stub spec body (defaults to {@link QUICK_SPEC_STUB}). Useful\n * when a quick task already has a one-line description worth persisting.\n */\n specBody?: string;\n}\n\n/**\n * Quick mode — the fast path for small / spike tasks.\n *\n * Writes a stub spec to `.noir/specs/<taskId>-<slug>.md` (via\n * {@link writeSpec}) and fast-forwards the task from `draft` to `executing`,\n * recording the spec and plan gates as `decision: 'skipped'`. The verify gate\n * is intentionally LEFT alone — it still fires as `approved` when the task\n * later reaches `done`, providing the one real checkpoint (discipline lite).\n *\n * The task must already be started (`engine.startTask(..., 'quick')`); this\n * function reads the slug from the persisted TaskState. Skipped gates are\n * RECORDED (Noir §9.1 observable-checkpoint invariant), never silently dropped\n * — the audit KV and `history` both carry the `skipped` entries.\n */\nexport async function runQuick(\n engine: WorkflowEngine,\n taskId: string,\n opts?: QuickOpts,\n): Promise<TaskState> {\n const task = engine.status(taskId);\n if (!task) throw new Error(`Unknown task: ${taskId}`);\n\n // Flush the stub spec first so the artifact exists before any gate fires.\n writeSpec(engine.root, taskId, task.slug, opts?.specBody ?? QUICK_SPEC_STUB);\n\n // draft → clarifying → specified (spec gate SKIPPED) → planned (plan gate\n // SKIPPED) → executing. Verify is left for the normal flow (runs as approved).\n await engine.advance(taskId); // draft → clarifying (no gate)\n await engine.advance(taskId, { skip: true }); // → specified (spec gate skipped)\n await engine.advance(taskId, { skip: true }); // → planned (plan gate skipped)\n return engine.advance(taskId); // → executing (no gate) — land here\n}\n\n/** Terminal states with nothing left to resume. `blocked` is NOT terminal. */\nconst TERMINAL_STATES: ReadonlySet<WorkflowState> = new Set<WorkflowState>(['done', 'abandoned']);\n\n/**\n * Reconstruct the in-flight TaskState across a session break.\n *\n * Reads `workflow:active` from the store KV → the most-recently-started\n * taskId (T4 semantics; v1 is one-task-per-project) → the persisted\n * `workflow:<taskId>` TaskState. Returns `null` when there is no active task,\n * the task record is missing, or the active task is terminal (`done` /\n * `abandoned` — nothing to resume). A `blocked` task IS resumable.\n *\n * Uses only the public {@link Store} API, so a freshly-`openStore`'d handle\n * against the same on-disk DB is sufficient (no live engine required).\n */\nexport async function resumeTask(store: Store): Promise<TaskState | null> {\n const activeId = store.getState<string>('workflow:active');\n if (!activeId) return null;\n\n const task = store.getState<TaskState>(`workflow:${activeId}`);\n if (!task) return null;\n if (TERMINAL_STATES.has(task.state)) return null;\n\n return task;\n}\n"],"mappings":";AAAA,SAAS,YAAY,WAAW,cAAc,qBAAqB;AACnE,SAAS,YAAY;AACrB,SAAS,aAAa;AAMf,SAAS,YAAY,MAAc,QAAgB,SAAuB;AAC/E,QAAM,MAAM,KAAK,MAAM,SAAS,QAAQ;AACxC,QAAM,OAAO,KAAK,KAAK,GAAG,MAAM,KAAK;AAErC,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAClC,gBAAc,MAAM,SAAS,OAAO;AACtC;AAMO,SAAS,UAAU,MAAc,QAAgB,MAAc,MAAoB;AACxF,QAAM,MAAM,MAAM,SAAS,IAAI;AAC/B,QAAM,OAAO,MAAM,SAAS,MAAM,QAAQ,IAAI;AAE9C,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAElC,QAAM,UAAU;AAAA,UACR,MAAM;AAAA,QACR,IAAI;AAAA;AAAA;AAAA,EAGV,IAAI;AAEJ,gBAAc,MAAM,SAAS,OAAO;AACtC;AAMO,SAAS,SAAS,MAAc,QAAgB,MAAc,MAAoB;AACvF,QAAM,MAAM,MAAM,OAAO,IAAI;AAC7B,QAAM,OAAO,MAAM,QAAQ,MAAM,QAAQ,IAAI;AAE7C,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAElC,QAAM,UAAU;AAAA,UACR,MAAM;AAAA,QACR,IAAI;AAAA;AAAA;AAAA,EAGV,IAAI;AAEJ,gBAAc,MAAM,SAAS,OAAO;AACtC;AAGO,SAAS,QAAQ,MAAc,QAAgB,MAA6B;AACjF,QAAM,OAAO,MAAM,QAAQ,MAAM,QAAQ,IAAI;AAC7C,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO;AAC9B,SAAO,aAAa,MAAM,OAAO;AACnC;AAKO,SAAS,UAAU,MAAc,QAAgB,MAAc,MAAoB;AACxF,QAAM,MAAM,MAAM,SAAS,IAAI;AAC/B,QAAM,OAAO,MAAM,SAAS,MAAM,QAAQ,IAAI;AAE9C,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAElC,QAAM,UAAU;AAAA,UACR,MAAM;AAAA,QACR,IAAI;AAAA;AAAA;AAAA,EAGV,IAAI;AAEJ,gBAAc,MAAM,SAAS,OAAO;AACtC;AAKO,SAAS,UAAU,MAAc,QAAgB,UAAkB,MAAoB;AAC5F,QAAM,MAAM,MAAM,SAAS,IAAI;AAC/B,QAAM,OAAO,MAAM,SAAS,MAAM,QAAQ,QAAQ;AAElD,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAElC,QAAM,UAAU;AAAA,UACR,MAAM;AAAA,QACR,QAAQ;AAAA;AAAA;AAAA,EAGd,IAAI;AAEJ,gBAAc,MAAM,SAAS,OAAO;AACtC;AAKO,SAAS,kBAAkB,MAAc,GAAW,OAAqB;AAC9E,QAAM,MAAM,MAAM,aAAa,IAAI;AACnC,QAAM,OAAO,MAAM,aAAa,MAAM,CAAC;AAEvC,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAElC,QAAM,UAAU,KAAK,KAAK;AAAA;AAAA,mBAET,CAAC;AAAA;AAAA;AAAA;AAKlB,gBAAc,MAAM,SAAS,OAAO;AACtC;AAKO,SAAS,mBAAmB,MAAc,OAAqB;AACpE,QAAM,OAAO,KAAK,MAAM,SAAS,cAAc;AAG/C,YAAU,KAAK,MAAM,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;AAElD,MAAI;AACJ,MAAI,WAAW,IAAI,GAAG;AAGpB,UAAM,WAAW,aAAa,MAAM,OAAO;AAC3C,UAAM,SAAS,SAAS,SAAS,IAAI,IAAI,WAAW,GAAG,QAAQ;AAAA;AAC/D,cAAU,GAAG,MAAM,GAAG,KAAK;AAAA;AAAA,EAC7B,OAAO;AACL,cAAU;AAAA;AAAA,EAEZ,KAAK;AAAA;AAAA,EAEL;AAEA,gBAAc,MAAM,SAAS,OAAO;AACtC;AAMO,SAAS,iBAAiB,MAAc,QAAgB,SAA6B;AAC1F,QAAM,MAAM,MAAM,SAAS,IAAI;AAC/B,QAAM,OAAO,MAAM,UAAU,MAAM,MAAM;AAEzC,YAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAClC,gBAAc,MAAM,KAAK,UAAU,SAAS,MAAM,CAAC,GAAG,OAAO;AAC/D;;;AC7IO,SAAS,QAAQ,OAAoC;AAC1D,UAAQ,OAAO;AAAA,IACb,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAsBO,SAAS,WAAW,OAAc,QAAgB,QAAqC;AAC5F,QAAM,MAAM,SAAS,MAAM;AAC3B,QAAM,QAAQ,MAAM,SAAuB,GAAG,KAAK,CAAC;AACpD,QAAM,WAAuB,EAAE,GAAG,QAAQ,IAAI,KAAK,IAAI,EAAE;AACzD,QAAM,SAAuB,KAAK,CAAC,GAAG,OAAO,QAAQ,CAAC;AACtD,SAAO;AACT;AAaO,SAAS,gBAAgB,OAAc,QAA8B;AAC1E,SAAO,MAAM,SAAuB,SAAS,MAAM,EAAE,KAAK,CAAC;AAC7D;;;ACnEO,IAAM,SAAS;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,IAAM,SAAS;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAaO,IAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;;;ACnCA,IAAM,cAAsD;AAAA,EAC1D,OAAO,CAAC,YAAY;AAAA,EACpB,YAAY,CAAC,WAAW;AAAA,EACxB,WAAW,CAAC,SAAS;AAAA,EACrB,SAAS,CAAC,WAAW;AAAA,EACrB,WAAW,CAAC,WAAW;AAAA,EACvB,WAAW,CAAC,MAAM;AAAA,EAClB,MAAM,CAAC;AAAA,EACP,SAAS,CAAC,SAAS,cAAc,aAAa,WAAW,aAAa,WAAW;AAAA,EACjF,WAAW,CAAC;AACd;AAEO,SAAS,cAAc,MAAqB,IAA4B;AAC7E,UAAQ,YAAY,IAAI,KAAK,CAAC,GAAG,SAAS,EAAE;AAC9C;AAEO,SAAS,gBAAgB,MAAqB,IAAkC;AACrF,MAAI,CAAC,cAAc,MAAM,EAAE,GAAG;AAC5B,UAAM,OACJ,OAAO,YACH,0CACA,OAAO,cACL,0CACA,OAAO,SACL,4CACA;AACV,UAAM,IAAI,MAAM,sBAAsB,IAAI,WAAM,EAAE,GAAG,IAAI,EAAE;AAAA,EAC7D;AACA,SAAO;AACT;AAGO,SAAS,cAAc,GAAyB;AACrD,UAAQ,GAAG;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;AAEO,SAAS,UAAU,OAAoC;AAC5D,QAAM,MAA6C;AAAA,IACjD,OAAO;AAAA,IACP,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,SAAS;AAAA,IACT,WAAW;AAAA,IACX,WAAW;AAAA,EACb;AACA,SAAO,IAAI,KAAK,KAAK;AACvB;;;AC/CA,IAAM,aAAa;AACnB,IAAM,cAAc,CAAC,QAAQ,QAAQ,QAAQ;AAQ7C,IAAM,sBAA0C;AAAA,EAC9C,KAAK,EAAE,cAAc,CAAC,WAAW,MAAM,EAAE;AAC3C;AAuCA,SAAS,kBAAkB,OAAoC;AAC7D,aAAW,KAAK,aAAa;AAC3B,QAAI,QAAQ,CAAC,MAAM,MAAO,QAAO;AAAA,EACnC;AACA,SAAO;AACT;AAmBO,IAAM,iBAAN,MAAqB;AAAA,EAG1B,YACmB,OAOR,MACQ,WAQjB,YACA;AAjBiB;AAOR;AACQ;AAUjB,SAAK,aAAa,cAAc;AAAA,EAClC;AAAA,EAnBmB;AAAA,EAOR;AAAA,EACQ;AAAA,EAXF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkCjB,MAAM,UACJ,QACA,MACA,MACA,WACoB;AACpB,UAAM,OAAkB;AAAA,MACtB;AAAA,MACA;AAAA,MACA,WAAW,KAAK;AAAA,MAChB,OAAO;AAAA,MACP,OAAO;AAAA,MACP;AAAA,MACA,SAAS,CAAC;AAAA,MACV,GAAI,cAAc,SAAY,EAAE,UAAU,IAAI,CAAC;AAAA,MAC/C,WAAW,KAAK,IAAI;AAAA,IACtB;AACA,SAAK,QAAQ,IAAI;AACjB,SAAK,MAAM,SAAiB,YAAY,MAAM;AAC9C,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,MAAM,QAAQ,QAAgB,MAAwC;AACpE,UAAM,OAAO,KAAK,YAAY,MAAM;AAKpC,QAAI,MAAM,SAAS,MAAM,MAAM;AAC7B,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AAEA,QAAI,MAAM,SAAS,CAAC,KAAK,MAAM,QAAQ,KAAK,GAAG;AAC7C,YAAM,IAAI,MAAM,2BAA2B;AAAA,IAC7C;AAEA,UAAM,OAAO,MAAM,OAAO;AAC1B,UAAM,cAAqB,OAAQ,MAAM,KAAe,KAAK,YAAY,IAAI;AAK7E,QAAI,QAAQ,gBAAgB,KAAK,OAAO;AACtC,aAAO;AAAA,IACT;AAEA,UAAM,cAAc,cAAc,WAAW;AAC7C,QAAI,CAAC,MAAM;AAET,sBAAgB,KAAK,OAAO,WAAW;AAAA,IACzC;AAIA,UAAM,YAAY,kBAAkB,WAAW;AAC/C,QAAI,cAAc,MAAM;AACtB,YAAM,WAAW,MAAM,QAAQ,WAAW,MAAM,OAAO,YAAY;AAQnE,YAAM,UAAU,KAAK,kBAAkB,MAAM,WAAW,IAAI;AAC5D,YAAM,QAAyB;AAAA,QAC7B,OAAO;AAAA,QACP;AAAA;AAAA;AAAA;AAAA,QAIA,GAAI,MAAM,QACN,EAAE,QAAQ,KAAK,MAAM,OAAO,IAC5B,YAAY,OACV,EAAE,QAAQ,QAAQ,IAClB,CAAC;AAAA,MACT;AAEA,iBAAW,KAAK,OAAO,QAAQ,KAAK;AACpC,WAAK,UAAU,gBAAgB,KAAK,OAAO,MAAM;AAAA,IACnD;AAEA,SAAK,QAAQ;AACb,SAAK,QAAQ;AACb,QAAI,KAAM,MAAK,YAAY;AAC3B,SAAK,YAAY,KAAK,IAAI;AAE1B,SAAK,QAAQ,IAAI;AACjB,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcQ,kBAAkB,MAAiB,WAAkB,MAAmC;AAC9F,QAAI,cAAc,OAAQ,QAAO;AACjC,QAAI,KAAK,SAAS,QAAS,QAAO;AAClC,UAAM,YAAY,KAAK;AACvB,QAAI,cAAc,OAAW,QAAO;AACpC,QAAI,CAAC,KAAK,WAAW,IAAI,aAAa,SAAS,SAAS,EAAG,QAAO;AAClE,QAAI,MAAM,MAAO,QAAO;AACxB,QAAI,QAAQ,KAAK,MAAM,KAAK,QAAQ,KAAK,IAAI,MAAM,KAAM,QAAO;AAChE,WAAO,uBAAuB,SAAS;AAAA,EACzC;AAAA;AAAA,EAGA,OAAO,QAAkC;AACvC,UAAM,OAAO,KAAK,MAAM,SAAoB,YAAY,MAAM,CAAC;AAC/D,QAAI,CAAC,KAAM,QAAO;AAIlB,SAAK,UAAU,gBAAgB,KAAK,OAAO,MAAM;AACjD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,eAA8B;AAC5B,WAAO,KAAK,MAAM,SAAiB,UAAU;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,MAAM,WAAW,QAA+B;AAC9C,UAAM,OAAO,KAAK,MAAM,SAAoB,YAAY,MAAM,CAAC;AAC/D,QAAI,CAAC,KAAM,OAAM,IAAI,MAAM,iBAAiB,MAAM,EAAE;AACpD,SAAK,YAAY,KAAK,IAAI;AAC1B,SAAK,QAAQ,IAAI;AAIjB,qBAAiB,KAAK,MAAM,QAAQ,gBAAgB,KAAK,OAAO,MAAM,CAAC;AAAA,EACzE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,WAAW,QAAgB,QAAqC;AACpE,UAAM,OAAO,KAAK,YAAY,MAAM;AACpC,QAAI,WAAW,QAAW;AACxB,YAAM,UAAU,OAAO,KAAK;AAC5B,UAAI,QAAQ,WAAW,GAAG;AACxB,cAAM,IAAI,MAAM,iEAAiE;AAAA,MACnF;AACA,WAAK,cAAc;AAAA,IACrB;AACA,SAAK,QAAQ;AACb,SAAK,YAAY,KAAK,IAAI;AAC1B,SAAK,QAAQ,IAAI;AACjB,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,QAAQ,QAAoC;AAChD,UAAM,OAAO,KAAK,YAAY,MAAM;AACpC,SAAK,QAAQ;AACb,SAAK,YAAY,KAAK,IAAI;AAC1B,SAAK,QAAQ,IAAI;AACjB,WAAO;AAAA,EACT;AAAA,EAEQ,YAAY,MAAwB;AAC1C,UAAM,OAAO,UAAU,KAAK,KAAK;AACjC,QAAI,SAAS,MAAM;AACjB,YAAM,IAAI,MAAM,4BAA4B,KAAK,KAAK,EAAE;AAAA,IAC1D;AACA,WAAO;AAAA,EACT;AAAA,EAEQ,YAAY,QAA2B;AAC7C,UAAM,OAAO,KAAK,MAAM,SAAoB,YAAY,MAAM,CAAC;AAC/D,QAAI,CAAC,KAAM,OAAM,IAAI,MAAM,iBAAiB,MAAM,EAAE;AACpD,WAAO;AAAA,EACT;AAAA,EAEQ,QAAQ,MAAuB;AACrC,SAAK,MAAM,SAAoB,YAAY,KAAK,MAAM,GAAG,IAAI;AAAA,EAC/D;AACF;AAEA,SAAS,YAAY,QAAwB;AAC3C,SAAO,YAAY,MAAM;AAC3B;;;AC3VO,IAAM,kBAAkB;AAyB/B,eAAsB,SACpB,QACA,QACA,MACoB;AACpB,QAAM,OAAO,OAAO,OAAO,MAAM;AACjC,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,iBAAiB,MAAM,EAAE;AAGpD,YAAU,OAAO,MAAM,QAAQ,KAAK,MAAM,MAAM,YAAY,eAAe;AAI3E,QAAM,OAAO,QAAQ,MAAM;AAC3B,QAAM,OAAO,QAAQ,QAAQ,EAAE,MAAM,KAAK,CAAC;AAC3C,QAAM,OAAO,QAAQ,QAAQ,EAAE,MAAM,KAAK,CAAC;AAC3C,SAAO,OAAO,QAAQ,MAAM;AAC9B;AAGA,IAAM,kBAA8C,oBAAI,IAAmB,CAAC,QAAQ,WAAW,CAAC;AAchG,eAAsB,WAAW,OAAyC;AACxE,QAAM,WAAW,MAAM,SAAiB,iBAAiB;AACzD,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,OAAO,MAAM,SAAoB,YAAY,QAAQ,EAAE;AAC7D,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,gBAAgB,IAAI,KAAK,KAAK,EAAG,QAAO;AAE5C,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noir-ai/workflow",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.2.0-beta.1",
4
4
  "description": "Noir workflow — the spec-driven lifecycle engine (intake → document) with observable, escapable gates.",
5
5
  "license": "MIT",
6
6
  "author": "agaaaptr",
@@ -43,8 +43,8 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@noir-ai/core": "1.0.0-beta.1",
47
- "@noir-ai/store": "1.0.0-beta.1"
46
+ "@noir-ai/core": "1.2.0-beta.1",
47
+ "@noir-ai/store": "1.2.0-beta.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^26.1.1"