@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +27 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +5 -3
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +118 -0
- package/framework/hooks/shared/reparse-scan.js +100 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +14 -2
- package/framework/skills/core/morph-plan/SKILL.md +6 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +29 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +28 -0
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
- package/framework/standards/backend/dotnet/testing.md +127 -0
- package/package.json +1 -1
- package/src/commands/project/worktree.js +250 -86
- package/src/commands/state/approve.js +11 -0
- package/src/commands/state/archive.js +12 -3
- package/src/commands/state/finish.js +30 -16
- package/src/commands/state/handoff.js +3 -2
- package/src/commands/state/usecase.js +77 -0
- package/src/commands/validation/verify.js +6 -0
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/specs/usecase-promoter.js +135 -19
- package/src/lib/specs/usecase-registry.js +237 -0
- package/src/lib/validators/traceability-check.js +198 -0
- package/src/lib/validators/verify-runner.js +14 -0
- package/src/lib/worktree/worktree-audit.js +6 -2
- 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
|
-
|
|
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: `
|
|
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.
|
|
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 —
|
|
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
|
-
|
|
142
|
-
|
|
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) +
|