@polymorphism-tech/morph-spec 8.37.1 → 8.38.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 (128) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. package/src/utils/hooks-installer.js +75 -15
@@ -35,14 +35,24 @@
35
35
  */
36
36
 
37
37
  import { execSync, execFileSync } from 'child_process';
38
- import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
38
+ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
39
39
  import { join, resolve, dirname, basename } from 'path';
40
40
  import chalk from 'chalk';
41
41
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
42
42
  import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
43
+ import {
44
+ finishBackupDir,
45
+ writeFeatureScopeBackup,
46
+ writeWorktreeIgnoredBackup,
47
+ enforceBackupRetention,
48
+ newBackupStamp,
49
+ } from '../../lib/worktree/finish-backup.js';
43
50
  // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
44
51
  // the SAME answer this file does, and hooks can never import from `src/`.
45
52
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
53
+ import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
54
+ import { seedGraphFromRoot } from '../../../framework/hooks/shared/graph-refresh.js';
55
+ import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
46
56
 
47
57
  // The container name lives in lib/worktree/dirs.js (single source, C1
48
58
  // rationale documented there); re-exported here so existing importers and the
@@ -288,28 +298,9 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
288
298
  * @param {string[]} rels - worktree-relative paths to exclude
289
299
  */
290
300
  function excludeWorktreePaths(mainCwd, rels) {
291
- if (!rels.length) return;
292
- try {
293
- // `--git-common-dir`, never `join(mainCwd, '.git')`: the primary root may
294
- // itself be a worktree (whose `.git` is a FILE), and the common dir is the
295
- // only exclude every linked worktree actually reads.
296
- const common = execSync('git rev-parse --git-common-dir', { cwd: mainCwd, stdio: 'pipe' }).toString().trim();
297
- if (!common) return;
298
- const infoDir = join(resolve(mainCwd, common), 'info');
299
- const excludePath = join(infoDir, 'exclude');
300
- const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
301
- const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
302
- // Anchored (`/x`): these live at the top of a worktree, and an unanchored
303
- // `node_modules` would also swallow nested ones the user may track.
304
- const missing = rels.map((rel) => `/${rel.replace(/\\/g, '/')}`).filter((p) => !present.has(p));
305
- if (!missing.length) return;
306
- mkdirSync(infoDir, { recursive: true });
307
- appendFileSync(excludePath, [
308
- current && !current.endsWith('\n') ? '\n' : '',
309
- present.has(EXCLUDE_MARKER) ? '' : `${EXCLUDE_MARKER}\n`,
310
- `${missing.join('\n')}\n`,
311
- ].join(''));
312
- } catch { /* fail-open */ }
301
+ // Anchored (`/x`): these live at the top of a worktree, and an unanchored
302
+ // `node_modules` would also swallow nested ones the user may track.
303
+ ensureCommonExclude(mainCwd, rels.map((rel) => `/${rel.replace(/\\/g, '/')}`), EXCLUDE_MARKER);
313
304
  }
314
305
 
315
306
  /**
@@ -412,6 +403,28 @@ export async function materializeState(worktreePath) {
412
403
  }
413
404
  }
414
405
 
406
+ /**
407
+ * Codebase graph for a new worktree. The tree is born from a root commit, so
408
+ * the root's graph is either this tree's HEAD graph already or one
409
+ * `morph-spec graph refresh` away — and the `reuse` validator reads the graph
410
+ * of the tree `verify` runs in, so a worktree without one skips reuse checking
411
+ * exactly where the new code is written. Nothing is launched in the
412
+ * background: the automatic refresh only ever touches the root (a detached
413
+ * process holding a worktree directory made `finish` fail on win32).
414
+ * Fail-open: no root graph, no copy.
415
+ *
416
+ * @param {string} worktreePath
417
+ * @param {string} root
418
+ * @returns {{seeded: boolean, reason?: string}}
419
+ */
420
+ function seedWorktreeGraph(worktreePath, root) {
421
+ try {
422
+ return seedGraphFromRoot(worktreePath, root);
423
+ } catch {
424
+ return { seeded: false, reason: 'seed-failed' };
425
+ }
426
+ }
427
+
415
428
  // ── Command handler ─────────────────────────────────────────────────────────
416
429
 
417
430
  /**
@@ -521,6 +534,8 @@ export async function runWorktreeSetup(feature, options = {}) {
521
534
  // committed feature.json files) so hooks work from the very first session.
522
535
  const materialized = await ensureLocalState(worktreePath);
523
536
 
537
+ const graph = seedWorktreeGraph(worktreePath, cwd);
538
+
524
539
  // Fleet handoff: the fresh instance that opens this worktree reads
525
540
  // handoff.md before anything else (SessionStart hook points at it). The doc
526
541
  // is derived and git-invisible (see handoff-generator). Fail-open — and a
@@ -560,6 +575,7 @@ export async function runWorktreeSetup(feature, options = {}) {
560
575
  feature,
561
576
  materialized: materialized.materialized,
562
577
  ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
578
+ ...(graph.seeded ? { graph: 'seeded' } : {}),
563
579
  nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
564
580
  };
565
581
  } catch (err) {
@@ -654,6 +670,7 @@ export async function runWorktreeProvision(name, options = {}) {
654
670
  setupInfraLinks(worktreePath, root);
655
671
  const envBlock = await allocateWorktreeEnv(worktreePath, root, `task/${name}`);
656
672
  const materialized = await ensureLocalState(worktreePath);
673
+ const graph = seedWorktreeGraph(worktreePath, root);
657
674
 
658
675
  return {
659
676
  ok: true,
@@ -666,6 +683,7 @@ export async function runWorktreeProvision(name, options = {}) {
666
683
  name,
667
684
  materialized: materialized.materialized,
668
685
  ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
686
+ ...(graph.seeded ? { graph: 'seeded' } : {}),
669
687
  };
670
688
  } catch (err) {
671
689
  return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
@@ -930,7 +948,7 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
930
948
 
931
949
  /**
932
950
  * Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
933
- * once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
951
+ * once backed up (see writeWorktreeIgnoredBackup). `.morph/state.json` is provably
934
952
  * derivable (loadState self-heals it from the committed feature.json files) and
935
953
  * the rest is morph's own diagnostic output, which `setupInfraLinks` documents
936
954
  * as worktree-local. They are small, so backing them up costs nothing and buys
@@ -1054,60 +1072,6 @@ export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
1054
1072
  return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
1055
1073
  }
1056
1074
 
1057
- /**
1058
- * Back up the worktree's morph-owned gitignored leftovers before the removal
1059
- * deletes them. Same destination and rationale as backupFeatureScope.
1060
- *
1061
- * @param {string} worktreePath
1062
- * @param {string} root
1063
- * @param {string} feature
1064
- * @param {string[]} rels - worktree-relative paths
1065
- * @param {string} stamp
1066
- * @returns {string|null} backup path, or null if ANY copy failed (caller must
1067
- * then decline the removal — a partial backup authorizes nothing)
1068
- */
1069
- function backupWorktreeIgnored(worktreePath, root, feature, rels, stamp) {
1070
- const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}-worktree-ignored`);
1071
- try {
1072
- for (const rel of rels) {
1073
- const src = join(worktreePath, rel);
1074
- if (!existsSync(src)) continue; // a directory entry already covered by its children
1075
- const target = join(dest, rel);
1076
- mkdirSync(dirname(target), { recursive: true });
1077
- cpSync(src, target, { recursive: true });
1078
- }
1079
- return existsSync(dest) ? dest : null;
1080
- } catch {
1081
- return null;
1082
- }
1083
- }
1084
-
1085
- /**
1086
- * Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
1087
- * the shed below can never be the reason something is lost.
1088
- *
1089
- * Lives under `.git/` on purpose: it is not a working-tree path, so it can never
1090
- * dirty `git status` (which the very next validation reads) and is never merged,
1091
- * committed, or cleaned by `git clean`.
1092
- *
1093
- * @param {string} root
1094
- * @param {string} feature
1095
- * @param {string} stamp - caller-supplied timestamp (keeps this pure/testable)
1096
- * @returns {string|null} absolute backup path, or null if the copy failed
1097
- */
1098
- function backupFeatureScope(root, feature, stamp) {
1099
- const src = join(root, '.morph', 'features', feature);
1100
- if (!existsSync(src)) return null;
1101
- const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}`);
1102
- try {
1103
- mkdirSync(dirname(dest), { recursive: true });
1104
- cpSync(src, dest, { recursive: true });
1105
- return dest;
1106
- } catch {
1107
- return null; // caller MUST treat this as "do not shed"
1108
- }
1109
- }
1110
-
1111
1075
  // ── finish primitives (used by `morph-spec finish`) ───────────────────────
1112
1076
  //
1113
1077
  // The old `worktree finish` command dissolved here: `morph-spec finish` (see
@@ -1119,17 +1083,19 @@ function backupFeatureScope(root, feature, stamp) {
1119
1083
  /**
1120
1084
  * Shed the feature's OWN transient root-side bookkeeping so the merge can own
1121
1085
  * this feature's state while the whole-root clean-check keeps guarding UNRELATED
1122
- * work. Backs up FIRST (see backupFeatureScope), then discards — the shed is
1123
- * safe WITHOUT having to be right about what is in scope: if the classification
1124
- * the merge implies turns out wrong, the bytes are still on disk. Worktree-flow
1125
- * only; the common flow committed the archive on the branch, so there is no
1126
- * untracked root-side scaffold to shed.
1086
+ * work. Backs up FIRST (see writeFeatureScopeBackup), then discards — the shed
1087
+ * is safe WITHOUT having to be right about what is in scope: if the
1088
+ * classification the merge implies turns out wrong, the bytes are still on
1089
+ * disk. Worktree-flow only; the common flow committed the archive on the
1090
+ * branch, so there is no untracked root-side scaffold to shed.
1127
1091
  *
1128
1092
  * @param {string} root
1129
1093
  * @param {string} feature
1094
+ * @param {{ stamp?: string }} [opts] - the finish's one stamp, shared with the
1095
+ * teardown's backup so the two folders count as one backup
1130
1096
  * @returns {{ shedBackup: string|null }} backup path if anything was shed
1131
1097
  */
1132
- export function shedRootFeatureScope(root, feature) {
1098
+ export function shedRootFeatureScope(root, feature, { stamp } = {}) {
1133
1099
  const featPathspec = `.morph/features/${feature}`;
1134
1100
  let dirtyInScope = '';
1135
1101
  try {
@@ -1138,7 +1104,7 @@ export function shedRootFeatureScope(root, feature) {
1138
1104
 
1139
1105
  if (dirtyInScope.trim() === '') return { shedBackup: null };
1140
1106
 
1141
- const shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
1107
+ const shedBackup = writeFeatureScopeBackup({ root, feature, stamp });
1142
1108
  // No backup, no shed. Falling through leaves the root dirty, so the caller's
1143
1109
  // clean-check refuses — the exact fail-closed behaviour from before any shed.
1144
1110
  if (shedBackup) {
@@ -1363,14 +1329,17 @@ export function pickRemovalFailure(stderrs) {
1363
1329
  * `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
1364
1330
  * branch is integrated), false for `--pr` (the open PR still needs the branch).
1365
1331
  *
1332
+ * `stamp` names the backup; `finish` passes the one it gave the shed, so both
1333
+ * folders of that finish count as one backup under the retention.
1334
+ *
1366
1335
  * @param {string} worktreePath
1367
- * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
1336
+ * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
1368
1337
  * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1369
1338
  * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
1370
1339
  * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1371
1340
  * removalFailure: {kind: string, detail: string}|null }>}
1372
1341
  */
1373
- export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false }) {
1342
+ export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, stamp }) {
1374
1343
  // Drop the infra junctions FIRST, without following them — this is what makes
1375
1344
  // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1376
1345
  //
@@ -1392,7 +1361,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1392
1361
  let ignoredBackup = null;
1393
1362
  const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
1394
1363
  if (clearedToRemove && ignored.shieldable.length) {
1395
- ignoredBackup = backupWorktreeIgnored(worktreePath, root, feature, ignored.shieldable, new Date().toISOString().replace(/[:.]/g, '-'));
1364
+ ignoredBackup = writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels: ignored.shieldable, stamp });
1396
1365
  }
1397
1366
  const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
1398
1367
 
@@ -1462,7 +1431,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1462
1431
  const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1463
1432
  ? `morph-spec worktree remove --target "${worktreePath}"`
1464
1433
  : `morph-spec worktree remove ${feature}`;
1465
- const backupDir = join(root, '.git', 'morph-spec', 'finish-backup');
1434
+ const backupDir = finishBackupDir(root);
1466
1435
  const sample = (list) => `${list.slice(0, 5).join(', ')}${list.length > 5 ? `, +${list.length - 5}` : ''}`;
1467
1436
  const declineReason = worktreeRemoved
1468
1437
  ? null
@@ -1659,13 +1628,18 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1659
1628
  emit({ removed: false, error: 'status-failed', path: worktreePath, root,
1660
1629
  hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
1661
1630
  }
1662
- const dirt = status.split(/\r?\n/).filter((l) => l.trim());
1631
+ const dirt = withoutLeftoverIgnore(worktreePath, status.split(/\r?\n/).filter((l) => l.trim()));
1663
1632
  if (dirt.length) {
1664
1633
  emit({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
1665
1634
  hint: `o worktree tem trabalho não commitado — commit ou descarte antes, ou rode \`morph-spec worktree remove --target "${worktreePath}"\` sem --if-clean para remover com backup` }, 1);
1666
1635
  }
1667
1636
  }
1668
1637
 
1638
+ // A leftover temporary .graphifyignore (a `graph refresh` killed mid-run) is a
1639
+ // copy of the root's rules: not a file to back up. Deleted only now, after
1640
+ // every refusal, so a refused removal leaves the tree as it was.
1641
+ dropLeftoverIgnore(worktreePath);
1642
+
1669
1643
  // The env-registry key this tree allocated under: `<f>` for morph/<f>,
1670
1644
  // `task/<n>` for morph-task/<n>. Read BEFORE the teardown (the entry is gone
1671
1645
  // from `git worktree list` afterwards). The old `'worktree'` placeholder
@@ -1683,9 +1657,13 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1683
1657
  e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
1684
1658
  } catch { e2e = null; /* fail-open */ }
1685
1659
 
1660
+ // The stamp this removal's backup is written under — protected from the
1661
+ // retention that runs after it.
1662
+ const stamp = newBackupStamp();
1686
1663
  const teardown = await teardownWorktree(worktreePath, {
1687
1664
  root,
1688
1665
  feature: feature || envKey || 'worktree',
1666
+ stamp,
1689
1667
  branch,
1690
1668
  options: { discardIgnored: options.discardIgnored === true },
1691
1669
  // Removing a worktree is not closing a feature: the branch is the work, and
@@ -1724,6 +1702,11 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1724
1702
  if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
1725
1703
  out.hint = teardown.declineReason;
1726
1704
  }
1705
+ // After the new backup, never before — and never the new backup itself.
1706
+ if (teardown.ignoredBackup) {
1707
+ const retention = enforceBackupRetention(root, { protectStamps: [stamp] });
1708
+ if (retention) out.backupRetention = retention;
1709
+ }
1727
1710
  emit(out, teardown.worktreeRemoved ? 0 : 1);
1728
1711
  }
1729
1712
 
@@ -6,6 +6,7 @@ import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state
6
6
  import { unmetPrereqs } from '../../core/state/gate-order.js';
7
7
  import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
8
8
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
9
+ import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
9
10
 
10
11
  const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
11
12
 
@@ -169,6 +170,19 @@ export async function advanceCommand(featureName, options = {}) {
169
170
  const gate = PHASE_GATE[phase];
170
171
  const approvedBy = resolveApprovedBy(options);
171
172
 
173
+ // The screen checklist is part of what the uiux phase owes. The predicate
174
+ // is approve.js's too: an "auto" signature never closes a gate the
175
+ // gate-check would pause, and a recorded decision or a human signature
176
+ // (--approver) is the answer to that pause; --force is not, as in approve.
177
+ const checklistBlock = phase === 'uiux' ? uiuxAutoApprovalBlock(process.cwd(), featureName, feature) : null;
178
+ if (checklistBlock && approvedBy === 'auto') {
179
+ console.error(chalk.red(`\n❌ A fase "uiux" não pode ser encerrada com assinatura automática — ${checklistBlock}.`));
180
+ console.error(chalk.gray('\n Complete o checklist (ver a skill morph-uiux) e rode de novo. Quem decidiu aprovar assim'));
181
+ console.error(chalk.gray(` mesmo grava a decisão (morph-spec gate-decision ${featureName} uiux --pause false --reason "...")`));
182
+ console.error(chalk.gray(' ou assina com --approver <nome>. O --force não dispensa o checklist.\n'));
183
+ process.exit(1);
184
+ }
185
+
172
186
  // Gate monotonicity (mirrors approve.js): advance is a gate write-path too,
173
187
  // so it must not stamp gate N while a predecessor is pending. In the
174
188
  // sanctioned flow advance is always in-order (it approves G1 before the plan
@@ -242,6 +256,10 @@ export async function advanceCommand(featureName, options = {}) {
242
256
  if (missingArtifacts.length) {
243
257
  console.log(chalk.yellow(` ⚠️ --force: avançou SEM ${missingArtifacts.join(', ')}.`));
244
258
  }
259
+ if (checklistBlock) {
260
+ const by = options.force ? '--force' : `aprovado por ${approvedBy}`;
261
+ console.log(chalk.yellow(` ⚠️ ${by}: avançou com o ${checklistBlock}.`));
262
+ }
245
263
  // Report what actually happened: claiming "approved (auto)" when nothing
246
264
  // was written is how the destroyed signature stayed invisible.
247
265
  if (gate && alreadyApproved) {
@@ -7,6 +7,7 @@ import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, deriv
7
7
  import { unmetPrereqs } from '../../core/state/gate-order.js';
8
8
  import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
9
9
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
10
+ import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
10
11
 
11
12
  /**
12
13
  * The literal token `review-report.md` carries to declare which tree it
@@ -308,6 +309,20 @@ export async function approveCommand(featureName, gateName, options = {}) {
308
309
  process.exit(1);
309
310
  }
310
311
 
312
+ // The same backstop when no gate-check ran: the pause it WOULD record for
313
+ // the screen checklist, by the predicate advance.js reads too. `--force`
314
+ // does not bypass it, for the reason above.
315
+ if (gateName === 'uiux' && approvedBy === 'auto') {
316
+ const block = uiuxAutoApprovalBlock(process.cwd(), featureName, feature);
317
+ if (block) {
318
+ console.error(chalk.red(`\n❌ O Gate "uiux" não pode ser auto-aprovado — ${block}.`));
319
+ console.error(chalk.gray(' Complete o checklist (ver a skill morph-uiux) e rode de novo, aprove com'));
320
+ console.error(chalk.gray(' --approver <nome>, ou, se a pausa já foi decidida, grave a decisão:'));
321
+ console.error(chalk.gray(` morph-spec gate-decision ${featureName} uiux --pause false --reason "<motivo>"\n`));
322
+ process.exit(1);
323
+ }
324
+ }
325
+
311
326
  let selfAssessed = false;
312
327
  if (gateName === 'review') {
313
328
  if (feature.workType === 'hotfix') {
@@ -3,6 +3,8 @@ import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from '
3
3
  import { join, resolve, sep } from 'path';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
6
+ import { applySpecRevocations } from '../../lib/specs/acervo-lifecycle.js';
7
+ import { ADR_INDEX_PATHSPEC } from '../../lib/specs/adr-index.js';
6
8
  import { isValidFeatureName } from '../project/worktree.js';
7
9
 
8
10
  /** Refuse a move whose resolved target escapes `scopeDir`. */
@@ -64,7 +66,11 @@ function readReviewApproved(fjPath) {
64
66
  * @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
65
67
  * @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
66
68
  * committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[],
67
- * refusedUseCases?: Array<{id: string, incoming: string, existing: string, existingFeature: string, message: string}> }}
69
+ * refusedUseCases?: Array<{id: string, incoming: string, existing: string, existingFeature: string, message: string}>,
70
+ * keptSuperseded?: Array<{id: string, file: string, supersededBy: string|null, scope?: string}>,
71
+ * supersededUseCases?: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
72
+ * revocationProblems?: Array<{id: string|null, reason: string, message: string}>,
73
+ * adrIndex?: {added: string[], revoked: string[], narrowed: string[]}|null }}
68
74
  */
69
75
  export function runArchive(feature, opts = {}) {
70
76
  const cwd = opts.cwd || process.cwd();
@@ -158,10 +164,12 @@ export function runArchive(feature, opts = {}) {
158
164
  // never to a refused close.
159
165
  let promotedUseCases = [];
160
166
  let refusedUseCases = [];
167
+ let keptSuperseded = [];
161
168
  try {
162
- const { promoted, refused } = promoteUseCases(cwd, archivePath);
169
+ const { promoted, refused, keptSuperseded: kept } = promoteUseCases(cwd, archivePath);
163
170
  promotedUseCases = promoted;
164
171
  refusedUseCases = refused;
172
+ keptSuperseded = kept;
165
173
  if (promoted.length && useGit && staged) {
166
174
  // This staging exists for the NEW files: promoted use cases are untracked,
167
175
  // and the scoped `commit -- <pathspec>` below only knows paths git already
@@ -173,17 +181,39 @@ export function runArchive(feature, opts = {}) {
173
181
  }
174
182
  } catch { /* promotion must never block archiving */ }
175
183
 
184
+ // 3b) Apply what the feature's spec.md declared it revokes: acervo use cases
185
+ // turn `superseded`, and the ADR index gains/loses files. After the
186
+ // promotion, so a substitute promoted just now is already in place (and
187
+ // one it refused is known not to be); same commit, same fail-open contract.
188
+ let lifecycle = { superseded: [], adrIndex: null, problems: [] };
189
+ try {
190
+ lifecycle = applySpecRevocations(cwd, archivePath, { refused: refusedUseCases });
191
+ } catch { /* revocation must never block archiving */ }
192
+ const acervoChanged = promotedUseCases.length > 0 || lifecycle.superseded.length > 0;
193
+ const adrIndexChanged = lifecycle.adrIndex?.changed === true;
194
+ if (useGit && staged) {
195
+ if (lifecycle.superseded.length) {
196
+ try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
197
+ }
198
+ if (adrIndexChanged) {
199
+ try { execFileSync('git', ['add', '-A', '--', ADR_INDEX_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
200
+ }
201
+ }
202
+
176
203
  // 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
177
204
  // (same anti-sweep discipline as `finish`'s worktree teardown). This is what
178
205
  // lets the archived state travel with the branch on `finish` (archive-before-merge).
179
- // The acervo pathspec joins only when something was promoted: git refuses a
180
- // pathspec that matches nothing, and that refusal would leave the tree dirty
181
- // and break the checkout/merge that follows.
206
+ // The acervo pathspecs join only when something under them changed: git
207
+ // refuses a pathspec that matches nothing, and that refusal would leave the
208
+ // tree dirty and break the checkout/merge that follows.
182
209
  let committed = false;
183
210
  if (useGit && staged) {
184
- const pathspecs = promotedUseCases.length
185
- ? [featPathspec, archivePathspec, SPECS_USECASES_PATHSPEC]
186
- : [featPathspec, archivePathspec];
211
+ const pathspecs = [
212
+ featPathspec,
213
+ archivePathspec,
214
+ ...(acervoChanged ? [SPECS_USECASES_PATHSPEC] : []),
215
+ ...(adrIndexChanged ? [ADR_INDEX_PATHSPEC] : []),
216
+ ];
187
217
  try {
188
218
  execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
189
219
  committed = true;
@@ -206,7 +236,14 @@ export function runArchive(feature, opts = {}) {
206
236
  } catch { /* index is derivable — non-fatal */ }
207
237
  }
208
238
 
209
- return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases, refusedUseCases };
239
+ const adr = lifecycle.adrIndex;
240
+ return {
241
+ ok: true, archived: true, committed, staged, archivePath, promotedUseCases, refusedUseCases,
242
+ keptSuperseded,
243
+ supersededUseCases: lifecycle.superseded,
244
+ revocationProblems: lifecycle.problems,
245
+ adrIndex: adrIndexChanged ? { added: adr.added, revoked: adr.revoked, narrowed: adr.narrowed } : null,
246
+ };
210
247
  }
211
248
 
212
249
  /**
@@ -253,6 +290,22 @@ export async function archiveCommand(featureName, options = {}) {
253
290
  console.log(chalk.yellow(` ${r.message}`));
254
291
  }
255
292
  }
293
+ for (const k of result.keptSuperseded ?? []) {
294
+ console.log(chalk.yellow(` ${k.id} continua superseded (superseded_by: ${k.supersededBy ?? '?'}) — a cópia promovida não trazia o status, e o acervo o manteve.`));
295
+ }
296
+ for (const s of result.supersededUseCases ?? []) {
297
+ console.log(chalk.gray(` ${s.id} → status: superseded (superseded_by: ${s.supersededBy}${s.scope ? `; ainda vale em: ${s.scope}` : ''}).`));
298
+ }
299
+ if (result.adrIndex) {
300
+ const { added, revoked, narrowed } = result.adrIndex;
301
+ console.log(chalk.gray(` ADR index (.morph/specs/adrs/): +${added.length} −${revoked.length} ~${narrowed.length}.`));
302
+ }
303
+ if (result.revocationProblems?.length) {
304
+ console.log(chalk.yellow(`\n⚠️ ${result.revocationProblems.length} revogação(ões) do spec.md NÃO aplicada(s):`));
305
+ for (const p of result.revocationProblems) {
306
+ console.log(chalk.yellow(` ${p.message}`));
307
+ }
308
+ }
256
309
  if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
257
310
  else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
258
311
  else console.log('');