@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
@@ -171,34 +171,6 @@ export function searchTemplates(keyword, projectPath = null) {
171
171
  });
172
172
  }
173
173
 
174
- /**
175
- * Get template absolute path with fallback logic.
176
- *
177
- * Resolution order:
178
- * 1. Project-local override (.morph/framework/templates/{path})
179
- * 2. Framework universal template (framework/templates/{path})
180
- *
181
- * @param {string} templateId - Template identifier
182
- * @param {string} projectPath - Project root path
183
- * @returns {string|null} Absolute template path or null if not found
184
- */
185
- export function resolveTemplatePathById(templateId, projectPath) {
186
- const template = getTemplateById(templateId, projectPath);
187
- if (!template) {
188
- return null;
189
- }
190
-
191
- // 1. Project-local override
192
- const projectLocalPath = join(projectPath, '.morph/framework/templates', template.path);
193
- if (existsSync(projectLocalPath)) {
194
- return projectLocalPath;
195
- }
196
-
197
- // 2. Framework universal template
198
- const frameworkPath = join(projectPath, 'framework/templates', template.path);
199
- return existsSync(frameworkPath) ? frameworkPath : null;
200
- }
201
-
202
174
  /** Absolute path of this repo's own framework/templates/ directory. */
203
175
  const FRAMEWORK_TEMPLATES_DIR = join(__dirname, '../../../framework/templates');
204
176
 
@@ -206,8 +178,8 @@ const FRAMEWORK_TEMPLATES_DIR = join(__dirname, '../../../framework/templates');
206
178
  * Resolve a template id to its absolute path inside THIS framework repo's
207
179
  * `framework/templates/` directory — for generators running against the
208
180
  * framework itself (scaffold-generator, context-generator), not against an
209
- * installed `.morph/`. Use resolveTemplatePathById() when a project root and
210
- * its project-local overrides are in play.
181
+ * installed `.morph/`. Use architecture-scanner.js's `resolveTemplatePath()`
182
+ * when a project root and its project-local overrides are in play.
211
183
  *
212
184
  * @param {string} templateId - Template identifier (REGISTRY.json `id`)
213
185
  * @returns {string} Absolute path to the template file
@@ -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
+ }
@@ -112,26 +112,62 @@ function parseShortstat(text) {
112
112
  };
113
113
  }
114
114
 
115
+ /**
116
+ * Count brand-new UNTRACKED files (never `git add`-ed) — the gap plain
117
+ * `git diff --shortstat` never sees, tracked-edits-only by design. Fail-open:
118
+ * any error (not a repo, git missing) reads as 0 untracked, never throws —
119
+ * this must not take down the whole `getDiffStat` result over a probe that
120
+ * is secondary to the diff itself.
121
+ *
122
+ * `--exclude-standard` matches gitignore the same way `git status` does, so
123
+ * scratch/build output never inflates the count.
124
+ *
125
+ * @param {string} cwd
126
+ * @returns {number}
127
+ */
128
+ function countUntrackedFiles(cwd) {
129
+ try {
130
+ const out = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd, stdio: 'pipe' })
131
+ .toString();
132
+ return out.split('\n').filter((l) => l.trim()).length;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ }
137
+
115
138
  /**
116
139
  * `git diff --shortstat <baseRef>` against the CURRENT WORKING TREE (tracked
117
140
  * edits — staged, unstaged, and everything committed since baseRef —
118
141
  * deliberately not `baseRef...HEAD`, so a gate-check mid-implement still sees
119
- * uncommitted work). Same as plain `git diff`, this does NOT count brand-new
120
- * UNTRACKED files (never `git add`-ed) — a real, known gap if a gate check
121
- * runs before `git add`; not fixed here since covering it needs `--no-index`
122
- * or `git add -N` bookkeeping this shared helper doesn't own. Fail-open: null
123
- * when `baseRef` is falsy, cwd isn't a git repo, or the diff itself fails
124
- * (e.g. baseRef doesn't resolve).
142
+ * uncommitted work), PLUS a count of brand-new UNTRACKED files (never
143
+ * `git add`-ed) that plain `git diff` never shows regardless of commit vs
144
+ * working tree. `filesChanged` is the combined total (tracked-changed +
145
+ * untracked) so a caller reading just that one number gets the true scope of
146
+ * what changed; `untrackedFiles` is broken out separately so a caller that
147
+ * cares CAN tell diff-vs-new apart instead of the two being silently fused.
148
+ *
149
+ * Both git calls run with the same `cwd` — every current caller passes a
150
+ * feature/worktree root there, so `git diff`'s repo-wide default and
151
+ * `git ls-files`'s cwd-relative listing agree; a caller passing a
152
+ * sub-directory as `cwd` would see `untrackedFiles` scoped to that
153
+ * sub-directory while `insertions`/`deletions` stay repo-wide — not a
154
+ * concern today, but worth knowing if that assumption ever changes.
155
+ *
156
+ * Fail-open: null when `baseRef` is falsy, cwd isn't a git repo, or the diff
157
+ * itself fails (e.g. baseRef doesn't resolve). The untracked probe fails open
158
+ * on its own (see countUntrackedFiles) and never turns a good diff into null.
125
159
  *
126
160
  * @param {string|null} baseRef - typically `detectDefaultBranch(cwd)`
127
161
  * @param {string} [cwd]
128
- * @returns {{ filesChanged: number, insertions: number, deletions: number }|null}
162
+ * @returns {{ filesChanged: number, insertions: number, deletions: number, untrackedFiles: number }|null}
129
163
  */
130
164
  export function getDiffStat(baseRef, cwd = process.cwd()) {
131
165
  if (!baseRef) return null;
132
166
  try {
133
167
  const out = execFileSync('git', ['diff', '--shortstat', baseRef], { cwd, stdio: 'pipe' }).toString().trim();
134
- return parseShortstat(out);
168
+ const stat = parseShortstat(out);
169
+ const untrackedFiles = countUntrackedFiles(cwd);
170
+ return { ...stat, filesChanged: stat.filesChanged + untrackedFiles, untrackedFiles };
135
171
  } catch {
136
172
  return null;
137
173
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * templates-reconcile.js — reconcile a pre-existing `.morph/framework/templates/`
3
+ * copy against the project's prior recorded morph-spec version.
4
+ *
5
+ * Shared by BOTH install paths that can encounter this directory:
6
+ * - `morph-spec update` (src/commands/project/update.js) — always knows the
7
+ * project's prior version (`checkProjectOutdated().current`, read from
8
+ * `.morph/.morphversion` BEFORE this run overwrites it).
9
+ * - `morph-spec init`/`setup-infra` (src/scripts/setup-infra.js) — the SAME
10
+ * source (`getProjectMorphVersion()`, the function `checkProjectOutdated`
11
+ * itself calls), read at the same point in the run (before its own write
12
+ * of `.morph/.morphversion`), when it's available — e.g. a repair run
13
+ * over an already-initialized project. Falls back to a plain warning when
14
+ * it isn't (a project with no recorded version has nothing here for this
15
+ * function to reconcile against safely).
16
+ */
17
+
18
+ import { existsSync } from 'fs';
19
+ import fs from 'fs-extra';
20
+ import { compareVersions } from '../../utils/version-checker.js';
21
+
22
+ /**
23
+ * Last CLI version whose `update`/`setup-infra` still wrote
24
+ * `.morph/framework/templates/` unconditionally, before Task 2 of the R3
25
+ * perf plan removed that copy. Used as the decision boundary in
26
+ * `reconcileLegacyTemplatesCopy` below — see its doc comment for why a
27
+ * version check is used instead of diffing file content.
28
+ */
29
+ export const LAST_VERSION_THAT_COPIED_TEMPLATES = '8.17.0';
30
+
31
+ /**
32
+ * Reconcile a pre-existing `.morph/framework/templates/` copy now that
33
+ * neither install path unconditionally writes it anymore (Task 2, R3 perf
34
+ * plan — resolution falls back to the npm package's own framework/templates/,
35
+ * see template-registry.js / architecture-scanner.js's layered resolver).
36
+ *
37
+ * Decided from the project's PRIOR recorded CLI version (before THIS run
38
+ * writes a new one), not by diffing content against the package. A content
39
+ * diff can't tell "stale copy from an older CLI version" apart from
40
+ * "deliberate user override" — and staleness is the dominant case for
41
+ * anyone who actually runs `update`/`setup-infra` again, since template
42
+ * content does change between releases. The version boundary is unambiguous
43
+ * instead:
44
+ *
45
+ * - No local copy → nothing to do.
46
+ * - Prior version is unrecorded, OR <= LAST_VERSION_THAT_COPIED_TEMPLATES
47
+ * → the copy could ONLY have been put there by the OLD installer:
48
+ * `.morph/framework/**` is Edit-denied to the agent (MORPH_DENY in
49
+ * hooks-installer.js — "installer-managed, immutable at runtime"),
50
+ * the directory is gitignored, and every install/update before this fix
51
+ * unconditionally wiped + recopied it. There is no path by which a
52
+ * project on an old version could have a genuine override sitting
53
+ * here. Safe to delete unconditionally — it is dead weight, not data.
54
+ * - Prior version > LAST_VERSION_THAT_COPIED_TEMPLATES → this install path
55
+ * has never written this directory for this project. Anything found
56
+ * here was placed deliberately (by the user or other tooling) — this is
57
+ * the forward-looking override use case the layered resolver exists
58
+ * for. Preserve untouched, unconditionally, with no diffing.
59
+ *
60
+ * @param {string} templatesDest - .morph/framework/templates (may not exist)
61
+ * @param {string|null} priorProjectVersion - project's recorded morph-spec
62
+ * version BEFORE this run (e.g. `checkProjectOutdated().current`)
63
+ * @returns {'absent'|'removed-legacy'|'preserved-override'}
64
+ */
65
+ export function reconcileLegacyTemplatesCopy(templatesDest, priorProjectVersion) {
66
+ if (!existsSync(templatesDest)) return 'absent';
67
+
68
+ const isLegacy = !priorProjectVersion
69
+ || compareVersions(priorProjectVersion, LAST_VERSION_THAT_COPIED_TEMPLATES) <= 0;
70
+
71
+ if (!isLegacy) return 'preserved-override';
72
+
73
+ try {
74
+ fs.removeSync(templatesDest);
75
+ return 'removed-legacy';
76
+ } catch {
77
+ // Couldn't remove — don't risk a half-broken state; leave it and let
78
+ // the layered resolver's override-first check pick it up as before.
79
+ return 'preserved-override';
80
+ }
81
+ }
@@ -8,7 +8,9 @@
8
8
  * Signals checked (in order):
9
9
  * - designSystemDivergence: boolean — conflicting design sources detected
10
10
  * - standardsViolations: string[] — violations of mandate.md proibições
11
- * - verificationStatus: string — último `morph-spec verify` ('fail' pausa)
11
+ * - verificationStatus: string — último `morph-spec verify` ('fail' pausa, qualquer gate)
12
+ * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
13
+ * ship (gate 'review') — código mudou depois do último verify
12
14
  * - evalScore: number — morph-eval score (pause if < 9)
13
15
  * - taskCount: number — total tasks (pause if > 20)
14
16
  */
@@ -64,6 +66,20 @@ export function shouldPauseGate(config, signals) {
64
66
  };
65
67
  }
66
68
 
69
+ // Furo 2: no gate de SHIP (review) apenas, um stamp de verification cujo
70
+ // sha diverge do HEAD atual significa que código mudou depois do último
71
+ // `morph-spec verify` — o resultado carimbado não cobre mais o que está
72
+ // prestes a ser aprovado. Pausa só aqui, nunca em plan/uiux/proposal: essa
73
+ // é uma regra de ship, não uma regra geral de gate. Stamp AUSENTE não entra
74
+ // aqui (verificationStale é fail-open para `false` em collectSignals
75
+ // quando não há stamp) — features legadas sem stamp continuam liberadas.
76
+ if (signals.gate === 'review' && signals.verificationStale === true) {
77
+ return {
78
+ pause: true,
79
+ reason: 'verification stamp desatualizado (sha difere do HEAD) — rode `morph-spec verify` de novo antes do ship',
80
+ };
81
+ }
82
+
67
83
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
68
84
  return {
69
85
  pause: true,
@@ -93,6 +109,9 @@ export function getRiskSignals(signals) {
93
109
  out.push(`standards violations: ${signals.standardsViolations.join(', ')}`);
94
110
  }
95
111
  if (signals.verificationStatus === 'fail') out.push('verify vermelho');
112
+ if (signals.gate === 'review' && signals.verificationStale === true) {
113
+ out.push('verification stamp desatualizado (stale) no ship');
114
+ }
96
115
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
97
116
  out.push(`eval score ${signals.evalScore}/10`);
98
117
  }
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { readFileSync, existsSync } from 'fs';
13
- import { join } from 'path';
13
+ import { join, basename } from 'path';
14
+ import { parseWorktreeList, resolvePrimaryRoot } from '../../commands/project/worktree.js';
14
15
 
15
16
  const EVENTS_FILE = join('.morph', 'logs', 'events.jsonl');
16
17
 
@@ -37,6 +38,45 @@ export function loadTelemetry(projectPath) {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * Enumerate every git worktree that has its OWN `.morph/logs/events.jsonl` —
43
+ * the data source for `cost --all-trees` (Furo 3). Reuses the SAME worktree
44
+ * enumeration `doctor --worktrees`/`fleet` already use (`parseWorktreeList` +
45
+ * `resolvePrimaryRoot` from commands/project/worktree.js — see
46
+ * src/lib/worktree/fleet-scanner.js and worktree-audit.js for the sibling
47
+ * consumers) instead of re-walking `git worktree list` here.
48
+ *
49
+ * `git worktree list --porcelain` always lists the PRIMARY worktree first —
50
+ * that entry is labeled `'root'`; every other entry is labeled by its
51
+ * directory basename (typically the feature slug, e.g. `worktrees/{f}` →
52
+ * `f`). Only trees whose events.jsonl actually exists are returned — a
53
+ * worktree that never ran the harness contributes nothing, silently.
54
+ *
55
+ * Fail-open, like `loadTelemetry`: outside a git repo (or on any git error)
56
+ * `resolvePrimaryRoot`/`parseWorktreeList` degrade to a single root entry —
57
+ * never throws.
58
+ *
59
+ * @param {string} [cwd]
60
+ * @returns {Array<{ tree: string, path: string, events: Object[] }>}
61
+ */
62
+ export function loadTelemetryAllTrees(cwd = process.cwd()) {
63
+ const root = resolvePrimaryRoot(cwd);
64
+ const entries = parseWorktreeList(root);
65
+ const trees = entries.length ? entries : [{ path: root }];
66
+
67
+ const out = [];
68
+ for (const t of trees) {
69
+ const eventsPath = join(t.path, EVENTS_FILE);
70
+ if (!existsSync(eventsPath)) continue;
71
+ out.push({
72
+ tree: t.path === root ? 'root' : basename(t.path),
73
+ path: t.path,
74
+ events: loadTelemetry(t.path),
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
40
80
  function rank(map) {
41
81
  return [...map.entries()]
42
82
  .map(([name, count]) => ({ name, count }))
@@ -211,6 +251,26 @@ function finalizeTask(state) {
211
251
  * check) — today only Read events carry `bytes`, but this stays correct if
212
252
  * that widens rather than reporting a silent 0.
213
253
  *
254
+ * Dispatch prompt size (Furo 3 — contract with the hooks-side telemetry-log
255
+ * rewrite): a dispatch event's `prompt` field is now TRUNCATED to 2k chars on
256
+ * write, with the original length carried separately as `promptChars`. When
257
+ * `promptChars` (a number) is present it is preferred; a legacy stream that
258
+ * only ever had the untruncated `prompt` string falls back to
259
+ * `prompt.length`, unchanged from before — every existing byte count stays
260
+ * exact against old logs.
261
+ *
262
+ * SessionUsage events (`{ event: 'SessionUsage', inputTokens, outputTokens,
263
+ * cacheReadTokens, cacheCreationTokens }`) are a session-level rollup, not a
264
+ * per-tool event — they don't touch toolCounts/bytesByKind/dispatches, only
265
+ * sum into `sessionUsage` (real, hook-observed token counts — NOT the bytes
266
+ * proxy). They participate in the `--feature` filter exactly like every
267
+ * other event (via `scoped`, computed once at the top): a rollup with no
268
+ * `feature` field is silently excluded by a feature-scoped call, same as any
269
+ * other feature-less event would be — no special-casing. `sessionUsage` is
270
+ * `null` when the stream carries none (nothing to show), never a zeroed
271
+ * object, so a caller can tell "observed, all zero" apart from "never
272
+ * observed" the same way `evalScore` does in gate-check's signals.
273
+ *
214
274
  * @param {Object[]} events - raw events (from loadTelemetry)
215
275
  * @param {{ feature?: string }} [opts] - optional feature filter
216
276
  * @returns {{
@@ -220,11 +280,14 @@ function finalizeTask(state) {
220
280
  * bytesByKind: Object<string, number>,
221
281
  * bytesTotal: number,
222
282
  * dispatches: {count:number, promptChars:number},
283
+ * sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number}|null,
223
284
  * tools: Array<{name:string,count:number}>,
224
285
  * tasks: Array<Object>,
225
286
  * phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
226
287
  * }}
227
288
  */
289
+ const SESSION_USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
290
+
228
291
  export function aggregateCost(events, opts = {}) {
229
292
  const scoped = opts.feature
230
293
  ? (events || []).filter(e => e.feature === opts.feature)
@@ -237,6 +300,8 @@ export function aggregateCost(events, opts = {}) {
237
300
  const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
238
301
  const activeByFeature = new Map(); // featureKey -> open task state
239
302
  const tasks = [];
303
+ const sessionUsageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
304
+ let sessionUsageCount = 0;
240
305
 
241
306
  for (const e of scoped) {
242
307
  if (!e || typeof e !== 'object') continue;
@@ -260,6 +325,14 @@ export function aggregateCost(events, opts = {}) {
260
325
  continue;
261
326
  }
262
327
 
328
+ if (e.event === 'SessionUsage') {
329
+ sessionUsageCount += 1;
330
+ for (const key of SESSION_USAGE_KEYS) {
331
+ if (typeof e[key] === 'number' && Number.isFinite(e[key])) sessionUsageTotals[key] += e[key];
332
+ }
333
+ continue;
334
+ }
335
+
263
336
  if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) || 0) + 1);
264
337
  const active = activeByFeature.get(featureKey(e));
265
338
  if (active && e.tool) {
@@ -277,12 +350,14 @@ export function aggregateCost(events, opts = {}) {
277
350
  }
278
351
  }
279
352
 
280
- if ((e.tool === 'Agent' || e.tool === 'Task') && typeof e.prompt === 'string' && e.prompt) {
353
+ const hasPromptChars = typeof e.promptChars === 'number' && Number.isFinite(e.promptChars);
354
+ if ((e.tool === 'Agent' || e.tool === 'Task') && (hasPromptChars || (typeof e.prompt === 'string' && e.prompt))) {
355
+ const chars = hasPromptChars ? e.promptChars : e.prompt.length;
281
356
  dispatches.count += 1;
282
- dispatches.promptChars += e.prompt.length;
357
+ dispatches.promptChars += chars;
283
358
  if (active) {
284
359
  active.dispatches.count += 1;
285
- active.dispatches.promptChars += e.prompt.length;
360
+ active.dispatches.promptChars += chars;
286
361
  }
287
362
  }
288
363
  }
@@ -309,6 +384,7 @@ export function aggregateCost(events, opts = {}) {
309
384
  bytesByKind: mapToObj(bytesByKindGlobal),
310
385
  bytesTotal: sumValues(bytesByKindGlobal),
311
386
  dispatches,
387
+ sessionUsage: sessionUsageCount > 0 ? { ...sessionUsageTotals, sessions: sessionUsageCount } : null,
312
388
  tools: rank(toolCounts),
313
389
  tasks,
314
390
  phases,
@@ -1,24 +1,33 @@
1
1
  /**
2
- * Verify Runner — deterministic build+tests+validators cadence.
2
+ * Verify Runner — deterministic build+tests+validators+e2e cadence.
3
3
  *
4
4
  * v8 adds a DETERMINISTIC verification node that runs BEFORE the LLM judge
5
5
  * in the per-task loop: build + tests + validators (code, free) first; the
6
6
  * LLM judge is only invoked once verify passes. This is a BINARY gate
7
7
  * (pass/fail per node), NOT a second "bar 9" — the bar 9 remains morph-eval's.
8
8
  *
9
+ * The `e2e` node closes the last gap in that chain: build proves the code
10
+ * COMPILES, tests prove units behave, validators read structure — none of
11
+ * them prove the application RUNS. See e2e-check.js for why that distinction
12
+ * is load-bearing rather than academic.
13
+ *
9
14
  * Pure orchestration — reuses (never reimplements):
10
15
  * - runBuildCheck() from framework/hooks/shared/build-check.js
11
16
  * - runTestSuite() from src/lib/tasks/test-runner.js
12
17
  * - runValidation() from ./validation-runner.js
18
+ * - runE2eCheck() from framework/hooks/shared/e2e-check.js
13
19
  * - loadRubric() + aggregateScores() from ./score-aggregator.js
14
20
  *
15
21
  * @module verify-runner
16
22
  */
17
23
 
24
+ import { execFileSync } from 'child_process';
18
25
  import { existsSync, readFileSync } from 'fs';
19
- import { join } from 'path';
26
+ import { join, resolve } from 'path';
20
27
 
21
28
  import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
29
+ import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
30
+ import { buildComposeEnv } from '../e2e/compose-env.js';
22
31
  import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
23
32
  import { runValidation } from './validation-runner.js';
24
33
  import { loadRubric, aggregateScores } from './score-aggregator.js';
@@ -41,9 +50,12 @@ const OUTPUT_TAIL_LINES = 20;
41
50
  * @param {number} [options.timeout] - Build timeout in ms (default 120000)
42
51
  * @param {boolean} [options.skipTests] - Skip the tests node
43
52
  * @param {boolean} [options.skipBuild] - Skip the build node
53
+ * @param {boolean} [options.skipE2e] - Skip the e2e node
54
+ * @param {boolean} [options.freshE2e] - Force a stack rebuild instead of reusing a warm one
44
55
  * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
45
56
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
46
57
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
58
+ * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
47
59
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
48
60
  */
49
61
  export async function runVerify(projectPath, featureName, taskId, options = {}) {
@@ -52,9 +64,12 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
52
64
  timeout = DEFAULT_BUILD_TIMEOUT,
53
65
  skipTests = false,
54
66
  skipBuild = false,
67
+ skipE2e = false,
68
+ freshE2e = false,
55
69
  _runBuildCheck = runBuildCheck,
56
70
  _runTestSuite = runTestSuite,
57
71
  _runValidation = runValidation,
72
+ _runE2eCheck = runE2eCheck,
58
73
  } = options;
59
74
 
60
75
  const stack = readStack(projectPath);
@@ -76,6 +91,20 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
76
91
  nodes.validators = await runValidatorsNode({ projectPath, featureName, rubricId, _runValidation });
77
92
  appendValidatorsFeedback(feedback, nodes.validators);
78
93
 
94
+ // 4. e2e — the only node that runs the application. Like tests, it is not
95
+ // invoked at all on a red build: a stack that cannot compile cannot come up,
96
+ // and the docker output would bury the actual compile error.
97
+ nodes.e2e = await runE2eNode({
98
+ projectPath,
99
+ featureName,
100
+ taskId,
101
+ skipE2e,
102
+ freshE2e,
103
+ buildNode: nodes.build,
104
+ _runE2eCheck,
105
+ });
106
+ appendE2eFeedback(feedback, nodes.e2e);
107
+
79
108
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
80
109
 
81
110
  return {
@@ -239,6 +268,63 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
239
268
  }
240
269
  }
241
270
 
271
+ // ============================================================================
272
+ // E2E node
273
+ // ============================================================================
274
+
275
+ async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
276
+ if (buildNode.status === 'fail') {
277
+ return {
278
+ status: 'skip',
279
+ command: null,
280
+ durationMs: 0,
281
+ reason: 'build failed — the stack could not come up anyway',
282
+ output: '',
283
+ };
284
+ }
285
+
286
+ const config = readConfig(projectPath);
287
+ const taskGroup = taskId ? readTaskGroup(projectPath, featureName, taskId) : null;
288
+ const gitCommonDir = resolveGitCommonDir(projectPath);
289
+
290
+ const start = Date.now();
291
+ const result = _runE2eCheck(projectPath, {
292
+ config,
293
+ featureName,
294
+ taskGroup,
295
+ scope: taskId ? 'task' : 'feature',
296
+ gitCommonDir,
297
+ // The port block matters MORE here than in `e2e up`: this is the path that
298
+ // runs in the loop, and two worktrees verifying at once would otherwise get
299
+ // distinct container names and then collide on host port 3000 — a red gate
300
+ // over an infrastructure clash instead of a defect.
301
+ env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
302
+ skipRequested: skipE2e,
303
+ forceFresh: freshE2e,
304
+ // The stack stays up between verifies on purpose: cold-starting a .NET
305
+ // container per task is the difference between a usable loop and one
306
+ // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
307
+ keepUp: true,
308
+ });
309
+ const durationMs = Date.now() - start;
310
+
311
+ return {
312
+ status: result.status,
313
+ command: result.command ?? null,
314
+ durationMs,
315
+ reason: result.reason ?? null,
316
+ output: result.output || '',
317
+ };
318
+ }
319
+
320
+ function appendE2eFeedback(feedback, e2eNode) {
321
+ if (e2eNode.status !== 'fail') return;
322
+ if (e2eNode.reason) feedback.push(`[e2e] ${e2eNode.reason}`);
323
+ for (const line of linesOf(e2eNode.output)) {
324
+ feedback.push(`[e2e] ${line}`);
325
+ }
326
+ }
327
+
242
328
  // ============================================================================
243
329
  // Rubric selection — flag > task group > config > default
244
330
  // ============================================================================
@@ -328,6 +414,39 @@ function readProjectPaths(projectPath) {
328
414
  }
329
415
  }
330
416
 
417
+ /** Read the whole config.json — the e2e node needs `e2e` and `project` together. */
418
+ function readConfig(projectPath) {
419
+ try {
420
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
421
+ if (!existsSync(configPath)) return null;
422
+ return JSON.parse(readFileSync(configPath, 'utf8').replace(/^/, ''));
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Resolve the shared git dir, where the e2e freshness stamp lives — the same
430
+ * location and rationale as the fleet env-registry (see env-allocator.js): it
431
+ * is the one place every linked worktree of a repo shares, and writing there
432
+ * can never dirty the `git status` the worktree-finish gates read.
433
+ *
434
+ * Fail-open: no git, no stamp — the cost is a rebuild, never a crash.
435
+ */
436
+ function resolveGitCommonDir(projectPath) {
437
+ try {
438
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
439
+ cwd: projectPath,
440
+ stdio: 'pipe',
441
+ encoding: 'utf-8',
442
+ windowsHide: true,
443
+ }).trim();
444
+ return common ? resolve(projectPath, common) : null;
445
+ } catch {
446
+ return null;
447
+ }
448
+ }
449
+
331
450
  function linesOf(text) {
332
451
  const trimmed = (text || '').trim();
333
452
  return trimmed ? trimmed.split('\n') : [];