@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
@@ -189,10 +189,74 @@ function writeLayer({ file, set = {}, unset = [], scrubKeys = [], force = false,
189
189
  }
190
190
  }
191
191
 
192
+ /**
193
+ * Where a write to `file` lands. A symlinked settings file (a dotfile manager's link) is written
194
+ * through to its target so the link survives; a project or local file whose link leads outside the
195
+ * project, or leads nowhere, is refused rather than read through or copied.
196
+ */
197
+ function writeTarget(file , fsImpl ) {
198
+ let stat ;
199
+ try { stat = fsImpl.lstatSync(file); } catch { return { ok: true, target: file }; }
200
+ if (!stat.isSymbolicLink()) return { ok: true, target: file };
201
+ let real ;
202
+ try { real = fsImpl.realpathSync(file); } catch {
203
+ return { ok: false, result: invalid(file, 'is a symbolic link that does not resolve to a file; nothing was written', 'scope') };
204
+ }
205
+ const directory = path.dirname(file);
206
+ if (path.basename(directory) === '.doflow') {
207
+ try {
208
+ const rel = path.relative(fsImpl.realpathSync(path.dirname(directory)), real);
209
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error('outside');
210
+ } catch {
211
+ return { ok: false, result: invalid(file, 'is a symbolic link to a path outside the project; nothing was written', 'scope') };
212
+ }
213
+ }
214
+ return { ok: true, target: real };
215
+ }
216
+
217
+ /** The most significant digits a JSON number keeps exactly when it is read as a double and written back. */
218
+ const EXACT_DIGITS = 15;
219
+ const NUMBER_AT = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
220
+
221
+ /**
222
+ * The first top-level key of `text` (a JSON document) holding a number that `JSON.parse` followed by
223
+ * `JSON.stringify` would change in value: more than 15 digits and not a safe integer.
224
+ */
225
+ function inexactNumberKey(text ) {
226
+ let depth = 0;
227
+ let current = null;
228
+ for (let i = 0; i < text.length;) {
229
+ const ch = text[i];
230
+ if (ch === '"') {
231
+ let end = i + 1;
232
+ while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
233
+ if (depth === 1) {
234
+ let after = end + 1;
235
+ while (after < text.length && /\s/.test(text[after])) after++;
236
+ if (text[after] === ':') { try { current = JSON.parse(text.slice(i, end + 1)); } catch { current = null; } }
237
+ }
238
+ i = end + 1;
239
+ } else if (ch === '{' || ch === '[') { depth++; i++; }
240
+ else if (ch === '}' || ch === ']') { depth--; i++; }
241
+ else if (ch === '-' || (ch >= '0' && ch <= '9')) {
242
+ NUMBER_AT.lastIndex = i;
243
+ const match = NUMBER_AT.exec(text);
244
+ if (!match) { i++; continue; }
245
+ const token = match[0];
246
+ const digits = token.replace(/[eE].*$/, '').replace(/[-.]/g, '').replace(/^0+/, '').replace(/0+$/, '');
247
+ if (digits.length > EXACT_DIGITS && !(/^-?\d+$/.test(token) && Number.isSafeInteger(Number(token))) && current !== null) return current;
248
+ i += token.length;
249
+ } else i++;
250
+ }
251
+ return null;
252
+ }
253
+
192
254
  const INVALID = Symbol('invalid layer');
193
255
 
194
256
  /** The read, merge and rename of `writeLayer`, run while it holds the layer's lock. */
195
257
  function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect, backup, fsImpl } ) {
258
+ const landing = writeTarget(file, fsImpl);
259
+ if (!landing.ok) return landing.result;
196
260
  let before = null;
197
261
  let existing = {};
198
262
  let layer ;
@@ -231,6 +295,13 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
231
295
  for (const [key, value] of Object.entries(set)) values[key] = value;
232
296
  for (const key of unsetKeys) delete values[key];
233
297
 
298
+ if (layer?.status === 'ok' && typeof layer.source === 'string') {
299
+ const kept = inexactNumberKey(layer.source);
300
+ if (kept !== null && Object.hasOwn(values, kept) && !Object.hasOwn(set, kept)) {
301
+ return invalid(file, `key '${kept}' holds a number with more digits than JSON can rewrite exactly; nothing was written; edit the file by hand`, 'number-precision');
302
+ }
303
+ }
304
+
234
305
  const after = layerDocument(registry, values);
235
306
  const bytes = serializeLayer(after);
236
307
  const tooLarge = oversize(file, bytes);
@@ -244,7 +315,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
244
315
  const directory = path.dirname(file);
245
316
  let temporary = null;
246
317
  try {
247
- temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
318
+ temporary = path.join(path.dirname(landing.target), `.${path.basename(landing.target)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
248
319
  fsImpl.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
249
320
  fsImpl.chmodSync(temporary, 0o600);
250
321
  if (path.basename(directory) === '.doflow') {
@@ -253,7 +324,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
253
324
  return invalid(file, '.doflow symlink points outside the project', 'scope');
254
325
  }
255
326
  }
256
- fsImpl.renameSync(temporary, file);
327
+ fsImpl.renameSync(temporary, landing.target);
257
328
  temporary = null;
258
329
  clearSettingsCache();
259
330
  return { ok: true, file, before, after };
@@ -185,7 +185,7 @@ const refuse = (why ) => Object.assign(new Error(why), { code: 'EBUSY' })
185
185
  * directory name this lock does not make once the lock is 10 s old, as in task-state. An older
186
186
  * runtime's lock (its `owner.json` token) is judged the same way. `reclaimed` records that this acquire
187
187
  * judged a lock reclaimable, so it holds only after `quiet` (every reclaim quiets; user ruling, 117
188
- * decisions: amends 115 C-4's "dead owner reclaimed at once"). See docs/architecture.md, "The slot lock".
188
+ * decisions: amends 115 C-4's "dead owner reclaimed at once").
189
189
  */
190
190
  function slotLock(fsImpl , deps ) {
191
191
  const start = deps.procStart(deps.pid);
@@ -13,7 +13,7 @@ const RULES = new Map([
13
13
  ['date-partition', ['maxAgeDays']],
14
14
  ['recovery', ['keepNewest']],
15
15
  ['lifecycle-settled', ['defaultHours']],
16
- ['report-orphan', ['orphanGraceHours', 'foreignMaxAgeDays']],
16
+ ['report-orphan', ['orphanGraceHours']],
17
17
  ['age', ['maxAgeDays']],
18
18
  ['existing', []],
19
19
  ['control', []],
@@ -132,7 +132,7 @@ function formatBound(store ) {
132
132
  case 'date-partition': return `prune partitions older than ${bound.maxAgeDays} days`;
133
133
  case 'recovery': return `keep newest ${bound.keepNewest}, plus the snapshot named by the ledger`;
134
134
  case 'lifecycle-settled': return `retain settled events for ${bound.defaultHours} hours`;
135
- case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders after ${bound.foreignMaxAgeDays} days`;
135
+ case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders are kept`;
136
136
  case 'age': return `prune inactive directories after ${bound.maxAgeDays} days`;
137
137
  case 'existing':
138
138
  case 'control': return bound.text;
@@ -31,16 +31,9 @@ function createPruneSettingsResolver({ roots, fsImpl = fs, onView } = {})
31
31
  if (!loadedView) {
32
32
  loadedView = true;
33
33
  try {
34
- let settingsRegistry;
35
- if (fsImpl !== fs) {
36
- const { loadSettingsRegistry } = require('../settings/registry.js');
37
- const loaded = loadSettingsRegistry({ fsImpl });
38
- if (!loaded.ok) throw new Error(loaded.detail);
39
- settingsRegistry = loaded.registry;
40
- }
41
- const { loadSettings } = require('../settings/resolve.js');
34
+ const { loadSettings, registryOn } = require('../settings/resolve.js');
42
35
  const projectRoot = roots['project-doflow'] ? path.dirname(roots['project-doflow']) : null;
43
- view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
36
+ view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
44
37
  } catch {
45
38
  // A settings registry fault fails closed for every root, as it did for each old call.
46
39
  view = null;
@@ -69,15 +62,8 @@ function readPruneSetting({ roots, rootId, fsImpl = fs } = {})
69
62
  const resolverEnv = {};
70
63
  if (roots['config-home']) resolverEnv.XDG_CONFIG_HOME = path.dirname(roots['config-home']);
71
64
  try {
72
- let settingsRegistry;
73
- if (fsImpl !== fs) {
74
- const { loadSettingsRegistry } = require('../settings/registry.js');
75
- const loaded = loadSettingsRegistry({ fsImpl });
76
- if (!loaded.ok) throw new Error(loaded.detail);
77
- settingsRegistry = loaded.registry;
78
- }
79
- const { loadSettings } = require('../settings/resolve.js');
80
- const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
65
+ const { loadSettings, registryOn } = require('../settings/resolve.js');
66
+ const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
81
67
  const resolved = view.get('storage.prune', global ? 'global' : 'project');
82
68
  return {
83
69
  setting: resolved.value === 'on' ? 'on' : 'off',
@@ -232,16 +232,15 @@ function lifecyclePathsSafe(project , fsImpl ) {
232
232
  }
233
233
  return true;
234
234
  }
235
- function definitelyMissing(fsImpl , target ) {
236
- try { fsImpl.lstatSync(target); return false; }
237
- catch (error ) { return error.code === 'ENOENT' || error.code === 'ENOTDIR'; }
238
- }
239
235
  function reportCandidates(store , entries , context ) {
240
236
  const { fsImpl, storeDir, roots, now, settings, shouldStop } = context;
241
237
  const project = projectRoot(roots);
242
238
  const key = project && projectKey(project, fsImpl), selected = [];
243
239
  for (const entry of entries) {
244
240
  if (shouldStop?.()) throw budgetError();
241
+ // A folder under another project's key stays: ENOENT or ENOTDIR on its recorded root never
242
+ // authorises deleting it, since an unmounted or renamed root looks the same as a gone one.
243
+ if (entry.name !== key) continue;
245
244
  const folder = path.join(storeDir, entry.name);
246
245
  let children, bodyNames = [];
247
246
  try {
@@ -251,50 +250,27 @@ function reportCandidates(store , entries , context ) {
251
250
  if (BODY.test(name) && safeStat(fsImpl, path.join(folder, name))?.isFile()) bodyNames.push(name);
252
251
  }
253
252
  } catch (error ) { if (error.partial) throw error; continue; }
254
- if (entry.name === key) {
255
- const lifecycle = path.join(project, '.doflow/state/lifecycle');
256
- if (!safeStat(fsImpl, path.join(lifecycle, 'migrated.json'))?.isFile() || !lifecyclePathsSafe(project, fsImpl)) continue;
257
- let snapshot;
258
- try { snapshot = readEvents(project, { fsImpl, lockFallback: false, shouldStop }); }
259
- catch { continue; }
260
- if (snapshot.incomplete) throw budgetError();
261
- if (snapshot.unreadable.length) continue;
262
- const eventIds = new Set();
263
- for (const event of snapshot.events) {
264
- if (shouldStop?.()) throw budgetError();
265
- const ids = [event.data.id, ...(Array.isArray(event.data.ids) ? event.data.ids : [])];
266
- for (const id of ids) {
267
- if (shouldStop?.()) throw budgetError();
268
- if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
269
- }
270
- }
271
- for (const body of bodyNames) {
272
- if (shouldStop?.()) throw budgetError();
273
- const stat = safeStat(fsImpl, path.join(folder, body));
274
- if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
275
- selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
276
- }
277
- } else {
278
- const marker = path.join(folder, '.root');
279
- if (!safeStat(fsImpl, marker)?.isFile() || !bodyNames.length) continue;
280
- let validFolder = true;
281
- for (const name of children) {
253
+ const lifecycle = path.join(project, '.doflow/state/lifecycle');
254
+ if (!safeStat(fsImpl, path.join(lifecycle, 'migrated.json'))?.isFile() || !lifecyclePathsSafe(project, fsImpl)) continue;
255
+ let snapshot;
256
+ try { snapshot = readEvents(project, { fsImpl, lockFallback: false, shouldStop }); }
257
+ catch { continue; }
258
+ if (snapshot.incomplete) throw budgetError();
259
+ if (snapshot.unreadable.length) continue;
260
+ const eventIds = new Set();
261
+ for (const event of snapshot.events) {
262
+ if (shouldStop?.()) throw budgetError();
263
+ const ids = [event.data.id, ...(Array.isArray(event.data.ids) ? event.data.ids : [])];
264
+ for (const id of ids) {
282
265
  if (shouldStop?.()) throw budgetError();
283
- if (name !== '.root' && !bodyNames.includes(name)) { validFolder = false; break; }
266
+ if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
284
267
  }
285
- if (!validFolder) continue;
286
- let root;
287
- try { root = fsImpl.readFileSync(marker, 'utf8').trim(); } catch { continue; }
288
- if (!path.isAbsolute(root) || !definitelyMissing(fsImpl, root)) continue;
289
- let newest = -Infinity;
290
- for (const body of bodyNames) {
291
- if (shouldStop?.()) throw budgetError();
292
- const stat = safeStat(fsImpl, path.join(folder, body));
293
- if (!stat) { newest = Infinity; break; }
294
- newest = Math.max(newest, stat.mtimeMs);
295
- }
296
- if (now - newest > store.bound.foreignMaxAgeDays * DAY)
297
- selected.push({ name: entry.name, reason: 'foreign-root-gone', bytes: entry.stat.size, mtimeMs: newest });
268
+ }
269
+ for (const body of bodyNames) {
270
+ if (shouldStop?.()) throw budgetError();
271
+ const stat = safeStat(fsImpl, path.join(folder, body));
272
+ if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
273
+ selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
298
274
  }
299
275
  }
300
276
  return selected;
@@ -81,6 +81,17 @@ function isRunNamespaceDir(name ) {
81
81
  return safeSegment(name) && !name.endsWith('.lock');
82
82
  }
83
83
 
84
+ /**
85
+ * Whether a flat run is superseded by a namespaced copy: it names that copy's namespace as its own
86
+ * feature (`featureSlug`, else `slug`) and the copy has the same file name. A flat run that names
87
+ * no feature, or one whose namespace holds no copy, is a run of its own and is never dropped. The
88
+ * one ownership rule for every reader that walks `orchestration/` (health, trace, work status).
89
+ */
90
+ function isSupersededFlatRun(name , value , copies ) {
91
+ const own = stringField(value, 'featureSlug') || stringField(value, 'slug');
92
+ return own !== null && isRunNamespaceDir(own) && copies.some((copy) => copy.namespace === own && copy.name === name);
93
+ }
94
+
84
95
  /**
85
96
  * Every run id the orchestration store holds, flat or under a `<slug>/` namespace (115 C-1: a task id
86
97
  * other than its feature's slug lives in `orchestration/<slug>/`). `cache.runLocations` maps each id
@@ -136,9 +147,8 @@ function readRunAt(cache , runId , where ) {
136
147
  const file = path.join(stateRoot(cache.projectRoot), 'orchestration', ...(where ? [where] : []), `${runId}.json`);
137
148
  let result ;
138
149
  try {
139
- // A namespaced copy is a file base never read (N-4): a link, a FIFO or a device there reads as
140
- // unreadable (the record is kept) and never blocks. The flat copy keeps its plain read (FU-d88kp0).
141
- const text = where && typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
150
+ // A link, a FIFO or a device there reads as unreadable (the record is kept) and never blocks.
151
+ const text = typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
142
152
  ? require('../task-scope.js').readRegularFileText(file, cache.fsImpl) : cache.fsImpl.readFileSync(file, 'utf8');
143
153
  if (text === null) throw new Error('not a regular file');
144
154
  const value = JSON.parse(text);
@@ -542,4 +552,4 @@ function workStatus({
542
552
  return answer('finished', run ? 'run-finished' : 'no-run', resolvedRun.id, feature, since);
543
553
  }
544
554
 
545
- module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES };
555
+ module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES, isRunNamespaceDir, isSupersededFlatRun };
@@ -6,7 +6,8 @@ const path = require('node:path');
6
6
  const fs = require('node:fs');
7
7
  const { TextDecoder } = require('node:util');
8
8
  const { WorkflowEngine, CALLER_ROLES } = require('./workflow-engine.js');
9
- const { finishRuntime } = require('./cli-result.js');
9
+ const { finishRuntime, usageError } = require('./cli-result.js');
10
+ const { ViewUsageError, resolveView, projectBrief, viewLines, withEnvelope, emitJson } = require('./verb-view.js');
10
11
  const { REPO_ROOT } = require('../helper/repo-root.js');
11
12
  const { loadAdviceRegistry } = require('./advice/registry.js');
12
13
  const { createAdviceClient } = require('./advice/client.js');
@@ -421,6 +422,25 @@ class TaskClassifier {
421
422
  }
422
423
  }
423
424
 
425
+ /**
426
+ * The brief view of a classify answer (feature 137, IC-005): the fields the skills' classify call sites read.
427
+ * Everything else is dropped; `--view full` returns the whole object.
428
+ */
429
+ const CLASSIFY_BRIEF = Object.freeze({
430
+ verb: 'classify',
431
+ carry: Object.freeze(['taskClass', 'callingSkill']),
432
+ keep: Object.freeze([
433
+ '$.outcome', '$.taskClass', '$.proposedClass', '$.callingSkill', '$.reason', '$.message', '$.validClasses',
434
+ '$.suggestions', '$.callerSuggestions', '$.fit.state', '$.fit.reason', '$.fit.hostingClasses', '$.secondOpinion',
435
+ '$.workflow.taskClass', '$.workflow.handoff', '$.workflow.stageIds', '$.workflow.hasImplementationStage',
436
+ '$.workflow.requiresImplementationReadiness', '$.workflow.stages[].id', '$.workflow.stages[].skill',
437
+ '$.workflow.stages[].agent', '$.workflow.stages[].kind', '$.workflow.stages[].optional',
438
+ '$.workflow.stages[].readinessTemplate', '$.workflow.stages[].mutatesSource', '$.workflow.gates[].id',
439
+ '$.workflow.gates[].afterStage', '$.workflow.gates[].kind', '$.workflow.gates[].trigger',
440
+ '$.workflow.gates[].prompt',
441
+ ]),
442
+ });
443
+
424
444
  /**
425
445
  * Handles `doflow classify` — validate a proposed task class against the workflow registry.
426
446
  *
@@ -437,16 +457,31 @@ class TaskClassifier {
437
457
  * @param {string} [options.request] request text for a second opinion
438
458
  * @param {string} [options.requestFile] file containing request text for a second opinion
439
459
  * @param {boolean} [options.json=false]
460
+ * @param {string} [options.view] `brief` or `full` (default): the JSON answer's view; text output is the same in both
440
461
  * @returns {number|Promise<number>} exit code; advisory calls are asynchronous
441
462
  */
442
- function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill, request, requestFile, taskId, projectRoot, env = process.env, json = false } = {}) {
463
+ function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill, request, requestFile, taskId, projectRoot, view, env = process.env, json = false } = {}) {
464
+ let resolvedView;
465
+ try {
466
+ resolvedView = resolveView(view, 'full');
467
+ } catch (error ) {
468
+ if (error instanceof ViewUsageError) return usageError('classify', error.message, json);
469
+ throw error;
470
+ }
471
+ const printJson = (opinion ) => {
472
+ const answer = { ...decision, secondOpinion: opinion };
473
+ if (resolvedView === 'full') return emitJson(answer);
474
+ return emitJson(withEnvelope(projectBrief(answer, CLASSIFY_BRIEF.keep), {
475
+ view: 'brief', next: viewLines('full', { spec: CLASSIFY_BRIEF, carried: { taskClass, callingSkill }, json: true }),
476
+ }));
477
+ };
443
478
  const classifier = new TaskClassifier({ repoRoot: REPO_ROOT });
444
479
  const decision = classifier.classify({ taskClass, rationale, proposedBy, callingSkill });
445
480
  const exitCode = decision.outcome === CLASSIFICATION_OUTCOMES.ACCEPTED
446
481
  ? 0 : decision.reason === REJECTION_REASONS.MISSING_CLASS ? 2 : 1;
447
482
  const hasRequest = request !== undefined || requestFile !== undefined;
448
483
  if (!hasRequest) {
449
- if (json) console.log(JSON.stringify({ ...decision, secondOpinion: null }, null, 2));
484
+ if (json) printJson(null);
450
485
  else printClassifyDecision(decision, null);
451
486
  return finishRuntime(exitCode);
452
487
  }
@@ -528,7 +563,7 @@ function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill,
528
563
  opinion = emptySecondOpinion('unavailable', 'config-invalid');
529
564
  }
530
565
  }
531
- if (json) console.log(JSON.stringify({ ...decision, secondOpinion: opinion }, null, 2));
566
+ if (json) printJson(opinion);
532
567
  else printClassifyDecision(decision, opinion);
533
568
  return finishRuntime(exitCode);
534
569
  }
@@ -176,9 +176,23 @@ function readRegularFileText(file , fsImpl = fs) {
176
176
  }
177
177
  }
178
178
 
179
+ /** Whether `slug` is a register feature (it has `decisions/register.json`), for a record file under
180
+ * a checkout's `.doflow/state`. Such a feature never reads another feature's identity-less flat
181
+ * record (045 isolation). A file whose checkout cannot be told, or a feature that does not resolve,
182
+ * has no register. */
183
+ function featureHasRegister(file , slug ) {
184
+ const marker = `${path.sep}.doflow${path.sep}state${path.sep}`;
185
+ const at = file.lastIndexOf(marker);
186
+ if (at <= 0) return false;
187
+ try {
188
+ const found = resolveFeature({ projectRoot: file.slice(0, at), slug });
189
+ return !found.error && Boolean(found.featureDir) && fs.existsSync(path.join(found.featureDir, 'decisions', 'register.json'));
190
+ } catch { return false; }
191
+ }
192
+
179
193
  /**
180
194
  * Whether a flat record may be read as `slug`'s (D-3): it names that feature (`featureSlug` on a
181
- * run, `slug` on a readiness record) or names none. A file that does not parse names nobody, so a
195
+ * run, `slug` on a readiness record), or names none and `slug` has no decision register. A file that does not parse names nobody, so a
182
196
  * namespaced read never falls back to it. The probe never blocks and follows no link: a flat file
183
197
  * that is not a regular file of at most `MAX_RECORD_FILE_BYTES` is not read; `unsafeIsOurs` is then
184
198
  * the answer, so a caller whose reader refuses such a file (the run store's `readRunRecord`) is
@@ -198,7 +212,8 @@ function flatRecordIsOurs(file , slug , fsImpl = fs, u
198
212
  data = JSON.parse(text);
199
213
  } catch { return false; }
200
214
  const named = [data?.featureSlug, data?.slug].find((value) => typeof value === 'string' && value !== '');
201
- return named === undefined || named === slug;
215
+ if (named === undefined) return !slug || !featureHasRegister(file, slug);
216
+ return named === slug;
202
217
  }
203
218
 
204
219
  /**
@@ -103,8 +103,7 @@ function sleep(ms ) {
103
103
  // Runtimes from before this protocol see a directory they did not create: they wait on a fresh one,
104
104
  // and on a stale one their rmdir fails (it is not empty), so they fail closed ("Could not lock",
105
105
  // `store-locked`) instead of taking it; their release of a lock reclaimed from them fails the same
106
- // way. Whatever a process leaves when it dies is reclaimed once LOCK_STALE_MS old. The bounds (what
107
- // this does not cover) are listed once, in docs/architecture.md under "The task-state lock".
106
+ // way. Whatever a process leaves when it dies is reclaimed once LOCK_STALE_MS old.
108
107
  const OWNER_PREFIX = 'owner-';
109
108
  // The longest a fresh acquire may take from its mkdir to its entry and still trust the directory is
110
109
  // its own: half the stale age, so a process that judged it stale cannot have done so before the
@@ -307,7 +306,7 @@ function ownedRelease(fsImpl , lockDir , me ) {
307
306
  try { fsImpl.rmdirSync(entry); } catch { return; } // reclaimed: the lock is not ours to remove
308
307
  // Removed only if it is still that directory: a release stalled here past LOCK_STALE_MS may find
309
308
  // an older runtime's new (empty) lock at the path. What stays is the gap between this stat and the
310
- // rmdir (docs/architecture.md, "The task-state lock", bound 3).
309
+ // rmdir.
311
310
  try {
312
311
  if (mine && sameDir(mine, fsImpl.statSync(lockDir))) fsImpl.rmdirSync(lockDir);
313
312
  } catch { /* a waiting process's entry is in it; that process holds next */ }
@@ -377,10 +376,14 @@ function acquireLock(fsImpl , file , options = {})
377
376
  + strayEntries(fsImpl, lockDir));
378
377
  }
379
378
 
380
- /** Names a non-directory entry in the lock (no DoFlow runtime makes one; it blocks the lock until removed). */
379
+ /** The non-directory entries in the lock (no DoFlow runtime makes one; each blocks the lock until removed). */
380
+ function strayNames(fsImpl , lockDir ) {
381
+ try { return fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return []; }
382
+ }
383
+
384
+ /** `strayNames` as the sentence the lock timeout carries. */
381
385
  function strayEntries(fsImpl , lockDir ) {
382
- let stray = [];
383
- try { stray = fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return ''; }
386
+ const stray = strayNames(fsImpl, lockDir);
384
387
  if (stray.length === 0) return '';
385
388
  return ` It holds ${stray.map((name) => `'${name}'`).join(', ')}, which no DoFlow runtime makes and which keeps it locked: `
386
389
  + `remove ${stray.length > 1 ? 'them' : 'it'} once no DoFlow process is running.`;
@@ -390,7 +393,8 @@ function strayEntries(fsImpl , lockDir ) {
390
393
  * Takes the cross-process lock for one state file with a bounded wait.
391
394
  * Makes 1 try plus `retries` retries (default 3) separated by `waitMs` (default 5); each try is one
392
395
  * tryAcquireLock, which waits at most 40 ms, and only on a process that reached the lock at the same moment.
393
- * Throws an Error('store-locked') after one try plus retries.
396
+ * Throws an Error('store-locked') after one try plus retries, carrying `lockPath` (the lock folder) and
397
+ * `strayEntries` (the names in it that no DoFlow runtime makes) as properties.
394
398
  * @param {Object} fsImpl
395
399
  * @param {string} file
396
400
  * @param {Object} [options]
@@ -421,6 +425,8 @@ function boundedAcquire(
421
425
  }
422
426
  const error = new Error('store-locked');
423
427
  (error ).code = 'store-locked';
428
+ (error ).lockPath = `${file}.lock`;
429
+ (error ).strayEntries = strayNames(fsImpl, `${file}.lock`);
424
430
  throw error;
425
431
  }
426
432
 
@@ -115,8 +115,10 @@ function latencyHotspots(scopes , th , latency ) {
115
115
  const groups = new Map ();
116
116
  for (const s of sc.spans) {
117
117
  if (!Number.isInteger(s.durationMs) || !ranItself(s)) continue;
118
- const key = `${s.kind}\n${s.name ?? ''}`;
119
- if (!groups.has(key)) groups.set(key, { kind: s.kind, name: s.name ?? null, spans: [] });
118
+ // Every derived `test` span is named 'test'; the command's class tells unrelated shell commands apart.
119
+ const name = s.kind === 'test' && typeof s.attrs?.test_class === 'string' ? s.attrs.test_class : (s.name ?? null);
120
+ const key = `${s.kind}\n${name ?? ''}`;
121
+ if (!groups.has(key)) groups.set(key, { kind: s.kind, name, spans: [] });
120
122
  groups.get(key).spans.push(s);
121
123
  }
122
124
  for (const g of [...groups.values()].sort((a, b) => cmp(a.kind, b.kind) || cmp(a.name ?? '', b.name ?? ''))) {
@@ -40,27 +40,33 @@ function parseAt(value ) {
40
40
  return Number.isNaN(ms) ? null : ms;
41
41
  }
42
42
 
43
- /** Every record in a directory, plus the names of the ones that did not parse. A file that cannot be
44
- * read is counted and named rather than skipped: a report that quietly drops what it could not read
45
- * overstates its own coverage, which is the defect `malformedLines` already guards against for runs. */
46
- function readRecords(dir , fsImpl = fs) {
47
- if (!fsImpl.existsSync(dir)) return { records: [], unreadable: [], exists: false };
48
- const records = [];
43
+ /** Every run the `list` action shows (flat and namespaced, this checkout and the other ones, each
44
+ * logical run once), plus the ones that could not be read, named with a fixed reason. A file that
45
+ * cannot be read is counted and named rather than skipped: a report that quietly drops what it could
46
+ * not read overstates its own coverage, which is the defect `malformedLines` already guards against
47
+ * for runs. The reason is never the error's own text, which can carry a path or file bytes. */
48
+ function readRecords(root ) {
49
+ const { orchestrationRuns, runEntriesAt, selectRunEntries, runStoreDir } = require('../implementation-gate.js');
50
+ const listed = orchestrationRuns({ stateRoot: root });
49
51
  const unreadable = [];
50
- for (const name of fsImpl.readdirSync(dir).sort()) {
51
- if (!name.endsWith('.json')) continue;
52
- try {
53
- const parsed = JSON.parse(fsImpl.readFileSync(path.join(dir, name), 'utf8'));
54
- if (!parsed || typeof parsed !== 'object' || !parsed.taskId) {
55
- unreadable.push({ file: name, reason: 'not an orchestration record' });
56
- continue;
57
- }
58
- records.push(parsed);
59
- } catch (err ) {
60
- unreadable.push({ file: name, reason: err.message });
52
+ let entries ;
53
+ if (listed.ok) entries = selectRunEntries(listed.entries);
54
+ else {
55
+ entries = selectRunEntries(runEntriesAt(root, 'current'));
56
+ unreadable.push({ file: '(other checkouts)', reason: 'could not be listed' });
57
+ }
58
+ const records = [];
59
+ for (const entry of entries) {
60
+ const name = entry.origin === 'other' ? entry.file : path.relative(runStoreDir(entry.root), entry.file);
61
+ if (entry.error) {
62
+ unreadable.push({ file: name, reason: entry.error === 'io' ? 'not a regular file' : 'not valid JSON' });
63
+ } else if (!entry.record || typeof entry.record !== 'object' || !entry.record.taskId) {
64
+ unreadable.push({ file: name, reason: 'not an orchestration record' });
65
+ } else {
66
+ records.push(entry.record);
61
67
  }
62
68
  }
63
- return { records, unreadable, exists: true };
69
+ return { records, unreadable, exists: entries.length > 0 || fs.existsSync(orchestrationDir(root)) };
64
70
  }
65
71
 
66
72
  /** A stage row is "not work done" when the orchestrator backfilled it or imported it rather than
@@ -196,8 +202,7 @@ function printClass(group ) {
196
202
  }
197
203
 
198
204
  function handleIndicatorsCommand({ json = false, projectRoot = process.cwd() } = {}) {
199
- const dir = orchestrationDir(projectRoot);
200
- const read = readRecords(dir);
205
+ const read = readRecords(projectRoot);
201
206
  const view = buildIndicators(read);
202
207
 
203
208
  if (json) {
@@ -9,6 +9,7 @@ const { resolveRunsLocation, normalizeRunRecord } = require('./ledger.js');
9
9
  const { SAFE_TOKEN } = require('./telemetry.js');
10
10
  const { finish } = require('./render.js');
11
11
  const { REPO_ROOT } = require('../../helper/repo-root.js');
12
+ const { isRunNamespaceDir, isSupersededFlatRun } = require('../storage/work-status.js');
12
13
 
13
14
  /**
14
15
  * `trace spans` (088 IC-011, FR-005, DD-5): spans projected offline from the records that already
@@ -63,7 +64,6 @@ const { REPO_ROOT } = require('../../helper/repo-root.js');
63
64
 
64
65
  const PARTITION = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
65
66
  const STORE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
66
- const NAMESPACE_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
67
67
  const HEX16 = /^[0-9a-f]{16}$/;
68
68
  const HEX32 = /^[0-9a-f]{32}$/;
69
69
  const WORKER_RUN_ID = /^w-[0-9a-f]{12}$/;
@@ -201,15 +201,14 @@ function readStores(fsImpl , stateDir , rel , gaps , kin
201
201
  if (!namespaced) return out.map(({ file, value }) => ({ file, value }));
202
202
  let entries = [];
203
203
  try { entries = fsImpl.readdirSync(base, { withFileTypes: true }); } catch { entries = []; }
204
- const namespaces = entries.filter((entry ) => entry.isDirectory() && NAMESPACE_DIR.test(entry.name) && !entry.name.endsWith('.lock'))
204
+ const namespaces = entries.filter((entry ) => entry.isDirectory() && isRunNamespaceDir(entry.name))
205
205
  .map((entry ) => entry.name).sort();
206
206
  for (const namespace of namespaces) {
207
207
  const dir = path.join(base, namespace);
208
208
  for (const name of listDir(fsImpl, dir, STORE_FILE)) read(`${rel}/${namespace}/${name}`, name, dir, namespace);
209
209
  }
210
- const copied = (flat ) => out.some((other) => other.namespace !== ''
211
- && other.name === flat.name && (typeof flat.value.featureSlug !== 'string' || flat.value.featureSlug === other.namespace));
212
- return out.filter((entry) => entry.namespace !== '' || !copied(entry)).map(({ file, value }) => ({ file, value }));
210
+ const copies = out.filter((other) => other.namespace !== '');
211
+ return out.filter((entry) => entry.namespace !== '' || !isSupersededFlatRun(entry.name, entry.value, copies)).map(({ file, value }) => ({ file, value }));
213
212
  }
214
213
 
215
214
  const objects = (v ) => (v && typeof v === 'object' && !Array.isArray(v)