@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +31 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +6 -2
- package/framework/agents.json +3 -2
- package/framework/docs/CLI.md +4 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +23 -2
- package/framework/hooks/claude-code/core/state-sync.js +5 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +34 -3
- package/framework/hooks/claude-code/core/validate-completion.js +116 -4
- package/framework/hooks/claude-code/core/worktree-context.js +9 -4
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/telemetry-logger.js +64 -1
- package/framework/schemas/feature.schema.json +5 -3
- package/framework/schemas/tasks.schema.json +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +7 -16
- package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +25 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +35 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +29 -15
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +318 -0
- package/src/commands/observability/cost.js +93 -5
- package/src/commands/project/update.js +2 -63
- package/src/commands/state/finish.js +38 -0
- package/src/commands/state/gate-check.js +10 -3
- package/src/commands/validation/validate-feature.js +7 -1
- package/src/commands/validation/verify.js +7 -0
- package/src/core/templates/template-registry.js +2 -30
- package/src/lib/e2e/compose-env.js +74 -0
- package/src/lib/git/default-branch.js +44 -8
- package/src/lib/installers/templates-reconcile.js +81 -0
- package/src/lib/risk-detector.js +20 -1
- package/src/lib/telemetry/telemetry-reader.js +80 -4
- package/src/lib/validators/verify-runner.js +121 -2
- 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
|
|
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)
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -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
|
-
|
|
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 +=
|
|
357
|
+
dispatches.promptChars += chars;
|
|
283
358
|
if (active) {
|
|
284
359
|
active.dispatches.count += 1;
|
|
285
|
-
active.dispatches.promptChars +=
|
|
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') : [];
|