@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.
Files changed (55) hide show
  1. package/README.md +4 -4
  2. package/bin/morph-spec.js +23 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +2 -2
  5. package/docs/QUICKSTART.md +2 -2
  6. package/framework/CLAUDE.md +12 -7
  7. package/framework/MORPH.md +49 -6
  8. package/framework/evals/rubrics/test-coverage.md +4 -1
  9. package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
  10. package/framework/hooks/claude-code/core/worktree-context.js +41 -5
  11. package/framework/hooks/shared/claim-registry.js +175 -0
  12. package/framework/hooks/shared/phase-utils.js +1 -0
  13. package/framework/hooks/shared/state-reader.js +68 -25
  14. package/framework/rules/morph-task-tracking.md +14 -8
  15. package/framework/rules/morph-workflow.md +36 -3
  16. package/framework/schemas/feature.schema.json +2 -1
  17. package/framework/schemas/tasks.schema.json +6 -1
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +4 -3
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  25. package/framework/skills/core/morph-plan/SKILL.md +83 -13
  26. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  27. package/framework/skills/core/morph-review/SKILL.md +7 -1
  28. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/package.json +1 -1
  31. package/src/commands/project/claim.js +91 -0
  32. package/src/commands/project/doctor.js +65 -0
  33. package/src/commands/project/fleet.js +37 -2
  34. package/src/commands/project/worktree.js +70 -7
  35. package/src/commands/state/advance.js +60 -1
  36. package/src/commands/state/approve.js +125 -2
  37. package/src/commands/state/archive.js +44 -5
  38. package/src/commands/state/create.js +3 -3
  39. package/src/commands/state/finish.js +103 -7
  40. package/src/commands/state/list.js +9 -1
  41. package/src/commands/state/report-check.js +202 -0
  42. package/src/commands/tasks/dag.js +25 -2
  43. package/src/commands/validation/validate-feature.js +9 -2
  44. package/src/core/paths/output-schema.js +66 -6
  45. package/src/core/state/lifecycle-audit.js +57 -0
  46. package/src/dashboard/scanner/state-scanner.js +11 -3
  47. package/src/lib/agents/output-collector.js +5 -0
  48. package/src/lib/generators/architecture-scanner.js +1 -1
  49. package/src/lib/generators/handoff-generator.js +4 -2
  50. package/src/lib/specs/usecase-check.js +173 -0
  51. package/src/lib/specs/usecase-promoter.js +120 -0
  52. package/src/lib/tasks/tasks-json-parser.js +16 -0
  53. package/src/lib/worktree/fleet-scanner.js +224 -15
  54. package/src/lib/worktree/worktree-audit.js +181 -0
  55. 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) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
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}`, '--', featPathspec, archivePathspec], { cwd, stdio: 'pipe' });
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
- // 4) Drop the feature from the thin root index. Edit the index file directly
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` checked out from
190
- // HEAD does not have it — copy the freshly-scaffolded folder
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 = `morph: ${feature}`;
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
- out.relinkHint = `as junctions de infra foram soltas antes desta recusa — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
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.description ? truncate(r.description, 44) : chalk.yellow('(sem descrição)');
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 tasksPath = join(process.cwd(), '.morph', 'features', featureName, '2-plan', 'tasks.json');
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
- // Critérios de aceite, Stack, Riscos e dependências, Compliance de stack.
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'],