@polymorphism-tech/morph-spec 8.22.0 → 8.24.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 (42) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +27 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +1 -1
  7. package/framework/MORPH.md +2 -2
  8. package/framework/agents.json +1 -0
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +118 -0
  11. package/framework/hooks/shared/reparse-scan.js +100 -0
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +14 -2
  19. package/framework/skills/core/morph-plan/SKILL.md +6 -4
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +29 -3
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  25. package/framework/standards/STANDARDS.json +28 -0
  26. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
  27. package/framework/standards/backend/dotnet/testing.md +127 -0
  28. package/package.json +1 -1
  29. package/src/commands/project/worktree.js +250 -86
  30. package/src/commands/state/approve.js +11 -0
  31. package/src/commands/state/archive.js +12 -3
  32. package/src/commands/state/finish.js +30 -16
  33. package/src/commands/state/handoff.js +3 -2
  34. package/src/commands/state/usecase.js +77 -0
  35. package/src/commands/validation/verify.js +6 -0
  36. package/src/lib/generators/handoff-generator.js +2 -2
  37. package/src/lib/specs/usecase-promoter.js +135 -19
  38. package/src/lib/specs/usecase-registry.js +237 -0
  39. package/src/lib/validators/traceability-check.js +198 -0
  40. package/src/lib/validators/verify-runner.js +14 -0
  41. package/src/lib/worktree/worktree-audit.js +6 -2
  42. package/src/utils/hooks-installer.js +12 -8
@@ -0,0 +1,237 @@
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
+ /** A UC filename's leading `UC-{nn}` — deliberately NOT imported from
44
+ * `usecase-promoter.js`: that module will come to depend on THIS one (to look
45
+ * up who reserved a given number), and a two-way import would be circular. */
46
+ const UC_ID_RE = /^(UC-(\d+))-/;
47
+
48
+ /** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
49
+ function formatUcId(n) {
50
+ return `UC-${String(n).padStart(2, '0')}`;
51
+ }
52
+
53
+ /** Directory holding both the registry file and its lock. */
54
+ export function registryDir({ gitCommonDir, cwd }) {
55
+ return gitCommonDir ? join(gitCommonDir, 'morph-spec') : join(cwd, '.morph', 'logs');
56
+ }
57
+
58
+ /** Registry file path — shared (git common dir) or local-fallback (no git). */
59
+ export function ucRegistryPath({ gitCommonDir, cwd }) {
60
+ return join(registryDir({ gitCommonDir, cwd }), 'uc-registry.json');
61
+ }
62
+
63
+ function readRegistry(path) {
64
+ try {
65
+ const raw = readFileSync(path, 'utf8').replace(/^/, '');
66
+ const parsed = JSON.parse(raw);
67
+ if (parsed && typeof parsed.reservations === 'object' && parsed.reservations !== null) {
68
+ return {
69
+ version: 1,
70
+ counter: Number.isInteger(parsed.counter) ? parsed.counter : 0,
71
+ reservations: parsed.reservations,
72
+ };
73
+ }
74
+ } catch { /* fail-open: absent or corrupt registry restarts empty */ }
75
+ return { version: 1, counter: 0, reservations: {} };
76
+ }
77
+
78
+ function writeRegistry(path, registry) {
79
+ mkdirSync(join(path, '..'), { recursive: true });
80
+ const tmp = `${path}.tmp.${process.pid}`;
81
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
82
+ renameSync(tmp, path);
83
+ }
84
+
85
+ /** Synchronous sleep — Node (unlike browsers) allows `Atomics.wait` on the main thread. */
86
+ function sleepSync(ms) {
87
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
88
+ }
89
+
90
+ /**
91
+ * Run `fn` while holding an exclusive filesystem lock at `lockPath`.
92
+ *
93
+ * `openSync(path, 'wx')` is `O_CREAT | O_EXCL` — atomic create-or-fail, the
94
+ * only primitive this needs and one that behaves identically on POSIX and
95
+ * Windows. A lock file whose mtime is older than `staleMs` is assumed to be
96
+ * left behind by a crashed process and is force-recovered — an orphaned lock
97
+ * must never strand every future reservation.
98
+ *
99
+ * @param {string} lockPath
100
+ * @param {() => T} fn
101
+ * @returns {T}
102
+ * @template T
103
+ */
104
+ function withLock(lockPath, fn, { timeoutMs = 5000, pollMs = 20, staleMs = 15000 } = {}) {
105
+ mkdirSync(join(lockPath, '..'), { recursive: true });
106
+ const deadline = Date.now() + timeoutMs;
107
+ for (;;) {
108
+ let fd;
109
+ try {
110
+ fd = openSync(lockPath, 'wx');
111
+ } catch (err) {
112
+ if (err.code !== 'EEXIST') throw err;
113
+ try {
114
+ const st = statSync(lockPath);
115
+ if (Date.now() - st.mtimeMs > staleMs) {
116
+ try { unlinkSync(lockPath); } catch { /* raced someone else's cleanup */ }
117
+ continue; // retry immediately — the stale lock is (probably) gone now
118
+ }
119
+ } catch { continue; } // lock vanished between openSync and statSync — retry
120
+ if (Date.now() >= deadline) {
121
+ throw new Error(`usecase-registry: could not acquire lock at ${lockPath} within ${timeoutMs}ms`);
122
+ }
123
+ sleepSync(pollMs);
124
+ continue;
125
+ }
126
+ try {
127
+ closeSync(fd);
128
+ return fn();
129
+ } finally {
130
+ try { unlinkSync(lockPath); } catch { /* best-effort */ }
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Highest `UC-{nn}` number found among files matching the pattern in `dir`. 0 if none/unreadable. */
136
+ function maxUcNumberInDir(dir) {
137
+ let max = 0;
138
+ try {
139
+ if (!existsSync(dir)) return 0;
140
+ for (const name of readdirSync(dir)) {
141
+ const m = UC_ID_RE.exec(name);
142
+ if (!m) continue;
143
+ const n = parseInt(m[2], 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
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * traceability-check.js — advisory, feature-scope-only check: do the files
3
+ * cited in recap.md's "**Files:**" bullet lists have an owning task in
4
+ * tasks.json's `outputs`?
5
+ *
6
+ * This is the DETERMINISTIC half of a two-layer fix. The NLH half lives in
7
+ * `framework/skills/core/morph-implement/SKILL.md` §7 ("outputs DEVE listar
8
+ * TODO arquivo criado ou modificado pela task") and targets the root cause: a
9
+ * file shipped without EVER being declared anywhere (not in outputs, not in
10
+ * recap.md — the real-world case that motivated this module, see the finding
11
+ * below). This module cannot catch THAT class of drift — a file absent from
12
+ * both documents is invisible to a check that only compares the two documents
13
+ * against each other. What it DOES catch is a narrower, complementary class:
14
+ * recap.md and tasks.json DISAGREEING — an author cited a file in the recap's
15
+ * prose but forgot (or a later task re-touched it without) declaring it in any
16
+ * task's `outputs`. That disagreement is real signal even though it is not the
17
+ * same defect the SKILL.md rule targets.
18
+ *
19
+ * Motivating evidence: run 2026-08-12T133732Z-vet-visit-followup-reminder —
20
+ * `ApplicationDbContextFactory.cs` shipped in production with no owning task
21
+ * in `outputs` AND no mention in recap.md (out of this module's reach by
22
+ * construction). Separately, `src/VetClinic.Api/appsettings.json` and
23
+ * `Properties/launchSettings.json` WERE cited in T1's recap `**Files:**` list
24
+ * but never appear in any task's `outputs` — exactly the disagreement this
25
+ * module flags.
26
+ *
27
+ * Advisory, never blocking: the returned `status` is only ever 'pass' | 'warn'
28
+ * | 'skip' — never 'fail'. `runVerify()` keeps this result OUTSIDE `nodes` on
29
+ * purpose so it can never flip the overall build/tests/validators/e2e status
30
+ * computed from `nodes` alone (see verify-runner.js).
31
+ *
32
+ * Fail-open throughout: no recap.md, no tasks.json, or a parse error all
33
+ * degrade to `{ status: 'skip' }` rather than throwing or warning about the
34
+ * absence itself — this is rastreabilidade tooling, not a completeness gate.
35
+ *
36
+ * @module traceability-check
37
+ */
38
+
39
+ import { existsSync, readFileSync } from 'fs';
40
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
41
+
42
+ /** Matches a `**Files:**` marker followed by one or more `- ` bullet lines. */
43
+ const FILES_BLOCK_RE = /\*\*Files:\*\*[ \t]*\r?\n((?:^[ \t]*-[ \t].*\r?\n?)+)/gm;
44
+
45
+ /** Backtick-quoted spans within a bullet line. */
46
+ const BACKTICK_RE = /`([^`]+)`/g;
47
+
48
+ /**
49
+ * Extract the set of file paths cited under every "**Files:**" block in a
50
+ * recap.md's text, applying a conservative decomposition of the natural
51
+ * language shorthand real recaps use:
52
+ *
53
+ * - A line with exactly one backtick span: that span is a candidate,
54
+ * whether or not it contains a `/` (a legitimate top-level file, e.g.
55
+ * `` `VetClinic.sln` ``, has no slash).
56
+ * - A line with multiple comma-separated backtick spans (e.g.
57
+ * `` `a/b.json`, `c.json`, `d/e.json` ``): only the FIRST span and any
58
+ * LATER span that itself contains `/` are candidates. A later span with
59
+ * no `/` is ambiguous shorthand (a bare filename implicitly relative to
60
+ * the first span's directory, or a suffix like `` `.Designer.cs` ``) and
61
+ * is skipped rather than guessed — false negatives are the acceptable
62
+ * failure mode for advisory tooling, false positives are not.
63
+ * - Any span containing `{`/`}` (brace-expansion shorthand, e.g.
64
+ * `` `Foo/{A,B,C}.cs` ``) is skipped entirely — decomposing it correctly
65
+ * would require guessing at delimiters this format doesn't standardize.
66
+ * - Any span ending in `/` is a directory reference, not a file — skipped.
67
+ *
68
+ * @param {string} recapText
69
+ * @returns {string[]} de-duplicated, normalized (forward-slash) candidate paths
70
+ */
71
+ export function extractCitedFiles(recapText) {
72
+ const cited = new Set();
73
+ if (!recapText) return [];
74
+
75
+ FILES_BLOCK_RE.lastIndex = 0;
76
+ let blockMatch;
77
+ while ((blockMatch = FILES_BLOCK_RE.exec(recapText)) !== null) {
78
+ const block = blockMatch[1];
79
+ const lines = block.split(/\r?\n/).filter((l) => /^[ \t]*-[ \t]/.test(l));
80
+
81
+ for (const line of lines) {
82
+ const spans = [...line.matchAll(BACKTICK_RE)].map((m) => m[1].trim()).filter(Boolean);
83
+ spans.forEach((span, idx) => {
84
+ if (idx > 0 && !span.includes('/')) return; // ambiguous shorthand continuation
85
+ if (span.includes('{') || span.includes('}')) return; // brace-expansion, too ambiguous to decompose
86
+ if (span.endsWith('/')) return; // directory reference, not a file
87
+ cited.add(normalizePath(span));
88
+ });
89
+ }
90
+ }
91
+
92
+ return [...cited];
93
+ }
94
+
95
+ /** Backslashes to forward slashes, strip a leading `./`. */
96
+ function normalizePath(p) {
97
+ return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
98
+ }
99
+
100
+ /**
101
+ * Union of `outputs` across every `done` task in tasks.json, normalized.
102
+ *
103
+ * @param {string} projectPath
104
+ * @param {string} featureName
105
+ * @returns {string[]|null} null when tasks.json is missing/unreadable/invalid
106
+ * (the caller treats null as "cannot judge coverage" and skips, not warns)
107
+ */
108
+ export function collectDoneOutputs(projectPath, featureName) {
109
+ let tasksPath;
110
+ try {
111
+ tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (!existsSync(tasksPath)) return null;
116
+
117
+ let data;
118
+ try {
119
+ data = JSON.parse(readFileSync(tasksPath, 'utf8'));
120
+ } catch {
121
+ return null;
122
+ }
123
+
124
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
125
+ const outputs = [];
126
+ for (const task of tasks) {
127
+ if (task?.status !== 'done') continue;
128
+ for (const output of task.outputs || []) {
129
+ if (typeof output === 'string' && output.trim()) outputs.push(normalizePath(output));
130
+ }
131
+ }
132
+ return outputs;
133
+ }
134
+
135
+ /**
136
+ * A cited file is covered if some done task's `outputs` names it exactly, or
137
+ * names a directory (an entry ending in `/`) that the cited file falls under.
138
+ *
139
+ * @param {string} file
140
+ * @param {string[]} outputs
141
+ * @returns {boolean}
142
+ */
143
+ function isCovered(file, outputs) {
144
+ for (const output of outputs) {
145
+ if (output === file) return true;
146
+ if (output.endsWith('/') && file.startsWith(output)) return true;
147
+ }
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Run the recap-vs-outputs traceability check for a feature.
153
+ *
154
+ * @param {string} projectPath
155
+ * @param {string} featureName
156
+ * @returns {{ status: 'pass'|'warn'|'skip', warnings: string[], reason: string|null, citedCount: number }}
157
+ */
158
+ export function checkOutputsTraceability(projectPath, featureName) {
159
+ let recapPath;
160
+ try {
161
+ recapPath = getAbsoluteOutputPath(projectPath, featureName, 'recap');
162
+ } catch {
163
+ return { status: 'skip', warnings: [], reason: 'unknown feature', citedCount: 0 };
164
+ }
165
+
166
+ if (!existsSync(recapPath)) {
167
+ return { status: 'skip', warnings: [], reason: 'no recap.md', citedCount: 0 };
168
+ }
169
+
170
+ let recapText;
171
+ try {
172
+ recapText = readFileSync(recapPath, 'utf8');
173
+ } catch {
174
+ return { status: 'skip', warnings: [], reason: 'recap.md unreadable', citedCount: 0 };
175
+ }
176
+
177
+ const cited = extractCitedFiles(recapText);
178
+ if (cited.length === 0) {
179
+ return { status: 'skip', warnings: [], reason: 'no Files: entries found in recap.md', citedCount: 0 };
180
+ }
181
+
182
+ const outputs = collectDoneOutputs(projectPath, featureName);
183
+ if (outputs === null) {
184
+ return { status: 'skip', warnings: [], reason: 'no tasks.json', citedCount: cited.length };
185
+ }
186
+
187
+ const warnings = cited
188
+ .filter((file) => !isCovered(file, outputs))
189
+ .sort()
190
+ .map((file) => `${file} — cited in recap.md but not listed in any done task's outputs`);
191
+
192
+ return {
193
+ status: warnings.length > 0 ? 'warn' : 'pass',
194
+ warnings,
195
+ reason: null,
196
+ citedCount: cited.length,
197
+ };
198
+ }
@@ -31,6 +31,7 @@ import { buildComposeEnv } from '../e2e/compose-env.js';
31
31
  import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
32
32
  import { runValidation } from './validation-runner.js';
33
33
  import { loadRubric, aggregateScores } from './score-aggregator.js';
34
+ import { checkOutputsTraceability } from './traceability-check.js';
34
35
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
35
36
 
36
37
  /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
@@ -56,6 +57,7 @@ const OUTPUT_TAIL_LINES = 20;
56
57
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
57
58
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
58
59
  * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
60
+ * @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
59
61
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
60
62
  */
61
63
  export async function runVerify(projectPath, featureName, taskId, options = {}) {
@@ -70,6 +72,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
70
72
  _runTestSuite = runTestSuite,
71
73
  _runValidation = runValidation,
72
74
  _runE2eCheck = runE2eCheck,
75
+ _checkOutputsTraceability = checkOutputsTraceability,
73
76
  } = options;
74
77
 
75
78
  const stack = readStack(projectPath);
@@ -105,6 +108,16 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
105
108
  });
106
109
  appendE2eFeedback(feedback, nodes.e2e);
107
110
 
111
+ // 5. traceability — advisory-only, feature-scope only (see traceability-check.js
112
+ // for the recap-vs-outputs disagreement it flags, and what it deliberately
113
+ // cannot catch). Lives OUTSIDE `nodes` on purpose: `status` below is computed
114
+ // from `nodes` alone, so this can never flip build/tests/validators/e2e green
115
+ // to red — recap/outputs disagreement is a rastreabilidade signal, not a
116
+ // correctness gate.
117
+ const traceability = taskId
118
+ ? { status: 'skip', warnings: [], reason: 'task-scoped verify does not run traceability', citedCount: 0 }
119
+ : _checkOutputsTraceability(projectPath, featureName);
120
+
108
121
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
109
122
 
110
123
  return {
@@ -117,6 +130,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
117
130
  status,
118
131
  nodes,
119
132
  feedback,
133
+ traceability,
120
134
  };
121
135
  }
122
136
 
@@ -131,7 +131,11 @@ export function auditWorktrees(cwd = process.cwd()) {
131
131
  type: 'non-morph-branch',
132
132
  path,
133
133
  detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga`,
134
- fix: `git worktree remove "${path}" (se já não serve)`,
134
+ // NEVER raw `git worktree remove` here: it follows the infra junctions
135
+ // out and empties the primary root's .claude/ and .morph/framework/.
136
+ // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
137
+ // --target precisely for worktrees like this one, with no feature.
138
+ fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
135
139
  });
136
140
  }
137
141
  continue;
@@ -142,7 +146,7 @@ export function auditWorktrees(cwd = process.cwd()) {
142
146
  type: 'no-feature',
143
147
  path,
144
148
  detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch)`,
145
- fix: `git worktree remove "${path}"`,
149
+ fix: `morph-spec worktree remove --target "${path}"`,
146
150
  });
147
151
  }
148
152
  }
@@ -28,7 +28,7 @@ import { execSync } from 'child_process';
28
28
  * delivery — it makes `morph-spec update` report the change honestly instead
29
29
  * of "already current".
30
30
  */
31
- const HOOKS_VERSION = '3.6.0';
31
+ const HOOKS_VERSION = '3.7.0';
32
32
 
33
33
  /** Marker for old dispatch.js (v1) */
34
34
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -132,16 +132,20 @@ const MORPH_HOOKS = [
132
132
  }]
133
133
  },
134
134
 
135
- // === PreToolUse: Bash — denylist (irreversible commands only). Solo. ===
135
+ // === PreToolUse: Bash — irreversible-command guards, chained (block-first-
136
+ // wins, one process). pre-bash-denylist (static pattern list) runs before
137
+ // block-destructive-worktree-removal (dynamic: scans the target for reparse
138
+ // points), so the cheap check short-circuits the filesystem walk. The second
139
+ // one is the only defense against the raw-removal vector that does not depend
140
+ // on someone having read MORPH.md. ===
136
141
  {
137
142
  event: 'PreToolUse',
138
143
  matcher: 'Bash',
139
- hooks: [
140
- {
141
- type: 'command',
142
- command: 'node framework/hooks/claude-code/core/pre-bash-denylist.js'
143
- }
144
- ]
144
+ hooks: [{
145
+ type: 'command',
146
+ command: 'node framework/hooks/claude-code/core/chain.js',
147
+ args: ['pre-bash-denylist', 'block-destructive-worktree-removal']
148
+ }]
145
149
  },
146
150
 
147
151
  // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) +