@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
@@ -342,7 +342,11 @@ function copyFeatureFolder(from , to , fsImpl , report , r
342
342
  }
343
343
  }
344
344
 
345
- /** `report` (optional) receives `copied`, `skipped` and, when the cleanup is kept, `failure`. */
345
+ /**
346
+ * `report` (optional) receives `copied`, `skipped` and, when the cleanup is kept, `kept` (head-moved,
347
+ * not-on-develop, wrong-branch, dirty, copy-failed, copy-conflict, worktree-remains or git-failed)
348
+ * and, for the copy and git faults, `failure`.
349
+ */
346
350
  function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsImpl, report = {} } ) {
347
351
  report.copied = 0; report.skipped = []; report.conflicts = [];
348
352
  const feature = intent.featureWorktree;
@@ -350,24 +354,29 @@ function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsIm
350
354
  try {
351
355
  let featureHead ;
352
356
  try { featureHead = String(execFileSync('git', ['-C', root, 'rev-parse', '--verify', `refs/heads/${branch}`], execOptions)).trim(); }
353
- catch { return !feature || !fsImpl.existsSync(feature); }
354
- if (featureHead !== intent.featureHead) return false;
357
+ catch {
358
+ if (!feature || !fsImpl.existsSync(feature)) return true;
359
+ report.kept = 'worktree-remains';
360
+ return false;
361
+ }
362
+ if (featureHead !== intent.featureHead) { report.kept = 'head-moved'; return false; }
355
363
  // Only a tip already on develop may lose its worktree: a branch someone kept committing on keeps
356
364
  // both the worktree (and its gitignored agent-docs/.doflow files) and the branch.
357
365
  try { execFileSync('git', ['-C', root, 'merge-base', '--is-ancestor', featureHead, 'refs/heads/develop'], execOptions); }
358
- catch { return false; }
366
+ catch { report.kept = 'not-on-develop'; return false; }
359
367
  if (feature && fsImpl.existsSync(feature)) {
360
368
  const current = String(execFileSync('git', ['-C', feature, 'symbolic-ref', '-q', 'HEAD'], execOptions)).trim();
361
- if (current !== `refs/heads/${branch}`) return false;
362
- if (String(execFileSync('git', ['-C', feature, 'status', '--porcelain', '--untracked-files=all'], execOptions)) !== '') return false;
369
+ if (current !== `refs/heads/${branch}`) { report.kept = 'wrong-branch'; return false; }
370
+ if (String(execFileSync('git', ['-C', feature, 'status', '--porcelain', '--untracked-files=all'], execOptions)) !== '') { report.kept = 'dirty'; return false; }
363
371
  const slug = branch.replace(/^feat\//, '');
364
372
  const folder = path.join(feature, 'agent-docs', 'doflow', slug);
365
373
  // The folder is gitignored, so `worktree remove` would delete it: copy it first, keep on any failure.
366
374
  if (slug && !slug.includes('/') && fsImpl.existsSync(folder)) {
367
375
  try { copyFeatureFolder(folder, path.join(root, 'agent-docs', 'doflow', slug), fsImpl, report); }
368
- catch (error ) { report.failure = `artifact copy failed: ${error.message}`; return false; }
376
+ catch (error ) { report.failure = `artifact copy failed: ${error.message}`; report.kept = 'copy-failed'; return false; }
369
377
  if (report.conflicts.length) {
370
378
  report.failure = `kept: main checkout copy differs from the worktree copy of ${report.conflicts.join(', ')}`;
379
+ report.kept = 'copy-conflict';
371
380
  return false;
372
381
  }
373
382
  }
@@ -375,7 +384,7 @@ function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsIm
375
384
  }
376
385
  execFileSync('git', ['-C', develop, 'branch', '-d', '--', branch], execOptions);
377
386
  return true;
378
- } catch (error ) { report.failure ||= error.message; return false; }
387
+ } catch (error ) { report.failure ||= error.message; report.kept ||= 'git-failed'; return false; }
379
388
  }
380
389
 
381
390
  module.exports = { mergeRoadmapRow, readOpenIntents, writeIntent, cleanupFeature };
@@ -13,8 +13,7 @@ function safeText(value ) {
13
13
  if (typeof value === 'string') {
14
14
  try {
15
15
  text = JSON.stringify(value).slice(1, -1)
16
- .replace(/\u2028/g, '\\u2028')
17
- .replace(/\u2029/g, '\\u2029');
16
+ .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (c ) => { const cp = c.codePointAt(0) ; return cp > 0xffff ? `\\u{${cp.toString(16)}}` : `\\u${cp.toString(16).padStart(4, '0')}`; });
18
17
  } catch {
19
18
  text = '';
20
19
  }
@@ -733,13 +732,22 @@ function deriveStatus(
733
732
  return { statuses, rowStatus };
734
733
  }
735
734
 
735
+ /**
736
+ * The task classes the roadmap loop runs: the feature chain, and the four classes that take a
737
+ * gate-b-only policy (core/registry/gates.json). research, review and operations stay out: they
738
+ * produce no change the loop could merge. dependency-change stays out too: a change to any
739
+ * package.json dependency key is always denied `protected-path:package.json` (gates.ts), so such a
740
+ * row could never merge without a person; a user ruling reopens it.
741
+ */
742
+ const LOOP_CLASSES = Object.freeze(['feature', 'bug', 'refactor', 'documentation', 'trivial-edit']);
743
+
736
744
  /**
737
745
  * S1 Pick table: chooses the next ready row in seed order.
738
746
  * - Ready only when all needs are merged
739
747
  * - Write set disjoint from all in-flight rows
740
748
  * - WIP bound (default 2)
741
749
  * - Row without a write set runs alone
742
- * - Non-feature class is class-unsupported
750
+ * - A class outside LOOP_CLASSES (research, review, operations, dependency-change, anything else) is class-unsupported
743
751
  * - Yields one reason per skipped row
744
752
  */
745
753
  function pick(
@@ -782,7 +790,7 @@ function pick(
782
790
  }
783
791
 
784
792
  // Class check
785
- if (row.class !== 'feature') {
793
+ if (!LOOP_CLASSES.includes(row.class)) {
786
794
  skips[row.id] = 'class-unsupported';
787
795
  continue;
788
796
  }
@@ -856,7 +864,8 @@ function render(
856
864
  for (const row of rows) {
857
865
  const info = getInfo(row.id);
858
866
  const needsStr = row.needs && row.needs.length > 0 ? row.needs.join(', ') : '-';
859
- const statusStr = info.status;
867
+ // A not-started row of a class the loop does not run waits for a person, not for time: `manual`.
868
+ const statusStr = info.status === 'not-started' && !LOOP_CLASSES.includes(row.class) ? 'manual' : info.status;
860
869
 
861
870
  let evidenceStr = '-';
862
871
  if (info.evidence) {
@@ -1107,6 +1116,7 @@ module.exports = {
1107
1116
  check,
1108
1117
  seedFindings,
1109
1118
  registryClasses,
1119
+ LOOP_CLASSES,
1110
1120
  SEED_RULES,
1111
1121
  ROW_KEYS,
1112
1122
  TOP_KEYS,
@@ -54,6 +54,11 @@ const { readSeedSwitch } = require('../settings/autopilot.js');
54
54
 
55
55
 
56
56
 
57
+
58
+
59
+
60
+
61
+
57
62
 
58
63
 
59
64
  function judgeDraft({
@@ -379,7 +384,7 @@ function printTextResult(
379
384
  if ((op === 'apply' || op === 'amend') && result.ok) {
380
385
  line1 += `, ${result.writes} files written`;
381
386
  if (Array.isArray(result.proposed) && result.proposed.length > 0) {
382
- line1 += `, ${result.proposed.length} proposed (no person verified, or autopilot is not verifiably off; a person runs in their terminal, with autopilot off: doflow roadmap promote ${result.proposed.map(safeText).join(' ')})`;
387
+ line1 += `, ${result.proposed.length} proposed (no person verified, or autopilot is not verifiably off; a person runs in their terminal: doflow roadmap promote ${result.proposed.map(safeText).join(' ')})`;
383
388
  }
384
389
  }
385
390
  if (result.seed && result.seed.sha256 === null) {
@@ -481,6 +486,9 @@ function handleSeedCommand(options = {}) {
481
486
  err = (s ) => console.error(s),
482
487
  classes,
483
488
  isTTY = process.stdin.isTTY === true,
489
+ confirm,
490
+ ttyClock,
491
+ ttySleep,
484
492
  } = options;
485
493
 
486
494
  const usage = (message ) => {
@@ -583,13 +591,37 @@ function handleSeedCommand(options = {}) {
583
591
  }
584
592
 
585
593
  // 4. Apply / Amend action
586
- // (a) maint-thin: a seed write is allowed whatever the switch says. Rows land as drafted (ready) only when BOTH
587
- // autopilot is verifiably off (the fail-closed seed reader) AND stdin is an interactive terminal: a model with a
588
- // shell controls the environment (an empty config home reads as off), so the switch alone cannot tell a person.
589
- // Otherwise every row it adds or amends lands `proposed`, which autopilot never picks or advances until a person
590
- // runs `roadmap promote`.
594
+ // (a) maint-thin / AU C05: a seed write is allowed whatever the switch says. Rows land as drafted (ready) only when
595
+ // stdin is an interactive terminal AND either autopilot is verifiably off (the fail-closed seed reader) or a person
596
+ // answers yes to the same `/dev/tty` [y/N] that `roadmap promote` uses. A model with a shell controls the
597
+ // environment (an empty config home reads as off), so the switch alone cannot tell a person; and without the prompt
598
+ // a model with a pty could land ready rows while autopilot is on, which widens the queue. No terminal, or no/other
599
+ // answer, lands every row it adds or amends `proposed`, which autopilot never picks or advances until a person runs
600
+ // `roadmap promote`. The prompt comes before the roadmap lease (Ctrl-C would otherwise leave the lease behind) and
601
+ // only for a draft that judges clean against the store as read now. No flag or environment variable skips it.
591
602
  const switchReader = readSwitch || readSeedSwitch;
592
- const landProposed = isTTY !== true || switchReader(env, fsImpl).enabled !== false;
603
+ let landProposed = isTTY !== true || switchReader(env, fsImpl).enabled !== false;
604
+ if (landProposed && isTTY === true) {
605
+ const preFacts = gitFacts || gatherGitFacts({ root });
606
+ const pre = preFacts.unreadable && preFacts.unreadable.length > 0 ? null
607
+ : judgeDraft({ draftRaw, seedRaw, gitFacts: preFacts, op: action, classes });
608
+ if (pre && pre.ok) {
609
+ const rowsList = (JSON.parse(draftRaw).rows || []).filter((r ) => r && typeof r === 'object');
610
+ const ids = rowsList.map((r ) => safeText(r.id));
611
+ const titles = rowsList.map((r ) => ` ${safeText(r.id)} ${safeText(r.title)}`);
612
+ const prompt = `${titles.join('\n')}\nLand ${ids.length} row(s) ready for autopilot (autopilot is not verifiably off): ${ids.join(' ')}? [y/N] `;
613
+ let answer = null;
614
+ try {
615
+ if (confirm) answer = confirm(prompt);
616
+ else {
617
+ const read = readTtyLine(prompt, { timeoutMs: SEED_PROMPT_TIMEOUT_MS, now: ttyClock, sleep: ttySleep });
618
+ answer = read.answer;
619
+ if (read.timedOut) err(`doflow roadmap seed: the [y/N] prompt timed out after ${Math.round(SEED_PROMPT_TIMEOUT_MS / 1000)} s with no answer; rows land proposed (run: doflow roadmap promote <id...>)`);
620
+ }
621
+ } catch { answer = null; }
622
+ if (typeof answer === 'string' && /^(y|yes)$/i.test(answer.trim())) landProposed = false;
623
+ }
624
+ }
593
625
 
594
626
  // (b) Acquire lease
595
627
  const leaseDir = path.join(root, '.doflow', 'state', 'roadmap');
@@ -818,25 +850,97 @@ function handleSeedCommand(options = {}) {
818
850
 
819
851
 
820
852
 
821
-
822
853
 
823
-
824
854
 
825
855
 
826
856
 
827
857
 
828
858
 
829
859
 
860
+
861
+
830
862
 
831
863
 
864
+ /** How long the seed prompt waits for a person before it counts as "no" (promote waits without limit). */
865
+ const SEED_PROMPT_TIMEOUT_MS = 60000;
866
+ const TTY_POLL_MS = 25;
867
+
868
+ function sleepMs(ms ) {
869
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
870
+ }
871
+
872
+
873
+
874
+
875
+
876
+
877
+
878
+
879
+ /**
880
+ * Reads one answer line from the controlling terminal (`/dev/tty`), never from stdin, an argument or the environment.
881
+ * `answer` is null when the terminal cannot be opened or read, the line is too long, or the optional timeout passes
882
+ * (`timedOut`). A timed read opens the terminal non-blocking and polls, so a pty that stays open without an answer
883
+ * cannot hold the caller for ever.
884
+ */
885
+ function readTtyLine(prompt , options = {}) {
886
+ const { timeoutMs = 0, now = Date.now, sleep = sleepMs } = options;
887
+ const timed = timeoutMs > 0;
888
+ let fd ;
889
+ try { fd = timed ? fs.openSync('/dev/tty', fs.constants.O_RDWR | fs.constants.O_NONBLOCK) : fs.openSync('/dev/tty', 'r+'); } catch { return { answer: null, timedOut: false }; }
890
+ const deadline = timed ? now() + timeoutMs : Infinity;
891
+ let timedOut = false;
892
+ // one byte; 0 at end of input, -1 when the timeout passed
893
+ const readByte = (buf ) => {
894
+ for (;;) {
895
+ try { return fs.readSync(fd, buf, 0, 1, null); }
896
+ catch (error ) {
897
+ if (!timed || (error?.code !== 'EAGAIN' && error?.code !== 'EWOULDBLOCK')) throw error;
898
+ if (now() >= deadline) { timedOut = true; return -1; }
899
+ sleep(TTY_POLL_MS);
900
+ }
901
+ }
902
+ };
903
+ try {
904
+ fs.writeSync(fd, prompt);
905
+ const buf = Buffer.alloc(1);
906
+ let line = '';
907
+ let ended = false;
908
+ for (let i = 0; i < 64 && !ended; i += 1) {
909
+ const n = readByte(buf);
910
+ if (n < 0) return { answer: null, timedOut: true };
911
+ if (n === 0 || buf[0] === 10) ended = true;
912
+ else line += String.fromCharCode(buf[0]);
913
+ }
914
+ // drain what is left of the typed line (bounded), so it is not left in the terminal queue
915
+ const tooLong = !ended;
916
+ for (let i = 0; i < 4096 && !ended; i += 1) {
917
+ const n = readByte(buf);
918
+ if (n < 0) return { answer: null, timedOut: true };
919
+ if (n === 0 || buf[0] === 10) ended = true;
920
+ }
921
+ return { answer: tooLong ? null : line, timedOut: false };
922
+ } catch {
923
+ return { answer: null, timedOut };
924
+ } finally {
925
+ try { fs.closeSync(fd); } catch { /* ignore close failure */ }
926
+ }
927
+ }
928
+
929
+ /** The untimed reader `roadmap promote` uses: a person runs that verb and may take as long as they need. */
930
+ function readTtyAnswer(prompt ) {
931
+ return readTtyLine(prompt).answer;
932
+ }
933
+
832
934
  /**
833
- * maint-thin: `roadmap promote <id...>`, person only. A model with a shell controls the environment, so an
834
- * environment check alone cannot tell a person: pointing XDG_CONFIG_HOME at an empty folder reads as "off". The verb
835
- * therefore first requires an interactive terminal on stdin (refused `needs-person-tty`, nothing written), then reads
836
- * the switch with the fail-closed seed reader as a second check: unless autopilot is verifiably off it refuses
837
- * `needs-person` and writes nothing. Under the roadmap lease it drops the
838
- * `proposed` mark of every named row, so autopilot may pick it. All or nothing: an unknown id, a row that is not
839
- * proposed, or a duplicate id is a finding and nothing is written. Exit 0 promoted, 1 refused or findings, 2 usage.
935
+ * maint-thin: `roadmap promote <id...>`, person only. A model with a shell controls the environment, so neither an
936
+ * environment check nor the autopilot switch can tell a person. The verb requires an interactive terminal on stdin
937
+ * (refused `needs-person-tty`, nothing written), validates the named rows, then prints their ids and titles and asks
938
+ * `[y/N]` on the controlling terminal (`/dev/tty`): no `/dev/tty`, or an answer other than y/yes, is refused
939
+ * `needs-person-confirm` and nothing is written. No flag or environment variable skips the prompt, and the autopilot
940
+ * switch is not read. The prompt comes before the roadmap lease (Ctrl-C would otherwise leave the lease behind); under
941
+ * the lease the store is read again and must equal what was shown, then the verb drops the `proposed` mark of every
942
+ * named row, so autopilot may pick it. All or nothing: an unknown id, a row that is not proposed, or a duplicate id is a finding (reported before any
943
+ * prompt) and nothing is written. Exit 0 promoted, 1 refused or findings, 2 usage.
840
944
  */
841
945
  function handlePromoteCommand(options = {}) {
842
946
  const {
@@ -845,15 +949,14 @@ function handlePromoteCommand(options = {})
845
949
  root = resolveMainRoot(process.cwd()),
846
950
  fsImpl = fs,
847
951
  gitFacts,
848
- readSwitch,
849
952
  alive,
850
- env = process.env,
851
953
  pid,
852
954
  host,
853
955
  now,
854
956
  out = (s ) => console.log(s),
855
957
  err = (s ) => console.error(s),
856
958
  isTTY = process.stdin.isTTY === true,
959
+ confirm = readTtyAnswer,
857
960
  } = options;
858
961
  const emit = (result , code , detail ) => {
859
962
  if (json) out(JSON.stringify(result));
@@ -879,13 +982,32 @@ function handlePromoteCommand(options = {})
879
982
  return emit({ ...base, reason: 'needs-person-tty' }, 1,
880
983
  'promote runs only from an interactive terminal: a person runs doflow roadmap promote in their own shell');
881
984
  }
882
- const switchReader = readSwitch || readSeedSwitch;
883
- if (switchReader(env, fsImpl).enabled !== false) {
884
- const { configHome } = require('../config-home.js');
885
- const home = configHome(env).dir;
886
- const homeText = home ? safeText(home) : 'the config home (none resolves: set HOME or XDG_CONFIG_HOME)';
887
- return emit({ ...base, reason: 'needs-person' }, 1,
888
- `autopilot is not verifiably off for this config home; a person sets autopilot.enabled to off in ${homeText}/settings.json or creates ${homeText}/autopilot/off, then promotes`);
985
+
986
+ // Read, validate and ask BEFORE taking the lease: Ctrl-C kills the process inside the terminal read, so a lease held
987
+ // across the prompt would be left behind. The lease is taken after the answer, and the store is read again under it.
988
+ let raw ;
989
+ try { raw = fsImpl.readFileSync(storePath, 'utf8'); }
990
+ catch (error ) { return emit({ ...base, findings: ids.map((id) => ({ rule: 'row-not-found', rowId: id, message: `no store at ${storePath}: ${safeText(error.message)}` })) }, 1); }
991
+ const storeFindings = seedFindings(raw);
992
+ if (storeFindings.length > 0) return emit({ ...base, findings: storeFindings }, 1);
993
+ const doc = JSON.parse(raw);
994
+ const findings = [];
995
+ const seen = new Set ();
996
+ for (const id of ids) {
997
+ const row = doc.rows.find((r ) => r.id === id);
998
+ if (seen.has(id)) findings.push({ rule: 'duplicate-id', rowId: id, message: `Row '${safeText(id)}' is named twice` });
999
+ else if (!row) findings.push({ rule: 'row-not-found', rowId: id, message: `Row '${safeText(id)}' not found in store` });
1000
+ else if (row.proposed !== true) findings.push({ rule: 'row-not-proposed', rowId: id, message: `Row '${safeText(id)}' is not proposed` });
1001
+ seen.add(id);
1002
+ }
1003
+ if (findings.length > 0) return emit({ ...base, findings }, 1);
1004
+ const titles = ids.map((id) => ` ${safeText(id)} ${safeText(doc.rows.find((r ) => r.id === id).title)}`);
1005
+ const prompt = `${titles.join('\n')}\nPromote ${ids.length} row(s): ${ids.map(safeText).join(' ')}? [y/N] `;
1006
+ let answer = null;
1007
+ try { answer = confirm(prompt); } catch { answer = null; }
1008
+ if (typeof answer !== 'string' || !/^(y|yes)$/i.test(answer.trim())) {
1009
+ return emit({ ...base, reason: 'needs-person-confirm' }, 1,
1010
+ 'promote needs a person to answer y on the controlling terminal (/dev/tty); there is no flag or variable that skips the prompt');
889
1011
  }
890
1012
 
891
1013
  const leaseDir = path.join(root, '.doflow', 'state', 'roadmap');
@@ -901,22 +1023,12 @@ function handlePromoteCommand(options = {})
901
1023
  const lease = acquireLease(leaseDir, me, alive || noPsAlive, fsImpl);
902
1024
  if (!lease.ok) return emit({ ...base, reason: 'lease-held' }, 1);
903
1025
  try {
904
- let raw ;
905
- try { raw = fsImpl.readFileSync(storePath, 'utf8'); }
906
- catch (error ) { return emit({ ...base, findings: ids.map((id) => ({ rule: 'row-not-found', rowId: id, message: `no store at ${storePath}: ${safeText(error.message)}` })) }, 1); }
907
- const storeFindings = seedFindings(raw);
908
- if (storeFindings.length > 0) return emit({ ...base, findings: storeFindings }, 1);
909
- const doc = JSON.parse(raw);
910
- const findings = [];
911
- const seen = new Set ();
912
- for (const id of ids) {
913
- const row = doc.rows.find((r ) => r.id === id);
914
- if (seen.has(id)) findings.push({ rule: 'duplicate-id', rowId: id, message: `Row '${safeText(id)}' is named twice` });
915
- else if (!row) findings.push({ rule: 'row-not-found', rowId: id, message: `Row '${safeText(id)}' not found in store` });
916
- else if (row.proposed !== true) findings.push({ rule: 'row-not-proposed', rowId: id, message: `Row '${safeText(id)}' is not proposed` });
917
- seen.add(id);
918
- }
919
- if (findings.length > 0) return emit({ ...base, findings }, 1);
1026
+ let current = null;
1027
+ try { current = fsImpl.readFileSync(storePath, 'utf8'); } catch { current = null; }
1028
+ if (current !== raw) {
1029
+ return emit({ ...base, reason: 'needs-person-confirm' }, 1,
1030
+ 'the roadmap store changed after the rows were shown; nothing was written, run doflow roadmap promote again');
1031
+ }
920
1032
  const rows = doc.rows.map((r ) => {
921
1033
  if (!ids.includes(r.id)) return r;
922
1034
  const { proposed, ...ready } = r;
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `doflow-run worktree --action cleanup --slug <slug>` (autonomy plan section 7c): remove one merged
5
+ * feature's worktree and branch with merge.ts's non-force `cleanupFeature`, after copying the
6
+ * worktree's gitignored `agent-docs/doflow/<slug>` back to the main checkout. It refuses, with the
7
+ * reason printed and exit 1, whenever `cleanupFeature` keeps the feature (`report.kept`), the roadmap
8
+ * lease is held (`lease-held`), the row has no `DoFlow-Row` trailer merge on develop (`not-merged`, the
9
+ * evidence the loop requires; a tip merely contained in develop is not enough) or the caller stands
10
+ * inside the worktree (`cwd-inside-worktree`). There is no `--force` (G53). This module is not part of the
11
+ * person-only set and must not import attendance.ts (attendance-scope guard).
12
+ *
13
+ * Exit: 0 `cleaned`, 1 `kept` or `denied` (reason in the output), 2 usage.
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const { execFileSync: defaultExecFileSync } = require('node:child_process');
20
+ const { acquireLease, releaseLease, resolveMainRoot, noPsAlive } = require('./loop.js');
21
+ const { cleanupFeature } = require('./merge.js');
22
+ const { parseSeed, deriveStatus } = require('./rows.js');
23
+ const { gatherGitFacts } = require('./facts.js');
24
+
25
+ /** A row slug is `<id>-<words>`; the pattern keeps a path separator and `..` out of every join below. */
26
+ const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
27
+ const ALLOWED_FLAGS = new Set(['--action', '--slug', '--json']);
28
+
29
+ function execOptionsFor(root , env ) {
30
+ const environment = { ...env };
31
+ for (const key of Object.keys(environment)) if (/^GIT_(?:DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+)$/.test(key)) delete environment[key];
32
+ return { cwd: root, encoding: 'utf8', env: environment, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 };
33
+ }
34
+
35
+ /** Branch ref to worktree path, as `git worktree list` reports them. */
36
+ function listWorktrees(root , execFileSync , execOptions ) {
37
+ const out = String(execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain', '-z'], execOptions));
38
+ const map = new Map ();
39
+ for (const block of out.split('\0\0').filter(Boolean)) {
40
+ const fields = block.split('\0');
41
+ const listed = fields.find((s) => s.startsWith('worktree '))?.slice(9);
42
+ const worktree = listed === undefined ? listed : path.resolve(listed);
43
+ const branch = fields.find((s) => s.startsWith('branch '))?.slice(7);
44
+ if (worktree && branch) map.set(branch, worktree);
45
+ }
46
+ return map;
47
+ }
48
+
49
+ /** True when the seed row for `slug` has a `DoFlow-Row` trailer merge on develop (deriveStatus, as the loop reads it). */
50
+ function mergeEvidence({ root, slug, fsImpl } ) {
51
+ let rows ;
52
+ try { rows = parseSeed(fsImpl.readFileSync(path.join(root, 'agent-docs', 'roadmap', 'rows.json'), 'utf8')).rows; }
53
+ catch { return { error: 'facts-unreadable', unreadable: ['seed'] }; }
54
+ const row = rows.find((entry ) => entry && entry.slug === slug);
55
+ if (!row) return { error: 'row-unknown' };
56
+ const facts = gatherGitFacts({ root });
57
+ if (facts.unreadable?.length) return { error: 'facts-unreadable', unreadable: facts.unreadable };
58
+ const info = deriveStatus([row], facts).statuses.get(row.id);
59
+ return { merged: info?.status === 'merged' && info.evidence?.kind === 'trailer' };
60
+ }
61
+
62
+ /** True when `cwd` is the worktree or inside it (real paths, so a symlinked temp folder cannot hide it). */
63
+ function callerInside(worktree , cwd , fsImpl ) {
64
+ if (!worktree) return false;
65
+ const real = (value ) => { try { return fsImpl.realpathSync(value); } catch { return path.resolve(value); } };
66
+ const relative = path.relative(real(worktree), real(cwd));
67
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
68
+ }
69
+
70
+ /**
71
+ * @returns {{code: number, result: object}} the exit code and the JSON body; never throws for a refusal.
72
+ */
73
+ function cleanupWorktree(options = {}) {
74
+ const fsImpl = options.fsImpl || fs;
75
+ const env = options.env || process.env;
76
+ const execFileSync = options.execFileSync || defaultExecFileSync;
77
+ const slug = options.slug;
78
+ const body = (outcome , extra = {}) => ({ schema: 1, action: 'cleanup', outcome, slug, ...extra });
79
+ if (typeof slug !== 'string' || !SLUG.test(slug) || slug.includes('..')) {
80
+ return { code: 2, result: { schema: 1, action: 'cleanup', error: 'usage', detail: '--slug <row slug> is required (letters, digits, dot, underscore, hyphen)' } };
81
+ }
82
+ const root = options.root || resolveMainRoot(options.cwd || process.cwd());
83
+ const execOptions = execOptionsFor(root, env);
84
+ const dir = path.join(root, '.doflow', 'state', 'roadmap');
85
+ const me = { schema: 1, pid: options.pid ?? process.pid, start: new Date(Date.now() - (process ).uptime() * 1000).toISOString(),
86
+ host: options.host ?? os.hostname(), runId: null, acquiredAt: new Date().toISOString() };
87
+ const lease = acquireLease(dir, me, options.alive || noPsAlive, fsImpl);
88
+ if (!lease.ok) {
89
+ const holder = lease.holder && typeof lease.holder === 'object' ? { pid: lease.holder.pid ?? null, host: lease.holder.host ?? null, runId: lease.holder.runId ?? null } : null;
90
+ return { code: 1, result: body('denied', { reason: 'lease-held', holder }) };
91
+ }
92
+ try {
93
+ let worktrees ;
94
+ try { worktrees = listWorktrees(root, execFileSync, execOptions); }
95
+ catch { return { code: 1, result: body('denied', { reason: 'facts-unreadable', rule: 'cleanup/facts', unreadable: ['worktrees'] }) }; }
96
+ const develop = worktrees.get('refs/heads/develop');
97
+ if (!develop) return { code: 1, result: body('denied', { reason: 'no-develop-worktree' }) };
98
+ const worktree = worktrees.get(`refs/heads/feat/${slug}`) || null;
99
+ let featureHead = null;
100
+ try { featureHead = String(execFileSync('git', ['-C', root, 'rev-parse', '--verify', `refs/heads/feat/${slug}`], execOptions)).trim(); }
101
+ catch { /* branch already gone: only a leftover worktree can remain */ }
102
+ if (featureHead === null && worktree === null) return { code: 0, result: body('cleaned', { already: true, artifacts: { copied: 0, skipped: [], conflicts: [] } }) };
103
+ const artifacts = {};
104
+ // The loop cleans a row only on trailer evidence (loop.ts, merged rows with no intent): a branch
105
+ // whose tip merely sits inside develop (a row claimed a moment ago) is not a merged feature.
106
+ const evidence = mergeEvidence({ root, slug, fsImpl });
107
+ if (evidence.error) return { code: 1, result: body('denied', { reason: evidence.error, rule: 'cleanup/merge-evidence', unreadable: evidence.unreadable }) };
108
+ if (!evidence.merged) { artifacts.kept = 'not-merged'; return { code: 1, result: body('kept', { reason: artifacts.kept, artifacts }) }; }
109
+ // Removing the directory the caller stands in would strand its session.
110
+ if (callerInside(worktree, options.cwd ?? process.cwd(), fsImpl)) {
111
+ artifacts.kept = 'cwd-inside-worktree';
112
+ return { code: 1, result: body('kept', { reason: artifacts.kept, artifacts }) };
113
+ }
114
+ const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${slug}`, featureHead },
115
+ root, develop, execFileSync, execOptions, fsImpl, report: artifacts });
116
+ if (cleaned) return { code: 0, result: body('cleaned', { artifacts }) };
117
+ return { code: 1, result: body('kept', { reason: artifacts.kept || 'git-failed', artifacts }) };
118
+ } finally {
119
+ releaseLease(dir, me, fsImpl);
120
+ }
121
+ }
122
+
123
+ /** The CLI handler: `--action cleanup --slug <slug> [--json]`; any other action is usage. */
124
+ function handleWorktreeCommand(options = {}) {
125
+ const json = Boolean(options.json);
126
+ const usage = (detail ) => {
127
+ if (json) console.log(JSON.stringify({ schema: 1, error: 'usage', detail }));
128
+ else console.error(`doflow worktree: error: ${detail}`);
129
+ process.exitCode = 2;
130
+ return 2;
131
+ };
132
+ if (options.action !== 'cleanup') return usage('--action cleanup is the only action');
133
+ const extra = ((options.flags || []) ).map((flag) => flag.split('=')[0]).find((flag) => !ALLOWED_FLAGS.has(flag));
134
+ if (extra !== undefined) return usage(`unsupported flag ${extra}; only --action, --slug and --json apply`);
135
+ const { code, result } = cleanupWorktree({ slug: options.slug, cwd: options.cwd, env: options.env, root: options.root });
136
+ if (result.error === 'usage') return usage(result.detail);
137
+ if (json) console.log(JSON.stringify(result));
138
+ else if (result.outcome === 'cleaned') console.log(`cleaned ${result.slug}${result.already ? ' (already gone)' : ''}`);
139
+ else {
140
+ const failure = result.artifacts?.failure ? ` (${result.artifacts.failure})` : '';
141
+ console.log(`${result.outcome} ${result.slug}: ${result.reason}${failure}`);
142
+ }
143
+ process.exitCode = code;
144
+ return code;
145
+ }
146
+
147
+ module.exports = { cleanupWorktree, handleWorktreeCommand };
@@ -35,6 +35,8 @@ const BOM = '\uFEFF';
35
35
 
36
36
 
37
37
 
38
+
39
+
38
40
 
39
41
 
40
42
  function isObject(value ) {
@@ -265,7 +267,7 @@ function readLayerFile(file , { registry, fsImpl = nodeFs, includeDocumen
265
267
  values.set(key, { raw, value: error ? null : raw, error });
266
268
  }
267
269
  return { status: 'ok', code: null, offset: null, reason: null, values, notes,
268
- ...(includeDocument ? { document: parsed } : {}) };
270
+ ...(includeDocument ? { document: parsed , source: text } : {}) };
269
271
  }
270
272
 
271
273
  module.exports = { MAX_BYTES, jsonFailure, validateValue, readLayerFile };
@@ -188,6 +188,7 @@ function validatePrepared(doc , ctx ) {
188
188
  if (Object.hasOwn(doc, 'presets') && !isObject(doc.presets)) errors.push(entry('presets', null, 'type', 'presets must be an object with review and debate preset ids'));
189
189
  let loaded = null; // the start verb's inputs, loaded once and only when a preset is checked
190
190
  const inputs = () => (loaded ??= ctx.inputs(taskClass));
191
+ let settingsFault = null; // a live settings fault the prepared values do not replace: one entry per preset
191
192
  for (const kind of ['review', 'debate']) {
192
193
  if (!Object.hasOwn(presets, kind)) continue;
193
194
  const order = presetIds(protocols, kind); // the effective core order: the only ids that have a rank
@@ -201,6 +202,10 @@ function validatePrepared(doc , ctx ) {
201
202
  // The effective preset setting (C-3): the user/project/local layers `review --action start` reads, with this
202
203
  // file's own entry as the project layer, applied by the resolver's rule (settingsPick) to the loosest preset.
203
204
  const settingKey = `${kind}.preset`;
205
+ if (settingsFault === null) {
206
+ try { inputs(); } catch (error ) { if (!error.settings) throw error; settingsFault = error.message; }
207
+ }
208
+ if (settingsFault !== null) { errors.push(entry(key, null, 'settings-unavailable', settingsFault)); continue; }
204
209
  const view = { ...inputs().settingsView };
205
210
  const current = view[settingKey];
206
211
  if (typeof settings[settingKey] === 'string' && (!current || ['default', 'user', 'project'].includes(current.layer))) {
@@ -239,6 +244,11 @@ function report(file , errors , json , notes =
239
244
  return finishRuntime(errors.length === 0 ? 0 : 1);
240
245
  }
241
246
 
247
+ /** The settings keys the prepared run writes to the project layer: applied before the live settings are judged. */
248
+ function replaced(doc ) {
249
+ return new Set(isObject(doc.settings) ? Object.keys(doc.settings).filter((key) => doc.settings[key] !== null) : []);
250
+ }
251
+
242
252
  function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, fsImpl = nodeFs } ) {
243
253
  let text ;
244
254
  try { text = fsImpl.readFileSync(path.resolve(file), 'utf8'); } catch { return usageError('config', `cannot read the prepared run ${file}`, json); }
@@ -255,8 +265,8 @@ function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, f
255
265
  registry, protocols: protocols.registry, classes: taskClasses(repoRoot, fsImpl), env, projectRoot: root, repoRoot, fsImpl, notes,
256
266
  // what `review --action start` loads: the protocol settings view and gates.json narrowed by the project's override
257
267
  inputs: (taskClass ) => {
258
- const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env);
259
- if ('error' in inputs) throw Object.assign(new Error(inputs.message), { usage: true });
268
+ const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env, replaced(doc));
269
+ if ('error' in inputs) throw Object.assign(new Error(inputs.message), inputs.error === 'settings-unavailable' ? { settings: true } : { usage: true });
260
270
  return inputs;
261
271
  },
262
272
  };
@@ -294,7 +294,7 @@ function validateSettingsRegistry(raw , refs = { meta: REF_MET
294
294
  const safe = spec.failSafe;
295
295
  // Records and worker lists use the consumer's invalid path as their fail-safe (IC-003).
296
296
  // A project-only key's candidates never widen (resolve.ts canWiden), so no fail-safe value is ever
297
- // taken; the consumer's invalid path voids it (worker/launch.ts dropDatabases), so the state form is allowed.
297
+ // taken; the consumer's invalid path voids it, so the state form is allowed.
298
298
  const stateSafe = (keyed || workerList || spec.scope === 'project-only') && isObject(safe) && safe.state === 'invalid';
299
299
  if (!stateSafe && (!has(spec, 'failSafe') || (isObject(safe) && !has(safe, 'gate')))) err('is securityRelevant and needs a failSafe value or {gate}');
300
300
  }
@@ -214,6 +214,18 @@ function loadSettings({ projectRoot = null, global = false, env = process.env, r
214
214
  return view;
215
215
  }
216
216
 
217
+ /**
218
+ * The registry to pass `loadSettings` with `fsImpl`: none for the real file system (the shipped one),
219
+ * else the registry read through that file system. Throws when it is unusable (callers fail closed).
220
+ */
221
+ function registryOn(fsImpl ) {
222
+ if (fsImpl === nodeFs) return {};
223
+ const { loadSettingsRegistry } = require('./registry.js');
224
+ const loaded = loadSettingsRegistry({ fsImpl });
225
+ if (!loaded.ok) throw new Error(loaded.detail);
226
+ return { registry: loaded.registry };
227
+ }
228
+
217
229
  function buildView(registry , home , root , global , env , fsImpl , protocols , only ) {
218
230
  const { REGISTRY_FILE } = require('./registry.js');
219
231
  const { REF_META } = require('./refs.js');
@@ -710,4 +722,4 @@ function buildView(registry , home , root , global
710
722
  return view;
711
723
  }
712
724
 
713
- module.exports = { loadSettings, clearSettingsCache };
725
+ module.exports = { loadSettings, registryOn, clearSettingsCache };