@polymorphism-tech/morph-spec 8.21.0 → 8.23.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 (43) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +43 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +7 -3
  8. package/framework/agents.json +3 -1
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/shared/e2e-check.js +0 -0
  11. package/framework/schemas/feature.schema.json +5 -3
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +14 -2
  19. package/framework/skills/core/morph-plan/SKILL.md +27 -4
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +60 -8
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  25. package/framework/standards/STANDARDS.json +28 -0
  26. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
  27. package/framework/standards/backend/dotnet/testing.md +127 -0
  28. package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
  29. package/package.json +1 -1
  30. package/src/commands/e2e/e2e.js +318 -0
  31. package/src/commands/state/approve.js +11 -0
  32. package/src/commands/state/archive.js +12 -3
  33. package/src/commands/state/finish.js +47 -4
  34. package/src/commands/state/handoff.js +3 -2
  35. package/src/commands/state/usecase.js +77 -0
  36. package/src/commands/validation/validate-feature.js +7 -1
  37. package/src/commands/validation/verify.js +13 -0
  38. package/src/lib/e2e/compose-env.js +74 -0
  39. package/src/lib/generators/handoff-generator.js +2 -2
  40. package/src/lib/specs/usecase-promoter.js +135 -19
  41. package/src/lib/specs/usecase-registry.js +237 -0
  42. package/src/lib/validators/traceability-check.js +198 -0
  43. package/src/lib/validators/verify-runner.js +135 -2
@@ -13,6 +13,11 @@ import {
13
13
  } from '../project/worktree.js';
14
14
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
15
15
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
16
+ import {
17
+ composeProjectName,
18
+ resolveComposeFile,
19
+ tearDownStack,
20
+ } from '../../../framework/hooks/shared/e2e-check.js';
16
21
  import { runArchive } from './archive.js';
17
22
 
18
23
  /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
@@ -85,12 +90,20 @@ export async function finishCommand(feature, options = {}) {
85
90
  // working on it (B1). Released on EVERY success path — hooking it into emit
86
91
  // is the single point that covers merge/PR/--keep alike. Fail-open: a claim
87
92
  // that outlives its feature only expires on its own TTL.
93
+ //
94
+ // The e2e stack rides along for the same reason: `morph-spec verify` leaves it
95
+ // UP on purpose (cold-starting a container per task is what makes the loop
96
+ // unusable), so something has to be the one place that reclaims the
97
+ // containers and their ports when the feature closes. Best-effort by
98
+ // contract — a stack that refuses to come down must never fail a finish that
99
+ // already merged.
88
100
  const emit = (obj) => {
89
101
  if (obj?.finished === true) {
90
102
  try {
91
103
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
92
104
  if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
93
105
  } catch { /* fail-open */ }
106
+ tearDownE2eStack(feature, root);
94
107
  }
95
108
  console.log(JSON.stringify(obj));
96
109
  };
@@ -392,12 +405,17 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
392
405
  // ── output helpers ────────────────────────────────────────────────────────
393
406
 
394
407
  /**
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.
408
+ * Report the use cases the archive promoted into `.morph/specs/usecases/`, and
409
+ * any that were REFUSED for a numbering collision (`ucCollisions` — see
410
+ * `usecase-promoter.js`'s reservation-backed guard). Both absent when there is
411
+ * nothing to report, so a feature with no use cases keeps the exact output
412
+ * shape it had before either existed.
398
413
  */
399
414
  function promotedNote(arch) {
400
- return arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {};
415
+ return {
416
+ ...(arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {}),
417
+ ...(arch?.refusedUseCases?.length ? { ucCollisions: arch.refusedUseCases } : {}),
418
+ };
401
419
  }
402
420
 
403
421
  function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
@@ -502,6 +520,31 @@ function readFeatureJson(cwd, feature) {
502
520
  }
503
521
  }
504
522
 
523
+ /**
524
+ * Reclaim the feature's verification stack, if the project has one.
525
+ *
526
+ * Runs from the ROOT rather than the worktree: by the time a finish emits
527
+ * success the worktree may already be gone, and the compose file is versioned
528
+ * so the root's copy is equivalent. Entirely fail-open — no compose, no docker,
529
+ * or a stubborn container are all non-events here.
530
+ */
531
+ function tearDownE2eStack(feature, root) {
532
+ try {
533
+ const config = JSON.parse(
534
+ readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '')
535
+ );
536
+ const composeFile = resolveComposeFile(root, config);
537
+ if (!composeFile) return;
538
+
539
+ const projectName = composeProjectName(feature);
540
+ tearDownStack(root, {
541
+ composeFile,
542
+ projectName,
543
+ env: { COMPOSE_PROJECT_NAME: projectName },
544
+ });
545
+ } catch { /* fail-open — teardown must never fail a completed finish */ }
546
+ }
547
+
505
548
  /** Drop a feature from the thin, gitignored root index (never via saveState). */
506
549
  function dropFromRootIndex(root, feature) {
507
550
  const rootPath = join(root, '.morph', 'state.json');
@@ -6,8 +6,9 @@
6
6
  * worktree session, a resumed session after a phase transition). The handoff
7
7
  * is the sanctioned way to carry context across: derived, regenerable, and
8
8
  * pointer-based (see handoff-generator.js). This command is also called
9
- * in-process by `worktree setup`, `create --worktree` and `advance` — the CLI
10
- * surface exists for the LLM/user to refresh the document on demand.
9
+ * in-process by `worktree setup`, `create --worktree`, `advance` and
10
+ * `approve` — the CLI surface exists for the LLM/user to refresh the
11
+ * document on demand.
11
12
  */
12
13
 
13
14
  import chalk from 'chalk';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `morph-spec usecase reserve [--count <n>] [--feature <name>] [--json]` —
3
+ * atomically mint the next N `UC-{nn}` numbers, shared across every worktree
4
+ * of this repo. See `usecase-registry.js` for why this exists (worktrees only
5
+ * ever see their own tree, so an independent max+1 scan collides).
6
+ *
7
+ * `--feature` is more than an audit label: `usecase-promoter.js`'s collision
8
+ * guard uses the reservation record to tell a legitimate cross-feature
9
+ * correction (never reserved — reusing a published number by design) from an
10
+ * accidental collision (both features reserved the same id). Passing it is
11
+ * how `morph-plan` gets that protection; omitting it degrades to "reserved,
12
+ * no owner recorded" — still collision-free, just unable to name the
13
+ * conflicting feature if one ever surfaces.
14
+ */
15
+
16
+ import chalk from 'chalk';
17
+ import { existsSync, readdirSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
20
+ import { reserveUseCases } from '../../lib/specs/usecase-registry.js';
21
+
22
+ /**
23
+ * Best-effort feature-name inference when `--feature` is omitted: only when
24
+ * exactly one active feature exists is the guess unambiguous. Anything else
25
+ * (zero, or more than one) is left null rather than guessed wrong — a wrong
26
+ * guess would poison the collision guard's provenance record.
27
+ */
28
+ function inferActiveFeature(cwd) {
29
+ const featuresDir = join(cwd, '.morph', 'features');
30
+ try {
31
+ if (!existsSync(featuresDir)) return null;
32
+ const entries = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory());
33
+ return entries.length === 1 ? entries[0].name : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {{ count?: string, feature?: string, json?: boolean, cwd?: string }} [options]
41
+ */
42
+ export function usecaseReserveCommand(options = {}) {
43
+ const cwd = options.cwd || process.cwd();
44
+ const count = Number.parseInt(options.count, 10);
45
+ const n = Number.isInteger(count) && count > 0 ? count : 1;
46
+ const feature = options.feature || inferActiveFeature(cwd) || null;
47
+
48
+ let gitCommonDir = null;
49
+ try { gitCommonDir = resolveGitCommonDir(cwd); } catch { gitCommonDir = null; }
50
+
51
+ let result;
52
+ try {
53
+ result = reserveUseCases(cwd, { feature, count: n, gitCommonDir });
54
+ } catch (error) {
55
+ if (options.json) {
56
+ console.log(JSON.stringify({ reserved: false, error: error.message }));
57
+ process.exit(1);
58
+ }
59
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
60
+ process.exit(1);
61
+ }
62
+
63
+ const warning = gitCommonDir
64
+ ? null
65
+ : 'projeto fora de um repositório git — numeração protegida só localmente; paralelismo entre worktrees NÃO está garantido.';
66
+
67
+ if (options.json) {
68
+ console.log(JSON.stringify({ reserved: true, ...result, feature, warning }));
69
+ process.exit(0);
70
+ }
71
+
72
+ console.log(chalk.green(`\n✅ Reservado: ${result.numbers.join(' ')}`));
73
+ if (feature) console.log(chalk.gray(` feature: ${feature}`));
74
+ if (warning) console.log(chalk.yellow(`⚠️ ${warning}\n`));
75
+ else console.log('');
76
+ process.exit(0);
77
+ }
@@ -80,7 +80,7 @@ const OUTPUT_SECTIONS = {
80
80
  ],
81
81
  },
82
82
  // morph-plan (mandate.md) emits: Stack, Standards obrigatórios,
83
- // Patterns proibidos, Checklist de done.
83
+ // Patterns proibidos, Verificação de runtime, Checklist de done.
84
84
  'mandate.md': {
85
85
  required: [
86
86
  'Stack',
@@ -89,6 +89,12 @@ const OUTPUT_SECTIONS = {
89
89
  ],
90
90
  recommended: [
91
91
  ['Patterns proibidos', 'Padrões proibidos', 'Padroes proibidos', 'Forbidden Patterns', 'Forbidden', 'Proibido'],
92
+ // Recommended rather than required on purpose: mandate.md freezes after
93
+ // Gate 2 (protect-spec-files blocks Write AND Edit), so promoting this to
94
+ // `required` would fail every in-flight feature with no way to fix it.
95
+ // New mandates get the section from morph-plan §4; the enforcement that
96
+ // actually matters is verify's e2e node, not this heading check.
97
+ ['Verificação de runtime', 'Verificacao de runtime', 'Runtime', 'E2E'],
92
98
  ],
93
99
  optional: [
94
100
  ['Riscos', 'Risks'],
@@ -37,6 +37,10 @@ function formatVerifyResult(result) {
37
37
  detail = node.reason || '';
38
38
  } else if (name === 'validators' && node.score != null) {
39
39
  detail = `score ${node.score}/10`;
40
+ } else if (node.reason) {
41
+ // A green e2e still carries meaning worth showing: smoke-only vs specs,
42
+ // warm reuse vs rebuild. Without it, "pass" hides how much was proven.
43
+ detail = `${node.durationMs}ms — ${node.reason}`;
40
44
  } else {
41
45
  detail = `${node.durationMs}ms`;
42
46
  }
@@ -49,6 +53,12 @@ function formatVerifyResult(result) {
49
53
  for (const line of result.feedback) lines.push(` ${line}`);
50
54
  }
51
55
 
56
+ if (result.traceability?.warnings?.length > 0) {
57
+ lines.push('');
58
+ lines.push(chalk.yellow('Traceability warnings (advisory — never fails verify):'));
59
+ for (const w of result.traceability.warnings) lines.push(` ⚠ ${w}`);
60
+ }
61
+
52
62
  lines.push('');
53
63
  return lines.join('\n');
54
64
  }
@@ -149,6 +159,8 @@ export async function verifyCommand(featureName, taskId, options = {}) {
149
159
  timeout: options.timeout,
150
160
  skipTests: options.skipTests,
151
161
  skipBuild: options.skipBuild,
162
+ skipE2e: options.skipE2e,
163
+ freshE2e: options.freshE2e,
152
164
  });
153
165
 
154
166
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
@@ -168,6 +180,7 @@ export async function verifyCommand(featureName, taskId, options = {}) {
168
180
  build: result.nodes.build.status,
169
181
  tests: result.nodes.tests.status,
170
182
  validators: result.nodes.validators.status,
183
+ e2e: result.nodes.e2e.status,
171
184
  },
172
185
  score: result.nodes.validators.score ?? null,
173
186
  attempts: (prevEntry?.attempts ?? 0) + 1,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Compose environment for the e2e stack — the port block a feature owns, plus
3
+ * the credentials its auth setup needs.
4
+ *
5
+ * This lives in ONE place because it has two callers that must never disagree:
6
+ * `morph-spec e2e up` (manual) and the `e2e` node inside `morph-spec verify`
7
+ * (the one that actually runs in the loop). When only the manual path built the
8
+ * port block, the isolation was real in the command nobody runs and absent in
9
+ * the one everybody runs — two worktrees would get distinct container names via
10
+ * COMPOSE_PROJECT_NAME and then collide on host port 3000 anyway, surfacing as
11
+ * "port is already allocated" and pausing a gate over an infrastructure clash
12
+ * rather than a defect.
13
+ *
14
+ * It sits in src/lib rather than framework/hooks/shared because it depends on
15
+ * env-allocator.js: hooks are installed standalone into target projects, where
16
+ * `src/` does not exist, so the dependency may only point this way.
17
+ *
18
+ * @module lib/e2e/compose-env
19
+ */
20
+
21
+ import { join } from 'path';
22
+
23
+ import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
24
+ import { allocateEnvBlock, resolveFleetConfig } from '../worktree/env-allocator.js';
25
+
26
+ /**
27
+ * Build the environment a `docker compose` invocation should carry.
28
+ *
29
+ * Port variables mirror `buildEnvFileContent()` in env-allocator.js so a
30
+ * compose file and a `.morph/worktree.env` describe the same world: `PORT` for
31
+ * the web tier, `ASPNETCORE_URLS` on base+1 for a .NET backend.
32
+ *
33
+ * `PLAYWRIGHT_BASE_URL` is derived here rather than left to the spec config —
34
+ * the suite has to be told where the app actually landed, otherwise a feature
35
+ * on port 4020 gets tested against a hardcoded localhost:3000 and fails with
36
+ * ECONNREFUSED that looks exactly like a broken app.
37
+ *
38
+ * Fail-open throughout: with no git dir (or a corrupt registry) the caller gets
39
+ * default ports, which only matters when two stacks run at once.
40
+ *
41
+ * @param {object} params
42
+ * @param {string} params.cwd - project root
43
+ * @param {object|null} [params.config] - .morph/config/config.json contents
44
+ * @param {string} params.featureName
45
+ * @param {string|null} [params.gitCommonDir]
46
+ * @returns {Record<string,string>}
47
+ */
48
+ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
49
+ const env = {};
50
+
51
+ if (gitCommonDir) {
52
+ try {
53
+ const { basePort, blockSize } = resolveFleetConfig(cwd);
54
+ const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
55
+
56
+ env.MORPH_PORT_BASE = String(block.base);
57
+ env.MORPH_PORT_RANGE = `${block.range[0]}-${block.range[1]}`;
58
+ env.PORT = String(block.base);
59
+ env.PLAYWRIGHT_BASE_URL = `http://localhost:${block.base}`;
60
+
61
+ if (/\bdotnet\b/i.test(String(config?.project?.stack ?? ''))) {
62
+ env.ASPNETCORE_URLS = `http://localhost:${block.base + 1}`;
63
+ }
64
+ } catch {
65
+ // No block → default ports. Surfaces later as a port conflict, never here.
66
+ }
67
+ }
68
+
69
+ // Credentials live in a gitignored env file, never in the committed config.
70
+ const envFile = config?.e2e?.auth?.envFile;
71
+ if (envFile) Object.assign(env, loadEnvFile(join(cwd, envFile)));
72
+
73
+ return env;
74
+ }
@@ -9,8 +9,8 @@
9
9
  * Deterministic by construction — everything here is derived from feature.json,
10
10
  * tasks.json, decisions.md and the worktree env manifest. The document is
11
11
  * therefore REGENERABLE (never hand-edited, never merged): `morph-spec handoff`
12
- * rebuilds it, and setup/create/advance regenerate it at the moments a new
13
- * session is most likely to pick the feature up.
12
+ * rebuilds it, and setup/create/advance/approve regenerate it at the moments a
13
+ * new session is most likely to pick the feature up.
14
14
  *
15
15
  * Every section is fail-open: a missing tasks.json or a corrupt decisions.md
16
16
  * degrades that section, never the document.
@@ -12,15 +12,29 @@
12
12
  * as a no-op rather than an error.
13
13
  * - **No-op without the folder.** A feature with no use cases promotes nothing.
14
14
  * - **Idempotent.** Re-promoting the same use case rewrites the same content.
15
- * - **Overwrite by ID, not by filename.** `UC-07-old-slug.md` in the archive is
16
- * REPLACED by an incoming `UC-07-new-slug.md`. Keying on the filename would
17
- * leave two files claiming UC-07 the first time a slug is reworded — and the
18
- * `bug` work type exists precisely to correct an existing use case.
15
+ * - **Overwrite by ID, not by filename — with a collision backstop.**
16
+ * `UC-07-old-slug.md` in the archive is REPLACED by an incoming
17
+ * `UC-07-new-slug.md`. Keying on the filename would leave two files
18
+ * claiming UC-07 the first time a slug is reworded — and the `bug` work
19
+ * type exists precisely to correct an existing use case, from a DIFFERENT
20
+ * feature than the one that first published it, sometimes rewording the
21
+ * slug in the same pass. That legitimate case is INDISTINGUISHABLE, by
22
+ * filename and slug alone, from two worktrees that independently minted
23
+ * the SAME number for two UNRELATED use cases (the numbering-collision bug
24
+ * this backstop exists for) — a user has already lost a use case this way
25
+ * in production. The one signal that tells them apart is `usecase-registry.js`'s
26
+ * reservation record: a feature that REPLACES a number it never reserved is
27
+ * reusing a published UC by design (the bug/update flow); a feature that
28
+ * REPLACES a number it DID reserve, and finds someone ELSE'S feature already
29
+ * sitting on that ID, is the collision — refuse that one specific promotion
30
+ * rather than silently destroy the other feature's use case.
19
31
  */
20
32
 
21
33
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
22
- import { join } from 'path';
34
+ import { basename, join, relative } from 'path';
23
35
  import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
36
+ import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
37
+ import { reservationOwner } from './usecase-registry.js';
24
38
 
25
39
  const UC = OUTPUT_SCHEMA.usecases;
26
40
  const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
@@ -35,38 +49,102 @@ export function useCaseId(filename) {
35
49
  }
36
50
 
37
51
  /**
38
- * Rewrite the frontmatter `status:` to `implemented`.
52
+ * Set (or replace) a single top-level frontmatter field, preserving every
53
+ * other line, the original EOL style, and anything after the closing `---`.
39
54
  *
40
- * A file with no frontmatter is copied verbatim: the use case belongs to the
41
- * author, and inventing a header would be a silent edit of their document.
55
+ * A file with no frontmatter block is returned UNCHANGED: the use case
56
+ * belongs to the author, and inventing a header would be a silent edit of
57
+ * their document.
42
58
  *
43
59
  * @param {string} content
60
+ * @param {string} key
61
+ * @param {string} value
44
62
  * @returns {string}
45
63
  */
46
- export function stampImplemented(content) {
64
+ function setFrontmatterField(content, key, value) {
47
65
  const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
48
66
  if (!m || m.index !== 0) return content;
49
67
 
50
68
  const [full, open, body, close] = m;
51
69
  const eol = full.includes('\r\n') ? '\r\n' : '\n';
52
- const statusLine = /^status:[ \t]*.*$/m;
53
- const next = statusLine.test(body)
54
- ? body.replace(statusLine, 'status: implemented')
55
- : `${body}${eol}status: implemented`;
70
+ const fieldLine = new RegExp(`^${key}:[ \\t]*.*$`, 'm');
71
+ const next = fieldLine.test(body)
72
+ ? body.replace(fieldLine, `${key}: ${value}`)
73
+ : `${body}${eol}${key}: ${value}`;
56
74
 
57
75
  return open + next + close + content.slice(full.length);
58
76
  }
59
77
 
78
+ /** Read a single top-level frontmatter field. `null` when absent or there is no frontmatter block. */
79
+ function readFrontmatterField(content, key) {
80
+ const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
81
+ if (!m || m.index !== 0) return null;
82
+ const line = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(m[2]);
83
+ return line ? line[1].trim() : null;
84
+ }
85
+
86
+ /**
87
+ * Rewrite the frontmatter `status:` to `implemented`.
88
+ *
89
+ * A file with no frontmatter is copied verbatim: the use case belongs to the
90
+ * author, and inventing a header would be a silent edit of their document.
91
+ *
92
+ * @param {string} content
93
+ * @returns {string}
94
+ */
95
+ export function stampImplemented(content) {
96
+ return setFrontmatterField(content, 'status', 'implemented');
97
+ }
98
+
99
+ /**
100
+ * Stamp the promoting feature's name into `sourceFeature:` — the provenance
101
+ * record the collision guard (below) compares against a reservation to tell a
102
+ * legitimate cross-feature correction from an accidental numbering collision.
103
+ *
104
+ * @param {string} content
105
+ * @param {string} feature
106
+ * @returns {string}
107
+ */
108
+ export function stampSourceFeature(content, feature) {
109
+ return setFrontmatterField(content, 'sourceFeature', feature);
110
+ }
111
+
112
+ /**
113
+ * Build the message for a refused promotion — named clearly enough that the
114
+ * next session (or human) can actually recover, not just "colisão".
115
+ *
116
+ * The recovery instruction has to be honest about WHERE the file sits: by the
117
+ * time this runs, `runArchive` has already moved the feature folder, and its
118
+ * own idempotent short-circuit (`existsSync(archivePath)`) means re-running
119
+ * `archive`/`finish` on this feature promotes NOTHING a second time — so
120
+ * "renumber and re-run finish" would be a lie. The file is wherever `srcDir`
121
+ * actually is (the feature's `2-plan/usecases/`, wherever that folder lives
122
+ * right now — pre- or post-archive, the caller decides), and recovery is
123
+ * either a fresh reservation + manual copy, or editing the archive in place.
124
+ *
125
+ * @returns {string}
126
+ */
127
+ function collisionMessage({ id, incoming, existing, existingFeature, feature, relIncomingPath }) {
128
+ return `${id}: colisão de numeração — "${incoming}" (feature "${feature}") não pôde substituir "${existing}" ` +
129
+ `já promovido no acervo por "${existingFeature}". O arquivo continua em ${relIncomingPath}. ` +
130
+ 'Se for um use case NOVO: renumere-o com `morph-spec usecase reserve` e promova manualmente para ' +
131
+ '.morph/specs/usecases/ (o finish/archive não reprocessa uma feature já arquivada). ' +
132
+ `Se for uma CORREÇÃO do mesmo UC publicado: edite .morph/specs/usecases/${existing} diretamente em vez de reservar um número novo.`;
133
+ }
134
+
60
135
  /**
61
136
  * Copy every use case of a feature into the project's permanent archive.
62
137
  *
63
138
  * @param {string} cwd - project root that owns `.morph/specs/`
64
139
  * @param {string} featureDir - the feature folder holding `2-plan/usecases/`
65
- * @returns {{ promoted: string[], replaced: string[] }} filenames, always arrays
140
+ * @returns {{ promoted: string[], replaced: string[], refused: Array<{id: string,
141
+ * incoming: string, existing: string, existingFeature: string, message: string}> }}
142
+ * filenames, always arrays
66
143
  */
67
144
  export function promoteUseCases(cwd, featureDir) {
68
- const empty = { promoted: [], replaced: [] };
145
+ const empty = { promoted: [], replaced: [], refused: [] };
69
146
  const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
147
+ const feature = basename(featureDir);
70
148
 
71
149
  let names;
72
150
  try {
@@ -80,6 +158,7 @@ export function promoteUseCases(cwd, featureDir) {
80
158
  const destDir = join(cwd, '.morph', 'specs', 'usecases');
81
159
  const promoted = [];
82
160
  const replaced = [];
161
+ const refused = [];
83
162
 
84
163
  try {
85
164
  mkdirSync(destDir, { recursive: true });
@@ -87,6 +166,11 @@ export function promoteUseCases(cwd, featureDir) {
87
166
  return empty;
88
167
  }
89
168
 
169
+ // Resolved ONCE per call — pure filesystem, no subprocess (git-paths.js) —
170
+ // and reused for every collision check below.
171
+ let gitCommonDir = null;
172
+ try { gitCommonDir = resolveGitCommonDir(cwd); } catch { gitCommonDir = null; }
173
+
90
174
  // Existing archive entries, indexed by use-case ID — the key the overwrite
91
175
  // rule is written against.
92
176
  const existingById = new Map();
@@ -99,22 +183,54 @@ export function promoteUseCases(cwd, featureDir) {
99
183
 
100
184
  for (const name of names) {
101
185
  try {
102
- const content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
103
186
  const id = useCaseId(name);
104
187
  const stale = id ? existingById.get(id) : null;
105
188
 
106
- // Same ID under a different filename: drop the old file, or the archive
107
- // would end up with two documents claiming the same UC number.
189
+ // Same ID under a different filename: normally the old file is dropped
190
+ // (a slug reword, or the `bug` flow correcting a published UC), or the
191
+ // archive would end up with two documents claiming the same UC number.
192
+ //
193
+ // Backstop: refuse instead when BOTH are true —
194
+ // (a) THIS feature holds a reservation for `id` in usecase-registry.js
195
+ // (it believes it MINTED a new number), and
196
+ // (b) the archive entry already there was promoted by a DIFFERENT
197
+ // feature (its stamped `sourceFeature` says so).
198
+ // A feature correcting a published UC never reserved that UC's number —
199
+ // it reused one already in the acervo — so that legitimate flow never
200
+ // trips this. Two features that each reserved the SAME number (the
201
+ // collision this exists for) always do. No reservation record at all
202
+ // (no git common dir, or a legacy archive entry predating this stamp) is
203
+ // fail-open: nothing to compare against, so nothing is refused.
108
204
  if (stale && stale !== name) {
205
+ let staleFeature = null;
206
+ try { staleFeature = readFrontmatterField(readFileSync(join(destDir, stale), 'utf8'), 'sourceFeature'); } catch { /* unreadable — no signal */ }
207
+ const incomingOwner = id ? reservationOwner(id, { cwd, gitCommonDir }) : null;
208
+
209
+ if (staleFeature && staleFeature !== feature && incomingOwner === feature) {
210
+ refused.push({
211
+ id,
212
+ incoming: name,
213
+ existing: stale,
214
+ existingFeature: staleFeature,
215
+ message: collisionMessage({
216
+ id, incoming: name, existing: stale, existingFeature: staleFeature, feature,
217
+ relIncomingPath: relative(cwd, join(srcDir, name)).replace(/\\/g, '/'),
218
+ }),
219
+ });
220
+ continue; // leave the existing archive entry untouched
221
+ }
222
+
109
223
  rmSync(join(destDir, stale), { force: true });
110
224
  replaced.push(stale);
111
225
  }
112
226
 
227
+ let content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
228
+ content = stampSourceFeature(content, feature);
113
229
  writeFileSync(join(destDir, name), content, 'utf8');
114
230
  promoted.push(name);
115
231
  if (id) existingById.set(id, name);
116
232
  } catch { /* one unreadable use case must not sink the rest */ }
117
233
  }
118
234
 
119
- return { promoted, replaced };
235
+ return { promoted, replaced, refused };
120
236
  }