@khoavu882/doflow 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) 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/settings/settings.json +8 -9
  9. package/core/harnesses/codex/hooks/hooks.json +0 -10
  10. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
  11. package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
  12. package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
  13. package/core/harnesses/copilot/hooks/doflow.json +0 -6
  14. package/core/harnesses/kiro/agents/doflow.json +0 -10
  15. package/core/harnesses/kiro/hooks/doflow.json +0 -10
  16. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
  17. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
  18. package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
  19. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
  20. package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
  21. package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
  22. package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
  23. package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
  24. package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
  25. package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
  26. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
  27. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
  28. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
  29. package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
  30. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +1 -1
  31. package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
  32. package/core/registry/contracts.json +1 -1
  33. package/core/registry/contribution-points.json +5 -2
  34. package/core/registry/gates.json +5 -5
  35. package/core/registry/harnesses.json +9 -9
  36. package/core/registry/intake.json +53 -0
  37. package/core/registry/lifecycle.json +133 -711
  38. package/core/registry/package-templates/intake-category/files/category.json +8 -0
  39. package/core/registry/package-templates/intake-category/template.json +4 -0
  40. package/core/registry/packages/doflow/doflow-package.json +2 -2
  41. package/core/registry/rules.json +8 -8
  42. package/core/registry/settings.json +0 -4
  43. package/core/registry/storage.json +8 -0
  44. package/core/registry/telemetry.json +1 -1
  45. package/core/registry/verification.json +0 -5
  46. package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
  47. package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
  48. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
  49. package/core/shared/guidance/rules/RULES.md +2 -2
  50. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
  51. package/core/shared/scripts/doflow/bin/doflow-run +5 -2
  52. package/core/shared/skills/do/SKILL.md +3 -3
  53. package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
  54. package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
  55. package/core/shared/skills/do-code-review/SKILL.md +7 -4
  56. package/core/shared/skills/do-design/SKILL.md +5 -3
  57. package/core/shared/skills/do-diagnose/SKILL.md +4 -2
  58. package/core/shared/skills/do-document/SKILL.md +1 -0
  59. package/core/shared/skills/do-document/references/embedded-research.md +1 -1
  60. package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
  61. package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
  62. package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
  63. package/core/shared/skills/do-flow/SKILL.md +4 -4
  64. package/core/shared/skills/do-flow/references/task_classes.md +5 -5
  65. package/core/shared/skills/do-implement/SKILL.md +9 -7
  66. package/core/shared/skills/do-plan/SKILL.md +9 -6
  67. package/core/shared/workflows/do-flow-chain.md +2 -0
  68. package/package.json +1 -1
  69. package/src/adapters/claude/index.js +23 -2
  70. package/src/adapters/codex/hooks.js +39 -3
  71. package/src/adapters/codex/index.js +13 -0
  72. package/src/adapters/copy-tree.js +1 -2
  73. package/src/cli/commands/status.js +1 -1
  74. package/src/cli/index.js +20 -8
  75. package/src/cli/runtime-commands.js +12 -4
  76. package/src/lifecycle/coverage.js +1 -30
  77. package/src/lifecycle/hook-tier.js +2 -2
  78. package/src/release/docs-blocks.js +32 -52
  79. package/src/runtime/context/pointer.js +67 -12
  80. package/src/runtime/context-pack.js +4 -1
  81. package/src/runtime/evidence-ledger.js +18 -0
  82. package/src/runtime/health.js +59 -1
  83. package/src/runtime/implementation-gate.js +4 -4
  84. package/src/runtime/intake/engine.js +677 -0
  85. package/src/runtime/intake/index.js +136 -0
  86. package/src/runtime/intake/manifest.js +140 -0
  87. package/src/runtime/intake/taxonomy.js +206 -0
  88. package/src/runtime/lifecycle/cli.js +73 -5
  89. package/src/runtime/lifecycle/followup.js +32 -6
  90. package/src/runtime/lifecycle/overview.js +2 -1
  91. package/src/runtime/packages/points.js +10 -0
  92. package/src/runtime/roadmap/attendance.js +137 -0
  93. package/src/runtime/roadmap/gates.js +6 -3
  94. package/src/runtime/roadmap/loop.js +113 -30
  95. package/src/runtime/roadmap/merge.js +17 -8
  96. package/src/runtime/roadmap/rows.js +13 -4
  97. package/src/runtime/roadmap/seed.js +153 -41
  98. package/src/runtime/roadmap/worktree-cleanup.js +147 -0
  99. package/src/runtime/settings/registry.js +1 -1
  100. package/src/runtime/slots.js +1 -1
  101. package/src/runtime/task-classifier.js +39 -4
  102. package/src/runtime/task-state.js +2 -3
  103. package/src/runtime/verb-view.js +267 -0
  104. package/src/runtime/verification/engine.js +0 -45
  105. package/src/runtime/worker/launch.js +5 -37
  106. package/src/runtime/worker/readiness.js +2 -2
  107. package/src/runtime/workflow-orchestrator.js +33 -5
  108. package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
  109. package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
  110. package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
  111. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
  112. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
  113. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
@@ -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
  }
@@ -1107,6 +1115,7 @@ module.exports = {
1107
1115
  check,
1108
1116
  seedFindings,
1109
1117
  registryClasses,
1118
+ LOOP_CLASSES,
1110
1119
  SEED_RULES,
1111
1120
  ROW_KEYS,
1112
1121
  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 };
@@ -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
  }
@@ -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);
@@ -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
  }
@@ -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 */ }