@khoavu882/doflow 2.0.0 → 2.0.2

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.
Files changed (176) hide show
  1. package/README.md +4 -5
  2. package/bin/doflow.js +1 -1
  3. package/core/.claude-plugin/plugin.json +1 -1
  4. package/core/.codex-plugin/plugin.json +1 -1
  5. package/core/.plugin/plugin.json +1 -1
  6. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
  8. package/core/harnesses/claude/hooks/session-end.sh +21 -1
  9. package/core/harnesses/claude/settings/settings.json +8 -9
  10. package/core/harnesses/codex/hooks/hooks.json +0 -10
  11. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
  12. package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
  13. package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
  14. package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
  15. package/core/harnesses/copilot/hooks/doflow.json +0 -6
  16. package/core/harnesses/kiro/agents/doflow.json +0 -10
  17. package/core/harnesses/kiro/hooks/doflow.json +0 -10
  18. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
  19. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
  20. package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
  21. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
  22. package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
  23. package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
  24. package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
  25. package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
  26. package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
  27. package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
  28. package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
  29. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
  30. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
  31. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
  32. package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
  33. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +26 -2
  34. package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
  35. package/core/registry/contracts.json +1 -1
  36. package/core/registry/contribution-points.json +5 -2
  37. package/core/registry/gates.json +5 -5
  38. package/core/registry/harnesses.json +9 -9
  39. package/core/registry/intake.json +53 -0
  40. package/core/registry/lifecycle.json +176 -503
  41. package/core/registry/package-templates/intake-category/files/category.json +8 -0
  42. package/core/registry/package-templates/intake-category/template.json +4 -0
  43. package/core/registry/packages/doflow/doflow-package.json +2 -2
  44. package/core/registry/rules.json +8 -8
  45. package/core/registry/settings.json +12 -16
  46. package/core/registry/storage.json +9 -2
  47. package/core/registry/telemetry.json +1 -1
  48. package/core/registry/verification.json +0 -5
  49. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
  50. package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
  51. package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
  52. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
  53. package/core/shared/guidance/rules/RULES.md +2 -2
  54. package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
  55. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
  56. package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
  57. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
  58. package/core/shared/scripts/doflow/bin/doflow-run +23 -2
  59. package/core/shared/skills/do/SKILL.md +3 -3
  60. package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
  61. package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
  62. package/core/shared/skills/do-code-review/SKILL.md +7 -4
  63. package/core/shared/skills/do-design/SKILL.md +11 -5
  64. package/core/shared/skills/do-design/references/data_model_example.md +167 -0
  65. package/core/shared/skills/do-diagnose/SKILL.md +4 -2
  66. package/core/shared/skills/do-document/SKILL.md +1 -0
  67. package/core/shared/skills/do-document/references/embedded-research.md +1 -1
  68. package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
  69. package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
  70. package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
  71. package/core/shared/skills/do-flow/SKILL.md +4 -4
  72. package/core/shared/skills/do-flow/references/task_classes.md +5 -5
  73. package/core/shared/skills/do-implement/SKILL.md +9 -7
  74. package/core/shared/skills/do-plan/SKILL.md +9 -6
  75. package/core/shared/templates/doflow/data-model-template.md +136 -102
  76. package/core/shared/workflows/do-flow-chain.md +2 -0
  77. package/package.json +1 -1
  78. package/src/adapters/antigravity/index.js +54 -12
  79. package/src/adapters/claude/index.js +23 -2
  80. package/src/adapters/codex/agents.js +15 -1
  81. package/src/adapters/codex/config.js +5 -2
  82. package/src/adapters/codex/hooks.js +39 -3
  83. package/src/adapters/codex/index.js +13 -0
  84. package/src/adapters/copilot/index.js +11 -4
  85. package/src/adapters/copy-tree.js +8 -2
  86. package/src/adapters/created-file.js +41 -0
  87. package/src/adapters/mcp-entries.js +11 -1
  88. package/src/adapters/opencode/index.js +32 -5
  89. package/src/adapters/pi/index.js +15 -4
  90. package/src/adapters/pi/mcp.js +44 -18
  91. package/src/cli/commands/remove.js +3 -1
  92. package/src/cli/commands/status.js +1 -1
  93. package/src/cli/commands/update.js +7 -3
  94. package/src/cli/index.js +43 -19
  95. package/src/cli/runtime-commands.js +13 -5
  96. package/src/cli/shared.js +5 -3
  97. package/src/helper/marker-merge.js +11 -7
  98. package/src/install/backup-set.js +7 -3
  99. package/src/install/backup.js +13 -7
  100. package/src/install/context.js +2 -1
  101. package/src/install/manifest.js +35 -11
  102. package/src/install/paths.js +38 -0
  103. package/src/install/targets.js +11 -2
  104. package/src/lifecycle/coverage.js +1 -30
  105. package/src/lifecycle/hook-tier.js +2 -2
  106. package/src/lifecycle/index.js +80 -3
  107. package/src/lifecycle/retired-harnesses.js +16 -1
  108. package/src/lifecycle/view.js +3 -2
  109. package/src/registry/index.js +21 -4
  110. package/src/release/docs-blocks.js +37 -53
  111. package/src/runtime/advice/doctor.js +2 -2
  112. package/src/runtime/brief-tools.js +2 -1
  113. package/src/runtime/capability-router.js +26 -3
  114. package/src/runtime/context/pointer.js +84 -17
  115. package/src/runtime/context-pack.js +52 -9
  116. package/src/runtime/dispatch/integrate.js +27 -8
  117. package/src/runtime/dispatch/loop.js +7 -0
  118. package/src/runtime/dispatch/verify-run.js +9 -3
  119. package/src/runtime/evidence-ledger.js +18 -0
  120. package/src/runtime/feature-pointer.js +10 -1
  121. package/src/runtime/health.js +68 -5
  122. package/src/runtime/implementation-gate.js +14 -6
  123. package/src/runtime/intake/engine.js +679 -0
  124. package/src/runtime/intake/index.js +136 -0
  125. package/src/runtime/intake/manifest.js +140 -0
  126. package/src/runtime/intake/taxonomy.js +206 -0
  127. package/src/runtime/knowledge/code-retrieval.js +11 -5
  128. package/src/runtime/knowledge/semble-provider.js +12 -2
  129. package/src/runtime/lifecycle/cli.js +76 -6
  130. package/src/runtime/lifecycle/followup.js +32 -6
  131. package/src/runtime/lifecycle/overview.js +34 -3
  132. package/src/runtime/lifecycle/release.js +16 -3
  133. package/src/runtime/lifecycle/retention.js +12 -15
  134. package/src/runtime/lifecycle/store-upkeep.js +13 -3
  135. package/src/runtime/mask.js +3 -3
  136. package/src/runtime/mcp-consultations.js +11 -5
  137. package/src/runtime/packages/points.js +10 -0
  138. package/src/runtime/research-request.js +3 -1
  139. package/src/runtime/review/command.js +3 -7
  140. package/src/runtime/review/start.js +24 -10
  141. package/src/runtime/roadmap/attendance.js +137 -0
  142. package/src/runtime/roadmap/gates.js +6 -3
  143. package/src/runtime/roadmap/loop.js +127 -33
  144. package/src/runtime/roadmap/merge.js +17 -8
  145. package/src/runtime/roadmap/rows.js +15 -5
  146. package/src/runtime/roadmap/seed.js +153 -41
  147. package/src/runtime/roadmap/worktree-cleanup.js +147 -0
  148. package/src/runtime/settings/files.js +3 -1
  149. package/src/runtime/settings/prepared-run.js +12 -2
  150. package/src/runtime/settings/registry.js +1 -1
  151. package/src/runtime/settings/resolve.js +13 -1
  152. package/src/runtime/settings/write.js +73 -2
  153. package/src/runtime/slots.js +1 -1
  154. package/src/runtime/storage/registry.js +2 -2
  155. package/src/runtime/storage/roots.js +4 -18
  156. package/src/runtime/storage/rules.js +22 -46
  157. package/src/runtime/storage/work-status.js +14 -4
  158. package/src/runtime/task-classifier.js +39 -4
  159. package/src/runtime/task-scope.js +17 -2
  160. package/src/runtime/task-state.js +13 -7
  161. package/src/runtime/trace/analyze.js +4 -2
  162. package/src/runtime/trace/indicators.js +25 -20
  163. package/src/runtime/trace/spans.js +4 -5
  164. package/src/runtime/verb-view.js +268 -0
  165. package/src/runtime/verification/engine.js +0 -45
  166. package/src/runtime/worker/command.js +4 -2
  167. package/src/runtime/worker/launch.js +5 -37
  168. package/src/runtime/worker/readiness.js +10 -6
  169. package/src/runtime/worker/supervisor.js +18 -6
  170. package/src/runtime/workflow-orchestrator.js +39 -7
  171. package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
  172. package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
  173. package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
  174. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
  175. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
  176. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
@@ -109,15 +109,16 @@ function readBounded(file , maxBytes )
109
109
  }
110
110
  }
111
111
 
112
- function parseRecordLines(file ) {
112
+ function parseRecordLines(file ) {
113
113
  const read = readBounded(file, MAX_RECORD_BYTES);
114
114
  if ('error' in read) return { error: read.error };
115
+ const text = 'text' in read ? read.text : '';
115
116
  const records = [];
116
- for (const [index, line] of ('text' in read ? read.text : '').split('\n').entries()) {
117
+ for (const [index, line] of text.split('\n').entries()) {
117
118
  if (!line.trim()) continue;
118
119
  try { records.push(JSON.parse(line)); } catch (error ) { return { error: `line ${index + 1}: ${error.message}` }; }
119
120
  }
120
- return { records };
121
+ return { records, text };
121
122
  }
122
123
 
123
124
 
@@ -243,20 +244,23 @@ function loadGatePolicy(cwd )
243
244
  * is on, so the class seat floor binds; maint-thin), the gate policy (the shipped gates.json narrowed by the
244
245
  * project's `.doflow/gates.override.json`, read as roadmap gate-b reads it, 119 IC-005; null when there is no task class, so
245
246
  * no floor), the protocols registry and the enforcement inputs (`loadEnforcementInputs`). A base, override or registry the loaders reject is a refusal carrying the loader's
246
- * reason (and the override file). Shared with the `protocol` verb.
247
+ * reason (and the override file). Shared with the `protocol` verb. `replaced` names the settings a prepared run
248
+ * (`config validate --prepared-run`) writes to the project layer: an invalid project candidate of such a key is
249
+ * replaced by it, so it does not refuse here.
247
250
  */
248
- function loadProtocolInputs(taskClass , cwd , env = process.env)
251
+ function loadProtocolInputs(taskClass , cwd , env = process.env, replaced = new Set())
249
252
  {
250
253
  const settingsView = {};
251
254
  try {
252
255
  const settings = require('../settings/resolve.js').loadSettings({ projectRoot: path.resolve(cwd), env });
253
256
  for (const key of PROTOCOL_SETTINGS) {
254
257
  const resolution = settings.get(key);
255
- // C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead.
256
- // A preset key refuses an invalid user candidate too.
257
- const invalid = key.endsWith('.preset') ? invalidPresetCandidate(resolution)
258
- : key.startsWith('review.') ? invalidProjectCandidate(resolution) : undefined;
259
- if (invalid) return { error: 'settings-unavailable', message: `${key} in ${invalid.source}: ${invalid.reason}` };
258
+ // C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead,
259
+ // except a project candidate that a prepared run replaces. A preset key refuses an invalid user candidate too.
260
+ const judged = replaced.has(key) ? { ...resolution, candidates: (resolution.candidates ?? []).filter((c ) => c.layer !== 'project') } : resolution;
261
+ const stands = key.endsWith('.preset') ? invalidPresetCandidate(judged)
262
+ : key.startsWith('review.') ? invalidProjectCandidate(judged) : undefined;
263
+ if (stands) return { error: 'settings-unavailable', message: `${key} in ${stands.source}: ${stands.reason}` };
260
264
  settingsView[key] = { value: resolution.value, layer: resolution.origin.layer };
261
265
  }
262
266
  } catch (error ) {
@@ -429,12 +433,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
429
433
  result.applicable = result.notApplicable === null;
430
434
 
431
435
  let nextCommand = null;
436
+ let seen = null;
432
437
  if (result.applicable) {
433
438
  const state = readHeadState(cwd, env);
434
439
  result.head = state.head;
435
440
  result.clean = state.clean;
436
441
  const parsed = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
437
442
  if ('error' in parsed) return refuse('records-unreadable', parsed.error);
443
+ seen = { head: state.head, clean: state.clean, text: parsed.text };
438
444
  result.reasons = reviewPrecondition({ records: parsed.records, head: state.head, clean: state.clean, slug: verifyId });
439
445
  if (result.reasons.length) {
440
446
  result.ok = false;
@@ -507,6 +513,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
507
513
  const decided = resolveProtocol({ ...facts, advicePick: null });
508
514
  if (!decided.ok) return refuseResolution(decided.reason);
509
515
  const advice = await askAdvice({ decision: { depth: decided.protocol.depth, scope }, risk: result.risk, taskClass, slug: resolvedSlug, files: paths ? paths.length : null, cwd, env, adviceClient });
516
+ // The advice await can take a while: what the precondition and the risk were read from must still stand.
517
+ if (seen && advice.status !== 'off') {
518
+ const now = readHeadState(cwd, env);
519
+ const records = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
520
+ if (now.head !== seen.head || now.clean !== seen.clean || 'error' in records || records.text !== seen.text) {
521
+ return refuse('state-moved', 'HEAD, the working tree or the verification records changed while the advice was asked; run review start again');
522
+ }
523
+ }
510
524
  const resolved = resolveProtocol({ ...facts, advicePick: advice.pick });
511
525
  if (!resolved.ok) return refuseResolution(resolved.reason);
512
526
  const { protocol } = resolved;
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `doflow-run attendance --task-id <slug> [--run <id>]` (autonomy plan section 2): a read-only answer
5
+ * to "is a person watching this stage?", printed as `unattended <source>` or `attended <reason>`, exit
6
+ * 0. A skill reads it once per stage and resolves its own questions to recorded defaults when the
7
+ * answer is `unattended`; no hook reads it.
8
+ *
9
+ * `unattended` when either holds:
10
+ * (a) `DOFLOW_SESSION_ROLE` is `worker` (the launcher sets it), matched case-insensitively and
11
+ * literally: an unrecognised value is NOT a worker here, although the hook identity policy
12
+ * treats it as one, because uncertainty must read as `attended`; or
13
+ * (b) all of: the run record `.doflow/state/roadmap/runs/<run>.json` is open (no `closedAt`); its
14
+ * slot for the seed row with this slug holds a `perform` or the loop's `postMerge` marker (the stage is
15
+ * still pending; the loop clears the marker on its next merge step) and no `held` or
16
+ * `paused` mark; that row is not on `hold`; and the autopilot kill switch is enabled (the reader the policy decisions use).
17
+ * Anything else, including a missing or unreadable record, is `attended`. The signal is derived from
18
+ * state the runtime owns; a forged value only reaches the verbs the forging session itself calls.
19
+ *
20
+ * This module is imported only by the CLI. The person-only code (seed, autopilot settings, config
21
+ * write, gates, decision register, package trust, merge) never imports it nor reads
22
+ * `DOFLOW_SESSION_ROLE` (test/guards/attendance-scope.test.ts).
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const { listCheckouts } = require('../checkouts.js');
28
+
29
+ const RUN_ID = /^rl-[0-9]{8}-[0-9a-hjkmnp-tv-z]{4}$/;
30
+ const ALLOWED_FLAGS = new Set(['--task-id', '--run', '--json']);
31
+
32
+ function mainRootOf(cwd ) {
33
+ try {
34
+ const info = listCheckouts({ cwd });
35
+ if (info && info.ok && info.main) return info.main;
36
+ } catch { /* fall back to the working directory */ }
37
+ return path.resolve(cwd);
38
+ }
39
+
40
+ /**
41
+ * The validation loop.ts readRunFile applies (steps, n and log included). loop.ts does not export it
42
+ * and is edited in parallel by other packages, so the check is repeated here; a record that fails it
43
+ * is unreadable to the loop and therefore reads as `attended`, never as a signal.
44
+ */
45
+ function validRecord(value ) {
46
+ return Boolean(value) && value.schema === 1 && typeof value.runId === 'string' && RUN_ID.test(value.runId)
47
+ && Number.isSafeInteger(value.steps) && Number.isSafeInteger(value.n)
48
+ && Boolean(value.rows) && typeof value.rows === 'object' && Array.isArray(value.log)
49
+ && typeof value.createdAt === 'string';
50
+ }
51
+
52
+ function readRecord(file , fsImpl ) {
53
+ const value = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
54
+ if (!validRecord(value)) throw new Error('not a schema 1 run record');
55
+ return value;
56
+ }
57
+
58
+ /** The named run's record, else the newest open one; null when there is none. Throws when unreadable. */
59
+ function loadRun(runsDir , requested , fsImpl ) {
60
+ if (requested) {
61
+ try { return readRecord(path.join(runsDir, `${requested}.json`), fsImpl); }
62
+ catch (error ) { if (error.code === 'ENOENT') return null; throw error; }
63
+ }
64
+ let names ;
65
+ try { names = fsImpl.readdirSync(runsDir); }
66
+ catch (error ) { if (error.code === 'ENOENT') return null; throw error; }
67
+ const open = names.filter((name) => /^rl-.*\.json$/.test(name)).sort()
68
+ .map((name) => readRecord(path.join(runsDir, name), fsImpl))
69
+ .filter((record) => !record.closedAt)
70
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.runId < b.runId ? -1 : 1));
71
+ return open.at(-1) || null;
72
+ }
73
+
74
+ /**
75
+ * @param {{taskId: string, run?: string, env?: object, cwd?: string, root?: string, fsImpl?: object, readKillSwitch?: Function}} options
76
+ * @returns {{attendance: 'unattended'|'attended', source?: string, reason?: string, run?: string}}
77
+ */
78
+ function resolveAttendance(options = {}) {
79
+ const env = options.env || process.env;
80
+ const fsImpl = options.fsImpl || fs;
81
+ const attended = (reason ) => ({ attendance: 'attended', reason });
82
+ if (String(env.DOFLOW_SESSION_ROLE || '').toLowerCase() === 'worker') return { attendance: 'unattended', source: 'worker-role' };
83
+
84
+ const root = options.root || mainRootOf(options.cwd || process.cwd());
85
+ let record ;
86
+ try { record = loadRun(path.join(root, '.doflow', 'state', 'roadmap', 'runs'), options.run, fsImpl); }
87
+ catch { return attended('run-unreadable'); }
88
+ if (!record) return attended('no-run');
89
+ if (record.closedAt) return attended('run-closed');
90
+
91
+ let rows ;
92
+ try { rows = require('./rows.js').parseSeed(fsImpl.readFileSync(path.join(root, 'agent-docs', 'roadmap', 'rows.json'), 'utf8')).rows; }
93
+ catch { return attended('seed-unreadable'); }
94
+ const row = rows.find((entry ) => entry && entry.slug === options.taskId);
95
+ if (!row) return attended('row-unknown');
96
+ if (row.hold) return attended('row-held');
97
+ const slot = record.rows?.[row.id];
98
+ // A held or paused slot is machine state the loop stopped on for a person (hold, gate, dispatch, budget).
99
+ if (slot?.held) return attended('slot-held');
100
+ if (slot?.paused) return attended('slot-paused');
101
+ // A finished row has no stage left, whatever marker an interrupted step left behind.
102
+ if (slot?.done) return attended('slot-done');
103
+ // A pending stage is the slot's `perform`, or the marker the loop leaves when it issues the post-merge
104
+ // verification perform (that perform is returned without being stored as slot.perform).
105
+ const perform = slot?.perform;
106
+ const postMerge = slot?.postMerge;
107
+ const performPending = Boolean(perform) && typeof perform === 'object' && typeof perform.stage === 'string';
108
+ const postMergePending = Boolean(postMerge) && typeof postMerge === 'object' && postMerge.purpose === 'post-merge' && typeof postMerge.stage === 'string';
109
+ if (!performPending && !postMergePending) return attended('no-pending-perform');
110
+
111
+ let enabled = false;
112
+ try { enabled = (options.readKillSwitch || require('../settings/autopilot.js').readKillSwitch)(env).enabled === true; } catch { enabled = false; }
113
+ if (!enabled) return attended('switch-off');
114
+ return { attendance: 'unattended', source: 'run-record', run: record.runId };
115
+ }
116
+
117
+ /** The CLI handler: exit 0 with the answer, 2 for a usage fault. */
118
+ function handleAttendanceCommand(options = {}) {
119
+ const json = Boolean(options.json);
120
+ const usage = (detail ) => {
121
+ if (json) console.log(JSON.stringify({ schema: 1, error: 'usage', detail }));
122
+ else console.error(`doflow attendance: error: ${detail}`);
123
+ process.exitCode = 2;
124
+ return 2;
125
+ };
126
+ const extra = ((options.flags || []) ).map((flag) => flag.split('=')[0]).find((flag) => !ALLOWED_FLAGS.has(flag));
127
+ if (extra !== undefined) return usage(`unsupported flag ${extra}; only --task-id, --run and --json apply`);
128
+ if (typeof options.taskId !== 'string' || !options.taskId) return usage('--task-id <row slug> is required');
129
+ if (options.run !== undefined && !RUN_ID.test(String(options.run))) return usage('--run must be rl-<yyyymmdd>-<4 crockford characters>');
130
+ const answer = resolveAttendance({ taskId: options.taskId, run: options.run, env: options.env, cwd: options.cwd });
131
+ if (json) console.log(JSON.stringify({ schema: 1, ...answer }));
132
+ else console.log(answer.attendance === 'unattended' ? `unattended ${answer.source}${answer.run ? ` ${answer.run}` : ''}` : `attended ${answer.reason}`);
133
+ process.exitCode = 0;
134
+ return 0;
135
+ }
136
+
137
+ module.exports = { resolveAttendance, handleAttendanceCommand };
@@ -251,10 +251,13 @@ function evaluate(policy , cls , gateId , facts ) {
251
251
  const decisions = Array.isArray(backing?.decisions) ? backing.decisions.filter(object) : [];
252
252
  for (const finding of converged) {
253
253
  if (finding.status === 'refuted' && !evidenceLocator(finding.evidence)) reasons.push(`refuted-without-evidence:${finding.id}`);
254
- if (finding.status === 'user-accepted' && !decisions.some((d ) => d.id === finding.decision && USER_CHANNELS.has(d.channel))) reasons.push(`accepted-without-user-decision:${finding.id}`);
254
+ // AU D4: a note or should-fix may rest on a default-channel (agent) decision, so an unattended session
255
+ // records what it decided without claiming a person did. A blocker (or any other severity) keeps both checks.
256
+ const blocker = finding.severity !== 'note' && finding.severity !== 'should-fix';
257
+ if (finding.status === 'user-accepted' && !decisions.some((d ) => d.id === finding.decision && (USER_CHANNELS.has(d.channel) || (!blocker && d.channel === 'default')))) reasons.push(`accepted-without-user-decision:${finding.id}`);
255
258
  // C5: any session can add a question-channel register entry, so channel membership is not a
256
- // person. Policy evaluation never accepts a user-accepted disposition; a person decides that gate.
257
- if (finding.status === 'user-accepted') reasons.push(`user-accepted-needs-person:${finding.id}`);
259
+ // person. Policy evaluation never accepts a user-accepted blocker; a person decides that gate.
260
+ if (finding.status === 'user-accepted' && blocker) reasons.push(`user-accepted-needs-person:${finding.id}`);
258
261
  if (finding.status === 'fixed' && backing?.ancestors?.[finding.fixedIn] !== true) reasons.push(`fixed-not-ancestor:${finding.id}`);
259
262
  }
260
263
  }
@@ -15,6 +15,7 @@ const path = require('node:path');
15
15
  const crypto = require('node:crypto');
16
16
  const { execFileSync: defaultExecFileSync } = require('node:child_process');
17
17
  const { listCheckouts } = require('../checkouts.js');
18
+ const { readRegularFileText } = require('../task-scope.js');
18
19
  const { withGitMemo } = require('../git-memo.js');
19
20
  const {
20
21
  parseSeed,
@@ -23,6 +24,7 @@ const {
23
24
  render,
24
25
  renderRoadmapFile,
25
26
  check,
27
+ LOOP_CLASSES,
26
28
  } = require('./rows.js');
27
29
  const { gatherGitFacts, sha256Digest } = require('./facts.js');
28
30
  const { loadPolicy, verifyRecordReasons } = require('./gates.js');
@@ -376,6 +378,8 @@ const PLAN_FILES = ['plan.md', 'planning/plan.md'];
376
378
  const DISPATCH_ACTOR = 'doflow-dispatch';
377
379
  /** The actor this loop names in the dispatch commands it yields. */
378
380
  const LOOP_ACTOR = 'doflow-roadmap';
381
+ /** The envelope's attendance field: the literal the stage skill reads. attendance.ts (P3) owns the verb and the rule. */
382
+ const attendanceOf = (runId ) => ({ mode: 'unattended', run: runId });
379
383
  const QUOTA = /^quota\b/i;
380
384
  /** Stage artifacts, two layouts each (the decision-register.ts artifact list). */
381
385
  const DOC_ARTIFACTS = {
@@ -658,11 +662,17 @@ const tripwireFile = (ctx ) => path.join(ctx.dir, 'tripwire', 'event
658
662
  function readTripwire(ctx ) {
659
663
  const file = tripwireFile(ctx);
660
664
  try {
661
- const value = JSON.parse(ctx.fsImpl.readFileSync(file, 'utf8'));
665
+ try { ctx.fsImpl.lstatSync(file); } catch (error ) {
666
+ if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
667
+ throw error;
668
+ }
669
+ // A bounded read: a link, a FIFO or a file over the record cap is never opened as the record.
670
+ const text = readRegularFileText(file, ctx.fsImpl);
671
+ if (text === null) throw new Error('not a regular file within the record size cap');
672
+ const value = JSON.parse(text);
662
673
  if (!value || value.schema !== 1 || !Array.isArray(value.reverts) || !Array.isArray(value.unverified)) throw new Error('not a schema 1 tripwire record');
663
674
  return value;
664
675
  } catch (error ) {
665
- if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
666
676
  throw new Unreadable(['tripwire'], error.message);
667
677
  }
668
678
  }
@@ -680,21 +690,37 @@ function runRecordFileOf(checkout , row ) {
680
690
  return verificationRunRecordFile(verificationRunsRoot(checkout), row.slug);
681
691
  }
682
692
 
683
- /** IC-008: the bytes a stage must change. The implementation artifact is the feature branch head. */
684
- function stageArtifact(ctx , row , worktree , stage ) {
685
- if (stage === 'implementation') {
693
+ /** Stage kinds with no file of their own: performed = a new, non-backfilled handoff of the stage (AU-P1). */
694
+ const HANDOFF_ONLY_KINDS = ['reproduction', 'analysis', 'retrieval'];
695
+
696
+ /** The stage kind of a program node, else the stage id (the feature chain's ids are its kinds). */
697
+ const kindOf = (run , stage ) => (run?.program || []).find((n ) => n?.type === 'stage' && n.id === stage)?.kind ?? stage;
698
+
699
+ /**
700
+ * IC-008: the bytes a stage must change, keyed by the stage's KIND so a class's own stage ids
701
+ * (regression-verification, authoring, ...) resolve. implementation and synthesis: the feature
702
+ * branch head; verification: the verify run record; reproduction, analysis, retrieval: no file,
703
+ * the count of the stage's handoffs; the rest: the stage's document.
704
+ */
705
+ function stageArtifact(ctx , row , worktree , stage , kind = stage, run = null) {
706
+ if (kind === 'implementation' || kind === 'synthesis') {
686
707
  const { featureHead } = readHeads(ctx, row);
687
708
  return { artifact: `refs/heads/feat/${row.slug}`, sha256: sha256Digest(featureHead) };
688
709
  }
689
- if (stage === 'verification') {
710
+ if (HANDOFF_ONLY_KINDS.includes(kind)) {
711
+ const handoffs = (run?.history || []).filter((entry ) => entry && entry.node === stage
712
+ && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')).length;
713
+ return { artifact: `handoff:${stage}`, sha256: sha256Digest(String(handoffs)) };
714
+ }
715
+ if (kind === 'verification') {
690
716
  // Where `verify --action report` appends (IC-007): the lifecycle root, not the feature checkout.
691
717
  const file = runRecordFileOf(worktree, row);
692
718
  try { return { artifact: file, sha256: sha256Digest(ctx.fsImpl.readFileSync(file)) }; }
693
719
  catch (error ) { if (error.code === 'ENOENT') return { artifact: file, sha256: null }; throw new Unreadable([`artifact:${file}`], error.message); }
694
720
  }
695
721
  const base = artifactRootOf(ctx, row, worktree);
696
- const candidates = DOC_ARTIFACTS[stage];
697
- if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}'`);
722
+ const candidates = DOC_ARTIFACTS[kind];
723
+ if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}' (kind '${kind}')`);
698
724
  for (const rel of candidates) {
699
725
  try { return { artifact: rel, sha256: sha256Digest(ctx.fsImpl.readFileSync(path.join(base, rel))) }; }
700
726
  catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([`artifact:${path.join(base, rel)}`], error.message); }
@@ -726,33 +752,47 @@ function verifyArgvOf(ctx , row , worktree ) {
726
752
  return typeof script === 'string' && script.trim() ? ['npm', 'test'] : null;
727
753
  }
728
754
 
755
+ /**
756
+ * `verify --scope`: the change-scope tier is required at every risk level and resolves from a plan or
757
+ * a declared scope. Four loop classes declare theirs in the readiness record (verify refuses a flag
758
+ * that differs from it); documentation has no readiness stage and no plan, so the row's own write set
759
+ * is its declared scope.
760
+ */
761
+ const scopeFlagsOf = (row ) => (row.class === 'documentation' && Array.isArray(row.writeSet) && row.writeSet.length
762
+ ? ['--scope', row.writeSet.join(',')] : []);
763
+
729
764
  /** IC-008 envelope. Argv arrays only: the session runs them, nothing here is a shell string. */
730
- function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null) {
765
+ function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null,
766
+ kind = stage, dispatches = stage === 'implementation') {
731
767
  const handoff = ['orchestrate', '--action', 'handoff', '--task-id', row.slug, '--calling-skill', skill, '--note', '<text>'];
732
768
  const base = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage, skill, worktree,
733
- artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString() };
769
+ artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString(), attendance: attendanceOf(ctx.record.runId) };
734
770
  const next = ['roadmap', '--action', 'next', '--run', ctx.record.runId];
735
- if (stage === 'verification') {
736
- return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row)], handoff], next };
771
+ if (kind === 'verification') {
772
+ return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row), ...scopeFlagsOf(row)], handoff], next,
773
+ afterNote: 'verify exits non-zero for a FAIL or INCONCLUSIVE result and still records it: run the handoff next whatever it exits' };
737
774
  }
738
- if (stage !== 'implementation') return { ...base, after: handoff, next };
775
+ // Only the feature chain dispatches workers (plan tasks); the other classes' implementation is a plain stage.
776
+ // Nothing but the session commits its edits here, and a stage whose branch head did not move is not performed.
777
+ if (!dispatches) return { ...base, ...(kind === 'implementation' || kind === 'synthesis' ? { commit: { branch: `feat/${row.slug}` } } : {}), after: handoff, next };
739
778
  return { ...base, after: [
740
- ['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
779
+ ['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', '--fallback', 'auto', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
741
780
  ['dispatch', '--action', 'run', '--slug', row.slug, '--by', LOOP_ACTOR],
742
781
  handoff,
743
782
  ], next };
744
783
  }
745
784
 
746
785
  /** IC-008: performed = the artifact exists, its bytes changed, and the stage's own handoff is newer. */
747
- function performedCheck(ctx , row , worktree , run , perform ) {
748
- const current = stageArtifact(ctx, row, worktree, perform.stage);
786
+ function performedCheck(ctx , row , worktree , run , perform ) {
787
+ const current = stageArtifact(ctx, row, worktree, perform.stage, kindOf(run, perform.stage), run);
749
788
  if (current.sha256 === null) return { ok: false, current };
750
789
 
751
790
  // FU-rcshhx: allow adopting rows whose design/plan artifacts were already completed and recorded
752
- // in the orchestration run history, even if current.sha256 === perform.before?.sha256.
791
+ // in the orchestration run history, even if current.sha256 === perform.before?.sha256. A completion that a
792
+ // later skill's catch-up only backfilled is not such a record (AU-P1).
753
793
  const isDesignOrPlan = perform.stage === 'design' || perform.stage === 'planning';
754
794
  const alreadyCompleted = isDesignOrPlan && (run.history || []).some((entry ) =>
755
- entry && entry.node === perform.stage && (entry.action === 'complete-stage' || entry.action === 'annotate')
795
+ entry && entry.node === perform.stage && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
756
796
  );
757
797
  if (alreadyCompleted) return { ok: true, current };
758
798
 
@@ -761,7 +801,7 @@ function performedCheck(ctx , row , worktree , run , perform
761
801
  const handedOff = (run.history || []).some((entry ) => entry && entry.node === perform.stage
762
802
  && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
763
803
  && Date.parse(entry.at) > issued);
764
- return { ok: handedOff, current };
804
+ return { ok: handedOff, current, handedOff };
765
805
  }
766
806
 
767
807
  function orchestrationFor(ctx , row , worktree ) {
@@ -775,7 +815,9 @@ function orchestrationFor(ctx , row , worktree )
775
815
  // artifactRootOf finds it (the feature checkout, else the main one).
776
816
  const planExitCheck = () => require('../plan-content.js').planStageExit({ roots: [...new Set([worktree, runRoot, ctx.root])], slug: row.slug });
777
817
  const orch = new WorkflowOrchestrator({ repoRoot: ctx.repoRoot, projectRoot: runRoot, sourceRoot: worktree,
778
- stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck });
818
+ stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck,
819
+ // FR-006, as the orchestrate verb wires it: the ledger is found from any checkout of the row.
820
+ taskAttemptCheck: () => require('../task-attempts.js').checkCompletion({ slug: row.slug, cwd: worktree, fsImpl: ctx.fsImpl }) });
779
821
  let run ;
780
822
  try { run = orch.readRun(row.slug); } catch (error ) { throw new Unreadable([source], error.message); }
781
823
  if (run && (!Array.isArray(run.program) || !Number.isInteger(run.cursor) || typeof run.state !== 'string' || !Array.isArray(run.history))) {
@@ -981,6 +1023,10 @@ async function mergeStep(ctx , row , source
981
1023
  const intent = source.intent || null;
982
1024
  let gate = null;
983
1025
  let approvedHead = null;
1026
+ // The attendance marker of an earlier post-merge perform is consumed by this call (re-set below when the
1027
+ // verification is still pending), so it never outlives the step it names. It is not a `perform`: the
1028
+ // performed checks and the merge step read only slot.perform and the merge intent.
1029
+ delete slot.postMerge;
984
1030
  if (intent && slot.held?.kind === 'intent') delete slot.held;
985
1031
  if (!intent) {
986
1032
  const entry = [...source.run.history].reverse().find((h ) => h?.action === 'decide-gate' && h.node === 'gate-b' && h.detail === 'approve');
@@ -1020,8 +1066,11 @@ async function mergeStep(ctx , row , source
1020
1066
  const before = { artifact: runRecordFileOf(intent.developWorktree, row), sha256: null };
1021
1067
  const perform = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage: 'verification', skill: 'do-test',
1022
1068
  purpose: 'post-merge', worktree: intent.developWorktree, artifactRoot: null, head: intent.commit, before, issuedAt: ctx.now().toISOString(),
1023
- after: ['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', intent.developHead, ...postMergePlan(ctx, row, intent)],
1069
+ attendance: attendanceOf(ctx.record.runId),
1070
+ after: ['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', intent.developHead, ...postMergePlan(ctx, row, intent), ...scopeFlagsOf(row)],
1024
1071
  next: ['roadmap', '--action', 'next', '--run', ctx.record.runId] };
1072
+ // AU-P10: the one fact attendance.ts reads to answer `unattended` for this stage (the envelope above says so too).
1073
+ slot.postMerge = { stage: 'verification', purpose: 'post-merge', head: intent.commit, issuedAt: perform.issuedAt };
1025
1074
  return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform });
1026
1075
  }
1027
1076
  }
@@ -1115,19 +1164,20 @@ async function gateStep(ctx , row , orch , gateId , worktree
1115
1164
  audit: `${gateId} ${row.slug} denied by policy: ${result.reasons.join(', ')}${result.rule ? ` (rule ${result.rule})` : ''}` });
1116
1165
  }
1117
1166
 
1118
- function performStep(ctx , row , worktree , node ) {
1167
+ function performStep(ctx , row , worktree , node , run ) {
1119
1168
  const slot = (ctx.record.rows[row.id] ||= {});
1120
- if (node.id === 'review') {
1169
+ const kind = node.kind ?? node.id;
1170
+ if (kind === 'review') {
1121
1171
  // IC-007: gate-b's own verify rule, applied before the review envelope is spent. No question, no prompt.
1122
1172
  const head = readHeads(ctx, row).featureHead;
1123
1173
  const reasons = postMergeReasons(row, worktree, head, ctx.fsImpl);
1124
1174
  if (reasons.length) {
1125
1175
  return heldDeny(ctx, row, 'review', reasons[0],
1126
- sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification').sha256 })), { step: 'review', reasons });
1176
+ sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification', 'verification').sha256 })), { step: 'review', reasons });
1127
1177
  }
1128
1178
  if (slot.held?.kind === 'review') delete slot.held;
1129
1179
  }
1130
- slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id));
1180
+ slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id, kind, run), null, kind, false);
1131
1181
  return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform: slot.perform });
1132
1182
  }
1133
1183
 
@@ -1238,7 +1288,7 @@ function implementationStep(ctx , row , worktree ) {
1238
1288
  slot.perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan',
1239
1289
  { artifact: `refs/heads/feat/${row.slug}`, sha256: slot.claimHead ? sha256Digest(slot.claimHead) : null }, verifyCommand);
1240
1290
  }
1241
- return finish(ctx, { ...base, outcome: 'denied', reason: 'stage-not-performed', rule: 'perform/implementation', perform: slot.perform });
1291
+ return hold('stage-not-performed', { outcome: 'denied', reason: 'stage-not-performed', rule: 'perform/implementation', perform: slot.perform });
1242
1292
  }
1243
1293
  case 'blocked':
1244
1294
  return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: facts.repeated ? 'dispatch/repeated' : 'dispatch/blocked', annotations: { dispatch: 'blocked', cause: facts.cause } });
@@ -1252,10 +1302,32 @@ function implementationStep(ctx , row , worktree ) {
1252
1302
  }
1253
1303
  }
1254
1304
 
1305
+ /**
1306
+ * A feature run gets its decision register from do-brainstorm, which creates the folder. The other
1307
+ * classes have no such stage, and gate-b's policy approval is recorded in that register, so the loop
1308
+ * creates it once, before any artifact lands in the folder. A folder that already holds artifacts and
1309
+ * no register is left alone (initRegister's `predates-register`): the gate then names it unreadable.
1310
+ */
1311
+ function ensureRegister(ctx , row , worktree ) {
1312
+ const { initRegister } = require('../decision-register.js');
1313
+ const featureDir = path.join(worktree, 'agent-docs', 'doflow', row.slug);
1314
+ if (ctx.fsImpl.existsSync(path.join(featureDir, 'decisions', 'register.json'))) {
1315
+ if (ctx.record.rows[row.id]?.held?.kind === 'register') delete ctx.record.rows[row.id].held;
1316
+ return null;
1317
+ }
1318
+ const stop = boundary(ctx, row);
1319
+ if (stop) return stop;
1320
+ const made = initRegister({ featureDir, slug: row.slug, fsImpl: ctx.fsImpl });
1321
+ if (!made.finding) return null;
1322
+ // The folder predates a register: gate-b could not record its approval. Denied once, then held by digest.
1323
+ return heldDeny(ctx, row, 'register', 'facts-unreadable', sha256Digest(JSON.stringify([made.finding, made.message])),
1324
+ { step: 'pick', rule: 'loop/facts', annotations: { unreadable: ['decision-register'], finding: made.finding } });
1325
+ }
1326
+
1255
1327
  /** One in-flight row: pending perform check, then its orchestration state. Returns a result or a skip. */
1256
1328
  async function stepRow(ctx , row , worktrees ) {
1257
1329
  const slot = (ctx.record.rows[row.id] ||= {});
1258
- if (row.class !== 'feature') return { skip: 'class-unsupported' };
1330
+ if (!LOOP_CLASSES.includes(row.class)) return { skip: 'class-unsupported' };
1259
1331
  // A seed hold (decision C8) also stops autopilot from advancing a row already in flight.
1260
1332
  if (row.hold) return { skip: 'row-held' };
1261
1333
  // maint-thin: so does a proposed row (a branch a person started by hand); a person promotes it first.
@@ -1268,13 +1340,19 @@ async function stepRow(ctx , row , worktrees )
1268
1340
  // A claim interrupted after `worktree add`: finish it.
1269
1341
  const stop = boundary(ctx, row);
1270
1342
  if (stop) return stop;
1271
- orch.start({ taskId: row.slug, taskClass: 'feature', now: ctx.now() });
1343
+ orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
1272
1344
  return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree }, audit: `pick ${row.slug}: run started in ${worktree}` });
1273
1345
  }
1346
+ if (row.class !== 'feature') {
1347
+ const refused = ensureRegister(ctx, row, worktree);
1348
+ if (refused) return refused;
1349
+ }
1274
1350
  const node = run.cursor < run.program.length ? run.program[run.cursor] : null;
1275
- const atImplementation = run.state === 'RUNNING' && node?.type === 'stage' && node.id === 'implementation';
1351
+ // Only the feature chain dispatches workers at implementation; any other class's implementation is a plain stage.
1352
+ const dispatches = row.class === 'feature';
1353
+ const atImplementation = dispatches && run.state === 'RUNNING' && node?.type === 'stage' && node.id === 'implementation';
1276
1354
  if (slot.perform && !(atImplementation && slot.perform.stage === 'implementation')) {
1277
- if (slot.perform.stage === 'implementation') {
1355
+ if (dispatches && slot.perform.stage === 'implementation') {
1278
1356
  // The session handed off right after `dispatch run`: land the dispatch before checking the head.
1279
1357
  const landing = landDispatch(ctx, row, worktree, dispatchFactsOf(ctx, row));
1280
1358
  if (landing) return landing;
@@ -1289,16 +1367,29 @@ async function stepRow(ctx , row , worktrees )
1289
1367
  }
1290
1368
  }
1291
1369
  if (!checked.ok) {
1370
+ // K59: a stage that never hands off is denied once per input state, then held by digest, so it
1371
+ // neither spends a step per call nor keeps other rows from advancing. A changed artifact or a
1372
+ // new handoff changes the digest and the stage is judged again.
1373
+ const digest = sha256Digest(JSON.stringify({ stage: slot.perform.stage, sha256: checked.current.sha256, handedOff: Boolean(checked.handedOff) }));
1374
+ if (slot.held?.kind === 'stage' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
1375
+ slot.held = { kind: 'stage', reason: 'stage-not-performed', inputsDigest: digest };
1376
+ // A handoff-only stage whose only handoff was a catch-up backfill (another skill's handoff walked past it) can never
1377
+ // change its digest on its own: only an annotate of the stage, which counts as a handoff, releases the hold.
1378
+ const backfilled = (run.history || []).some((entry ) => entry && entry.node === slot.perform.stage && entry.backfilled === true);
1379
+ const release = HANDOFF_ONLY_KINDS.includes(kindOf(run, slot.perform.stage))
1380
+ ? { release: ['orchestrate', '--action', 'annotate', '--task-id', row.slug, '--node', slot.perform.stage, '--note', '<what was done>'], backfilled }
1381
+ : undefined;
1292
1382
  return finish(ctx, { outcome: 'denied', reason: 'stage-not-performed', row: row.id, slug: row.slug, rule: `perform/${slot.perform.stage}`,
1293
- evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], perform: slot.perform });
1383
+ evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], annotations: release, perform: slot.perform });
1294
1384
  }
1385
+ if (slot.held?.kind === 'stage' || slot.held?.kind === 'dispatch') delete slot.held;
1295
1386
  delete slot.perform;
1296
1387
  }
1297
1388
  if (run.state === 'REJECTED') return { skip: 'run-rejected' };
1298
1389
  if (run.state === 'AWAITING_GATE' && node?.type === 'gate') return gateStep(ctx, row, orch, node.id, worktree);
1299
1390
  if (run.state === 'COMPLETED') return mergeStep(ctx, row, { run, worktree });
1300
1391
  if (run.state === 'RUNNING' && node?.type === 'stage') {
1301
- return node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node);
1392
+ return dispatches && node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node, run);
1302
1393
  }
1303
1394
  throw new Unreadable([`orchestration:${row.slug}`], `run state '${run.state}' has no actionable node`);
1304
1395
  }
@@ -1324,6 +1415,7 @@ function finishMergedRow(ctx , row , rows , statuses , worktre
1324
1415
  const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${row.slug}`, featureHead }, report: artifacts,
1325
1416
  root: ctx.root, develop, execFileSync: ctx.execFileSync, execOptions: ctx.gitOptions, fsImpl: ctx.fsImpl });
1326
1417
  slot.done = true;
1418
+ delete slot.postMerge; // a row merged without an open intent has no post-merge verification left to mark
1327
1419
  if (!cleaned) {
1328
1420
  if (slot.held?.kind === 'cleanup' && !written) return { skip: 'cleanup-kept' };
1329
1421
  slot.held = { kind: 'cleanup', reason: 'cleanup-kept' };
@@ -1354,7 +1446,7 @@ async function claimStep(ctx , row , seed , developHead )
1354
1446
  const stopStart = boundary(ctx, row);
1355
1447
  if (stopStart) return stopStart;
1356
1448
  const { orch } = orchestrationFor(ctx, row, worktree);
1357
- orch.start({ taskId: row.slug, taskClass: 'feature', now: ctx.now() });
1449
+ orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
1358
1450
  return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree, developHead },
1359
1451
  audit: `pick ${row.slug} at ${developHead} into ${worktree}` });
1360
1452
  }
@@ -1633,4 +1725,6 @@ module.exports = {
1633
1725
  // The lease primitive, exported for its interleaving test (C8).
1634
1726
  acquireLease,
1635
1727
  releaseLease, resolveMainRoot, noPsAlive,
1728
+ // The orchestrator the loop builds for a row, exported for its wiring test (FU-e4yr45).
1729
+ orchestrationFor,
1636
1730
  };