@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
|
@@ -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
|
|
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)
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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,
|