@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
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
|
+
import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `morph-spec archive <feature>` — atomic, git-aware archive of a completed
|
|
@@ -52,7 +53,7 @@ function readReviewApproved(fjPath) {
|
|
|
52
53
|
* @param {string} feature
|
|
53
54
|
* @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
|
|
54
55
|
* @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
|
|
55
|
-
* committed?: boolean, staged?: boolean, archivePath?: string }}
|
|
56
|
+
* committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[] }}
|
|
56
57
|
*/
|
|
57
58
|
export function runArchive(feature, opts = {}) {
|
|
58
59
|
const cwd = opts.cwd || process.cwd();
|
|
@@ -64,6 +65,14 @@ export function runArchive(feature, opts = {}) {
|
|
|
64
65
|
|
|
65
66
|
// Idempotent short-circuit: already archived here. Checked FIRST so a re-run
|
|
66
67
|
// after a partial finish reports "already done", never "not found".
|
|
68
|
+
//
|
|
69
|
+
// Consequence, deliberate: this short-circuits BEFORE the use-case promotion
|
|
70
|
+
// below, so a promotion that degraded fail-open is NOT retried by re-running
|
|
71
|
+
// `finish`. That is the price of "closing a feature never fails" — an archived
|
|
72
|
+
// feature stays archived, and the only signal is an absent `promotedUseCases`
|
|
73
|
+
// in the output. Recovering means copying the UCs into `.morph/specs/usecases/`
|
|
74
|
+
// by hand; making this retry would mean re-entering the archive path for an
|
|
75
|
+
// already-closed feature, which is a worse trade.
|
|
67
76
|
if (existsSync(archivePath)) {
|
|
68
77
|
return { ok: true, alreadyArchived: true, archived: false, archivePath };
|
|
69
78
|
}
|
|
@@ -117,18 +126,45 @@ export function runArchive(feature, opts = {}) {
|
|
|
117
126
|
} catch { /* non-fatal — the move is what matters */ }
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// 3)
|
|
129
|
+
// 3) Promote the feature's use cases into the project's permanent archive
|
|
130
|
+
// (.morph/specs/usecases/). Read from the ALREADY-MOVED folder so there is
|
|
131
|
+
// a single source path, and land inside the SAME scoped commit below — the
|
|
132
|
+
// archive travels with the integration, exactly like the archived state
|
|
133
|
+
// (MORPH.md §2.2). Fail-open: any failure degrades to "promoted nothing",
|
|
134
|
+
// never to a refused close.
|
|
135
|
+
let promotedUseCases = [];
|
|
136
|
+
try {
|
|
137
|
+
const { promoted } = promoteUseCases(cwd, archivePath);
|
|
138
|
+
promotedUseCases = promoted;
|
|
139
|
+
if (promoted.length && useGit && staged) {
|
|
140
|
+
// This staging exists for the NEW files: promoted use cases are untracked,
|
|
141
|
+
// and the scoped `commit -- <pathspec>` below only knows paths git already
|
|
142
|
+
// tracks. The replaced-by-ID deletions are tracked, so that same commit
|
|
143
|
+
// would carry them even without this line (verified by mutating it away) —
|
|
144
|
+
// `-A` is belt-and-braces, but dropping the `git add` entirely would
|
|
145
|
+
// silently promote nothing on a first delivery.
|
|
146
|
+
try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
147
|
+
}
|
|
148
|
+
} catch { /* promotion must never block archiving */ }
|
|
149
|
+
|
|
150
|
+
// 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
|
|
121
151
|
// (same anti-sweep discipline as `finish`'s worktree teardown). This is what
|
|
122
152
|
// lets the archived state travel with the branch on `finish` (archive-before-merge).
|
|
153
|
+
// The acervo pathspec joins only when something was promoted: git refuses a
|
|
154
|
+
// pathspec that matches nothing, and that refusal would leave the tree dirty
|
|
155
|
+
// and break the checkout/merge that follows.
|
|
123
156
|
let committed = false;
|
|
124
157
|
if (useGit && staged) {
|
|
158
|
+
const pathspecs = promotedUseCases.length
|
|
159
|
+
? [featPathspec, archivePathspec, SPECS_USECASES_PATHSPEC]
|
|
160
|
+
: [featPathspec, archivePathspec];
|
|
125
161
|
try {
|
|
126
|
-
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--',
|
|
162
|
+
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
|
|
127
163
|
committed = true;
|
|
128
164
|
} catch { /* nothing to commit / no identity — not fatal, the move + stage stand */ }
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
//
|
|
167
|
+
// 5) Drop the feature from the thin root index. Edit the index file directly
|
|
132
168
|
// rather than calling saveState — saveState would re-create
|
|
133
169
|
// .morph/features/{name}/feature.json and undo the archive.
|
|
134
170
|
const rootPath = join(cwd, '.morph', 'state.json');
|
|
@@ -144,7 +180,7 @@ export function runArchive(feature, opts = {}) {
|
|
|
144
180
|
} catch { /* index is derivable — non-fatal */ }
|
|
145
181
|
}
|
|
146
182
|
|
|
147
|
-
return { ok: true, archived: true, committed, staged, archivePath };
|
|
183
|
+
return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases };
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
/**
|
|
@@ -179,6 +215,9 @@ export async function archiveCommand(featureName, options = {}) {
|
|
|
179
215
|
|
|
180
216
|
console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
|
|
181
217
|
console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
|
|
218
|
+
if (result.promotedUseCases?.length) {
|
|
219
|
+
console.log(chalk.gray(` ${result.promotedUseCases.length} use case(s) promoted → .morph/specs/usecases/ (status: implemented).`));
|
|
220
|
+
}
|
|
182
221
|
if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
|
|
183
222
|
else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
|
|
184
223
|
else console.log('');
|
|
@@ -186,9 +186,9 @@ export async function createCommand(featureName, options = {}) {
|
|
|
186
186
|
if (worktreeResult?.created && worktreeResult.path) {
|
|
187
187
|
try {
|
|
188
188
|
// feature.json in the root is NOT committed yet (create just
|
|
189
|
-
// wrote it), so the branch `runWorktreeSetup`
|
|
190
|
-
//
|
|
191
|
-
// into the worktree and re-materialize its local state.json.
|
|
189
|
+
// wrote it), so the branch `runWorktreeSetup` forked off the
|
|
190
|
+
// DEFAULT branch cannot have it — copy the freshly-scaffolded
|
|
191
|
+
// folder into the worktree and re-materialize its local state.json.
|
|
192
192
|
cpSync(featureDir, join(worktreeResult.path, '.morph', 'features', featureName), { recursive: true });
|
|
193
193
|
// The `.worktree` registration is ROOT-OWNED metadata (runWorktreeSetup
|
|
194
194
|
// stamped it onto the root's feature.json, which cpSync just copied in).
|
|
@@ -12,8 +12,50 @@ import {
|
|
|
12
12
|
branchExists,
|
|
13
13
|
} from '../project/worktree.js';
|
|
14
14
|
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
15
|
+
import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
|
|
15
16
|
import { runArchive } from './archive.js';
|
|
16
17
|
|
|
18
|
+
/** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
|
|
19
|
+
const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
|
|
20
|
+
|
|
21
|
+
/** Git's soft limit for a subject line. */
|
|
22
|
+
const PR_TITLE_MAX = 72;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The PR title for `finish --pr`.
|
|
26
|
+
*
|
|
27
|
+
* Was always `morph: {slug}` — so every PR in a repo whose hand-made ones follow
|
|
28
|
+
* conventional commits (`fix(ui): coluna CRM nos dossiês…`) arrived shapeless
|
|
29
|
+
* and said nothing about what changed (D4).
|
|
30
|
+
*
|
|
31
|
+
* Now: `{type}({slug}): {subject}` — the type from the feature's `workType`, the
|
|
32
|
+
* slug kept as the scope (it is the traceability the old title at least had),
|
|
33
|
+
* and the subject from the feature's own identity: `name` first (a human
|
|
34
|
+
* deliberately titled it), then `description`, first line only. With neither,
|
|
35
|
+
* degrade to `{type}: {slug}` rather than emit an empty subject.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} feature
|
|
38
|
+
* @param {object|null} fj - the feature.json
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function buildPrTitle(feature, fj) {
|
|
42
|
+
const type = PR_TYPE_BY_WORK_TYPE[fj?.workType] || 'chore';
|
|
43
|
+
|
|
44
|
+
const named = typeof fj?.name === 'string' ? fj.name.trim() : '';
|
|
45
|
+
// The default `name` IS the slug (create stamps it), which as a subject would
|
|
46
|
+
// only repeat the scope — treat it as absent.
|
|
47
|
+
const described = typeof fj?.description === 'string' ? fj.description.trim() : '';
|
|
48
|
+
const raw = (named && named !== feature ? named : described).split('\n')[0].trim();
|
|
49
|
+
if (!raw) return `${type}: ${feature}`;
|
|
50
|
+
|
|
51
|
+
const prefix = `${type}(${feature}): `;
|
|
52
|
+
const room = PR_TITLE_MAX - prefix.length;
|
|
53
|
+
// A scope so long there is no room left for a subject: drop the scope rather
|
|
54
|
+
// than ship a title that is all prefix.
|
|
55
|
+
if (room < 12) return `${type}: ${raw}`.slice(0, PR_TITLE_MAX);
|
|
56
|
+
return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
/**
|
|
18
60
|
* `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
|
|
19
61
|
* feature's lifecycle, for BOTH the worktree flow and the common flow.
|
|
@@ -37,9 +79,22 @@ import { runArchive } from './archive.js';
|
|
|
37
79
|
export async function finishCommand(feature, options = {}) {
|
|
38
80
|
const cwd = options.cwd || process.cwd();
|
|
39
81
|
const root = resolvePrimaryRoot(cwd);
|
|
40
|
-
const emit = (obj) => console.log(JSON.stringify(obj));
|
|
41
82
|
const rootNorm = resolve(root).replace(/\\/g, '/');
|
|
42
83
|
|
|
84
|
+
// A closed feature must not keep warning other sessions that someone is
|
|
85
|
+
// working on it (B1). Released on EVERY success path — hooking it into emit
|
|
86
|
+
// is the single point that covers merge/PR/--keep alike. Fail-open: a claim
|
|
87
|
+
// that outlives its feature only expires on its own TTL.
|
|
88
|
+
const emit = (obj) => {
|
|
89
|
+
if (obj?.finished === true) {
|
|
90
|
+
try {
|
|
91
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
92
|
+
if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
|
|
93
|
+
} catch { /* fail-open */ }
|
|
94
|
+
}
|
|
95
|
+
console.log(JSON.stringify(obj));
|
|
96
|
+
};
|
|
97
|
+
|
|
43
98
|
// Gate 0: valid kebab name — anti command-injection (the name flows into every
|
|
44
99
|
// git/gh call below) and the same gate the worktree path always applied.
|
|
45
100
|
if (!isValidFeatureName(feature)) {
|
|
@@ -85,6 +140,28 @@ export async function finishCommand(feature, options = {}) {
|
|
|
85
140
|
}
|
|
86
141
|
worktreePath = found.path;
|
|
87
142
|
featureCwd = worktreePath;
|
|
143
|
+
|
|
144
|
+
// Validation 0b: the session's cwd must not be INSIDE the worktree we are
|
|
145
|
+
// about to tear down (A4). Windows holds a lock on the process's working
|
|
146
|
+
// directory, so `git worktree remove` fails — and by then the archive is
|
|
147
|
+
// committed, the branch merged, and the infra junctions already dropped.
|
|
148
|
+
// The result is a worse state than never having tried: an orphan directory,
|
|
149
|
+
// a de-registered worktree, and a `relinkHint` telling the user to repair
|
|
150
|
+
// by hand. Refuse HERE, before the first mutation — the cost is one
|
|
151
|
+
// `cd`, and it is the exact failure observed in the ProspectPRO session.
|
|
152
|
+
const cwdNorm = resolve(cwd).replace(/\\/g, '/');
|
|
153
|
+
const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
|
|
154
|
+
if (cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`)) {
|
|
155
|
+
emit({
|
|
156
|
+
finished: false,
|
|
157
|
+
error: 'cwd-inside-worktree',
|
|
158
|
+
feature,
|
|
159
|
+
path: worktreePath,
|
|
160
|
+
root,
|
|
161
|
+
hint: `saia do worktree antes do finish — o \`git worktree remove\` não consegue apagar o diretório de trabalho do próprio processo. Rode de novo a partir de "${root}"`,
|
|
162
|
+
});
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
88
165
|
} else {
|
|
89
166
|
// COMMON flow: the feature branch must exist and be the ROOT's current
|
|
90
167
|
// branch (so the archive commits on it and we can checkout the default to
|
|
@@ -120,7 +197,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
120
197
|
emit({ finished: false, error: 'review-not-approved', feature });
|
|
121
198
|
process.exit(1);
|
|
122
199
|
}
|
|
123
|
-
const prTitle =
|
|
200
|
+
const prTitle = buildPrTitle(feature, fj);
|
|
124
201
|
const prBody = (fj?.description && String(fj.description).trim())
|
|
125
202
|
? String(fj.description).trim()
|
|
126
203
|
: `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
@@ -187,6 +264,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
187
264
|
|
|
188
265
|
async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch }) {
|
|
189
266
|
const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
|
|
267
|
+
const ucNote = promotedNote(arch);
|
|
190
268
|
|
|
191
269
|
if (isWorktreeFlow) {
|
|
192
270
|
// The root stays on the default; shed this feature's own root-side scaffold,
|
|
@@ -214,13 +292,13 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
214
292
|
|
|
215
293
|
if (options.keep) {
|
|
216
294
|
dropFromRootIndex(root, feature);
|
|
217
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote });
|
|
295
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote, ...ucNote });
|
|
218
296
|
process.exit(0);
|
|
219
297
|
}
|
|
220
298
|
|
|
221
299
|
const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
|
|
222
300
|
dropFromRootIndex(root, feature);
|
|
223
|
-
emit(mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }));
|
|
301
|
+
emit({ ...mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }), ...ucNote });
|
|
224
302
|
process.exit(0);
|
|
225
303
|
}
|
|
226
304
|
|
|
@@ -249,13 +327,13 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
249
327
|
}
|
|
250
328
|
tryGit(['branch', '-d', expectedBranch], root);
|
|
251
329
|
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
252
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null });
|
|
330
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null, ...ucNote });
|
|
253
331
|
process.exit(0);
|
|
254
332
|
}
|
|
255
333
|
|
|
256
334
|
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
257
335
|
|
|
258
|
-
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody }) {
|
|
336
|
+
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
|
|
259
337
|
try {
|
|
260
338
|
execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
|
|
261
339
|
} catch (err) {
|
|
@@ -301,6 +379,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
301
379
|
pushed: expectedBranch,
|
|
302
380
|
base: defaultBranch,
|
|
303
381
|
pr: prUrl,
|
|
382
|
+
...promotedNote(arch),
|
|
304
383
|
...(shedBackup ? { shedBackup } : {}),
|
|
305
384
|
...(teardown
|
|
306
385
|
? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
|
|
@@ -312,6 +391,15 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
312
391
|
|
|
313
392
|
// ── output helpers ────────────────────────────────────────────────────────
|
|
314
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Report the use cases the archive promoted into `.morph/specs/usecases/`.
|
|
396
|
+
* Absent when nothing was promoted, so a feature with no use cases keeps the
|
|
397
|
+
* exact output shape it had before this existed.
|
|
398
|
+
*/
|
|
399
|
+
function promotedNote(arch) {
|
|
400
|
+
return arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {};
|
|
401
|
+
}
|
|
402
|
+
|
|
315
403
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
316
404
|
return {
|
|
317
405
|
finished: true,
|
|
@@ -335,7 +423,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
|
335
423
|
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
336
424
|
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
337
425
|
out.cleanupHint = teardown.declineReason;
|
|
338
|
-
|
|
426
|
+
// The unlink runs before the removal by design (it is what keeps `git
|
|
427
|
+
// worktree remove` from following the junctions out into the primary root),
|
|
428
|
+
// so a declined removal always passes through a moment with the infra
|
|
429
|
+
// loose. The teardown now re-links on that path — report which of the two
|
|
430
|
+
// actually happened instead of always announcing the worse one (A4).
|
|
431
|
+
out.relinked = teardown.relinked === true;
|
|
432
|
+
out.relinkHint = teardown.relinked
|
|
433
|
+
? 'as junctions de infra foram soltas antes da tentativa e RESTAURADAS após a recusa — o worktree segue utilizável'
|
|
434
|
+
: `as junctions de infra foram soltas antes desta recusa e não puderam ser restauradas — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
|
|
339
435
|
}
|
|
340
436
|
return out;
|
|
341
437
|
}
|
|
@@ -44,8 +44,14 @@ function buildRow(name, fj, fdir, location) {
|
|
|
44
44
|
const gates = {};
|
|
45
45
|
for (const g of G) gates[g] = fj.approvalGates?.[g]?.approved === true;
|
|
46
46
|
|
|
47
|
+
// Gate 3 approved WITHOUT the independent evaluator (`approve --self-assessed`).
|
|
48
|
+
// A recorded degradation is only worth recording if it is visible — this is
|
|
49
|
+
// the surface that makes it so (D2).
|
|
50
|
+
const selfAssessed = fj.approvalGates?.review?.selfAssessed === true;
|
|
51
|
+
|
|
47
52
|
const flags = [];
|
|
48
53
|
if (fj.__corrupt) flags.push('corrupt');
|
|
54
|
+
if (selfAssessed) flags.push('self-assessed-g3');
|
|
49
55
|
// Identity + gate-order anomalies come from the shared rules
|
|
50
56
|
// (core/state/feature-identity.js, core/state/gate-order.js) — same source
|
|
51
57
|
// doctor reports from.
|
|
@@ -60,6 +66,7 @@ function buildRow(name, fj, fdir, location) {
|
|
|
60
66
|
status: fj.status || 'unknown',
|
|
61
67
|
phase: location === 'archived' ? 'archived' : derivePhase(fdir),
|
|
62
68
|
gates,
|
|
69
|
+
selfAssessed,
|
|
63
70
|
flags,
|
|
64
71
|
location,
|
|
65
72
|
createdAt: fj.createdAt || null,
|
|
@@ -93,7 +100,8 @@ export function listCommand(options = {}) {
|
|
|
93
100
|
));
|
|
94
101
|
for (const r of rows) {
|
|
95
102
|
const flag = r.flags.length ? chalk.yellow('⚠') : ' ';
|
|
96
|
-
const desc = r.
|
|
103
|
+
const desc = (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
|
|
104
|
+
+ (r.description ? truncate(r.description, 44) : chalk.yellow('(sem descrição)'));
|
|
97
105
|
// padEnd counts the invisible color codes; pad the plain marks manually.
|
|
98
106
|
console.log(
|
|
99
107
|
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec report-check <feature> [task] [--json]` — the deterministic half
|
|
3
|
+
* of the missing-report protocol (B4).
|
|
4
|
+
*
|
|
5
|
+
* MORPH.md already made the sub-agent's canonical report file the authoritative
|
|
6
|
+
* return value ("o ARQUIVO é o retorno, a última mensagem é cortesia"), but it
|
|
7
|
+
* never said what to do when the file is not there. The naive reading — "no
|
|
8
|
+
* report, so the agent failed" — is wrong and expensive: the agent may have
|
|
9
|
+
* done every bit of the work and lost the means to record it (observed: a
|
|
10
|
+
* worktree removed out from under a running sub-agent).
|
|
11
|
+
*
|
|
12
|
+
* So this command answers the question the orchestrator actually has, and
|
|
13
|
+
* answers it from evidence rather than from the agent's own word: **did the
|
|
14
|
+
* WORK land, independently of whether the REPORT did?**
|
|
15
|
+
*
|
|
16
|
+
* verdict action
|
|
17
|
+
* ───────────────────────────── ──────────────────
|
|
18
|
+
* ok proceed
|
|
19
|
+
* report-incomplete complete-degraded
|
|
20
|
+
* work-landed-report-missing accept-degraded
|
|
21
|
+
* no-work-no-report redispatch
|
|
22
|
+
* report-missing-evidence-unknown redispatch (conservative default)
|
|
23
|
+
*
|
|
24
|
+
* Exit 0 only for `ok` — anything needing an action exits non-zero so a caller
|
|
25
|
+
* can branch on it. `--json` always prints the machine-readable verdict.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
import { existsSync, readFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The four mandatory items of the report contract, as detectable markers.
|
|
34
|
+
*
|
|
35
|
+
* HEURISTIC BY CONSTRUCTION, and deliberately loose: the contract asks for
|
|
36
|
+
* content, not for a fixed template, and reports written before this command
|
|
37
|
+
* existed must still audit cleanly. A false "complete" is the cheap error here
|
|
38
|
+
* (the orchestrator reads the report anyway); a false "incomplete" would send
|
|
39
|
+
* it re-dispatching finished work, which is the expensive one.
|
|
40
|
+
*/
|
|
41
|
+
const REPORT_MARKERS = [
|
|
42
|
+
// (2) files touched — a list line carrying a path or a backticked identifier
|
|
43
|
+
{ id: 'files', re: /^\s*[-*]\s+.*[`/\\].*$/m },
|
|
44
|
+
// (4) self-score 0-10
|
|
45
|
+
{ id: 'score', re: /score[^\n]*?\b(10|\d)(?:[.,]\d+)?\b/i },
|
|
46
|
+
// (3) problems found (or an explicit "none")
|
|
47
|
+
{ id: 'problems', re: /problema|issue|blocker|nenhum|impedimento/i },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** (1) "what was done" — prose beyond the structural markers. */
|
|
51
|
+
const MIN_PROSE_CHARS = 80;
|
|
52
|
+
|
|
53
|
+
function reportPath(cwd, feature, taskId) {
|
|
54
|
+
return join(cwd, '.morph', 'features', feature, '3-implement', 'reports', `${taskId}.md`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function evaluatorPath(cwd, feature) {
|
|
58
|
+
return join(cwd, '.morph', 'features', feature, '4-review', 'evaluator-report.md');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inspect one report file: does it exist, and which of the four mandatory
|
|
63
|
+
* items can be found in it.
|
|
64
|
+
*/
|
|
65
|
+
export function inspectReport(path) {
|
|
66
|
+
if (!existsSync(path)) return { exists: false, complete: false, missing: ['what', 'files', 'problems', 'score'] };
|
|
67
|
+
let body = '';
|
|
68
|
+
try {
|
|
69
|
+
body = readFileSync(path, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return { exists: true, complete: false, missing: ['what', 'files', 'problems', 'score'], unreadable: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const missing = REPORT_MARKERS.filter((m) => !m.re.test(body)).map((m) => m.id);
|
|
75
|
+
// Prose = the body minus headings and list lines, so a bare file list does
|
|
76
|
+
// not pass as "what was done".
|
|
77
|
+
const prose = body
|
|
78
|
+
.split('\n')
|
|
79
|
+
.filter((l) => !/^\s*(#|[-*]\s)/.test(l))
|
|
80
|
+
.join(' ')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim();
|
|
83
|
+
if (prose.length < MIN_PROSE_CHARS) missing.unshift('what');
|
|
84
|
+
|
|
85
|
+
return { exists: true, complete: missing.length === 0, missing };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many of a task's declared outputs actually exist on disk. */
|
|
89
|
+
function outputEvidence(cwd, taskOutputs) {
|
|
90
|
+
const expected = Array.isArray(taskOutputs) ? taskOutputs : [];
|
|
91
|
+
const present = expected.filter((rel) => {
|
|
92
|
+
try {
|
|
93
|
+
return existsSync(join(cwd, rel));
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}).length;
|
|
98
|
+
return { outputsExpected: expected.length, outputsPresent: present };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Combine report state + evidence into a verdict and the action it implies. */
|
|
102
|
+
function decide(report, evidence) {
|
|
103
|
+
if (report.exists && report.complete) return { verdict: 'ok', action: 'proceed' };
|
|
104
|
+
if (report.exists) return { verdict: 'report-incomplete', action: 'complete-degraded' };
|
|
105
|
+
if (evidence.outputsExpected === 0) {
|
|
106
|
+
// No declared outputs means no evidence either way. Re-dispatching costs a
|
|
107
|
+
// sub-agent; silently accepting a phantom completion costs correctness.
|
|
108
|
+
return { verdict: 'report-missing-evidence-unknown', action: 'redispatch' };
|
|
109
|
+
}
|
|
110
|
+
return evidence.outputsPresent > 0
|
|
111
|
+
? { verdict: 'work-landed-report-missing', action: 'accept-degraded' }
|
|
112
|
+
: { verdict: 'no-work-no-report', action: 'redispatch' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkTask(cwd, feature, task) {
|
|
116
|
+
const report = inspectReport(reportPath(cwd, feature, task.id));
|
|
117
|
+
const evidence = outputEvidence(cwd, task.outputs);
|
|
118
|
+
return { task: task.id, report, evidence, ...decide(report, evidence) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ACTION_HINT = {
|
|
122
|
+
proceed: 'relatório íntegro — siga o fluxo normal.',
|
|
123
|
+
'complete-degraded': 'complete os itens faltantes a partir da mensagem de retorno do sub-agent, carimbe o relatório como DEGRADADO e registre a falha de protocolo no recap.',
|
|
124
|
+
'accept-degraded': 'o TRABALHO entrou — não re-despache. Grave você mesmo o relatório canônico a partir da mensagem de retorno, carimbado como DEGRADADO, e registre a falha de protocolo no recap e nas `notes` da task.',
|
|
125
|
+
redispatch: 're-despache a task UMA vez com o mesmo prompt. Segunda ausência sem trabalho entregue = falha real: escale ao humano.',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} feature
|
|
130
|
+
* @param {string|undefined} taskId
|
|
131
|
+
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
132
|
+
*/
|
|
133
|
+
export function reportCheckCommand(feature, taskId, options = {}) {
|
|
134
|
+
const cwd = options.cwd || process.cwd();
|
|
135
|
+
const emit = (obj, code) => {
|
|
136
|
+
if (options.json) console.log(JSON.stringify(obj, null, 2));
|
|
137
|
+
else printHuman(obj);
|
|
138
|
+
process.exit(code);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const tasksPath = join(cwd, '.morph', 'features', feature, '2-plan', 'tasks.json');
|
|
142
|
+
if (!existsSync(tasksPath)) {
|
|
143
|
+
emit({ error: `feature "${feature}" não tem 2-plan/tasks.json — nada a auditar`, feature }, 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let tasks = [];
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
149
|
+
tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
150
|
+
} catch {
|
|
151
|
+
emit({ error: `2-plan/tasks.json de "${feature}" está corrompido`, feature }, 1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (taskId) {
|
|
155
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
156
|
+
if (!task) emit({ error: `task "${taskId}" não existe em tasks.json`, feature }, 1);
|
|
157
|
+
const result = checkTask(cwd, feature, task);
|
|
158
|
+
emit({ feature, ...result, hint: ACTION_HINT[result.action] }, result.verdict === 'ok' ? 0 : 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Whole-feature audit: every DONE task owes a report; a pending one does not.
|
|
162
|
+
const results = tasks.filter((t) => t.status === 'done').map((t) => checkTask(cwd, feature, t));
|
|
163
|
+
const evaluator = inspectReport(evaluatorPath(cwd, feature));
|
|
164
|
+
const clean = results.every((r) => r.verdict === 'ok');
|
|
165
|
+
emit({ feature, results, evaluator, clean }, clean ? 0 : 1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printHuman(obj) {
|
|
169
|
+
if (obj.error) {
|
|
170
|
+
console.error(chalk.red(`\n❌ ${obj.error}\n`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const line = (r) => {
|
|
174
|
+
const icon = r.verdict === 'ok' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
175
|
+
const ev = r.evidence.outputsExpected
|
|
176
|
+
? `outputs ${r.evidence.outputsPresent}/${r.evidence.outputsExpected}`
|
|
177
|
+
: 'outputs não declarados';
|
|
178
|
+
console.log(` ${icon} ${String(r.task).padEnd(6)} ${r.verdict.padEnd(32)} ${ev}`);
|
|
179
|
+
if (r.verdict !== 'ok') {
|
|
180
|
+
if (r.report.exists && r.report.missing.length) {
|
|
181
|
+
console.log(chalk.gray(` faltando no relatório: ${r.report.missing.join(', ')}`));
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.gray(` → ${ACTION_HINT[r.action]}`));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (obj.task) {
|
|
188
|
+
console.log(chalk.bold(`\n📄 report-check ${obj.feature} ${obj.task}`));
|
|
189
|
+
line(obj);
|
|
190
|
+
console.log('');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(chalk.bold(`\n📄 report-check ${obj.feature}`));
|
|
195
|
+
if (!obj.results.length) console.log(chalk.gray(' nenhuma task done — nada a auditar'));
|
|
196
|
+
for (const r of obj.results) line(r);
|
|
197
|
+
console.log(
|
|
198
|
+
` ${obj.evaluator.exists ? chalk.green('✓') : chalk.yellow('⚠')} ${'Gate 3'.padEnd(6)} `
|
|
199
|
+
+ `evaluator-report.md ${obj.evaluator.exists ? (obj.evaluator.complete ? 'íntegro' : 'incompleto') : 'ausente'}`,
|
|
200
|
+
);
|
|
201
|
+
console.log('');
|
|
202
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
|
|
5
5
|
import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
|
|
6
6
|
import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
7
|
+
import { checkUseCases } from '../../lib/specs/usecase-check.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
|
|
@@ -17,7 +18,8 @@ import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export async function dagCommand(featureName, options = {}) {
|
|
19
20
|
try {
|
|
20
|
-
const
|
|
21
|
+
const planDir = join(process.cwd(), '.morph', 'features', featureName, '2-plan');
|
|
22
|
+
const tasksPath = join(planDir, 'tasks.json');
|
|
21
23
|
if (!existsSync(tasksPath)) {
|
|
22
24
|
console.error(chalk.red(`\n❌ tasks.json not found for feature "${featureName}"`));
|
|
23
25
|
console.error(chalk.gray(`Expected at: .morph/features/${featureName}/2-plan/tasks.json\n`));
|
|
@@ -42,13 +44,34 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// Use-case cross-check. Runs AFTER the --mermaid short-circuit on purpose:
|
|
48
|
+
// rendering the DAG is pure visualization and must never be blocked by the
|
|
49
|
+
// state of the spec. A broken `usecase` reference IS fatal here — the file
|
|
50
|
+
// either exists or it does not, so there is no false positive to weigh
|
|
51
|
+
// against a plan that points at a use case nobody can read.
|
|
52
|
+
const ucCheck = checkUseCases(data, join(planDir, 'usecases'));
|
|
53
|
+
|
|
45
54
|
const plan = planDispatch(data);
|
|
46
55
|
|
|
47
56
|
if (options.json) {
|
|
48
|
-
console.log(JSON.stringify(plan, null, 2));
|
|
57
|
+
console.log(JSON.stringify({ ...plan, useCases: ucCheck }, null, 2));
|
|
58
|
+
if (ucCheck.errors.length) process.exit(1);
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
63
|
+
|
|
64
|
+
if (ucCheck.errors.length) {
|
|
65
|
+
console.error(chalk.red(`\n❌ Use cases — ${plural(ucCheck.errors.length, 'erro', 'erros')}\n`));
|
|
66
|
+
for (const e of ucCheck.errors) console.error(chalk.red(` ${e}`));
|
|
67
|
+
console.error(chalk.gray('\n Corrija a referência no tasks.json ou restaure o use case.\n'));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (ucCheck.warnings.length) {
|
|
71
|
+
console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
|
|
72
|
+
for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
|
|
53
76
|
console.log('━'.repeat(60));
|
|
54
77
|
|
|
@@ -36,17 +36,24 @@ import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
|
36
36
|
*/
|
|
37
37
|
const OUTPUT_SECTIONS = {
|
|
38
38
|
// morph-brainstorming emits: Problema, Escopo, Usuários e fluxos críticos,
|
|
39
|
-
//
|
|
39
|
+
// Stack, Riscos e dependências, Compliance de stack.
|
|
40
|
+
//
|
|
41
|
+
// "Critérios de aceite" is now OPTIONAL, not required: the behaviour layer
|
|
42
|
+
// moved to the use cases (2-plan/usecases/), whose post-conditions must be
|
|
43
|
+
// observable in a test, and morph-brainstorming no longer emits the section.
|
|
44
|
+
// `optional` — not `recommended` — precisely because the skill never emits it
|
|
45
|
+
// anymore: a warning on every new proposal would be systematic noise. Legacy
|
|
46
|
+
// proposals that DO carry the section keep validating exactly as before.
|
|
40
47
|
'proposal.md': {
|
|
41
48
|
required: [
|
|
42
49
|
['Problema', 'Problem'],
|
|
43
50
|
['Escopo', 'Scope'],
|
|
44
|
-
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
45
51
|
],
|
|
46
52
|
recommended: [
|
|
47
53
|
['Riscos', 'Risks'],
|
|
48
54
|
],
|
|
49
55
|
optional: [
|
|
56
|
+
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
50
57
|
'Stack',
|
|
51
58
|
['Usuários', 'Usuarios', 'Users', 'User Journey', 'User Stories', 'Fluxos'],
|
|
52
59
|
['Dependências', 'Dependencias', 'Dependencies'],
|