@polymorphism-tech/morph-spec 8.20.0 → 8.21.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 +2 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +2 -2
- 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/telemetry-logger.js +64 -1
- 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 +6 -15
- 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 +4 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +4 -3
- 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/package.json +1 -1
- package/src/commands/observability/cost.js +93 -5
- package/src/commands/project/update.js +2 -63
- package/src/commands/state/gate-check.js +10 -3
- package/src/core/templates/template-registry.js +2 -30
- 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/scripts/setup-infra.js +23 -5
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,
|
|
@@ -27,11 +27,12 @@ import {
|
|
|
27
27
|
} from '../utils/file-copier.js';
|
|
28
28
|
import { parseStacks, getProjectTags, shouldInstall } from '../lib/stack-filter.js';
|
|
29
29
|
import { injectMorphImport } from '../utils/claude-md-injector.js';
|
|
30
|
-
import { saveProjectMorphVersion, getInstalledCLIVersion } from '../utils/version-checker.js';
|
|
30
|
+
import { saveProjectMorphVersion, getInstalledCLIVersion, getProjectMorphVersion } from '../utils/version-checker.js';
|
|
31
31
|
import { installClaudeHooks, installGlobalStatusline } from '../utils/claude-settings-manager.js';
|
|
32
32
|
import { installSkills } from '../utils/skills-installer.js';
|
|
33
33
|
import { installAgents, installDomainAgents } from '../utils/agents-installer.js';
|
|
34
34
|
import { resolvePrimaryRoot } from '../commands/project/worktree.js';
|
|
35
|
+
import { reconcileLegacyTemplatesCopy } from '../lib/installers/templates-reconcile.js';
|
|
35
36
|
|
|
36
37
|
const FRAMEWORK_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
|
|
37
38
|
|
|
@@ -275,12 +276,29 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
275
276
|
// byte-for-byte redundant 348KB copy on every install/update.
|
|
276
277
|
log('Step 5: Skipping framework templates copy (resolved from package; local override still supported)...');
|
|
277
278
|
// Cópia legada de instalação pré-R3: se existir, ela SOMBREIA os templates
|
|
278
|
-
// do pacote (override-first).
|
|
279
|
-
//
|
|
280
|
-
//
|
|
279
|
+
// do pacote (override-first). `update` sempre reconcilia porque
|
|
280
|
+
// checkProjectOutdated() já leu a versão anterior do projeto antes de
|
|
281
|
+
// chamar update. Aqui (init/setup-infra) fazemos o mesmo QUANDO a mesma
|
|
282
|
+
// fonte está disponível: getProjectMorphVersion() lê .morph/.morphversion
|
|
283
|
+
// — o MESMO arquivo/campo que checkProjectOutdated().current usa — e essa
|
|
284
|
+
// leitura acontece ANTES do Passo 17 (saveProjectMorphVersion) sobrescrever
|
|
285
|
+
// esse arquivo com a versão atual, então "versão anterior" aqui é real, não
|
|
286
|
+
// a que este próprio run está prestes a gravar. Sem esse registro (projeto
|
|
287
|
+
// nunca rastreado antes, ou greenfield), não há fonte segura — mantém o
|
|
288
|
+
// aviso como fallback, igual ao comportamento anterior.
|
|
281
289
|
const legacyTemplatesDir = join(frameworkDestDir, 'templates');
|
|
282
290
|
if (await pathExists(legacyTemplatesDir)) {
|
|
283
|
-
|
|
291
|
+
const priorVersionInfo = await getProjectMorphVersion(targetPath);
|
|
292
|
+
if (priorVersionInfo) {
|
|
293
|
+
const result = reconcileLegacyTemplatesCopy(legacyTemplatesDir, priorVersionInfo.morphVersion);
|
|
294
|
+
if (result === 'removed-legacy') {
|
|
295
|
+
log(' ✓ .morph/framework/templates/ (cópia legada de instalação antiga removida — resolvida do pacote)');
|
|
296
|
+
} else {
|
|
297
|
+
log(' ⚠ .morph/framework/templates/ (override local presente, versão anterior pós-R3 — preservado, não reconciliado)');
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
log(' ⚠ .morph/framework/templates/ existe (instalação antiga) e tem precedência sobre os templates do pacote — sem versão anterior registrada em .morph/.morphversion para decidir com segurança; rode `morph-spec update` para reconciliar, ou apague se não for customização sua.');
|
|
301
|
+
}
|
|
284
302
|
}
|
|
285
303
|
|
|
286
304
|
// --- 6. Copy framework/standards → .morph/framework/standards ---
|