@polymorphism-tech/morph-spec 8.20.0 → 8.22.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 (48) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +31 -2
  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 +6 -2
  8. package/framework/agents.json +3 -2
  9. package/framework/docs/CLI.md +4 -3
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +23 -2
  11. package/framework/hooks/claude-code/core/state-sync.js +5 -0
  12. package/framework/hooks/claude-code/core/telemetry-log.js +34 -3
  13. package/framework/hooks/claude-code/core/validate-completion.js +116 -4
  14. package/framework/hooks/claude-code/core/worktree-context.js +9 -4
  15. package/framework/hooks/shared/e2e-check.js +0 -0
  16. package/framework/hooks/shared/telemetry-logger.js +64 -1
  17. package/framework/schemas/feature.schema.json +5 -3
  18. package/framework/schemas/tasks.schema.json +1 -1
  19. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  20. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  21. package/framework/skills/core/morph-apply/SKILL.md +7 -16
  22. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
  23. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  24. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  25. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  26. package/framework/skills/core/morph-plan/SKILL.md +25 -4
  27. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  28. package/framework/skills/core/morph-review/SKILL.md +35 -8
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +29 -15
  31. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  32. package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
  33. package/package.json +1 -1
  34. package/src/commands/e2e/e2e.js +318 -0
  35. package/src/commands/observability/cost.js +93 -5
  36. package/src/commands/project/update.js +2 -63
  37. package/src/commands/state/finish.js +38 -0
  38. package/src/commands/state/gate-check.js +10 -3
  39. package/src/commands/validation/validate-feature.js +7 -1
  40. package/src/commands/validation/verify.js +7 -0
  41. package/src/core/templates/template-registry.js +2 -30
  42. package/src/lib/e2e/compose-env.js +74 -0
  43. package/src/lib/git/default-branch.js +44 -8
  44. package/src/lib/installers/templates-reconcile.js +81 -0
  45. package/src/lib/risk-detector.js +20 -1
  46. package/src/lib/telemetry/telemetry-reader.js +80 -4
  47. package/src/lib/validators/verify-runner.js +121 -2
  48. package/src/scripts/setup-infra.js +23 -5
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `morph-spec e2e up|status|down` — lifecycle of the verification stack.
3
+ *
4
+ * The stack is deliberately NOT torn down between verifies: cold-starting a
5
+ * .NET container per task is the difference between a loop people run and one
6
+ * they disable. `up` brings it (or reuses it), `status` says whether what is
7
+ * running still matches the working tree, and `down` releases it — which
8
+ * `morph-spec finish` also does as part of teardown.
9
+ *
10
+ * All the real logic lives in framework/hooks/shared/e2e-check.js, shared with
11
+ * the `verify` e2e node so the two can never drift.
12
+ *
13
+ * @module commands/e2e
14
+ */
15
+
16
+ import chalk from 'chalk';
17
+ import { execFileSync } from 'child_process';
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join, resolve } from 'path';
20
+
21
+ import {
22
+ bringUpStack,
23
+ composeProjectName,
24
+ computeSourceStamp,
25
+ defaultExec,
26
+ getStackStatus,
27
+ isDockerAvailable,
28
+ prepareAuth,
29
+ readStamp,
30
+ resolveComposeFile,
31
+ resolveSpecCommand,
32
+ tearDownStack,
33
+ } from '../../../framework/hooks/shared/e2e-check.js';
34
+ import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
35
+
36
+ /** Exit code for a usage error, matching `verify`'s convention. */
37
+ const EXIT_USAGE = 2;
38
+
39
+ // ============================================================================
40
+ // Shared context
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Everything the three subcommands need, resolved once.
45
+ * Returns null (after printing) when the project has no compose file — that is
46
+ * a usage error here, unlike in the verify node where it is a graded skip.
47
+ */
48
+ function resolveContext(featureName, { json = false } = {}) {
49
+ const cwd = process.cwd();
50
+ const config = readConfig(cwd);
51
+ const composeFile = resolveComposeFile(cwd, config);
52
+
53
+ if (!composeFile) {
54
+ const message =
55
+ 'No compose file found (compose.yaml, docker-compose.yml, …). ' +
56
+ 'Set project.e2e.composeFile in .morph/config/config.json if it lives elsewhere.';
57
+ if (json) console.log(JSON.stringify({ error: message }, null, 2));
58
+ else console.error(chalk.red(`\n❌ ${message}\n`));
59
+ return null;
60
+ }
61
+
62
+ const projectName = composeProjectName(featureName);
63
+ const gitCommonDir = resolveGitCommonDir(cwd);
64
+
65
+ return {
66
+ cwd,
67
+ config,
68
+ composeFile,
69
+ projectName,
70
+ gitCommonDir,
71
+ env: buildEnv({ cwd, config, featureName, projectName, gitCommonDir }),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Compose environment: the port block this feature owns, plus any credentials
77
+ * from the gitignored auth env file.
78
+ *
79
+ * The port block comes from `buildComposeEnv` rather than being assembled here
80
+ * so this command and the `e2e` node inside `verify` agree by construction —
81
+ * isolation that exists only in the command nobody runs is no isolation at all.
82
+ * `COMPOSE_PROJECT_NAME` isolates container NAMES but not host ports; a compose
83
+ * that hardcodes ports instead of reading `${PORT:-3000}` cannot be isolated at
84
+ * all, which is surfaced as a warning rather than silently accepted.
85
+ */
86
+ function buildEnv({ cwd, config, featureName, projectName, gitCommonDir }) {
87
+ return {
88
+ COMPOSE_PROJECT_NAME: projectName,
89
+ ...buildComposeEnv({ cwd, config, featureName, gitCommonDir }),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Warn once when the compose file publishes fixed host ports: with a hardcoded
95
+ * `"3000:3000"` the fleet port block cannot take effect, so two features
96
+ * running simultaneously will collide no matter what we inject.
97
+ */
98
+ function warnOnFixedPorts(cwd, composeFile) {
99
+ try {
100
+ const raw = readFileSync(join(cwd, composeFile), 'utf8');
101
+ const publishesFixed = /^\s*-\s*["']?\d+:\d+["']?\s*$/m.test(raw);
102
+ if (publishesFixed) {
103
+ console.log(
104
+ chalk.yellow(
105
+ '⚠️ Compose publishes fixed host ports. Parallel worktrees will collide — ' +
106
+ 'use ${PORT:-3000}:3000 so the fleet port block applies.'
107
+ )
108
+ );
109
+ }
110
+ } catch {
111
+ // Unreadable compose is the caller's problem, not this warning's.
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // up
117
+ // ============================================================================
118
+
119
+ /**
120
+ * `morph-spec e2e up <feature> [--fresh]`
121
+ *
122
+ * Exit codes: 0 = stack healthy · 1 = failed to come up (or auth broke) ·
123
+ * 2 = usage (no compose file / no docker).
124
+ */
125
+ export async function e2eUpCommand(featureName, options = {}) {
126
+ const ctx = resolveContext(featureName, options);
127
+ if (!ctx) process.exit(EXIT_USAGE);
128
+
129
+ if (!isDockerAvailable(ctx.cwd)) {
130
+ console.error(chalk.red('\n❌ Docker is not available in this environment.\n'));
131
+ process.exit(EXIT_USAGE);
132
+ }
133
+
134
+ if (!options.json) warnOnFixedPorts(ctx.cwd, ctx.composeFile);
135
+
136
+ const result = bringUpStack(ctx.cwd, {
137
+ composeFile: ctx.composeFile,
138
+ projectName: ctx.projectName,
139
+ gitCommonDir: ctx.gitCommonDir,
140
+ env: ctx.env,
141
+ forceFresh: Boolean(options.fresh),
142
+ });
143
+
144
+ const auth =
145
+ result.status === 'pass'
146
+ ? prepareAuth(ctx.cwd, { config: ctx.config, env: ctx.env })
147
+ : { status: 'skip', reason: null };
148
+
149
+ const failed = result.status === 'fail' || auth.status === 'fail';
150
+ const payload = {
151
+ feature: featureName,
152
+ project: ctx.projectName,
153
+ composeFile: ctx.composeFile,
154
+ status: failed ? 'fail' : 'pass',
155
+ reused: result.reused,
156
+ reason: result.reason,
157
+ auth: { status: auth.status, reason: auth.reason },
158
+ };
159
+
160
+ if (options.json) {
161
+ console.log(JSON.stringify(payload, null, 2));
162
+ } else if (failed) {
163
+ console.error(chalk.red(`\n✗ ${result.reason || auth.reason}\n`));
164
+ if (result.output) console.error(result.output);
165
+ } else {
166
+ const how = result.reused ? chalk.gray(`(${result.reason})`) : chalk.gray('(built)');
167
+ console.log(chalk.green(`\n✓ Stack up — ${ctx.projectName} ${how}`));
168
+ if (auth.reason) console.log(chalk.gray(` auth: ${auth.reason}`));
169
+ console.log('');
170
+ }
171
+
172
+ process.exit(failed ? 1 : 0);
173
+ }
174
+
175
+ // ============================================================================
176
+ // status
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Where the app actually landed.
181
+ *
182
+ * The reviewer hands this to the evaluator instead of letting it guess
183
+ * `localhost:3000` — each feature owns a port block, so the default port is the
184
+ * one address most likely to be wrong. Mirrors what `playwright.config.ts` is
185
+ * asked to do, deliberately: one rule, two readers.
186
+ *
187
+ * @param {Record<string,string>} env
188
+ * @returns {string}
189
+ */
190
+ export function resolveBaseUrl(env = {}) {
191
+ return env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${env.PORT ?? 3000}`;
192
+ }
193
+
194
+ /**
195
+ * `morph-spec e2e status <feature> [--json]`
196
+ *
197
+ * Answers the question that matters before trusting a green run: is what is
198
+ * running still built from the code on disk? Exit 0 when up AND fresh, 1
199
+ * otherwise — so it can gate a script.
200
+ */
201
+ export async function e2eStatusCommand(featureName, options = {}) {
202
+ const ctx = resolveContext(featureName, options);
203
+ if (!ctx) process.exit(EXIT_USAGE);
204
+
205
+ const status = getStackStatus(ctx.cwd, {
206
+ composeFile: ctx.composeFile,
207
+ projectName: ctx.projectName,
208
+ env: ctx.env,
209
+ });
210
+
211
+ const stamp = computeSourceStamp(ctx.cwd);
212
+ const previous = ctx.gitCommonDir ? readStamp(ctx.gitCommonDir, ctx.projectName) : null;
213
+ const fresh = Boolean(stamp && previous?.stamp === stamp);
214
+
215
+ const spec = resolveSpecCommand(ctx.cwd, ctx.config, ctx.config?.project?.frontendPath);
216
+
217
+ const payload = {
218
+ feature: featureName,
219
+ project: ctx.projectName,
220
+ composeFile: ctx.composeFile,
221
+ up: status.up,
222
+ healthy: status.healthy,
223
+ fresh,
224
+ baseUrl: resolveBaseUrl(ctx.env),
225
+ builtAt: previous?.builtAt ?? null,
226
+ specs: spec ? `${spec.command} ${spec.args.join(' ')}` : null,
227
+ services: status.services.map((s) => ({
228
+ name: s.Service ?? s.Name ?? null,
229
+ state: s.State ?? null,
230
+ health: s.Health || null,
231
+ })),
232
+ };
233
+
234
+ if (options.json) {
235
+ console.log(JSON.stringify(payload, null, 2));
236
+ } else {
237
+ console.log('');
238
+ console.log(chalk.bold(`E2E stack — ${ctx.projectName}`));
239
+ console.log(chalk.gray(`compose: ${ctx.composeFile}`));
240
+ console.log(chalk.gray(`url: ${payload.baseUrl}`));
241
+ console.log('');
242
+ console.log(` up ${status.up ? chalk.green('yes') : chalk.gray('no')}`);
243
+ console.log(` healthy ${status.healthy ? chalk.green('yes') : chalk.gray('no')}`);
244
+ console.log(
245
+ ` fresh ${fresh ? chalk.green('yes') : chalk.yellow('no — source changed since build')}`
246
+ );
247
+ console.log(` specs ${spec ? payload.specs : chalk.gray('none (smoke only)')}`);
248
+ for (const s of payload.services) {
249
+ console.log(chalk.gray(` · ${s.name}: ${s.state}${s.health ? ` / ${s.health}` : ''}`));
250
+ }
251
+ console.log('');
252
+ }
253
+
254
+ process.exit(status.up && status.healthy && fresh ? 0 : 1);
255
+ }
256
+
257
+ // ============================================================================
258
+ // down
259
+ // ============================================================================
260
+
261
+ /**
262
+ * `morph-spec e2e down <feature> [--volumes]`
263
+ *
264
+ * Teardown is best-effort by design — a stack that will not come down must
265
+ * never block `finish`.
266
+ */
267
+ export async function e2eDownCommand(featureName, options = {}) {
268
+ const ctx = resolveContext(featureName, options);
269
+ if (!ctx) process.exit(EXIT_USAGE);
270
+
271
+ const result = tearDownStack(ctx.cwd, {
272
+ composeFile: ctx.composeFile,
273
+ projectName: ctx.projectName,
274
+ env: ctx.env,
275
+ volumes: Boolean(options.volumes),
276
+ });
277
+
278
+ if (options.json) {
279
+ console.log(JSON.stringify({ feature: featureName, project: ctx.projectName, status: result.status }, null, 2));
280
+ } else if (result.status === 'pass') {
281
+ console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}\n`));
282
+ } else {
283
+ console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
284
+ }
285
+
286
+ process.exit(0);
287
+ }
288
+
289
+ // ============================================================================
290
+ // Shared helpers
291
+ // ============================================================================
292
+
293
+ function readConfig(cwd) {
294
+ try {
295
+ const path = join(cwd, '.morph', 'config', 'config.json');
296
+ if (!existsSync(path)) return null;
297
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /** Same resolution (and same fail-open) as verify-runner's. */
304
+ function resolveGitCommonDir(cwd) {
305
+ try {
306
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
307
+ cwd,
308
+ stdio: 'pipe',
309
+ encoding: 'utf-8',
310
+ windowsHide: true,
311
+ }).trim();
312
+ return common ? resolve(cwd, common) : null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ export { defaultExec };
@@ -1,16 +1,26 @@
1
1
  /**
2
- * `morph-spec cost [feature] [--by task|phase] [--json]`
2
+ * `morph-spec cost [feature] [--by task|phase] [--all-trees] [--json]`
3
3
  *
4
4
  * A cost-facing view over the same harness event stream `telemetry` reads
5
5
  * (.morph/logs/events.jsonl): how much wall-clock time was spent inside
6
6
  * measured tasks (TaskStart→TaskEnd), how many context bytes were
7
7
  * re-ingested (grouped by kind: rubric/standard/skill/agents/plan-artifact/
8
8
  * code), and how many sub-agent dispatches happened (with total prompt
9
- * chars).
9
+ * chars). When the stream has `SessionUsage` events (real, hook-observed
10
+ * token counts), those are summed and shown too — see the sessionUsage
11
+ * caveat below.
10
12
  *
11
13
  * IMPORTANT: bytes re-ingested is a DETERMINISTIC PROXY for cost, not a
12
14
  * token count. Hooks only see what tools read off disk — never the model's
13
15
  * actual token usage — so this measures re-ingestion pressure, not spend.
16
+ * `sessionUsage` (when present) is the exception: real tokens, observed
17
+ * directly, not a proxy — see aggregateCost's docstring for the contract.
18
+ *
19
+ * `--all-trees` (Furo 3) widens the source from "just the current tree" to
20
+ * every git worktree that has its own events.jsonl — a fleet running N
21
+ * features in N worktrees otherwise only ever sees whichever tree `cost` was
22
+ * run from. Reuses the same worktree enumeration `doctor --worktrees`/`fleet`
23
+ * use (see loadTelemetryAllTrees in telemetry-reader.js).
14
24
  *
15
25
  * Read-only, always exits 0 on a normal (even empty) run — fail-open, an
16
26
  * empty stream is an answer, not an error. Only `--by` with an unknown value
@@ -18,7 +28,7 @@
18
28
  */
19
29
 
20
30
  import chalk from 'chalk';
21
- import { loadTelemetry, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
31
+ import { loadTelemetry, loadTelemetryAllTrees, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
22
32
 
23
33
  const BYTES_CAVEAT =
24
34
  'Bytes re-ingeridos são um PROXY determinístico de custo (o que as ferramentas leram do disco) — NÃO são tokens reais. Hooks não enxergam o uso real de tokens do modelo.';
@@ -77,6 +87,19 @@ function printPhases(phases) {
77
87
  }
78
88
  }
79
89
 
90
+ /**
91
+ * Real (hook-observed) token counts, when the stream has `SessionUsage`
92
+ * events — the non-proxy sibling of the bytes-re-ingested numbers above.
93
+ * No-op when absent (aggregateCost returns `null`, never a zeroed object).
94
+ */
95
+ function printSessionUsage(sessionUsage) {
96
+ if (!sessionUsage) return;
97
+ console.log('\n' + chalk.white.bold('Tokens reais (sessões observadas)')
98
+ + chalk.gray(` · ${sessionUsage.sessions} SessionUsage`));
99
+ console.log(` input=${sessionUsage.inputTokens} output=${sessionUsage.outputTokens} `
100
+ + `cacheRead=${sessionUsage.cacheReadTokens} cacheCreation=${sessionUsage.cacheCreationTokens}`);
101
+ }
102
+
80
103
  function printSummary(summary) {
81
104
  console.log(`\n duração dentro de tasks medidas: ${fmtDuration(summary.taskDurationSeconds)}`);
82
105
  console.log(` dispatches: ${summary.dispatches.count} (${summary.dispatches.promptChars} chars de prompt)`);
@@ -88,11 +111,67 @@ function printSummary(summary) {
88
111
  console.log('\n' + chalk.white.bold('Mix de tools'));
89
112
  for (const t of summary.tools) console.log(` ${t.name.padEnd(16)} ${t.count}`);
90
113
  }
114
+
115
+ printSessionUsage(summary.sessionUsage);
116
+ }
117
+
118
+ /**
119
+ * `--all-trees` rendering: a short line per tree (so a fleet operator can
120
+ * spot which tree is expensive) followed by the COMBINED aggregate — full
121
+ * detail (task/phase breakdown honors `--by` here too, same as the
122
+ * single-tree path; a typeable flag combination is never a silent no-op).
123
+ *
124
+ * @param {string} [feature]
125
+ * @param {{ json?: boolean, by?: 'task'|'phase' }} options
126
+ * @param {string} cwd
127
+ */
128
+ function printAllTrees(feature, options, cwd) {
129
+ const treesData = loadTelemetryAllTrees(cwd);
130
+ const perTree = treesData.map(({ tree, path, events }) => ({
131
+ tree,
132
+ path,
133
+ ...aggregateCost(events, { feature }),
134
+ }));
135
+ const combined = aggregateCost(treesData.flatMap((t) => t.events), { feature });
136
+
137
+ if (options.json) {
138
+ console.log(JSON.stringify({ allTrees: true, trees: perTree, combined }));
139
+ return;
140
+ }
141
+
142
+ if (combined.total === 0) {
143
+ const scope = feature ? ` para a feature "${feature}"` : '';
144
+ console.log(chalk.gray(`Telemetria vazia${scope} em todas as árvores — nenhuma tem eventos capturados ainda.`));
145
+ return;
146
+ }
147
+
148
+ console.log(chalk.white.bold(
149
+ `Custo do harness (proxy determinística) — todas as árvores (${perTree.length} com eventos, ${combined.total} eventos no total)`
150
+ ));
151
+ console.log(chalk.gray(`⚠ ${BYTES_CAVEAT}`));
152
+
153
+ for (const t of perTree) {
154
+ console.log('\n' + chalk.cyan.bold(`── Árvore: ${t.tree}`) + chalk.gray(` — ${t.path}`));
155
+ console.log(` ${t.total} eventos · duração em tasks: ${fmtDuration(t.taskDurationSeconds)} · `
156
+ + `bytes: ${fmtBytes(t.bytesTotal)} · dispatches: ${t.dispatches.count}`);
157
+ }
158
+
159
+ console.log('\n' + chalk.white.bold('── Total agregado (todas as árvores)'));
160
+ if (combined.features.length) {
161
+ console.log(chalk.gray(`Features: ${combined.features.join(', ')}`));
162
+ }
163
+ if (options.by === 'task') {
164
+ printTasks(combined.tasks);
165
+ } else if (options.by === 'phase') {
166
+ printPhases(combined.phases);
167
+ } else {
168
+ printSummary(combined);
169
+ }
91
170
  }
92
171
 
93
172
  /**
94
173
  * @param {string} [feature] - narrow to a single feature
95
- * @param {{ json?: boolean, by?: 'task'|'phase', cwd?: string }} [options]
174
+ * @param {{ json?: boolean, by?: 'task'|'phase', allTrees?: boolean, cwd?: string }} [options]
96
175
  */
97
176
  export function costCommand(feature, options = {}) {
98
177
  if (options.by && !BY_MODES.includes(options.by)) {
@@ -102,7 +181,16 @@ export function costCommand(feature, options = {}) {
102
181
  return;
103
182
  }
104
183
 
105
- const events = loadTelemetry(options.cwd || process.cwd());
184
+ const cwd = options.cwd || process.cwd();
185
+
186
+ if (options.allTrees) {
187
+ // Same fail-open + Windows-flush discipline as the single-tree path
188
+ // below — no forced process.exit(), --json included.
189
+ printAllTrees(feature, options, cwd);
190
+ return;
191
+ }
192
+
193
+ const events = loadTelemetry(cwd);
106
194
  const summary = aggregateCost(events, { feature });
107
195
 
108
196
  if (options.json) {
@@ -24,8 +24,7 @@ import {
24
24
  checkProjectOutdated,
25
25
  saveProjectMorphVersion,
26
26
  getUpdateInstructions,
27
- detectInstallMethod,
28
- compareVersions
27
+ detectInstallMethod
29
28
  } from '../../utils/version-checker.js';
30
29
  import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
31
30
  import { installSkills } from '../../utils/skills-installer.js';
@@ -33,6 +32,7 @@ import { installAgents, installDomainAgents } from '../../utils/agents-installer
33
32
  import { injectMorphImport } from '../../utils/claude-md-injector.js';
34
33
  import { getProjectTags } from '../../lib/stack-filter.js';
35
34
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
35
+ import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
36
36
 
37
37
  /**
38
38
  * Backup user's config.json before cleaning
@@ -89,67 +89,6 @@ async function cleanFrameworkDirs(morphPath, targetPath) {
89
89
  }
90
90
  }
91
91
 
92
- /**
93
- * Last CLI version whose `update`/`setup-infra` still wrote
94
- * `.morph/framework/templates/` unconditionally, before Task 2 of the R3
95
- * perf plan removed that copy. Used as the decision boundary in
96
- * `reconcileLegacyTemplatesCopy` below — see its doc comment for why a
97
- * version check is used instead of diffing file content.
98
- */
99
- const LAST_VERSION_THAT_COPIED_TEMPLATES = '8.17.0';
100
-
101
- /**
102
- * Reconcile a pre-existing `.morph/framework/templates/` copy now that
103
- * `update` no longer writes it (Task 2, R3 perf plan — resolution falls
104
- * back to the npm package's own framework/templates/, see
105
- * template-registry.js / architecture-scanner.js's layered resolver).
106
- *
107
- * Decided from the project's PRIOR recorded CLI version (before THIS
108
- * update writes a new one), not by diffing content against the package.
109
- * A content diff can't tell "stale copy from an older CLI version" apart
110
- * from "deliberate user override" — and staleness is the dominant case for
111
- * anyone who actually runs `update`, since template content does change
112
- * between releases. The version boundary is unambiguous instead:
113
- *
114
- * - No local copy → nothing to do.
115
- * - Prior version is unrecorded, OR <= LAST_VERSION_THAT_COPIED_TEMPLATES
116
- * → the copy could ONLY have been put there by the OLD installer:
117
- * `.morph/framework/**` is Edit-denied to the agent (MORPH_DENY in
118
- * hooks-installer.js — "installer-managed, immutable at runtime"),
119
- * the directory is gitignored, and every `update` before this fix
120
- * unconditionally wiped + recopied it. There is no path by which a
121
- * project on an old version could have a genuine override sitting
122
- * here. Safe to delete unconditionally — it is dead weight, not data.
123
- * - Prior version > LAST_VERSION_THAT_COPIED_TEMPLATES → `update` and
124
- * `setup-infra` have never written this directory for this project.
125
- * Anything found here was placed deliberately (by the user or other
126
- * tooling) — this is the forward-looking override use case the
127
- * layered resolver exists for. Preserve untouched, unconditionally,
128
- * with no diffing.
129
- *
130
- * @param {string} templatesDest - .morph/framework/templates (may not exist)
131
- * @param {string|null} priorProjectVersion - project's recorded morph-spec
132
- * version BEFORE this update run (checkProjectOutdated().current)
133
- * @returns {'absent'|'removed-legacy'|'preserved-override'}
134
- */
135
- export function reconcileLegacyTemplatesCopy(templatesDest, priorProjectVersion) {
136
- if (!existsSync(templatesDest)) return 'absent';
137
-
138
- const isLegacy = !priorProjectVersion
139
- || compareVersions(priorProjectVersion, LAST_VERSION_THAT_COPIED_TEMPLATES) <= 0;
140
-
141
- if (!isLegacy) return 'preserved-override';
142
-
143
- try {
144
- fs.removeSync(templatesDest);
145
- return 'removed-legacy';
146
- } catch {
147
- // Couldn't remove — don't risk a half-broken state; leave it and let
148
- // the layered resolver's override-first check pick it up as before.
149
- return 'preserved-override';
150
- }
151
- }
152
-
153
92
  /**
154
93
  * Restore user's config.json after reinstallation
155
94
  * @param {string} morphPath - Path to .morph directory
@@ -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
  };
@@ -502,6 +515,31 @@ function readFeatureJson(cwd, feature) {
502
515
  }
503
516
  }
504
517
 
518
+ /**
519
+ * Reclaim the feature's verification stack, if the project has one.
520
+ *
521
+ * Runs from the ROOT rather than the worktree: by the time a finish emits
522
+ * success the worktree may already be gone, and the compose file is versioned
523
+ * so the root's copy is equivalent. Entirely fail-open — no compose, no docker,
524
+ * or a stubborn container are all non-events here.
525
+ */
526
+ function tearDownE2eStack(feature, root) {
527
+ try {
528
+ const config = JSON.parse(
529
+ readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '')
530
+ );
531
+ const composeFile = resolveComposeFile(root, config);
532
+ if (!composeFile) return;
533
+
534
+ const projectName = composeProjectName(feature);
535
+ tearDownStack(root, {
536
+ composeFile,
537
+ projectName,
538
+ env: { COMPOSE_PROJECT_NAME: projectName },
539
+ });
540
+ } catch { /* fail-open — teardown must never fail a completed finish */ }
541
+ }
542
+
505
543
  /** Drop a feature from the thin, gitignored root index (never via saveState). */
506
544
  function dropFromRootIndex(root, feature) {
507
545
  const rootPath = join(root, '.morph', 'state.json');
@@ -87,9 +87,12 @@ function evaluatorCompositeScore(cwd, featureName) {
87
87
  * never had (it shipped fully tested with zero callers; see the R4 report).
88
88
  *
89
89
  * `verificationStatus` feeds a real pause rule (verify vermelho pausa —
90
- * política adicionada em risk-detector.js no fecho do R4); `verification`
91
- * completo, verificationStale, diffstat e sha seguem como contexto de
92
- * auditoria persistido, sem regra própria.
90
+ * política adicionada em risk-detector.js no fecho do R4, qualquer gate).
91
+ * `verificationStale` também alimenta uma regra própria, mas só no ship
92
+ * (`gate === 'review'`) — sha do stamp divergindo do HEAD atual. `gate` em si
93
+ * é o que permite risk-detector.js diferenciar as duas. `verification`
94
+ * completo, diffstat e sha seguem como contexto de auditoria persistido, sem
95
+ * regra própria.
93
96
  *
94
97
  * @param {string} featureName
95
98
  * @param {object} feature
@@ -104,6 +107,10 @@ function collectSignals(featureName, feature, cwd, gateName) {
104
107
  const verification = feature.verification || null;
105
108
 
106
109
  const signals = {
110
+ // Furo 2: disk-derived, never in OVERRIDABLE_SIGNAL_KEYS — a caller
111
+ // cannot spoof which gate risk-detector.js thinks it is deciding, same
112
+ // anti-spoof rationale as workType (hotfix's forced pause).
113
+ gate: gateName,
107
114
  workType: feature.workType || 'feature',
108
115
  taskCount: tasks.length,
109
116
  standardsViolations: [],
@@ -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
  }
@@ -149,6 +153,8 @@ export async function verifyCommand(featureName, taskId, options = {}) {
149
153
  timeout: options.timeout,
150
154
  skipTests: options.skipTests,
151
155
  skipBuild: options.skipBuild,
156
+ skipE2e: options.skipE2e,
157
+ freshE2e: options.freshE2e,
152
158
  });
153
159
 
154
160
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
@@ -168,6 +174,7 @@ export async function verifyCommand(featureName, taskId, options = {}) {
168
174
  build: result.nodes.build.status,
169
175
  tests: result.nodes.tests.status,
170
176
  validators: result.nodes.validators.status,
177
+ e2e: result.nodes.e2e.status,
171
178
  },
172
179
  score: result.nodes.validators.score ?? null,
173
180
  attempts: (prevEntry?.attempts ?? 0) + 1,