@polymorphism-tech/morph-spec 8.5.0 → 8.7.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.
- package/README.md +4 -4
- package/bin/morph-spec.js +23 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +2 -2
- package/framework/CLAUDE.md +12 -7
- package/framework/MORPH.md +49 -6
- package/framework/evals/rubrics/test-coverage.md +4 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
- package/framework/hooks/claude-code/core/worktree-context.js +41 -5
- package/framework/hooks/shared/claim-registry.js +175 -0
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/hooks/shared/state-reader.js +68 -25
- package/framework/rules/morph-task-tracking.md +14 -8
- package/framework/rules/morph-workflow.md +36 -3
- package/framework/schemas/feature.schema.json +2 -1
- package/framework/schemas/tasks.schema.json +6 -1
- 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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +4 -3
- 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 +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +83 -13
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +7 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/claim.js +91 -0
- package/src/commands/project/doctor.js +65 -0
- package/src/commands/project/fleet.js +37 -2
- package/src/commands/project/worktree.js +70 -7
- package/src/commands/state/advance.js +60 -1
- package/src/commands/state/approve.js +125 -2
- package/src/commands/state/archive.js +44 -5
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +103 -7
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/commands/tasks/dag.js +25 -2
- package/src/commands/validation/validate-feature.js +9 -2
- package/src/core/paths/output-schema.js +66 -6
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/agents/output-collector.js +5 -0
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +4 -2
- package/src/lib/specs/usecase-check.js +173 -0
- package/src/lib/specs/usecase-promoter.js +120 -0
- package/src/lib/tasks/tasks-json-parser.js +16 -0
- package/src/lib/worktree/fleet-scanner.js +224 -15
- package/src/lib/worktree/worktree-audit.js +181 -0
- package/src/utils/file-copier.js +5 -1
|
@@ -15,7 +15,7 @@ import { join } from 'path';
|
|
|
15
15
|
* Schema for output types produced by morph-spec NLH phases.
|
|
16
16
|
*
|
|
17
17
|
* Fields:
|
|
18
|
-
* filename - The file's basename (e.g. 'spec.md')
|
|
18
|
+
* filename - The file's basename (e.g. 'spec.md'). Single-file types only.
|
|
19
19
|
* phaseDir - Subdirectory within .morph/features/{feature}/
|
|
20
20
|
* phase - The morph-spec phase that produces this output
|
|
21
21
|
* protected - Whether the file requires an approval gate before editing
|
|
@@ -25,6 +25,13 @@ import { join } from 'path';
|
|
|
25
25
|
* Lets the implement phase update task progress without
|
|
26
26
|
* unfreezing the plan contract. When present, the protect
|
|
27
27
|
* hook does a semantic diff instead of a blanket block.
|
|
28
|
+
*
|
|
29
|
+
* Multi-file types (`multi: true`) hold N files in a directory instead of one
|
|
30
|
+
* fixed basename. They carry `dirname` + `filenamePattern` + `displayFilename`
|
|
31
|
+
* INSTEAD of `filename`, and every derivation keyed by basename
|
|
32
|
+
* (FILENAME_TO_OUTPUT, PROTECTED_SPEC_FILES, MUTABLE_SPEC_FIELDS) skips them —
|
|
33
|
+
* there is no single basename to key on. `getOutputPath` returns the DIRECTORY
|
|
34
|
+
* for these types.
|
|
28
35
|
*/
|
|
29
36
|
export const OUTPUT_SCHEMA = {
|
|
30
37
|
// 0-proposal/
|
|
@@ -43,6 +50,19 @@ export const OUTPUT_SCHEMA = {
|
|
|
43
50
|
mandate: { filename: 'mandate.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
44
51
|
tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan', mutableFields: ['status', 'outputs', 'notes'] },
|
|
45
52
|
decisions: { filename: 'decisions.md', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
53
|
+
// N files, one per system use case — the behaviour layer the plan distils
|
|
54
|
+
// BEFORE spec.md/tasks.json. Deliberately NOT protected: editing a use case
|
|
55
|
+
// after Gate 2 is the supported way to change behaviour and re-run implement;
|
|
56
|
+
// the audit trail lives in git.
|
|
57
|
+
usecases: {
|
|
58
|
+
dirname: 'usecases',
|
|
59
|
+
filenamePattern: '^UC-\\d+-[a-z0-9-]+\\.md$',
|
|
60
|
+
displayFilename: 'UC-{nn}-{slug}.md',
|
|
61
|
+
phaseDir: '2-plan',
|
|
62
|
+
phase: 'plan',
|
|
63
|
+
protected: false,
|
|
64
|
+
multi: true,
|
|
65
|
+
},
|
|
46
66
|
|
|
47
67
|
// 3-implement/
|
|
48
68
|
recap: { filename: 'recap.md', phaseDir: '3-implement', phase: 'implement', protected: false },
|
|
@@ -60,9 +80,21 @@ export const OUTPUT_SCHEMA = {
|
|
|
60
80
|
// Builder Functions (Windows-safe via path.join)
|
|
61
81
|
// ============================================================================
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Leaf segment of a schema entry: the basename for single-file types, the
|
|
85
|
+
* directory name for multi-file ones. Keeps every path builder from having to
|
|
86
|
+
* know about `multi` on its own.
|
|
87
|
+
*/
|
|
88
|
+
function leafSegment(entry) {
|
|
89
|
+
return entry.multi ? entry.dirname : entry.filename;
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
/**
|
|
64
93
|
* Get the morph-relative path for a specific output type of a feature.
|
|
65
94
|
*
|
|
95
|
+
* For `multi` types this is the DIRECTORY holding the N files
|
|
96
|
+
* (".morph/features/my-feature/2-plan/usecases"), not a single file.
|
|
97
|
+
*
|
|
66
98
|
* @param {string} featureName
|
|
67
99
|
* @param {string} outputType - camelCase output type key from OUTPUT_SCHEMA
|
|
68
100
|
* @returns {string} e.g. ".morph/features/my-feature/2-plan/spec.md"
|
|
@@ -72,19 +104,38 @@ export function getOutputPath(featureName, outputType) {
|
|
|
72
104
|
if (!entry) {
|
|
73
105
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
74
106
|
}
|
|
75
|
-
return join('.morph', 'features', featureName, entry.phaseDir, entry
|
|
107
|
+
return join('.morph', 'features', featureName, entry.phaseDir, leafSegment(entry))
|
|
76
108
|
.replace(/\\/g, '/');
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
/**
|
|
80
112
|
* Get the absolute filesystem path for a specific output type.
|
|
113
|
+
* For `multi` types this is the directory — see {@link getOutputPath}.
|
|
81
114
|
*/
|
|
82
115
|
export function getAbsoluteOutputPath(projectRoot, featureName, outputType) {
|
|
83
116
|
const entry = OUTPUT_SCHEMA[outputType];
|
|
84
117
|
if (!entry) {
|
|
85
118
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
86
119
|
}
|
|
87
|
-
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, entry
|
|
120
|
+
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, leafSegment(entry));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Human-readable path of a schema entry, relative to the feature directory.
|
|
125
|
+
*
|
|
126
|
+
* SINGLE source for the `morph-outputs` tables: `scripts/generate-refs.js`
|
|
127
|
+
* writes them and `test/framework/refs.test.js` verifies them, and if the two
|
|
128
|
+
* rebuilt the string independently they would drift apart the first time the
|
|
129
|
+
* schema grew a new shape. Multi-file types render the filename PATTERN, so a
|
|
130
|
+
* reader sees it's a folder of N files.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} entry - an OUTPUT_SCHEMA value
|
|
133
|
+
* @returns {string} e.g. "2-plan/spec.md" or "2-plan/usecases/UC-{nn}-{slug}.md"
|
|
134
|
+
*/
|
|
135
|
+
export function getSchemaDisplayPath(entry) {
|
|
136
|
+
return entry.multi
|
|
137
|
+
? `${entry.phaseDir}/${entry.dirname}/${entry.displayFilename}`
|
|
138
|
+
: `${entry.phaseDir}/${entry.filename}`;
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
/**
|
|
@@ -119,6 +170,9 @@ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
|
|
|
119
170
|
|
|
120
171
|
/**
|
|
121
172
|
* Get all output paths for a feature, ready for state.json `outputs` block.
|
|
173
|
+
*
|
|
174
|
+
* Multi-file types contribute their DIRECTORY, so `created` for them means
|
|
175
|
+
* "the folder exists" — the state index tracks presence, not file count.
|
|
122
176
|
*/
|
|
123
177
|
export function getAllOutputPaths(featureName) {
|
|
124
178
|
const outputs = {};
|
|
@@ -135,8 +189,14 @@ export function getAllOutputPaths(featureName) {
|
|
|
135
189
|
// Derived Constants
|
|
136
190
|
// ============================================================================
|
|
137
191
|
|
|
192
|
+
/**
|
|
193
|
+
* filename → output type. Multi-file types are absent by construction: they
|
|
194
|
+
* have N basenames, so there is nothing to key on.
|
|
195
|
+
*/
|
|
138
196
|
export const FILENAME_TO_OUTPUT = Object.fromEntries(
|
|
139
|
-
Object.entries(OUTPUT_SCHEMA)
|
|
197
|
+
Object.entries(OUTPUT_SCHEMA)
|
|
198
|
+
.filter(([, entry]) => !entry.multi)
|
|
199
|
+
.map(([key, entry]) => [entry.filename, key])
|
|
140
200
|
);
|
|
141
201
|
|
|
142
202
|
export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
@@ -148,7 +208,7 @@ export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
|
148
208
|
*/
|
|
149
209
|
export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
150
210
|
Object.entries(OUTPUT_SCHEMA)
|
|
151
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate)
|
|
211
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && !entry.multi)
|
|
152
212
|
.map(([, entry]) => [entry.filename, entry.approvalGate])
|
|
153
213
|
);
|
|
154
214
|
|
|
@@ -160,7 +220,7 @@ export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
|
160
220
|
*/
|
|
161
221
|
export const MUTABLE_SPEC_FIELDS = Object.fromEntries(
|
|
162
222
|
Object.entries(OUTPUT_SCHEMA)
|
|
163
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields)
|
|
223
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields && !entry.multi)
|
|
164
224
|
.map(([, entry]) => [entry.filename, entry.mutableFields])
|
|
165
225
|
);
|
|
166
226
|
|
|
@@ -10,14 +10,68 @@
|
|
|
10
10
|
* • orphan-folder — a feature folder with no feature.json
|
|
11
11
|
* • stale-index — a root-index entry with no feature folder (auto-heals on
|
|
12
12
|
* the next loadState, but surfaced so it's not a mystery)
|
|
13
|
+
* • branch-drift — the root's copy of a feature.json disagrees with the copy
|
|
14
|
+
* committed on `morph/{f}` (B5 — the root's view lags)
|
|
13
15
|
* • corrupt — a feature.json that won't parse
|
|
14
16
|
*/
|
|
15
17
|
|
|
18
|
+
import { execFileSync } from 'child_process';
|
|
16
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
17
20
|
import { join } from 'path';
|
|
18
21
|
import { gateOrderViolations } from './gate-order.js';
|
|
19
22
|
import { missingIdentity } from './feature-identity.js';
|
|
20
23
|
|
|
24
|
+
/** The operational truth of a feature.json: its status and which gates are approved. */
|
|
25
|
+
function operationalShape(fj) {
|
|
26
|
+
const gates = {};
|
|
27
|
+
for (const [g, v] of Object.entries(fj?.approvalGates || {})) gates[g] = v?.approved === true;
|
|
28
|
+
return JSON.stringify({ status: fj?.status ?? null, gates });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Does the root's working-tree copy of a feature.json disagree with the copy
|
|
33
|
+
* COMMITTED on that feature's own branch?
|
|
34
|
+
*
|
|
35
|
+
* `.morph/state.json` is gitignored and derived, and every worktree keeps its
|
|
36
|
+
* own — so "the index diverges between worktrees" is by design, not a defect to
|
|
37
|
+
* fix by making the index authoritative (that would undo the whole per-feature
|
|
38
|
+
* state model). The defect is that the divergence was INVISIBLE: since v8.4 the
|
|
39
|
+
* authoritative feature.json travels committed on `morph/{f}`, so a primary root
|
|
40
|
+
* parked on the default holds a copy that silently lags, and everything derived
|
|
41
|
+
* from it (status, gates, and the index rebuilt from it) lags with it.
|
|
42
|
+
*
|
|
43
|
+
* Comparison is on the OPERATIONAL fields only — status and approved-gate flags
|
|
44
|
+
* — not on bytes: reformatting or a timestamp bump is not a divergence anyone
|
|
45
|
+
* needs to hear about.
|
|
46
|
+
*
|
|
47
|
+
* Fail-open at every step: no repo, no branch, no blob, unparseable JSON → no
|
|
48
|
+
* issue. A diagnostic that invents problems is worse than one that misses some.
|
|
49
|
+
*
|
|
50
|
+
* @returns {string|null} the issue detail, or null when the copies agree
|
|
51
|
+
*/
|
|
52
|
+
function branchDrift(baseDir, feature, rootFj) {
|
|
53
|
+
const branch = `morph/${feature}`;
|
|
54
|
+
try {
|
|
55
|
+
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: baseDir, stdio: 'pipe' })
|
|
56
|
+
.toString().trim();
|
|
57
|
+
// On the feature's own branch the working tree IS the branch — nothing to compare.
|
|
58
|
+
if (current === branch) return null;
|
|
59
|
+
|
|
60
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: baseDir, stdio: 'pipe' });
|
|
61
|
+
|
|
62
|
+
const raw = execFileSync('git', ['show', `${branch}:.morph/features/${feature}/feature.json`], {
|
|
63
|
+
cwd: baseDir, stdio: 'pipe',
|
|
64
|
+
}).toString();
|
|
65
|
+
const branchFj = JSON.parse(raw);
|
|
66
|
+
|
|
67
|
+
if (operationalShape(branchFj) === operationalShape(rootFj)) return null;
|
|
68
|
+
return `root copy disagrees with ${branch} (status/gates) — the branch is authoritative; `
|
|
69
|
+
+ `rode \`git show ${branch}:.morph/features/${feature}/feature.json\` para ver`;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
/**
|
|
22
76
|
* @param {string} [baseDir] - project root (defaults to cwd)
|
|
23
77
|
* @returns {Array<{ feature: string, type: string, detail: string }>} issues
|
|
@@ -49,6 +103,9 @@ export function auditFeatureLifecycle(baseDir = process.cwd()) {
|
|
|
49
103
|
continue;
|
|
50
104
|
}
|
|
51
105
|
|
|
106
|
+
const drift = branchDrift(baseDir, name, fj);
|
|
107
|
+
if (drift) issues.push({ feature: name, type: 'branch-drift', detail: drift });
|
|
108
|
+
|
|
52
109
|
const miss = missingIdentity(fj);
|
|
53
110
|
if (miss.length) {
|
|
54
111
|
issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { WORKTREE_DIR_NAMES } from '../../commands/project/worktree.js';
|
|
3
4
|
|
|
4
5
|
export function scanState(projectDir) {
|
|
5
6
|
const statePath = join(projectDir, '.morph', 'state.json');
|
|
@@ -32,15 +33,22 @@ export function scanState(projectDir) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// Aggregate in-progress features that only exist inside a feature worktree
|
|
35
|
-
// (
|
|
36
|
+
// (worktrees/{wt}/.morph/features/*/feature.json). In the v8 multi-instance
|
|
36
37
|
// model a feature's authoritative feature.json is committed on its
|
|
37
38
|
// `morph/{feature}` branch and only lands in the root .morph/features/ after
|
|
38
39
|
// `morph-spec finish`. This is read-only visibility for the dashboard — root
|
|
39
40
|
// features (the merged, authoritative state) always take precedence, and
|
|
40
41
|
// worktree-sourced entries are tagged with `_worktree` so the UI can
|
|
41
42
|
// distinguish them.
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
//
|
|
44
|
+
// BOTH directory names are scanned: this scanner resolves worktrees by
|
|
45
|
+
// CONVENTION (unlike fleet/finish, which read `git worktree list`), so it is
|
|
46
|
+
// the one that would otherwise go blind to pre-C1 `.worktrees/` checkouts.
|
|
47
|
+
const worktreeDirs = WORKTREE_DIR_NAMES
|
|
48
|
+
.map((name) => join(projectDir, name))
|
|
49
|
+
.filter((dir) => existsSync(dir));
|
|
50
|
+
|
|
51
|
+
for (const worktreesDir of worktreeDirs) {
|
|
44
52
|
let worktreeNames = [];
|
|
45
53
|
try {
|
|
46
54
|
worktreeNames = readdirSync(worktreesDir, { withFileTypes: true })
|
|
@@ -42,6 +42,11 @@ export function collectPhaseOutputs(projectRoot, featureName) {
|
|
|
42
42
|
const results = [];
|
|
43
43
|
|
|
44
44
|
for (const [key, entry] of Object.entries(OUTPUT_SCHEMA)) {
|
|
45
|
+
// Multi-file types resolve to a DIRECTORY — there is no single document to
|
|
46
|
+
// weigh as one source. Skipped explicitly rather than left to fail as an
|
|
47
|
+
// EISDIR swallowed by the catch below.
|
|
48
|
+
if (entry.multi) continue;
|
|
49
|
+
|
|
45
50
|
const absPath = getAbsoluteOutputPath(projectRoot, featureName, key);
|
|
46
51
|
if (!existsSync(absPath)) continue;
|
|
47
52
|
|
|
@@ -19,7 +19,7 @@ import path from 'path';
|
|
|
19
19
|
import { existsSync, readFileSync } from 'fs';
|
|
20
20
|
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
21
21
|
|
|
22
|
-
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', '.worktrees']);
|
|
22
|
+
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', 'worktrees', '.worktrees']);
|
|
23
23
|
const MAX_FILES_PER_SCANNER = 200;
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -29,9 +29,11 @@ const PHASE_DIRS = {
|
|
|
29
29
|
review: '4-review',
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
/** Ordered pointer candidates — only existing
|
|
32
|
+
/** Ordered pointer candidates — only existing paths make it into the doc. */
|
|
33
33
|
const POINTER_CANDIDATES = [
|
|
34
|
-
['0-proposal/proposal.md', 'proposta
|
|
34
|
+
['0-proposal/proposal.md', 'proposta: problema, escopo e riscos'],
|
|
35
|
+
// Behaviour before solution — the same order morph-plan writes them in.
|
|
36
|
+
['2-plan/usecases', 'use cases: comportamento e pós-condições testáveis (pasta)'],
|
|
35
37
|
['2-plan/spec.md', 'especificação técnica'],
|
|
36
38
|
['2-plan/mandate.md', 'contrato de implementação (leia antes de cada task)'],
|
|
37
39
|
['2-plan/tasks.json', 'DAG de tasks (status vivo)'],
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-check between a feature's use cases and its `tasks.json`.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanical half of the use-case layer. Whether a post-condition is
|
|
5
|
+
* *observable in a test* is semantic and stays with Gate 2 and the evaluator —
|
|
6
|
+
* no checker can decide it. What IS decidable is the plumbing, and the plumbing
|
|
7
|
+
* is where the requirement actually leaked before:
|
|
8
|
+
*
|
|
9
|
+
* - a task pointing at a use case that does not exist → ERROR (a broken plan)
|
|
10
|
+
* - a post-condition no task ever mentions → warning (orphan)
|
|
11
|
+
* - a task on a use case whose `doneCriteria` carries → warning (paraphrased
|
|
12
|
+
* none of its post-conditions verbatim instead of copied)
|
|
13
|
+
*
|
|
14
|
+
* Two deliberate asymmetries:
|
|
15
|
+
*
|
|
16
|
+
* **Coverage is computed from the post-condition's side, never the task's.**
|
|
17
|
+
* `usecase` is optional by design — a technical task with no observable
|
|
18
|
+
* behaviour has none — so "task without a use case" is never a finding.
|
|
19
|
+
*
|
|
20
|
+
* **Matching normalizes case and whitespace.** The rule is "copy the text
|
|
21
|
+
* verbatim", but a plan that prefixes the label (`"P2 — cada veterinário…"`)
|
|
22
|
+
* naturally lowercases the first letter. A raw `includes()` would reject the
|
|
23
|
+
* very example the `morph-plan` skill teaches. Accents and wording still have
|
|
24
|
+
* to match: that is the part worth being strict about.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open like the promoter: no `usecases/` folder means nothing to check.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { useCaseId } from './usecase-promoter.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A post-condition line: `- **P2** — Cada veterinário traz suas especialidades`.
|
|
35
|
+
* The separator is tolerant (em dash, en dash or hyphen) because the character
|
|
36
|
+
* is a typing accident, not a decision.
|
|
37
|
+
*/
|
|
38
|
+
const POSTCONDITION_RE = /^[ \t]*[-*][ \t]*\*\*(P\d+)\*\*[ \t]*[—–-][ \t]*(.+?)[ \t]*$/gm;
|
|
39
|
+
|
|
40
|
+
/** Case-folded, whitespace-collapsed form used for containment tests. */
|
|
41
|
+
function normalize(text) {
|
|
42
|
+
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse one use-case document into its post-conditions.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content
|
|
49
|
+
* @returns {Array<{ label: string, text: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export function parsePostConditions(content) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const m of String(content).matchAll(POSTCONDITION_RE)) {
|
|
55
|
+
const [, label, text] = m;
|
|
56
|
+
if (seen.has(label)) continue; // first wins — a repeated label is one post-condition
|
|
57
|
+
seen.add(label);
|
|
58
|
+
out.push({ label, text: text.trim() });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read every use case of a feature, keyed by ID.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
67
|
+
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}> }>}
|
|
68
|
+
*/
|
|
69
|
+
export function loadUseCases(usecasesDir) {
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
if (!existsSync(usecasesDir)) return byId;
|
|
72
|
+
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = readdirSync(usecasesDir).sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return byId; // unreadable folder is "no use cases", never a crash
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const file of names) {
|
|
81
|
+
const id = useCaseId(file);
|
|
82
|
+
if (!id || !file.endsWith('.md')) continue;
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(join(usecasesDir, file), 'utf8');
|
|
85
|
+
byId.set(id, { id, file, postConditions: parsePostConditions(content) });
|
|
86
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
87
|
+
}
|
|
88
|
+
return byId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cross-check `tasks.json` against the feature's use cases.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} tasksData - parsed tasks.json (already schema-valid)
|
|
95
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
96
|
+
* @returns {{ errors: string[], warnings: string[], checked: boolean }}
|
|
97
|
+
* `checked: false` means there were no use cases to check against.
|
|
98
|
+
*/
|
|
99
|
+
export function checkUseCases(tasksData, usecasesDir) {
|
|
100
|
+
const errors = [];
|
|
101
|
+
const warnings = [];
|
|
102
|
+
const byId = loadUseCases(usecasesDir);
|
|
103
|
+
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
104
|
+
|
|
105
|
+
// A task referencing a use case that does not exist is a broken plan, and the
|
|
106
|
+
// check is exact: the file is there or it is not. This is the only finding
|
|
107
|
+
// that earns an error — everything below is advisory by construction.
|
|
108
|
+
for (const task of tasks) {
|
|
109
|
+
if (!task?.usecase) continue;
|
|
110
|
+
if (!byId.has(task.usecase)) {
|
|
111
|
+
const known = [...byId.keys()].sort().join(', ') || 'nenhum';
|
|
112
|
+
errors.push(
|
|
113
|
+
`${task.id}.usecase → ${task.usecase} não existe em 2-plan/usecases/ (encontrados: ${known})`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (byId.size === 0) return { errors, warnings, checked: false };
|
|
119
|
+
|
|
120
|
+
// Pre-normalize every doneCriteria once: this is O(tasks × postConditions)
|
|
121
|
+
// and both are small, but the normalization itself is not free.
|
|
122
|
+
const criteria = tasks.map((t) => ({ id: t?.id, text: normalize(t?.doneCriteria || ''), usecase: t?.usecase }));
|
|
123
|
+
|
|
124
|
+
// Orphan post-conditions — computed from the post-condition's side, so a task
|
|
125
|
+
// without a `usecase` never counts against anyone.
|
|
126
|
+
//
|
|
127
|
+
// A use case NOBODY touches collapses into a single aggregated warning. The
|
|
128
|
+
// per-post-condition list is the useful shape while a plan is being closed
|
|
129
|
+
// (three of four covered → fix the fourth), but `dag` also runs on every
|
|
130
|
+
// implement cycle, where an untouched use case would repeat N identical lines
|
|
131
|
+
// forever. Losing the finding entirely would be worse: a use case written and
|
|
132
|
+
// then ignored by the whole plan is the failure this layer exists to catch.
|
|
133
|
+
for (const uc of byId.values()) {
|
|
134
|
+
const withText = uc.postConditions.filter((pc) => normalize(pc.text));
|
|
135
|
+
if (withText.length === 0) continue;
|
|
136
|
+
|
|
137
|
+
const orphans = withText.filter((pc) => !criteria.some((c) => c.text.includes(normalize(pc.text))));
|
|
138
|
+
if (orphans.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
const untouched = orphans.length === withText.length && !criteria.some((c) => c.usecase === uc.id);
|
|
141
|
+
if (untouched) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`${uc.id} não é referenciado por nenhuma task — ${withText.length} pós-condição(ões) sem cobertura ` +
|
|
144
|
+
`(${withText.map((p) => p.label).join('/')})`
|
|
145
|
+
);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const pc of orphans) {
|
|
149
|
+
warnings.push(
|
|
150
|
+
`${uc.id} ${pc.label} não é coberta por nenhuma task — nenhum doneCriteria carrega "${pc.text}"`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Tasks that claim a use case but paraphrased it instead of copying it.
|
|
156
|
+
for (const c of criteria) {
|
|
157
|
+
if (!c.usecase) continue;
|
|
158
|
+
const uc = byId.get(c.usecase);
|
|
159
|
+
if (!uc || uc.postConditions.length === 0) continue;
|
|
160
|
+
const carries = uc.postConditions.some((pc) => {
|
|
161
|
+
const needle = normalize(pc.text);
|
|
162
|
+
return needle && c.text.includes(needle);
|
|
163
|
+
});
|
|
164
|
+
if (!carries) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
`${c.id}.doneCriteria não carrega nenhuma pós-condição de ${c.usecase} literalmente ` +
|
|
167
|
+
`(esperado o texto de ${uc.postConditions.map((p) => p.label).join('/')})`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { errors, warnings, checked: true };
|
|
173
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotion of a feature's use cases into the project's permanent archive.
|
|
3
|
+
*
|
|
4
|
+
* A use case is born inside a feature (`2-plan/usecases/UC-{nn}-{slug}.md`) and
|
|
5
|
+
* is promoted, at archive time, into `.morph/specs/usecases/` — the living
|
|
6
|
+
* description of the system's behaviour, which outlives the feature that
|
|
7
|
+
* introduced it. The archive is COMMITTED (no gitignore rule covers it).
|
|
8
|
+
*
|
|
9
|
+
* Contract, in the order it matters:
|
|
10
|
+
* - **Fail-open.** Every failure degrades to "nothing promoted". Promotion may
|
|
11
|
+
* never block the close of a feature — that is why the caller treats a throw
|
|
12
|
+
* as a no-op rather than an error.
|
|
13
|
+
* - **No-op without the folder.** A feature with no use cases promotes nothing.
|
|
14
|
+
* - **Idempotent.** Re-promoting the same use case rewrites the same content.
|
|
15
|
+
* - **Overwrite by ID, not by filename.** `UC-07-old-slug.md` in the archive is
|
|
16
|
+
* REPLACED by an incoming `UC-07-new-slug.md`. Keying on the filename would
|
|
17
|
+
* leave two files claiming UC-07 the first time a slug is reworded — and the
|
|
18
|
+
* `bug` work type exists precisely to correct an existing use case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
24
|
+
|
|
25
|
+
const UC = OUTPUT_SCHEMA.usecases;
|
|
26
|
+
const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
|
|
27
|
+
|
|
28
|
+
/** Project-relative path of the permanent use-case archive (git pathspec-ready). */
|
|
29
|
+
export const SPECS_USECASES_PATHSPEC = join('.morph', 'specs', 'usecases');
|
|
30
|
+
|
|
31
|
+
/** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
|
|
32
|
+
export function useCaseId(filename) {
|
|
33
|
+
const m = /^(UC-\d+)-/.exec(filename);
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite the frontmatter `status:` to `implemented`.
|
|
39
|
+
*
|
|
40
|
+
* A file with no frontmatter is copied verbatim: the use case belongs to the
|
|
41
|
+
* author, and inventing a header would be a silent edit of their document.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function stampImplemented(content) {
|
|
47
|
+
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
|
|
48
|
+
if (!m || m.index !== 0) return content;
|
|
49
|
+
|
|
50
|
+
const [full, open, body, close] = m;
|
|
51
|
+
const eol = full.includes('\r\n') ? '\r\n' : '\n';
|
|
52
|
+
const statusLine = /^status:[ \t]*.*$/m;
|
|
53
|
+
const next = statusLine.test(body)
|
|
54
|
+
? body.replace(statusLine, 'status: implemented')
|
|
55
|
+
: `${body}${eol}status: implemented`;
|
|
56
|
+
|
|
57
|
+
return open + next + close + content.slice(full.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy every use case of a feature into the project's permanent archive.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} cwd - project root that owns `.morph/specs/`
|
|
64
|
+
* @param {string} featureDir - the feature folder holding `2-plan/usecases/`
|
|
65
|
+
* @returns {{ promoted: string[], replaced: string[] }} filenames, always arrays
|
|
66
|
+
*/
|
|
67
|
+
export function promoteUseCases(cwd, featureDir) {
|
|
68
|
+
const empty = { promoted: [], replaced: [] };
|
|
69
|
+
const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
|
|
70
|
+
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(srcDir)) return empty;
|
|
74
|
+
names = readdirSync(srcDir).filter((n) => UC_FILENAME_RE.test(n)).sort();
|
|
75
|
+
} catch {
|
|
76
|
+
return empty;
|
|
77
|
+
}
|
|
78
|
+
if (!names.length) return empty;
|
|
79
|
+
|
|
80
|
+
const destDir = join(cwd, '.morph', 'specs', 'usecases');
|
|
81
|
+
const promoted = [];
|
|
82
|
+
const replaced = [];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(destDir, { recursive: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return empty;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Existing archive entries, indexed by use-case ID — the key the overwrite
|
|
91
|
+
// rule is written against.
|
|
92
|
+
const existingById = new Map();
|
|
93
|
+
try {
|
|
94
|
+
for (const name of readdirSync(destDir)) {
|
|
95
|
+
const id = UC_FILENAME_RE.test(name) ? useCaseId(name) : null;
|
|
96
|
+
if (id) existingById.set(id, name);
|
|
97
|
+
}
|
|
98
|
+
} catch { /* unreadable archive — treat as empty, never fatal */ }
|
|
99
|
+
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
try {
|
|
102
|
+
const content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
|
|
103
|
+
const id = useCaseId(name);
|
|
104
|
+
const stale = id ? existingById.get(id) : null;
|
|
105
|
+
|
|
106
|
+
// Same ID under a different filename: drop the old file, or the archive
|
|
107
|
+
// would end up with two documents claiming the same UC number.
|
|
108
|
+
if (stale && stale !== name) {
|
|
109
|
+
rmSync(join(destDir, stale), { force: true });
|
|
110
|
+
replaced.push(stale);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeFileSync(join(destDir, name), content, 'utf8');
|
|
114
|
+
promoted.push(name);
|
|
115
|
+
if (id) existingById.set(id, name);
|
|
116
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { promoted, replaced };
|
|
120
|
+
}
|
|
@@ -26,6 +26,8 @@ export function getSchema() {
|
|
|
26
26
|
const VALID_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
|
|
27
27
|
const VALID_EFFORT = ['S', 'M', 'L', 'XL'];
|
|
28
28
|
const VALID_GROUP = ['backend', 'frontend', 'tests', 'infra', 'docs'];
|
|
29
|
+
/** Use-case reference: the ID only (`UC-07`), never the filename. */
|
|
30
|
+
const USECASE_RE = /^UC-\d+$/;
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Validate a tasks.json structure.
|
|
@@ -79,6 +81,12 @@ export function validateTasksJson(data) {
|
|
|
79
81
|
if (task.group !== undefined && !VALID_GROUP.includes(task.group)) {
|
|
80
82
|
errors.push(`${ctx}.group must be one of ${VALID_GROUP.join(', ')}`);
|
|
81
83
|
}
|
|
84
|
+
// Optional: a task with no observable behaviour references no use case.
|
|
85
|
+
// When present it must be the bare ID — `morph-spec dag` is the gate that
|
|
86
|
+
// catches "UC-07-listar.md" or "UC7" before the plan reaches Gate 2.
|
|
87
|
+
if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !USECASE_RE.test(task.usecase))) {
|
|
88
|
+
errors.push(`${ctx}.usecase must match /^UC-\\d+$/ (the ID alone, e.g. "UC-07")`);
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
return { valid: errors.length === 0, errors };
|
|
@@ -193,6 +201,14 @@ export function groupTasks(data) {
|
|
|
193
201
|
* Convert tasks.json to the legacy taskList shape used in state.json.
|
|
194
202
|
* Lets the rest of the codebase keep using feature.taskList while migration
|
|
195
203
|
* is in progress.
|
|
204
|
+
*
|
|
205
|
+
* BOUNDARY — this is the ONE place `done` becomes `completed`, and it is a
|
|
206
|
+
* one-way translation OUT of the tasks.json vocabulary. In tasks.json the
|
|
207
|
+
* terminal status is `done` (tasks.schema.json's enum has no `completed`);
|
|
208
|
+
* `completed` belongs to the legacy taskList and to Claude Code's native
|
|
209
|
+
* session tasks. Leaking `completed` back into a tasks.json write is rejected
|
|
210
|
+
* by `protect-spec-files` — see framework/rules/morph-task-tracking.md, which
|
|
211
|
+
* spells the split out for the author.
|
|
196
212
|
*/
|
|
197
213
|
export function toTaskList(data) {
|
|
198
214
|
return data.tasks.map(t => ({
|