@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.
Files changed (37) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +2 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +1 -1
  7. package/framework/docs/CLI.md +2 -2
  8. package/framework/hooks/claude-code/core/enforce-phase-writes.js +23 -2
  9. package/framework/hooks/claude-code/core/state-sync.js +5 -0
  10. package/framework/hooks/claude-code/core/telemetry-log.js +34 -3
  11. package/framework/hooks/claude-code/core/validate-completion.js +116 -4
  12. package/framework/hooks/claude-code/core/worktree-context.js +9 -4
  13. package/framework/hooks/shared/telemetry-logger.js +64 -1
  14. package/framework/schemas/tasks.schema.json +1 -1
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +6 -15
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  21. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  22. package/framework/skills/core/morph-plan/SKILL.md +4 -4
  23. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  24. package/framework/skills/core/morph-review/SKILL.md +4 -3
  25. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  26. package/framework/skills/core/morph-standards/SKILL.md +29 -15
  27. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  28. package/package.json +1 -1
  29. package/src/commands/observability/cost.js +93 -5
  30. package/src/commands/project/update.js +2 -63
  31. package/src/commands/state/gate-check.js +10 -3
  32. package/src/core/templates/template-registry.js +2 -30
  33. package/src/lib/git/default-branch.js +44 -8
  34. package/src/lib/installers/templates-reconcile.js +81 -0
  35. package/src/lib/risk-detector.js +20 -1
  36. package/src/lib/telemetry/telemetry-reader.js +80 -4
  37. package/src/scripts/setup-infra.js +23 -5
@@ -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,
@@ -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). O `morph-spec update` reconcilia (remove cópia
279
- // stale por fronteira de versão); aqui só avisamos — init não conhece a
280
- // versão anterior do projeto para decidir com segurança.
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
- log(' ⚠ .morph/framework/templates/ existe (instalação antiga) e tem precedência sobre os templates do pacote — rode `morph-spec update` para reconciliar, ou apague se não for customização sua.');
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 ---