@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
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 8.20.0
6
+ **Version:** 8.21.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
368
368
 
369
369
  ---
370
370
 
371
- *morph-spec v8.20.0 by [Polymorphism Tech](https://polymorphism.tech)*
371
+ *morph-spec v8.21.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -428,7 +428,8 @@ program
428
428
  program
429
429
  .command('cost [feature]')
430
430
  .description('Cost-facing view over .morph/logs/events.jsonl: task duration, bytes re-ingested by kind (proxy, not tokens), dispatch counts')
431
- .option('--by <mode>', 'Group by: task|phase (default: feature-wide summary)')
431
+ .option('--by <mode>', 'Group by: task|phase (default: feature-wide summary; with --all-trees, applies to the combined aggregate)')
432
+ .option('--all-trees', 'Aggregate .morph/logs/events.jsonl across every git worktree that has one, not just the current tree — per-tree breakdown + combined total')
432
433
  .option('--json', 'Output the aggregate as JSON')
433
434
  .action((feature, options) => costCommand(feature, options));
434
435
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.20.0",
3
+ "version": "8.21.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v8.20.0 by Polymorphism Tech*
188
+ *morph-spec v8.21.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.20.0 by Polymorphism Tech*
252
+ *morph-spec v8.21.0 by Polymorphism Tech*
@@ -102,7 +102,7 @@
102
102
  "design", "ui", "ux", "design system", "tokens", "wcag",
103
103
  "accessibility", "mockup", "flow", "shadcn", "tailwind"
104
104
  ],
105
- "stack": ["nextjs"],
105
+ "stack": ["*"],
106
106
  "standards": [
107
107
  "frontend-design-system-naming",
108
108
  "frontend-design-system-animations",
@@ -9,7 +9,7 @@
9
9
  - **`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`proposal`/`uiux`/`plan`/`review`) em `trust: auto`. **`uiux`** é o único gate cujo `AskUserQuestion` o hook `state-sync.js` não reconhece por texto (só casa "Gate 1/2/3") — aprove-o sempre explicitamente por este comando ou por `advance`, nunca contando com o hook. **O Gate 3 exige `4-review/evaluator-report.md`**: sem o relatório do avaliador independente o comando recusa (exit 1). Se o dispatch do avaliador for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate). **Exceção `hotfix`:** esse tipo nunca despacha o avaliador (`morph-hotfix` §7); o Gate 3 exige `4-review/review-report.md` no lugar do `evaluator-report.md`, e a assinatura humana continua obrigatória via `--approver` (o backstop de `--mode auto` acima permanece recusando).
10
10
  - **`morph-spec advance <feature>`** — aprova o gate da fase corrente **e** cria a pasta da próxima fase num passo só (`--design` toma o ramo opcional de uiux a partir da proposta).
11
11
  - **`morph-spec score <feature> <task> <score> [--dims a,c,q,t]`** — grava `taskScores` no feature.json com timestamp server-side; **nunca edite taskScores à mão**. Em PowerShell, cite o valor de `--dims` entre aspas (`--dims "9.5,9,9,9"`) — sem aspas, a lista pode chegar como tokens separados; o parser aceita ambas as formas, mas citar evita qualquer ambiguidade.
12
- - **`morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>] [--json]`** — o caminho **default**: coleta do disco os sinais que `shouldPauseGate` (risk-detector.js) já sabe avaliar (`workType`, `taskCount`, verificação, sha/diffstat), decide a pausa e persiste `gateDecisions.{gate}`. `--signals` aceita só as chaves que o disco genuinamente não expõe sozinho — `designSystemDivergence`, `standardsViolations`, `evalScore` — qualquer outra chave é ignorada com aviso. No **review**, o `evalScore` real vem do token `**Composite-Score:** {n.n}` do `evaluator-report.md` (o avaliador o grava por contrato); sem o token, cai no proxy (média das `taskScores`); `--signals` vence ambos. Um stamp de `verification` com status `fail` (último `morph-spec verify` vermelho) **força pausa** em qualquer gate.
12
+ - **`morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>] [--json]`** — o caminho **default**: coleta do disco os sinais que `shouldPauseGate` (risk-detector.js) já sabe avaliar (`workType`, `taskCount`, verificação, sha/diffstat), decide a pausa e persiste `gateDecisions.{gate}`. `--signals` aceita só as chaves que o disco genuinamente não expõe sozinho — `designSystemDivergence`, `standardsViolations`, `evalScore` — qualquer outra chave é ignorada com aviso. No **review**, o `evalScore` real vem do token `**Composite-Score:** {n.n}` do `evaluator-report.md` (o avaliador o grava por contrato); sem o token, cai no proxy (média das `taskScores`); `--signals` vence ambos. Um stamp de `verification` com status `fail` (último `morph-spec verify` vermelho) **força pausa** em qualquer gate. Um stamp `pass` mas **stale** (sha diferente do HEAD atual — código mudou depois do último verify) força pausa **só no ship** (`review`); em `plan`/`uiux`/`proposal` a staleness é computada e reportada em `signals`, mas não pausa nada — é regra de ship, não de gate genérico. Stamp **ausente** nunca pausa por isso (features legadas sem stamp).
13
13
  - **`morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>] [--signals <json>]`** — o **escape manual**: registra uma decisão já tomada pela sessão (ex.: `discoveryMode: "interview"`, sinal que `shouldPauseGate` nem representa). Mesmo write path do `gate-check` — grava `gateDecisions` no feature.json com timestamp server-side (shape em `framework/schemas/feature.schema.json`); **nunca edite gateDecisions à mão** — o Edit manual colide com o hook state-sync.
14
14
 
15
15
  ## Features
@@ -38,4 +38,4 @@
38
38
  ## Observabilidade
39
39
 
40
40
  - **`morph-spec telemetry [--json] [--feature <f>] [--limit <n>]`** — reconstrói o caminho de execução da sessão a partir de `.morph/logs/events.jsonl` (skills invocadas, personas despachadas + prompts, standards/base de conhecimento lidos, mix de tools, prompts do usuário). Capturado silenciosamente pelo hook `telemetry-log` em todo tool call e todo UserPromptSubmit.
41
- - **`morph-spec cost [feature] [--by task|phase] [--json]`** — custo da execução a partir do mesmo `events.jsonl`: duração por task (TaskStart→TaskEnd), bytes de contexto re-ingerido por tipo (`rubric`/`standard`/`skill`/`agents`/`plan-artifact`/`code`), dispatches + tamanho de prompt e mix de tools. Bytes re-ingeridos são **proxy determinística** de custo de token (tokens reais não são observáveis por hook). Use antes/depois de mudar skill ou fluxo para medir o efeito.
41
+ - **`morph-spec cost [feature] [--by task|phase] [--all-trees] [--json]`** — custo da execução a partir do mesmo `events.jsonl`: duração por task (TaskStart→TaskEnd), bytes de contexto re-ingerido por tipo (`rubric`/`standard`/`skill`/`agents`/`plan-artifact`/`code`), dispatches + tamanho de prompt e mix de tools. Bytes re-ingeridos são **proxy determinística** de custo de token (tokens reais não eram observáveis por hook até o evento `SessionUsage` — quando presente no stream, `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheCreationTokens` são somados e exibidos à parte como "tokens reais", nunca misturados com a proxy de bytes). `--all-trees` agrega `.morph/logs/events.jsonl` de **toda árvore git** que tiver um (não só a corrente) — reusa a mesma enumeração de worktree do `doctor --worktrees`/`fleet`; mostra um resumo por árvore + o total combinado (`--by` aplica ao agregado combinado). Use antes/depois de mudar skill ou fluxo para medir o efeito.
@@ -102,8 +102,29 @@ export async function run(payload) {
102
102
  // advances once that file exists) — the deadlock this hook used to create.
103
103
  const entryGate = PHASE_DIR_ENTRY_GATE[targetDir];
104
104
  if (entryGate && isGateApproved(featureName, entryGate, root)) {
105
- logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
106
- return null;
105
+ // uiux is OPTIONAL — proposal approval alone is enough to enter
106
+ // 2-plan/ for a feature that never took the design branch (no
107
+ // 1-design/ dir), exactly as before this check. But once a feature
108
+ // HAS opted into uiux (1-design/ exists), that gate is not
109
+ // decorative: 2-plan/ must wait for it too, or a feature can skip
110
+ // straight from an approved proposal into planning with its design
111
+ // gate silently left pending.
112
+ const tookDesignBranch = targetDir === '2-plan'
113
+ && existsSync(resolveTargetPath(`.morph/features/${featureName}/1-design`, root));
114
+
115
+ if (!tookDesignBranch || isGateApproved(featureName, 'uiux', root)) {
116
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'ok');
117
+ return null;
118
+ }
119
+
120
+ logHookActivity('enforce-phase-writes', 'PreToolUse', 'blocked');
121
+ return blockResult(
122
+ `MORPH-SPEC: Writing to '2-plan/' is not allowed yet.\n` +
123
+ `This feature entered the uiux branch (1-design/ exists) and the 'uiux' gate is still pending.\n\n` +
124
+ `Approve the design gate first, then re-enter plan:\n` +
125
+ ` morph-spec approve ${featureName} uiux\n` +
126
+ ` morph-spec advance ${featureName}`
127
+ );
107
128
  }
108
129
 
109
130
  const phaseLabel = phase.toUpperCase();
@@ -94,6 +94,11 @@ function isApprovalAnswer(answer) {
94
94
 
95
95
  function gateFromQuestion(question) {
96
96
  if (typeof question !== 'string') return null;
97
+ // Checked BEFORE the generic patterns below: the pinned uiux template is
98
+ // "Gate uiux — Design de {feature} aprovado?" (morph-uiux/SKILL.md) — it
99
+ // must never fall through to 'plan' via a stray "plan"/"plano" substring in
100
+ // the feature name. `/gate\s+uiux/i` alone is unambiguous either way.
101
+ if (/gate\s+uiux/i.test(question)) return 'uiux';
97
102
  const lower = question.toLowerCase();
98
103
  if (lower.includes('gate 1') || lower.includes('proposta')) return 'proposal';
99
104
  if (lower.includes('gate 2') || lower.includes('plano') || lower.includes('plan')) return 'plan';
@@ -41,6 +41,32 @@ function normalize(p) {
41
41
  return (p || '').replace(/\\/g, '/');
42
42
  }
43
43
 
44
+ // events.jsonl grows forever across a project's lifetime (R2 rotation caps
45
+ // the FILE, not any single line) — a multi-KB sub-agent prompt or a long
46
+ // Bash command written verbatim on every dispatch/call is the dominant per-
47
+ // line cost. Capped AT THE WRITE SITE (not in the shared logger, so every
48
+ // OTHER event type stays exactly as-is) to `MAX_FIELD_CHARS`, with the
49
+ // ORIGINAL length preserved in a sibling `<field>Chars` key — `morph-spec
50
+ // cost` sums prompt chars for its dispatch-cost view, and must prefer that
51
+ // field over `prompt.length` once it's present (a truncated string's own
52
+ // length would silently undercount every dispatch over the cap). This shape
53
+ // is a CONTRACT with the telemetry reader — do not rename either half.
54
+ const MAX_FIELD_CHARS = 2000;
55
+ const TRUNCATE_SUFFIX = '…[truncado]';
56
+
57
+ /**
58
+ * Cap a free-text field (prompt/command) at MAX_FIELD_CHARS, always
59
+ * reporting the field's true original length alongside it.
60
+ *
61
+ * @param {string} value
62
+ * @returns {{ value: string, chars: number }}
63
+ */
64
+ function capField(value) {
65
+ const str = typeof value === 'string' ? value : '';
66
+ if (str.length <= MAX_FIELD_CHARS) return { value: str, chars: str.length };
67
+ return { value: str.slice(0, MAX_FIELD_CHARS) + TRUNCATE_SUFFIX, chars: str.length };
68
+ }
69
+
44
70
  /**
45
71
  * Classify a Read target into the telemetry `kind` taxonomy — a contract with
46
72
  * the observability side (src/lib/telemetry/telemetry-reader.js), so these
@@ -144,7 +170,8 @@ export async function run(payload) {
144
170
 
145
171
  // UserPromptSubmit carries the user's prompt and no tool_name.
146
172
  if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
147
- event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: payload.prompt || '' };
173
+ const userPrompt = capField(payload.prompt || '');
174
+ event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: userPrompt.value, promptChars: userPrompt.chars };
148
175
  } else {
149
176
  const tool = payload.tool_name;
150
177
  if (!tool) return null;
@@ -156,7 +183,9 @@ export async function run(payload) {
156
183
  } else if (tool === 'Agent' || tool === 'Task') {
157
184
  event.persona = input.subagent_type || '';
158
185
  if (input.description) event.desc = input.description;
159
- event.prompt = input.prompt || '';
186
+ const dispatchPrompt = capField(input.prompt || '');
187
+ event.prompt = dispatchPrompt.value;
188
+ event.promptChars = dispatchPrompt.chars;
160
189
  } else if (tool === 'Read') {
161
190
  event.target = targetFilePath || normalize(input.file_path || input.path);
162
191
  event.kind = classifyReadKind(event.target);
@@ -169,7 +198,9 @@ export async function run(payload) {
169
198
  // omit bytes
170
199
  }
171
200
  } else if (tool === 'Bash') {
172
- event.command = input.command || '';
201
+ const bashCommand = capField(input.command || '');
202
+ event.command = bashCommand.value;
203
+ event.commandChars = bashCommand.chars;
173
204
  } else if (tool === 'Write' || tool === 'Edit') {
174
205
  event.target = normalize(input.file_path || input.path);
175
206
  } else {
@@ -21,7 +21,7 @@
21
21
  * Fail-open: exits 0 on any error.
22
22
  */
23
23
 
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, statSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { createHash } from 'crypto';
27
27
  import { runBuildCheck } from '../../shared/build-check.js';
@@ -30,6 +30,7 @@ import {
30
30
  } from '../../shared/state-reader.js';
31
31
  import { contextResult, runStandalone } from '../../shared/hook-response.js';
32
32
  import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
33
+ import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
33
34
 
34
35
  /**
35
36
  * Audit recap.md against tasks.json + standard-citation rule.
@@ -121,6 +122,80 @@ function auditRecapAndTasks(featurePath) {
121
122
  return warnings;
122
123
  }
123
124
 
125
+ // Safety valve for a pathological outlier transcript — NOT needed for the
126
+ // normal case: a real 45 MB / ~16k-line transcript (inspected directly while
127
+ // building this) parses+sums in ~350ms locally, well inside a Stop hook's
128
+ // budget. Above this, skip rather than risk an unbounded read.
129
+ const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
130
+
131
+ /**
132
+ * Sum real token usage from a Claude Code session transcript (JSONL, the
133
+ * `transcript_path` the Stop payload carries).
134
+ *
135
+ * Each assistant TURN is written to the transcript as MULTIPLE JSONL lines —
136
+ * one per content block (thinking/text/tool_use) — every one of them
137
+ * repeating the exact SAME turn-level `message.usage` object under the same
138
+ * `message.id`. Verified against a real transcript: 5982 assistant lines but
139
+ * only 3315 unique message ids, with byte-identical usage across duplicates
140
+ * of the same id — summing every line naively double/triple-counts (measured
141
+ * ~2x on output_tokens on that file). This is why a per-line sum is wrong and
142
+ * a dedup-by-id sum is required; there is no running/cumulative total field
143
+ * to lean on instead — each turn's `usage` is that turn's own cost.
144
+ *
145
+ * Tolerant to shape: any missing usage field reads as 0. A corrupt/truncated
146
+ * line is skipped, not fatal — same discipline as telemetry-logger's reader.
147
+ *
148
+ * @param {string} [transcriptPath]
149
+ * @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
150
+ * null when there is nothing to report (no path, missing/oversized/
151
+ * unreadable file, or zero assistant messages carrying usage).
152
+ */
153
+ function computeSessionUsage(transcriptPath) {
154
+ if (!transcriptPath || typeof transcriptPath !== 'string') return null;
155
+ try {
156
+ if (!existsSync(transcriptPath)) return null;
157
+ if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
158
+
159
+ const raw = readFileSync(transcriptPath, 'utf-8');
160
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0;
161
+ const seenMessageIds = new Set();
162
+ let sawAny = false;
163
+
164
+ for (const line of raw.split('\n')) {
165
+ const trimmed = line.trim();
166
+ if (!trimmed) continue;
167
+ let entry;
168
+ try {
169
+ entry = JSON.parse(trimmed);
170
+ } catch {
171
+ continue; // one corrupt/truncated line must not lose the whole transcript
172
+ }
173
+ if (entry?.type !== 'assistant') continue;
174
+ const usage = entry?.message?.usage;
175
+ if (!usage || typeof usage !== 'object') continue;
176
+
177
+ // Dedup by message.id (fallback to the line's own uuid when a message
178
+ // carries none) — first occurrence wins, duplicates carry identical
179
+ // values so which one wins never matters.
180
+ const dedupKey = entry?.message?.id || entry?.uuid;
181
+ if (dedupKey) {
182
+ if (seenMessageIds.has(dedupKey)) continue;
183
+ seenMessageIds.add(dedupKey);
184
+ }
185
+
186
+ sawAny = true;
187
+ inputTokens += Number(usage.input_tokens) || 0;
188
+ outputTokens += Number(usage.output_tokens) || 0;
189
+ cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
190
+ cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
191
+ }
192
+
193
+ return sawAny ? { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } : null;
194
+ } catch {
195
+ return null; // fail-open
196
+ }
197
+ }
198
+
124
199
  // Phases where compaction is strongly recommended before starting
125
200
  const COMPACT_TRIGGER_PHASES = new Set(['implement']);
126
201
 
@@ -128,12 +203,15 @@ const COMPACT_TRIGGER_PHASES = new Set(['implement']);
128
203
  const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
129
204
 
130
205
  /**
131
- * @param {object} _payload - parsed Stop stdin payload (unused — this hook
132
- * reads its own state from disk, not from the tool payload)
206
+ * @param {object} payload - parsed Stop stdin payload. Most of this hook's
207
+ * logic reads its own state from disk rather than the payload, EXCEPT the
208
+ * session-usage capture below, which needs `payload.transcript_path` (and
209
+ * `payload.session_id` for the throttle key) — the one thing only the
210
+ * payload carries.
133
211
  * @returns {Promise<object|null>} a hookSpecificOutput advisory (phase
134
212
  * transition / build failure / recap drift), or null (pass/silent).
135
213
  */
136
- export async function run(_payload) {
214
+ export async function run(payload) {
137
215
  try {
138
216
  // Prevent infinite loop
139
217
  if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') return null;
@@ -264,6 +342,40 @@ export async function run(_payload) {
264
342
  }
265
343
  // ────────────────────────────────────────────────────────────────────────
266
344
 
345
+ // ── Session usage (real tokens, from the transcript) ──────────────────────
346
+ // Silent side effect — never contributes to `warnings`/additionalContext,
347
+ // same as telemetry-log.js's capture. Throttled: only re-emitted when the
348
+ // computed totals actually changed since the last SessionUsage recorded
349
+ // for this session_id, so an unchanged transcript across consecutive
350
+ // Stops (no new assistant turn) does not re-append an identical event.
351
+ try {
352
+ const usage = computeSessionUsage(payload?.transcript_path);
353
+ if (usage) {
354
+ const usagePath = join(memDir, 'session-usage.json');
355
+ let stored = {};
356
+ try {
357
+ if (existsSync(usagePath)) stored = JSON.parse(readFileSync(usagePath, 'utf-8'));
358
+ } catch { /* corrupt file — start fresh */ }
359
+
360
+ const sessionKey = payload?.session_id || 'default';
361
+ const last = stored[sessionKey];
362
+ const unchanged = last
363
+ && last.inputTokens === usage.inputTokens
364
+ && last.outputTokens === usage.outputTokens
365
+ && last.cacheReadTokens === usage.cacheReadTokens
366
+ && last.cacheCreationTokens === usage.cacheCreationTokens;
367
+
368
+ if (!unchanged) {
369
+ appendTelemetryEvent({ event: 'SessionUsage', ...usage }, cwd);
370
+ stored[sessionKey] = usage;
371
+ try { writeFileSync(usagePath, JSON.stringify(stored, null, 2), 'utf-8'); } catch { /* non-blocking */ }
372
+ }
373
+ }
374
+ } catch {
375
+ // Fail-open — usage capture must never block Stop
376
+ }
377
+ // ────────────────────────────────────────────────────────────────────────
378
+
267
379
  if (warnings.length === 0) return null;
268
380
 
269
381
  const message = [
@@ -27,7 +27,10 @@
27
27
  * Also the real call site for activity-log rotation (R1.2 of the performance
28
28
  * plan): SessionStart is the only reliable "between sessions" checkpoint the
29
29
  * harness gets, so this is where `.morph/logs/activity.jsonl` gets archived
30
- * to `.morph/logs/sessions/` once it crosses the size threshold.
30
+ * to `.morph/logs/sessions/` once it crosses the size threshold. The same
31
+ * checkpoint now also rotates `.morph/logs/events.jsonl` (telemetry-logger.js)
32
+ * once IT crosses its own threshold — events.jsonl grew unbounded even after
33
+ * R1.2, since that rotation only ever covered activity.jsonl.
31
34
  *
32
35
  * Fail-open: any error, or nothing to report, → pass() silently. This hook
33
36
  * never blocks a session from starting.
@@ -46,6 +49,7 @@ import {
46
49
  import { contextResult, runStandalone } from '../../shared/hook-response.js';
47
50
  import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
48
51
  import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
52
+ import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
49
53
 
50
54
  function projectDir() {
51
55
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -223,10 +227,11 @@ export async function run(payload) {
223
227
  return null;
224
228
  }
225
229
 
226
- // Rotate the activity log before anything else touches it this session.
227
- // Best-effort, never fatal to session start — rotateActivityLogIfLarge()
228
- // is itself fail-open (returns false on any error).
230
+ // Rotate the activity + events logs before anything else touches them
231
+ // this session. Best-effort, never fatal to session start — both rotate*
232
+ // helpers are themselves fail-open (return false on any error).
229
233
  try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
234
+ try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
230
235
 
231
236
  if (isSecondaryWorktree(dir)) {
232
237
  const feature = featureFromBranch(dir) || featureFromSingleDir(dir);
@@ -16,12 +16,22 @@
16
16
  * - Windows-safe paths via path.join
17
17
  */
18
18
 
19
- import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'fs';
19
+ import {
20
+ appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync,
21
+ } from 'fs';
20
22
  import { join, dirname } from 'path';
21
23
 
22
24
  const LOGS_DIR = '.morph/logs';
23
25
  export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
24
26
 
27
+ // SessionStart (worktree-context.js) rotates events.jsonl once it crosses
28
+ // this size, alongside the pre-existing activity.jsonl rotation — same
29
+ // call site, same "between sessions" checkpoint. events.jsonl has no natural
30
+ // per-turn cap (prompts/commands are truncated at the write site, but the
31
+ // LINE COUNT itself grows unbounded across a long-lived project), so without
32
+ // this it grows forever the way activity.jsonl used to before R1.2.
33
+ export const ROTATE_THRESHOLD_BYTES = 5 * 1024 * 1024; // 5 MB
34
+
25
35
  function getEventsPath(projectPath) {
26
36
  return join(projectPath || process.cwd(), EVENTS_FILE);
27
37
  }
@@ -73,3 +83,56 @@ export function readTelemetryEvents(projectPath) {
73
83
  return [];
74
84
  }
75
85
  }
86
+
87
+ /**
88
+ * Archive the current events.jsonl to `.morph/logs/sessions/`, mirroring
89
+ * activity-logger.js's `archiveSession()` — same directory, same rename-not-
90
+ * rewrite mechanics (O(1) even at multi-MB sizes). Named with an `events-`
91
+ * prefix (activity-logger's archives carry no prefix) so the two streams
92
+ * never collide inside the shared `sessions/` directory and stay
93
+ * distinguishable at a glance.
94
+ *
95
+ * Fail-silent — called only from `rotateEventsLogIfLarge` below, which has
96
+ * already confirmed the file exists.
97
+ *
98
+ * @param {string} [projectPath]
99
+ */
100
+ function archiveEvents(projectPath) {
101
+ try {
102
+ const eventsPath = getEventsPath(projectPath);
103
+ if (!existsSync(eventsPath)) return;
104
+
105
+ const sessionsDir = join(projectPath || process.cwd(), LOGS_DIR, 'sessions');
106
+ mkdirSync(sessionsDir, { recursive: true });
107
+
108
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
109
+ const archivePath = join(sessionsDir, `events-${stamp}.jsonl`);
110
+ renameSync(eventsPath, archivePath);
111
+ } catch {
112
+ // Fail-silent
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Rotate events.jsonl when it crosses `thresholdBytes`. Called from
118
+ * worktree-context.js on SessionStart, right next to the existing
119
+ * `rotateActivityLogIfLarge()` call — the only reliable "between sessions"
120
+ * checkpoint the harness gets. `thresholdBytes` is injectable so a test can
121
+ * force rotation without writing multiple megabytes.
122
+ *
123
+ * @param {string} [projectPath] - Project root (defaults to cwd)
124
+ * @param {number} [thresholdBytes] - Rotate once the log is at least this large
125
+ * @returns {boolean} true if a rotation was performed
126
+ */
127
+ export function rotateEventsLogIfLarge(projectPath, thresholdBytes = ROTATE_THRESHOLD_BYTES) {
128
+ try {
129
+ const eventsPath = getEventsPath(projectPath);
130
+ if (!existsSync(eventsPath)) return false;
131
+ const { size } = statSync(eventsPath);
132
+ if (size < thresholdBytes) return false;
133
+ archiveEvents(projectPath);
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
@@ -39,7 +39,7 @@
39
39
  "standards": {
40
40
  "type": "array",
41
41
  "items": { "type": "string", "minLength": 1 },
42
- "description": "Standards pinned by morph-plan for this task, as paths relative to the framework root (e.g. \"standards/backend/dotnet/vsa-handler-patterns.md\"). Optional. Part of the plan contract: frozen after Gate 2."
42
+ "description": "Standards pinned by morph-plan for this task, as PATHS relative to the framework root (e.g. \"standards/backend/dotnet/vsa-handler-patterns.md\"). Optional. Part of the plan contract: frozen after Gate 2. NOT the same vocabulary as agents.json's top-level `standards` field, which lists IDs (e.g. \"backend-dotnet-vsa-handler-patterns\", no slashes/extension) — same field name, different shape; do not treat one as the other."
43
43
  }
44
44
  },
45
45
  "additionalProperties": false
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -208,21 +208,12 @@ Quando `getPendingTasks(data).length === 0`:
208
208
  - Invocar `Skill(morph-review)` para gerar `.morph/features/{feature}/4-review/review-report.md`.
209
209
  - A skill morph-review chama `morph-eval` em escopo de feature inteira.
210
210
 
211
- ### 5. Gate 3 (trust-aware)
211
+ ### 5. Gate 3
212
212
 
213
- Rode `morph-spec gate-check {feature} review [--json]` — lê `trust`, decide via `shouldPauseGate` e persiste `gateDecisions.review` com timestamp server-side (shape canônico em `.morph/framework/schemas/feature.schema.json`), imprimindo `pause`/`reason`/`signals`. É o default — não recompute a tabela do MORPH.md §5 em linguagem natural.
214
-
215
- **`evalScore` sem override é uma PROXY** (média das `taskScores`, não o score composto real). Passe o composto do `review-report.md` (avaliador independente) e qualquer violação que ele tenha encontrado via `--signals` — as únicas chaves que este comando aceita sobrescrever:
216
- ```
217
- morph-spec gate-check {feature} review --signals '{"evalScore":9.2,"standardsViolations":[]}'
218
- ```
219
-
220
- Risco que nenhuma dessas chaves representa (julgamento do avaliador que não vira `standardsViolations`): use `gate-decision` direto em vez de `gate-check`.
221
-
222
- **Nunca edite `feature.json` à mão** (colide com o hook state-sync — erro "file modified since read"). Os caminhos acima gravam no mesmo lugar; o hook `gate-guard.js` confere depois se a ação bateu com a decisão persistida.
223
-
224
- - Pause: `AskUserQuestion` com options `["aprovado, fechar feature", "preciso ajustar", "rollback"]`.
225
- - Auto: exibir summary do review-report, aprovar via `morph-spec approve {feature} review --mode auto`, e prosseguir.
213
+ Apresentado e persistido por `morph-review` §8 (invocada no passo 4 acima) — gate-check, template
214
+ fixo nomeando a feature, aprovação registrada pós-resposta. Ao retomar o controle dela, **NÃO
215
+ re-apresente o gate**: siga direto para o encerramento (§6) conforme a decisão já persistida em
216
+ `gateDecisions.review`.
226
217
 
227
218
  ### 6. Pós Gate 3 — encerramento
228
219
 
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -147,6 +147,6 @@ options:
147
147
 
148
148
  ## Referências
149
149
  - `.morph/framework/MORPH.md` — seções 1 (Papéis), 2 (Fases), 5 (Gates), Trust Mode
150
- - `.morph/framework/standards/STANDARDS.json` — para referência de standards relevantes à stack
150
+ - `morph-spec standards --find "<query>"` — para referência de standards relevantes à stack (ver `morph-standards`)
151
151
  - `framework/skills/core/morph-stack-scan/SKILL.md` — invocada na Parte 0 (varredura), antes de qualquer pergunta
152
152
  - `.morph/framework/MORPH.md` §5 — regras de pausa do Gate 1 (trust mode)
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
4
4
  version: core
5
5
  when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -84,10 +84,10 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
84
84
 
85
85
  4. Produza `mandate.md` em `.morph/features/{feature}/2-plan/`:
86
86
  - Stack exata do projeto (leia de `.morph/config/config.json` → `project.stack` ou do CLAUDE.md).
87
- - Standards obrigatórios: liste os IDs relevantes de `.morph/framework/standards/STANDARDS.json`.
87
+ - Standards obrigatórios: descubra-os com `morph-spec standards --find "<domínio/tecnologia>"` (top 3 `id · path · digest` — o digest decide) por domínio tocado; liste os IDs escolhidos.
88
88
  - Patterns proibidos — **copie por extenso no mandate.md** a lista canônica de `MORPH.md` § "Stack baked-in" (`Guid.NewGuid()`→`Guid.CreateVersion7()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, repository genérico `IRepository<T>`/`IUnitOfWork`, SQL cru sem justificativa) mais qualquer proibição específica da feature. Copie o texto completo — o mandate.md é lido por implementadores em contexto fresco, sem `MORPH.md` carregado, então um ponteiro sozinho não basta aqui.
89
89
  - Política de acesso a dados (registre no mandate): `ApplicationDbContext` injetado direto no slice — para CRUD e para queries específicas (joins, projeção, paginação). Para concorrência ou SQL específico, store dedicado hand-written com justificativa em `decisions.md`.
90
- - **Features com agentes de IA (MAF):** se a feature envolve um agente (Microsoft Agent Framework), **use a tool Read** nos standards relevantes de `framework/standards/ai-agents/*.md` (ex.: `setup`, `agent-session`, `middleware-patterns`, `mcp-server`, `mcp-tools`, `multi-agent-patterns`, `production`) — filtre pelo que a feature exige via STANDARDS.json. Esses standards carregam as **convenções de projeto** (single-agent por padrão — Workflows só para fan-in/out real; provider, caching, observability, A2A) que uma fonte externa de docs (ex.: context7) não conhece. Padrão de divisão: **superfície da API** pode vir de docs ao vivo/context7 (mais frescas para um SDK em evolução), mas **as convenções vêm dos standards** — registre ambas no mandate. Não pule os `ai-agents/*.md` só porque o STANDARDS.json index lista que eles existem.
90
+ - **Features com agentes de IA (MAF):** se a feature envolve um agente (Microsoft Agent Framework), **use a tool Read** nos standards relevantes de `framework/standards/ai-agents/*.md` (ex.: `setup`, `agent-session`, `middleware-patterns`, `mcp-server`, `mcp-tools`, `multi-agent-patterns`, `production`) — filtre pelo que a feature exige via `morph-spec standards --find "<tema>" --category ai-agents`. Esses standards carregam as **convenções de projeto** (single-agent por padrão — Workflows só para fan-in/out real; provider, caching, observability, A2A) que uma fonte externa de docs (ex.: context7) não conhece. Padrão de divisão: **superfície da API** pode vir de docs ao vivo/context7 (mais frescas para um SDK em evolução), mas **as convenções vêm dos standards** — registre ambas no mandate. Não pule os `ai-agents/*.md` só porque o STANDARDS.json index lista que eles existem.
91
91
  - Checklist de done com critérios verificáveis por tarefa.
92
92
 
93
93
  5. Produza `tasks.json` em `.morph/features/{feature}/2-plan/` seguindo o schema em `.morph/framework/schemas/tasks.schema.json`.
@@ -199,7 +199,7 @@ Ao registrar a aprovação do Gate 2, atualize o frontmatter de cada use case de
199
199
  ## Referências
200
200
 
201
201
  - `.morph/framework/MORPH.md` — seções 2 (Fases), 3 (Contratos de Output), 5 (Gates), Trust Mode
202
- - `.morph/framework/standards/STANDARDS.json` — para identificar standards relevantes ao mandate
202
+ - `morph-spec standards --find "<query>"` — para identificar standards relevantes ao mandate (o índice inteiro só como navegação exploratória; ver `morph-standards`)
203
203
  - `.morph/framework/agents.json` — personas por domínio; lido UMA VEZ no passo 5a para carimbar `agent`/`standards` por task
204
204
  - `.morph/framework/schemas/tasks.schema.json` — schema obrigatório de tasks.json
205
205
  - `.morph/specs/usecases/` — acervo permanente de use cases do projeto (leia antes de numerar; em `bug`, é onde mora o UC a corrigir)
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "8.20.0"
7
+ cliVersion: "8.21.0"
8
8
  ---
9
9
 
10
10
  # morph-proposal