@polymorphism-tech/morph-spec 8.47.0 → 9.0.0

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 (222) hide show
  1. package/CLAUDE.md +48 -39
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +37 -51
  4. package/bin/validate.js +3 -111
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-proposal.md +2 -50
  17. package/framework/commands/morph-status.md +3 -3
  18. package/framework/docs/CLI.md +585 -286
  19. package/framework/evals/review-checklist.md +82 -41
  20. package/framework/hooks/README.md +10 -11
  21. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +33 -3
  23. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  24. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  25. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  26. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  27. package/framework/hooks/claude-code/core/state-sync.js +7 -21
  28. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  29. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  30. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  31. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  32. package/framework/hooks/claude-code/statusline.py +55 -41
  33. package/framework/hooks/package.json +3 -0
  34. package/framework/hooks/shared/activity-logger.js +1 -1
  35. package/framework/hooks/shared/build-check.js +10 -6
  36. package/framework/hooks/shared/command-tokens.js +169 -0
  37. package/framework/hooks/shared/comment-rules.js +1 -1
  38. package/framework/hooks/shared/comment-scanner.js +10 -23
  39. package/framework/hooks/shared/comment-stripper.js +1 -1
  40. package/framework/hooks/shared/e2e-check.js +64 -27
  41. package/framework/hooks/shared/errata.js +72 -0
  42. package/framework/hooks/shared/feature-locator.js +1 -1
  43. package/framework/hooks/shared/frontend-locations.js +6 -0
  44. package/framework/hooks/shared/gate-question.js +18 -0
  45. package/framework/hooks/shared/phase-utils.js +15 -32
  46. package/framework/hooks/shared/slice-progress.js +118 -0
  47. package/framework/hooks/shared/spawn-command.js +23 -0
  48. package/framework/hooks/shared/state-reader.js +61 -87
  49. package/framework/hooks/shared/state-writer.js +2 -3
  50. package/framework/mcp/registry.json +3 -4
  51. package/framework/phases.json +23 -39
  52. package/framework/schemas/config.schema.json +14 -9
  53. package/framework/schemas/feature.schema.json +15 -50
  54. package/framework/skills/README.md +3 -4
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  57. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  58. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  59. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  60. package/framework/skills/core/morph-media/SKILL.md +1 -1
  61. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  62. package/framework/skills/core/morph-review/SKILL.md +111 -136
  63. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  64. package/framework/skills/core/morph-uiux/SKILL.md +77 -152
  65. package/framework/standards/STANDARDS.json +86 -1218
  66. package/framework/standards/STANDARDS.meta.json +1305 -0
  67. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  68. package/framework/standards/ai-agents/setup.md +1 -1
  69. package/framework/standards/architecture/code-comments.md +6 -6
  70. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  71. package/framework/templates/README.md +2 -3
  72. package/framework/templates/REGISTRY.json +6 -170
  73. package/framework/templates/docs/plan.md +72 -0
  74. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  75. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  76. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  77. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  78. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  79. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  80. package/package.json +1 -1
  81. package/src/commands/e2e/e2e.js +2 -2
  82. package/src/commands/observability/cost.js +18 -60
  83. package/src/commands/project/doctor-procs.js +1 -1
  84. package/src/commands/project/doctor.js +9 -9
  85. package/src/commands/project/fleet.js +4 -4
  86. package/src/commands/project/heavy.js +3 -3
  87. package/src/commands/project/init.js +4 -0
  88. package/src/commands/project/setup-infra-cmd.js +2 -1
  89. package/src/commands/project/stack-scan.js +58 -0
  90. package/src/commands/project/standards.js +79 -25
  91. package/src/commands/project/status.js +29 -69
  92. package/src/commands/project/update.js +17 -1
  93. package/src/commands/project/worktree.js +1 -1
  94. package/src/commands/state/advance.js +62 -46
  95. package/src/commands/state/approve.js +81 -141
  96. package/src/commands/state/archive.js +20 -84
  97. package/src/commands/state/auto-approval.js +68 -0
  98. package/src/commands/state/create.js +28 -1
  99. package/src/commands/state/delete.js +1 -1
  100. package/src/commands/state/finish.js +11 -23
  101. package/src/commands/state/gate-check.js +85 -84
  102. package/src/commands/state/gate-decision.js +6 -4
  103. package/src/commands/state/handoff.js +1 -1
  104. package/src/commands/state/list.js +9 -9
  105. package/src/commands/state/report-check.js +157 -311
  106. package/src/commands/state/retype.js +9 -3
  107. package/src/commands/validation/mutate.js +77 -14
  108. package/src/commands/validation/plan-check.js +81 -0
  109. package/src/commands/validation/validate-feature.js +60 -135
  110. package/src/commands/validation/verify.js +40 -72
  111. package/src/core/paths/output-schema.js +19 -66
  112. package/src/core/state/gate-order.js +5 -6
  113. package/src/core/state/lifecycle-audit.js +2 -2
  114. package/src/core/state/state-manager.js +17 -98
  115. package/src/core/templates/template-registry.js +2 -2
  116. package/src/core/templates/template-seed.js +22 -0
  117. package/src/core/work-types.js +10 -10
  118. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  119. package/src/dashboard/app/components/CommandPalette.jsx +5 -5
  120. package/src/dashboard/app/components/EntityLink.jsx +1 -1
  121. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  122. package/src/dashboard/app/lib/phases.js +2 -4
  123. package/src/dashboard/app/pages/Activity.jsx +1 -1
  124. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  125. package/src/dashboard/app/pages/Features.jsx +9 -9
  126. package/src/dashboard/app/pages/Guide.jsx +2 -2
  127. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  128. package/src/dashboard/app/pages/Standards.jsx +1 -1
  129. package/src/dashboard/scanner/activity-scanner.js +5 -15
  130. package/src/dashboard/scanner/agents-scanner.js +1 -1
  131. package/src/dashboard/scanner/docs-scanner.js +2 -2
  132. package/src/dashboard/scanner/features-scanner.js +95 -60
  133. package/src/dashboard/scanner/project-api.js +1 -1
  134. package/src/dashboard/scanner/standards-scanner.js +2 -1
  135. package/src/lib/ci/ci-verify.js +17 -1
  136. package/src/lib/ci/github-ci.js +1 -1
  137. package/src/lib/detectors/index.js +3 -8
  138. package/src/lib/generators/handoff-generator.js +34 -65
  139. package/src/lib/generators/settings-generator.js +11 -89
  140. package/src/lib/git/evidence-staleness.js +1 -1
  141. package/src/lib/git/tree-fingerprint.js +19 -7
  142. package/src/lib/git/tree-state.js +2 -2
  143. package/src/lib/heavy/cleanup.js +1 -5
  144. package/src/lib/installers/memory-installer.js +6 -8
  145. package/src/lib/mutation/commands.js +3 -2
  146. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  147. package/src/lib/mutation/plan.js +14 -6
  148. package/src/lib/mutation/runner.js +1 -1
  149. package/src/lib/mutation/subset.js +46 -9
  150. package/src/lib/plan/artifact-vocabulary.js +60 -0
  151. package/src/lib/plan/checks.js +234 -0
  152. package/src/lib/plan/plan-parser.js +209 -0
  153. package/src/lib/plan/plan-template.js +18 -0
  154. package/src/lib/review-reports.js +222 -0
  155. package/src/lib/risk-detector.js +131 -140
  156. package/src/lib/specs/adr-index.js +50 -69
  157. package/src/lib/specs/design-seed.js +18 -0
  158. package/src/lib/specs/frontmatter.js +44 -0
  159. package/src/lib/specs/gfm-table.js +17 -0
  160. package/src/lib/specs/old-format.js +68 -0
  161. package/src/lib/specs/revocations.js +37 -95
  162. package/src/lib/specs/screen-checklist.js +18 -17
  163. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  164. package/src/lib/stack/stack-scan.js +267 -0
  165. package/src/lib/standards/registry.js +43 -0
  166. package/src/lib/standards/sections.js +58 -0
  167. package/src/lib/tasks/eval-node.js +4 -12
  168. package/src/lib/tasks/test-nodes.js +1 -74
  169. package/src/lib/tasks/test-runner.js +75 -117
  170. package/src/lib/telemetry/metrics.js +8 -69
  171. package/src/lib/telemetry/telemetry-reader.js +5 -118
  172. package/src/lib/telemetry/verify-event.js +5 -7
  173. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  174. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  175. package/src/lib/validators/reuse/reuse-validator.js +22 -45
  176. package/src/lib/validators/traceability-check.js +85 -129
  177. package/src/lib/validators/verify-runner.js +118 -235
  178. package/src/lib/worktree/docker-audit.js +1 -2
  179. package/src/lib/worktree/fleet-scanner.js +25 -38
  180. package/src/scripts/setup-infra.js +9 -0
  181. package/src/utils/agents-installer.js +76 -9
  182. package/src/utils/claude-settings-manager.js +0 -35
  183. package/src/utils/file-copier.js +27 -1
  184. package/src/utils/hooks-installer.js +16 -9
  185. package/src/utils/skills-installer.js +1 -2
  186. package/framework/evals/trace-schema.md +0 -67
  187. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  188. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  189. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  190. package/framework/hooks/shared/tasks-validator.js +0 -111
  191. package/framework/rules/morph-task-tracking.md +0 -54
  192. package/framework/rules/morph-workflow.md +0 -244
  193. package/framework/schemas/tasks.schema.json +0 -79
  194. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  195. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  196. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  197. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  198. package/framework/templates/docs/proposal.md +0 -192
  199. package/framework/templates/docs/spec.md +0 -57
  200. package/framework/templates/docs/ui-components.md +0 -52
  201. package/framework/templates/docs/ui-design-system.md +0 -76
  202. package/framework/templates/docs/ui-flows.md +0 -167
  203. package/framework/templates/examples/spec-examples.md +0 -91
  204. package/framework/templates/feature/decisions.md +0 -124
  205. package/framework/templates/feature/recap.md +0 -145
  206. package/framework/templates/tasks.template.json +0 -17
  207. package/src/commands/state/usecase.js +0 -143
  208. package/src/commands/tasks/dag.js +0 -181
  209. package/src/lib/agents/output-collector.js +0 -76
  210. package/src/lib/evaluator-report.js +0 -104
  211. package/src/lib/specs/acervo-format.js +0 -70
  212. package/src/lib/specs/acervo-lifecycle.js +0 -264
  213. package/src/lib/specs/done-criteria-check.js +0 -200
  214. package/src/lib/specs/usecase-check.js +0 -301
  215. package/src/lib/specs/usecase-promoter.js +0 -301
  216. package/src/lib/specs/usecase-registry.js +0 -237
  217. package/src/lib/tasks/dispatch-planner.js +0 -319
  218. package/src/lib/tasks/mermaid-renderer.js +0 -132
  219. package/src/lib/tasks/task-classifier.js +0 -30
  220. package/src/lib/tasks/task-parser.js +0 -209
  221. package/src/lib/tasks/tasks-json-parser.js +0 -296
  222. package/src/lib/tasks/test-scope.js +0 -597
@@ -1,237 +0,0 @@
1
- /**
2
- * usecase-registry.js — atomic, cross-worktree UC-{nn} number allocation.
3
- *
4
- * Two parallel worktrees planning at the same time each read the acervo
5
- * (`.morph/specs/usecases/`) plus their OWN feature's `2-plan/usecases/`, take
6
- * the max `UC-{nn}` and add one — but a worktree only ever sees its OWN tree.
7
- * Sibling features' use cases live in SIBLING worktrees (feature.json and every
8
- * UC file travel on the branch, not through any shared filesystem). Two
9
- * worktrees both computing max+1 from the same stale max both mint `UC-07` —
10
- * and at promotion time `usecase-promoter.js` overwrites by ID: the SECOND
11
- * feature to close silently destroys the FIRST one's use case. A user lost
12
- * work this way in production.
13
- *
14
- * This module closes the gap the same way `env-allocator.js` (port blocks) and
15
- * `claim-registry.js` (feature ownership) already do: a registry file under the
16
- * git COMMON dir, the one location every linked worktree of a repo shares —
17
- * `<git-common-dir>/morph-spec/uc-registry.json`. Writes are tmp+rename; a
18
- * corrupt registry reads as empty (fail-open — the cost is a re-seed from disk,
19
- * never a crash).
20
- *
21
- * Unlike the other two registries, correctness here cannot be advisory: the
22
- * whole point is that two concurrent callers must never receive the same
23
- * number. `env-allocator`/`claim-registry` key by FEATURE, so two different
24
- * features racing never contend for the same key; two worktrees reserving UC
25
- * numbers race for the SAME counter. So allocation runs inside a real mutual
26
- * exclusion lock (`uc-registry.lock`, `open(..., 'wx')` — atomic create-if-absent
27
- * on every platform Node targets, including Windows) instead of the bare
28
- * read-modify-write the other two registries use.
29
- *
30
- * No git (no common dir resolvable): falls back to a project-local registry
31
- * under `.morph/logs/` (already gitignored) — this still prevents a single
32
- * session from reserving the same number twice across repeated calls, but
33
- * carries none of the cross-worktree guarantee. Callers must surface that
34
- * degradation to the user.
35
- */
36
-
37
- import {
38
- closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
39
- renameSync, statSync, unlinkSync, writeFileSync,
40
- } from 'fs';
41
- import { join } from 'path';
42
-
43
- // From acervo-format.js, not usecase-promoter.js: the promoter depends on THIS
44
- // module (to look up who reserved a number), and a two-way import is circular.
45
- import { useCaseId } from './acervo-format.js';
46
-
47
- /** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
48
- function formatUcId(n) {
49
- return `UC-${String(n).padStart(2, '0')}`;
50
- }
51
-
52
- /** Directory holding both the registry file and its lock. */
53
- export function registryDir({ gitCommonDir, cwd }) {
54
- return gitCommonDir ? join(gitCommonDir, 'morph-spec') : join(cwd, '.morph', 'logs');
55
- }
56
-
57
- /** Registry file path — shared (git common dir) or local-fallback (no git). */
58
- export function ucRegistryPath({ gitCommonDir, cwd }) {
59
- return join(registryDir({ gitCommonDir, cwd }), 'uc-registry.json');
60
- }
61
-
62
- function readRegistry(path) {
63
- try {
64
- const raw = readFileSync(path, 'utf8').replace(/^/, '');
65
- const parsed = JSON.parse(raw);
66
- if (parsed && typeof parsed.reservations === 'object' && parsed.reservations !== null) {
67
- return {
68
- version: 1,
69
- counter: Number.isInteger(parsed.counter) ? parsed.counter : 0,
70
- reservations: parsed.reservations,
71
- };
72
- }
73
- } catch { /* fail-open: absent or corrupt registry restarts empty */ }
74
- return { version: 1, counter: 0, reservations: {} };
75
- }
76
-
77
- function writeRegistry(path, registry) {
78
- mkdirSync(join(path, '..'), { recursive: true });
79
- const tmp = `${path}.tmp.${process.pid}`;
80
- writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
81
- renameSync(tmp, path);
82
- }
83
-
84
- /** Synchronous sleep — Node (unlike browsers) allows `Atomics.wait` on the main thread. */
85
- function sleepSync(ms) {
86
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
87
- }
88
-
89
- /**
90
- * Run `fn` while holding an exclusive filesystem lock at `lockPath`.
91
- *
92
- * `openSync(path, 'wx')` is `O_CREAT | O_EXCL` — atomic create-or-fail, the
93
- * only primitive this needs and one that behaves identically on POSIX and
94
- * Windows. A lock file whose mtime is older than `staleMs` is assumed to be
95
- * left behind by a crashed process and is force-recovered — an orphaned lock
96
- * must never strand every future reservation.
97
- *
98
- * @param {string} lockPath
99
- * @param {() => T} fn
100
- * @returns {T}
101
- * @template T
102
- */
103
- function withLock(lockPath, fn, { timeoutMs = 5000, pollMs = 20, staleMs = 15000 } = {}) {
104
- mkdirSync(join(lockPath, '..'), { recursive: true });
105
- const deadline = Date.now() + timeoutMs;
106
- for (;;) {
107
- let fd;
108
- try {
109
- fd = openSync(lockPath, 'wx');
110
- } catch (err) {
111
- if (err.code !== 'EEXIST') throw err;
112
- try {
113
- const st = statSync(lockPath);
114
- if (Date.now() - st.mtimeMs > staleMs) {
115
- try { unlinkSync(lockPath); } catch { /* raced someone else's cleanup */ }
116
- continue; // retry immediately — the stale lock is (probably) gone now
117
- }
118
- } catch { continue; } // lock vanished between openSync and statSync — retry
119
- if (Date.now() >= deadline) {
120
- throw new Error(`usecase-registry: could not acquire lock at ${lockPath} within ${timeoutMs}ms`);
121
- }
122
- sleepSync(pollMs);
123
- continue;
124
- }
125
- try {
126
- closeSync(fd);
127
- return fn();
128
- } finally {
129
- try { unlinkSync(lockPath); } catch { /* best-effort */ }
130
- }
131
- }
132
- }
133
-
134
- /** Highest `UC-{nn}` number found among files matching the pattern in `dir`. 0 if none/unreadable. */
135
- function maxUcNumberInDir(dir) {
136
- let max = 0;
137
- try {
138
- if (!existsSync(dir)) return 0;
139
- for (const name of readdirSync(dir)) {
140
- // Any `UC-{nn}-` prefix counts, canonical or not: a number already on disk is never handed out again.
141
- const id = useCaseId(name);
142
- if (!id) continue;
143
- const n = parseInt(id.slice('UC-'.length), 10);
144
- if (Number.isFinite(n) && n > max) max = n;
145
- }
146
- } catch { /* unreadable dir — treat as empty, never fatal */ }
147
- return max;
148
- }
149
-
150
- /** Highest UC number across every feature's `2-plan/usecases/`, under both `.morph/features/` and `.morph/archive/`, in THIS tree. */
151
- function maxUcNumberLocalTree(cwd) {
152
- let max = 0;
153
- for (const bucket of ['features', 'archive']) {
154
- const bucketDir = join(cwd, '.morph', bucket);
155
- try {
156
- if (!existsSync(bucketDir)) continue;
157
- for (const entry of readdirSync(bucketDir, { withFileTypes: true })) {
158
- if (!entry.isDirectory()) continue;
159
- max = Math.max(max, maxUcNumberInDir(join(bucketDir, entry.name, '2-plan', 'usecases')));
160
- }
161
- } catch { /* unreadable bucket — treat as empty */ }
162
- }
163
- return max;
164
- }
165
-
166
- /**
167
- * The highest UC number this project tree can see right now: the permanent
168
- * acervo, plus every feature (active OR archived-but-not-yet-promoted, see
169
- * `usecase-promoter.js`'s refusal path) in THIS tree. Exported for tests and
170
- * for callers that want to preview the seed without reserving anything.
171
- *
172
- * @param {string} cwd
173
- * @returns {number}
174
- */
175
- export function scanLocalMax(cwd) {
176
- const acervoMax = maxUcNumberInDir(join(cwd, '.morph', 'specs', 'usecases'));
177
- return Math.max(acervoMax, maxUcNumberLocalTree(cwd));
178
- }
179
-
180
- /**
181
- * Atomically reserve the next `count` UC numbers, shared across every worktree
182
- * of this repo when a git common dir is resolvable.
183
- *
184
- * The seed is recomputed on EVERY call as `max(registry.counter, scanLocalMax(cwd))`
185
- * — monotonic by construction (a max can only grow), so this doubles as both
186
- * "seed the counter on the first-ever reservation" and "never regress even if
187
- * the registry were somehow behind disk" without needing a separate seeded flag.
188
- *
189
- * @param {string} cwd
190
- * @param {{ feature?: string|null, count?: number, gitCommonDir?: string|null }} opts
191
- * @returns {{ numbers: string[], base: number, count: number, shared: boolean, registryPath: string }}
192
- */
193
- export function reserveUseCases(cwd, { feature = null, count = 1, gitCommonDir = null } = {}) {
194
- const n = Number.isInteger(count) && count > 0 ? count : 1;
195
- const dir = registryDir({ gitCommonDir, cwd });
196
- const path = ucRegistryPath({ gitCommonDir, cwd });
197
- mkdirSync(dir, { recursive: true });
198
-
199
- return withLock(join(dir, 'uc-registry.lock'), () => {
200
- const registry = readRegistry(path);
201
- const base = Math.max(registry.counter || 0, scanLocalMax(cwd));
202
-
203
- // Test-only: widen the critical section so a concurrency test can prove
204
- // two REAL processes racing for this lock actually serialize, instead of
205
- // just happening not to overlap. Mirrors `finish.js`'s MORPH_GH_BIN test
206
- // injection. Unset in every real invocation.
207
- const testDelay = Number.parseInt(process.env.MORPH_UC_RESERVE_TEST_DELAY_MS, 10);
208
- if (Number.isFinite(testDelay) && testDelay > 0) sleepSync(testDelay);
209
-
210
- const numbers = [];
211
- const now = new Date().toISOString();
212
- for (let i = 1; i <= n; i += 1) {
213
- const id = formatUcId(base + i);
214
- numbers.push(id);
215
- registry.reservations[id] = { feature: feature || null, reservedAt: now };
216
- }
217
- registry.counter = base + n;
218
- writeRegistry(path, registry);
219
-
220
- return { numbers, base, count: n, shared: !!gitCommonDir, registryPath: path };
221
- });
222
- }
223
-
224
- /**
225
- * Which feature (if any) holds the reservation for a given `UC-{nn}` id.
226
- * Read-only — no lock, a slightly stale read is harmless for this use (the
227
- * promoter's collision backstop, never the allocation itself).
228
- *
229
- * @param {string} id - e.g. "UC-07"
230
- * @param {{ cwd: string, gitCommonDir?: string|null }} opts
231
- * @returns {string|null}
232
- */
233
- export function reservationOwner(id, { cwd, gitCommonDir = null }) {
234
- const path = ucRegistryPath({ gitCommonDir, cwd });
235
- const registry = readRegistry(path);
236
- return registry.reservations?.[id]?.feature ?? null;
237
- }
@@ -1,319 +0,0 @@
1
- /**
2
- * dispatch-planner.js — effort-weighted sequential vs parallel decision for a
3
- * feature's tasks.json DAG.
4
- *
5
- * Encodes the dispatch rules from morph-apply §2 ("Análise de DAG") so the
6
- * decision is deterministic and CLI-driven (`morph-spec dag <feature>`)
7
- * instead of re-derived by the LLM each session:
8
- *
9
- * 1. <= 3 tasks total → sequential (dispatch overhead beats the work).
10
- * 2. >= 2 independent group flows with enough weight → one agent per flow.
11
- * 3. >= 3 independent roots with effort M or heavier → parallel root batch.
12
- * 4. otherwise → sequential with auto-advance.
13
- *
14
- * Effort weighting: S=1, M=2, L=4, XL=8. S-only roots and light flows never
15
- * earn their own agent — they are returned as `inline` (the orchestrator runs
16
- * them in the sequential lane, or folds them into a single grouped agent), so
17
- * dispatch overhead never exceeds the work being dispatched.
18
- *
19
- * Pure module — operates on a parsed tasks.json document, no I/O.
20
- */
21
-
22
- export const EFFORT_WEIGHT = { S: 1, M: 2, L: 4, XL: 8 };
23
-
24
- /** A root must weigh at least this (effort M) to earn its own agent. */
25
- export const ROOT_DISPATCH_MIN_WEIGHT = 2;
26
-
27
- /** A flow must weigh at least this (an L, or 2×M…) to earn its own agent. */
28
- export const FLOW_DISPATCH_MIN_WEIGHT = 4;
29
-
30
- /** Mirror of the morph-apply cap on simultaneous agents. */
31
- export const MAX_PARALLEL_AGENTS = 4;
32
-
33
- function weightOf(task) {
34
- return EFFORT_WEIGHT[task.effort] ?? EFFORT_WEIGHT.M;
35
- }
36
-
37
- function groupOf(task) {
38
- // Same fallback as groupTasks() in tasks-json-parser.js
39
- return task.group || 'backend';
40
- }
41
-
42
- /**
43
- * Normalize a planned output path for collision comparison: forward slashes,
44
- * collapsed repeated slashes, no leading `./` or `/`, no trailing slash,
45
- * lowercased (Windows/macOS filesystems are case-insensitive). Returns '' for
46
- * empty/nullish input. `..`/`.` segments are left as-is on purpose (resolving
47
- * them would risk false-positives; two genuinely different relative spellings
48
- * stay distinct).
49
- */
50
- export function normalizeOutputPath(p) {
51
- return String(p ?? '')
52
- .trim()
53
- .replace(/\\/g, '/')
54
- .replace(/\/{2,}/g, '/')
55
- .replace(/^\.\//, '')
56
- .replace(/^\/+/, '')
57
- .replace(/\/+$/, '')
58
- .toLowerCase();
59
- }
60
-
61
- /**
62
- * Two normalized paths "overlap" if they are equal, or one is a directory
63
- * ancestor of the other — boundary-aware so `src/a` contains `src/a/b` but
64
- * NOT `src/ab`. Empty paths never overlap.
65
- */
66
- export function pathsOverlap(a, b) {
67
- if (!a || !b) return false;
68
- if (a === b) return true;
69
- const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
70
- return longer.startsWith(`${shorter}/`);
71
- }
72
-
73
- /**
74
- * Decide whether a parallel batch's units would write to overlapping paths,
75
- * and therefore whether each agent should run in its own git worktree
76
- * (`isolation: "worktree"`, per morph-apply §3b-w) instead of the shared
77
- * working tree.
78
- *
79
- * A *unit* is the set of task ids a single agent owns (one flow, or one batch
80
- * root). Tasks WITHIN a unit run serially under that agent, so their outputs
81
- * never race — only CROSS-unit overlaps matter. The recommendation is global
82
- * per batch: any collision → isolate the whole batch. Units whose tasks
83
- * declare no `outputs` are an *unknown footprint* — they do NOT force
84
- * isolation (fail-open) but are flagged so the caller knows the dag can't
85
- * prove the batch is collision-free.
86
- *
87
- * @param {Array<{ key: string, taskIds: string[] }>} units
88
- * @param {Map<string, object>} tasksById
89
- * @returns {{ recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }}
90
- */
91
- export function buildIsolation(units, tasksById) {
92
- const unknownFootprint = [];
93
- const unitOutputs = units.map(unit => {
94
- const outs = [];
95
- for (const id of unit.taskIds) {
96
- const task = tasksById.get(id);
97
- const declared = Array.isArray(task?.outputs) ? task.outputs : [];
98
- let validForTask = 0;
99
- for (const raw of declared) {
100
- const path = normalizeOutputPath(raw);
101
- if (path) { outs.push({ path, task: id }); validForTask += 1; }
102
- }
103
- // No usable output paths (none declared, or all normalize to '') → the
104
- // dag can't prove this task's footprint. Flag it (fail-open).
105
- if (validForTask === 0) unknownFootprint.push(id);
106
- }
107
- return { key: unit.key, outs };
108
- });
109
-
110
- const collisions = [];
111
- for (let i = 0; i < unitOutputs.length; i++) {
112
- for (let j = i + 1; j < unitOutputs.length; j++) {
113
- for (const a of unitOutputs[i].outs) {
114
- for (const b of unitOutputs[j].outs) {
115
- if (pathsOverlap(a.path, b.path)) {
116
- collisions.push({
117
- path: a.path === b.path ? a.path : `${a.path} ∩ ${b.path}`,
118
- units: [unitOutputs[i].key, unitOutputs[j].key],
119
- tasks: [a.task, b.task],
120
- });
121
- }
122
- }
123
- }
124
- }
125
- }
126
-
127
- const recommended = collisions.length > 0 ? 'worktree' : 'none';
128
- let reason;
129
- if (collisions.length > 0) {
130
- reason =
131
- `${collisions.length} colisão(ões) de outputs entre unidades paralelas — ` +
132
- `isole cada agente num worktree (morph-apply §3b-w)`;
133
- } else if (unknownFootprint.length > 0) {
134
- reason =
135
- `sem colisão detectada, mas ${unknownFootprint.length} task(s) paralela(s) sem ` +
136
- `outputs declarados (footprint desconhecido) — o dag não garante ausência de colisão`;
137
- } else {
138
- reason = 'nenhuma colisão de outputs entre unidades paralelas — batch na working tree compartilhada';
139
- }
140
-
141
- return { recommended, collisions, unknownFootprint, reason };
142
- }
143
-
144
- /**
145
- * Ids of tasks whose dependency chain crosses group boundaries — integration
146
- * tasks and everything downstream of them. These never belong to a flow: they
147
- * run in the sequential lane after the parallel batch converges.
148
- */
149
- function crossGroupTaskIds(tasks) {
150
- const byId = new Map(tasks.map(t => [t.id, t]));
151
- const cross = new Set();
152
-
153
- for (const task of tasks) {
154
- for (const dep of task.dependencies) {
155
- const depTask = byId.get(dep);
156
- if (depTask && groupOf(depTask) !== groupOf(task)) {
157
- cross.add(task.id);
158
- break;
159
- }
160
- }
161
- }
162
-
163
- // Propagate downstream: a task depending (transitively) on a cross-group
164
- // task cannot run inside a single-group flow either.
165
- let changed = true;
166
- while (changed) {
167
- changed = false;
168
- for (const task of tasks) {
169
- if (cross.has(task.id)) continue;
170
- if (task.dependencies.some(dep => cross.has(dep))) {
171
- cross.add(task.id);
172
- changed = true;
173
- }
174
- }
175
- }
176
-
177
- return cross;
178
- }
179
-
180
- function buildStats(tasks, pending) {
181
- const groups = {};
182
- const weights = {};
183
- for (const task of pending) {
184
- const g = groupOf(task);
185
- groups[g] = (groups[g] || 0) + 1;
186
- weights[g] = (weights[g] || 0) + weightOf(task);
187
- }
188
- return { total: tasks.length, pending: pending.length, groups, weights };
189
- }
190
-
191
- /**
192
- * Decide how to execute the remaining tasks of a parsed tasks.json document.
193
- *
194
- * @param {object} data - parsed tasks.json ({ version, feature, tasks[] })
195
- * @returns {{
196
- * mode: 'sequential' | 'parallel-flows' | 'parallel-roots',
197
- * reason: string,
198
- * stats: { total: number, pending: number, groups: object, weights: object },
199
- * flows: Array<{ group: string, taskIds: string[], weight: number }>,
200
- * batch: string[],
201
- * inline: string[],
202
- * sequentialRemainder: string[],
203
- * isolation: { recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }
204
- * }}
205
- */
206
- export function planDispatch(data) {
207
- const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
208
- const pending = tasks.filter(t => t.status === 'pending');
209
- const stats = buildStats(tasks, pending);
210
- const empty = { flows: [], batch: [], inline: [] };
211
- const tasksById = new Map(tasks.map(t => [t.id, t]));
212
- // A batch with no parallel units can't have concurrent-write collisions.
213
- const noIsolation = buildIsolation([], tasksById);
214
-
215
- if (pending.length === 0) {
216
- return {
217
- mode: 'sequential',
218
- reason: 'nenhuma task pendente — nada a despachar',
219
- stats, ...empty, sequentialRemainder: [], isolation: noIsolation,
220
- };
221
- }
222
-
223
- // Rule 1 — tiny plans never pay for dispatch.
224
- if (tasks.length <= 3) {
225
- return {
226
- mode: 'sequential',
227
- reason: `${tasks.length} task(s) no total (<= 3) — overhead de Agent dispatch não compensa`,
228
- stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
229
- };
230
- }
231
-
232
- // Rule 2 — independent domain flows (backend ‖ frontend …).
233
- const cross = crossGroupTaskIds(tasks);
234
- const flowsMap = new Map();
235
- for (const task of pending) {
236
- if (cross.has(task.id)) continue;
237
- const g = groupOf(task);
238
- if (!flowsMap.has(g)) flowsMap.set(g, { group: g, taskIds: [], weight: 0 });
239
- const flow = flowsMap.get(g);
240
- flow.taskIds.push(task.id);
241
- flow.weight += weightOf(task);
242
- }
243
- const allFlows = [...flowsMap.values()];
244
- const worthyFlows = allFlows
245
- .filter(f => f.weight >= FLOW_DISPATCH_MIN_WEIGHT)
246
- .sort((a, b) => b.weight - a.weight);
247
- const lightFlows = allFlows.filter(f => f.weight < FLOW_DISPATCH_MIN_WEIGHT);
248
-
249
- if (worthyFlows.length >= 2) {
250
- const flows = worthyFlows.slice(0, MAX_PARALLEL_AGENTS);
251
- const overflow = worthyFlows.slice(MAX_PARALLEL_AGENTS);
252
- const inline = [
253
- ...lightFlows.flatMap(f => f.taskIds),
254
- ...overflow.flatMap(f => f.taskIds),
255
- ];
256
- const sequentialRemainder = pending
257
- .filter(t => cross.has(t.id))
258
- .map(t => t.id);
259
-
260
- const flowDesc = flows.map(f => `${f.group}:${f.taskIds.length} (peso ${f.weight})`).join(' ‖ ');
261
- return {
262
- mode: 'parallel-flows',
263
- reason:
264
- `${flows.length} fluxos de domínio independentes com peso suficiente — ${flowDesc} → ` +
265
- `1 agente por fluxo` +
266
- (inline.length ? `; ${inline.length} task(s) leve(s) inline (sem dispatch próprio)` : '') +
267
- (sequentialRemainder.length ? `; integração cross-group sequencial no fim (${sequentialRemainder.join(', ')})` : ''),
268
- stats, flows, batch: [], inline, sequentialRemainder,
269
- // Units = the dispatched flows (one agent each). Inline/overflow/cross-
270
- // group remainder run in the sequential lane, never concurrently.
271
- isolation: buildIsolation(
272
- flows.map(f => ({ key: f.group, taskIds: f.taskIds })),
273
- tasksById,
274
- ),
275
- };
276
- }
277
-
278
- // Rule 3 — root fan-out, weighted by effort (S roots never earn an agent).
279
- const roots = pending.filter(t => t.dependencies.length === 0);
280
- const weightedRoots = roots
281
- .filter(t => weightOf(t) >= ROOT_DISPATCH_MIN_WEIGHT)
282
- .sort((a, b) => weightOf(b) - weightOf(a));
283
- const sRoots = roots.filter(t => weightOf(t) < ROOT_DISPATCH_MIN_WEIGHT);
284
-
285
- if (weightedRoots.length >= 3) {
286
- const batch = weightedRoots.slice(0, MAX_PARALLEL_AGENTS).map(t => t.id);
287
- const inline = sRoots.map(t => t.id);
288
- const sequentialRemainder = pending
289
- .filter(t => !batch.includes(t.id) && !inline.includes(t.id))
290
- .map(t => t.id);
291
-
292
- return {
293
- mode: 'parallel-roots',
294
- reason:
295
- `${weightedRoots.length} raízes independentes com effort M+ → batch paralelo (${batch.join(', ')})` +
296
- (inline.length ? `; raízes S inline — não ganham dispatch próprio (${inline.join(', ')})` : '') +
297
- `; restante sequencial`,
298
- stats, flows: [], batch, inline, sequentialRemainder,
299
- // Units = each batch root (one agent each); inline S-roots and the
300
- // sequential remainder don't run concurrently with the batch.
301
- isolation: buildIsolation(
302
- batch.map(id => ({ key: id, taskIds: [id] })),
303
- tasksById,
304
- ),
305
- };
306
- }
307
-
308
- // Rule 4 — sequential.
309
- const reason = roots.length >= 3
310
- ? `${roots.length} raízes independentes, mas apenas ${weightedRoots.length} com effort M+ ` +
311
- `(raízes S não ganham dispatch próprio — overhead > trabalho) → sequencial com auto-advance`
312
- : 'sem fluxos ou raízes independentes suficientes → sequencial com auto-advance';
313
-
314
- return {
315
- mode: 'sequential',
316
- reason,
317
- stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
318
- };
319
- }
@@ -1,132 +0,0 @@
1
- /**
2
- * mermaid-renderer.js — pure renderer: tasks.json DAG -> Mermaid flowchart TD.
3
- *
4
- * Backs `morph-spec dag <feature> --mermaid` (S8-T1), so the plan's DAG can be
5
- * visualized at Gate 2 (IndyDevDan rec. 2: draw the workflow in Mermaid before
6
- * automating it).
7
- *
8
- * STRICT Mermaid rules enforced here (see project CLAUDE.md — violating these
9
- * breaks rendering in Obsidian):
10
- * - every node label wrapped in double quotes, no exceptions
11
- * - labels never start with a numeric-with-dot prefix ("1. ", "2. ...") —
12
- * Obsidian's markdown-list parser chokes on it even inside quotes
13
- * - `&` inside labels -> `e`
14
- * - line breaks inside labels -> `<br/>`, never a literal `\n`
15
- * - double quotes inside titles are stripped so they can't break out of the
16
- * surrounding label quotes
17
- *
18
- * Pure module: no fs, no Date.now()/Math.random(). Tasks are sorted by
19
- * numeric id so output is stable regardless of input order.
20
- */
21
-
22
- const MAX_TITLE_LENGTH = 40;
23
-
24
- const VALID_STATUS_CLASSES = ['done', 'in_progress', 'blocked', 'skipped', 'pending'];
25
-
26
- // Web-safe fill/stroke pairs, one per task status.
27
- const CLASS_DEFS = [
28
- 'classDef done fill:#c6f6d5,stroke:#2f855a,color:#22543d',
29
- 'classDef in_progress fill:#bee3f8,stroke:#2b6cb0,color:#2a4365',
30
- 'classDef blocked fill:#feb2b2,stroke:#c53030,color:#63171b',
31
- 'classDef skipped fill:#e2e8f0,stroke:#718096,color:#2d3748',
32
- 'classDef pending fill:#edf2f7,stroke:#a0aec0,color:#2d3748',
33
- ];
34
-
35
- function numericId(id) {
36
- const match = /^T(\d+)$/.exec(id ?? '');
37
- return match ? parseInt(match[1], 10) : Number.POSITIVE_INFINITY;
38
- }
39
-
40
- /**
41
- * Sanitize arbitrary text for embedding inside a double-quoted Mermaid node
42
- * label, per the project's strict Mermaid rules.
43
- *
44
- * - double quotes stripped (they would close the label's own quotes)
45
- * - `&` -> `e`
46
- * - truncated to MAX_TITLE_LENGTH chars + `…`
47
- * - line breaks -> `<br/>`
48
- *
49
- * Ordering matters: truncation runs on the raw text BEFORE newlines expand to
50
- * `<br/>`. Newline->`<br/>` is the only length-changing substitution (1 char ->
51
- * 5); truncating after it could slice a tag mid-way ("...<b…"), so we truncate
52
- * first — every emitted `<br/>` is then guaranteed whole. The char-for-char
53
- * substitutions (`"`->`'`, `&`->`e`) don't move the truncation boundary.
54
- */
55
- export function sanitizeLabel(text) {
56
- let out = String(text ?? '');
57
- out = out.replace(/"/g, "'");
58
- out = out.replace(/&/g, 'e');
59
- if (out.length > MAX_TITLE_LENGTH) {
60
- out = `${out.slice(0, MAX_TITLE_LENGTH)}…`;
61
- }
62
- out = out.replace(/\r\n|\r|\n/g, '<br/>');
63
- return out;
64
- }
65
-
66
- /**
67
- * Same fallback as groupOf() in dispatch-planner.js / groupTasks() in
68
- * tasks-json-parser.js — tasks without a `group` field are treated as
69
- * 'backend'.
70
- */
71
- function groupOf(task) {
72
- return task.group || 'backend';
73
- }
74
-
75
- function statusClass(status) {
76
- return VALID_STATUS_CLASSES.includes(status) ? status : 'pending';
77
- }
78
-
79
- /**
80
- * Render a parsed tasks.json document as a Mermaid `flowchart TD`.
81
- *
82
- * @param {{ tasks?: Array<{ id: string, title: string, dependencies?: string[], effort?: string, group?: string, status?: string }> }} tasksData
83
- * @returns {string}
84
- */
85
- export function renderMermaidFlowchart(tasksData) {
86
- const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
87
-
88
- if (tasks.length === 0) {
89
- return ['flowchart TD', ' empty["sem tasks"]'].join('\n');
90
- }
91
-
92
- const sorted = [...tasks].sort((a, b) => numericId(a.id) - numericId(b.id));
93
- const knownIds = new Set(sorted.map(t => t.id));
94
-
95
- // Group tasks by `group`, preserving the deterministic sorted-by-id order
96
- // in which each group is first encountered.
97
- const groups = new Map();
98
- for (const task of sorted) {
99
- const g = groupOf(task);
100
- if (!groups.has(g)) groups.set(g, []);
101
- groups.get(g).push(task);
102
- }
103
-
104
- const lines = ['flowchart TD'];
105
-
106
- for (const [group, groupTasks] of groups) {
107
- lines.push(` subgraph ${group}["${sanitizeLabel(group)}"]`);
108
- for (const task of groupTasks) {
109
- const label = `${task.id} · ${sanitizeLabel(task.title)} (${task.effort})`;
110
- lines.push(` ${task.id}["${label}"]`);
111
- }
112
- lines.push(' end');
113
- }
114
-
115
- // Edges — one per dependency. Dependencies pointing at a non-existent id
116
- // are silently skipped (never throw on a dangling reference).
117
- for (const task of sorted) {
118
- const deps = Array.isArray(task.dependencies) ? task.dependencies : [];
119
- for (const dep of deps) {
120
- if (!knownIds.has(dep)) continue;
121
- lines.push(` ${dep} --> ${task.id}`);
122
- }
123
- }
124
-
125
- lines.push(...CLASS_DEFS);
126
-
127
- for (const task of sorted) {
128
- lines.push(` class ${task.id} ${statusClass(task.status)}`);
129
- }
130
-
131
- return lines.join('\n');
132
- }