@polymorphism-tech/morph-spec 8.15.1 → 8.16.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 (48) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +8 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +3 -3
  8. package/framework/hooks/README.md +3 -3
  9. package/framework/hooks/claude-code/core/chain.js +123 -0
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  11. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  12. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  13. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  14. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  15. package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
  16. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  17. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  18. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  19. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  20. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  21. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  22. package/framework/hooks/claude-code/statusline.py +26 -4
  23. package/framework/hooks/shared/activity-logger.js +120 -69
  24. package/framework/hooks/shared/claim-registry.js +61 -1
  25. package/framework/hooks/shared/git-paths.js +94 -0
  26. package/framework/hooks/shared/hook-response.js +67 -0
  27. package/framework/hooks/shared/state-reader.js +95 -29
  28. package/framework/hooks/shared/state-writer.js +16 -0
  29. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  30. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  31. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  32. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  33. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  34. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  35. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  36. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  37. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  38. package/framework/skills/core/morph-review/SKILL.md +1 -1
  39. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  40. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/observability/cost.js +137 -0
  43. package/src/commands/state/approve.js +30 -9
  44. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  45. package/src/lib/installers/memory-installer.js +1 -1
  46. package/src/lib/telemetry/telemetry-reader.js +213 -0
  47. package/src/utils/hooks-installer.js +84 -79
  48. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'fs';
14
14
  import { join, dirname } from 'path';
15
+ import { clearStateCache } from './state-reader.js';
15
16
 
16
17
  const STATE_FILE = '.morph/state.json';
17
18
  const STATE_VERSION = '5.0.0';
@@ -73,6 +74,21 @@ export function persistFeature(featureName, feature, projectPath) {
73
74
  root.metadata = root.metadata || {};
74
75
  root.metadata.totalFeatures = Object.keys(root.features).length;
75
76
  atomicWriteJson(statePath, root);
77
+
78
+ // R1's loadState() memoizes per projectPath for the lifetime of the
79
+ // process. That was always safe when every hook was its own short-lived
80
+ // process (README history), but core/chain.js now runs multiple hooks
81
+ // — e.g. state-sync then gate-guard — IN ONE process. Without this,
82
+ // a loadState() call earlier in that same process (state-sync's
83
+ // handleWriteEdit reads via loadState before this write) would leave a
84
+ // stale snapshot cached, and gate-guard's loadState() right after this
85
+ // write would silently read the PRE-write state instead of what was
86
+ // just persisted — inverting the "gate-guard runs after state-sync so it
87
+ // sees the gate state-sync just recorded" ordering guarantee the
88
+ // registry comment documents. Invalidating here (the one place
89
+ // feature.json/state.json are actually written) covers every current
90
+ // and future caller, not just this one chain.
91
+ clearStateCache();
76
92
  return true;
77
93
  } catch {
78
94
  return 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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # create-umbraco-lp
@@ -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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # migrate-lp-to-umbraco
@@ -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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -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.15.1"
7
+ cliVersion: "8.16.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.15.1"
7
+ cliVersion: "8.16.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.15.1"
7
+ cliVersion: "8.16.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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -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.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-proposal
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
6
6
  user-invokable: true
7
- cliVersion: "8.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
4
4
  version: core
5
5
  when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
6
6
  user-invokable: true
7
- cliVersion: "8.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-stack-scan
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "8.15.1"
7
+ cliVersion: "8.16.0"
8
8
  ---
9
9
 
10
10
  # morph-standards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.15.1",
3
+ "version": "8.16.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `morph-spec cost [feature] [--by task|phase] [--json]`
3
+ *
4
+ * A cost-facing view over the same harness event stream `telemetry` reads
5
+ * (.morph/logs/events.jsonl): how much wall-clock time was spent inside
6
+ * measured tasks (TaskStart→TaskEnd), how many context bytes were
7
+ * re-ingested (grouped by kind: rubric/standard/skill/agents/plan-artifact/
8
+ * code), and how many sub-agent dispatches happened (with total prompt
9
+ * chars).
10
+ *
11
+ * IMPORTANT: bytes re-ingested is a DETERMINISTIC PROXY for cost, not a
12
+ * token count. Hooks only see what tools read off disk — never the model's
13
+ * actual token usage — so this measures re-ingestion pressure, not spend.
14
+ *
15
+ * Read-only, always exits 0 on a normal (even empty) run — fail-open, an
16
+ * empty stream is an answer, not an error. Only `--by` with an unknown value
17
+ * is a usage error (exit 1).
18
+ */
19
+
20
+ import chalk from 'chalk';
21
+ import { loadTelemetry, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
22
+
23
+ const BYTES_CAVEAT =
24
+ '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.';
25
+
26
+ const BY_MODES = ['task', 'phase'];
27
+
28
+ function fmtBytes(n) {
29
+ if (!n) return '0 B';
30
+ if (n < 1024) return `${n} B`;
31
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
32
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
33
+ }
34
+
35
+ function fmtDuration(s) {
36
+ if (s === null || s === undefined) return '-';
37
+ if (s < 60) return `${s.toFixed(1)}s`;
38
+ const m = Math.floor(s / 60);
39
+ const rem = Math.round(s % 60);
40
+ return `${m}m${rem}s`;
41
+ }
42
+
43
+ function printBytesByKind(bytesByKind) {
44
+ const entries = Object.entries(bytesByKind);
45
+ if (!entries.length) {
46
+ console.log(chalk.gray(' (0 bytes re-ingeridos — nenhum Read com bytes/kind neste escopo)'));
47
+ return;
48
+ }
49
+ for (const [kind, bytes] of entries.sort((a, b) => b[1] - a[1])) {
50
+ console.log(` ${chalk.yellow(kind.padEnd(14))} ${fmtBytes(bytes)}`);
51
+ }
52
+ }
53
+
54
+ function printTasks(tasks) {
55
+ if (!tasks.length) {
56
+ console.log(chalk.gray('\nNenhuma task com TaskStart/TaskEnd capturada.'));
57
+ return;
58
+ }
59
+ for (const t of tasks) {
60
+ const status = t.open ? chalk.yellow('[em aberto]') : (t.status || '-');
61
+ console.log('\n' + chalk.white.bold(`Task ${t.task}`) + chalk.gray(` · ${t.feature ?? '(sem feature)'}`) + ` · ${status}`);
62
+ console.log(` duração: ${fmtDuration(t.durationSeconds)}`);
63
+ console.log(` dispatches: ${t.dispatches.count} (${t.dispatches.promptChars} chars de prompt)`);
64
+ console.log(' bytes re-ingeridos por kind:');
65
+ printBytesByKind(t.bytesByKind);
66
+ }
67
+ }
68
+
69
+ function printPhases(phases) {
70
+ if (!phases.length) {
71
+ console.log(chalk.gray('\nNenhum byte de Read com phase capturado.'));
72
+ return;
73
+ }
74
+ for (const p of phases) {
75
+ console.log('\n' + chalk.white.bold(`Fase ${p.phase}`) + chalk.gray(` · ${fmtBytes(p.bytesTotal)} total`));
76
+ printBytesByKind(p.bytesByKind);
77
+ }
78
+ }
79
+
80
+ function printSummary(summary) {
81
+ console.log(`\n duração dentro de tasks medidas: ${fmtDuration(summary.taskDurationSeconds)}`);
82
+ console.log(` dispatches: ${summary.dispatches.count} (${summary.dispatches.promptChars} chars de prompt)`);
83
+
84
+ console.log('\n' + chalk.white.bold('Bytes re-ingeridos por kind') + ` (${fmtBytes(summary.bytesTotal)} total)`);
85
+ printBytesByKind(summary.bytesByKind);
86
+
87
+ if (summary.tools.length) {
88
+ console.log('\n' + chalk.white.bold('Mix de tools'));
89
+ for (const t of summary.tools) console.log(` ${t.name.padEnd(16)} ${t.count}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {string} [feature] - narrow to a single feature
95
+ * @param {{ json?: boolean, by?: 'task'|'phase', cwd?: string }} [options]
96
+ */
97
+ export function costCommand(feature, options = {}) {
98
+ if (options.by && !BY_MODES.includes(options.by)) {
99
+ console.error(chalk.red(`\n❌ Invalid --by: "${options.by}"`));
100
+ console.error(chalk.gray(`Valid modes: ${BY_MODES.join(', ')}\n`));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+
105
+ const events = loadTelemetry(options.cwd || process.cwd());
106
+ const summary = aggregateCost(events, { feature });
107
+
108
+ if (options.json) {
109
+ // No forced process.exit() here: on Windows a piped stdout write is
110
+ // async, and exit()ing right after a large console.log can race the
111
+ // flush and truncate output. Letting the event loop drain naturally
112
+ // (nothing else keeps it alive in this command) exits 0 without risk.
113
+ console.log(JSON.stringify(summary));
114
+ return;
115
+ }
116
+
117
+ if (summary.total === 0) {
118
+ const scope = feature ? ` para a feature "${feature}"` : '';
119
+ console.log(chalk.gray(`Telemetria vazia${scope} — nenhum evento capturado ainda em .morph/logs/events.jsonl.`));
120
+ return;
121
+ }
122
+
123
+ const scope = feature ? chalk.cyan(` · feature ${feature}`) : '';
124
+ console.log(chalk.white.bold(`Custo do harness (proxy determinística) — ${summary.total} eventos${scope}`));
125
+ console.log(chalk.gray(`⚠ ${BYTES_CAVEAT}`));
126
+ if (summary.features.length) {
127
+ console.log(chalk.gray(`Features: ${summary.features.join(', ')}`));
128
+ }
129
+
130
+ if (options.by === 'task') {
131
+ printTasks(summary.tasks);
132
+ } else if (options.by === 'phase') {
133
+ printPhases(summary.phases);
134
+ } else {
135
+ printSummary(summary);
136
+ }
137
+ }
@@ -283,17 +283,38 @@ export async function approveCommand(featureName, gateName, options = {}) {
283
283
 
284
284
  let selfAssessed = false;
285
285
  if (gateName === 'review') {
286
- const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
287
- if (!existsSync(evaluatorReport)) {
288
- if (!options.selfAssessed) {
289
- console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
290
- console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
291
- console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
292
- console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
293
- console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
286
+ if (feature.workType === 'hotfix') {
287
+ // Hotfix Gate 3 does NOT require the independent evaluator: `morph-hotfix`
288
+ // (SKILL.md §7) never dispatches `morph-evaluator` — speed over ceremony
289
+ // is the point of a hotfix — and instead writes `4-review/review-report.md`
290
+ // (diff + real test evidence) before asking the human to approve. Requiring
291
+ // evaluator-report.md here made the skill's own documented flow unable to
292
+ // clear its own Gate 3 (the only escape was --self-assessed, which stamps
293
+ // a degradation for a report that was never supposed to exist). The human
294
+ // signature is already enforced above (line ~215) — this only checks the
295
+ // artifact the skill actually produces.
296
+ const reviewReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'review-report.md');
297
+ if (!existsSync(reviewReport)) {
298
+ console.error(chalk.red('\n❌ Gate 3 de hotfix exige `4-review/review-report.md` — não existe.'));
299
+ console.error(chalk.gray(' É o artefato que a `morph-hotfix` produz (diff do fix + evidência real de'));
300
+ console.error(chalk.gray(' teste do `morph-spec verify`), não o `evaluator-report.md` do avaliador'));
301
+ console.error(chalk.gray(' independente — hotfix nunca despacha o avaliador. Escreva o relatório antes'));
302
+ console.error(chalk.gray(' de aprovar; a assinatura humana já é exigida pelo backstop acima.\n'));
294
303
  process.exit(1);
295
304
  }
296
- selfAssessed = true;
305
+ } else {
306
+ const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
307
+ if (!existsSync(evaluatorReport)) {
308
+ if (!options.selfAssessed) {
309
+ console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
310
+ console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
311
+ console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
312
+ console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
313
+ console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
314
+ process.exit(1);
315
+ }
316
+ selfAssessed = true;
317
+ }
297
318
  }
298
319
  }
299
320
 
@@ -63,8 +63,17 @@ export function hookRegistrations(definitions = getHookDefinitions()) {
63
63
  for (const h of def.hooks || []) {
64
64
  const file = basename(String(h.command || '').trim());
65
65
  if (!file.endsWith('.js')) continue;
66
- if (!byFile.has(file)) byFile.set(file, []);
67
- byFile.get(file).push({ event: key, rawEvent: def.event, matcher: def.matcher || null });
66
+ // Grupos consolidados apontam para chain.js com os hooks reais em
67
+ // `args` (base names, na ordem de execução) — registre cada elo, não o
68
+ // runner: é o elo que o Inspector precisa mostrar. chain.js em si não é
69
+ // um hook de domínio e ficaria como nó fantasma em todo evento.
70
+ const files = file === 'chain.js' && Array.isArray(h.args) && h.args.length
71
+ ? h.args.map(a => `${a}.js`)
72
+ : [file];
73
+ for (const f of files) {
74
+ if (!byFile.has(f)) byFile.set(f, []);
75
+ byFile.get(f).push({ event: key, rawEvent: def.event, matcher: def.matcher || null });
76
+ }
68
77
  }
69
78
  }
70
79
  return byFile;
@@ -87,7 +87,7 @@ metadata:
87
87
  - \`.morph/framework/schemas/tasks.schema.json\` — tasks.json schema reference
88
88
  - \`.morph/framework/phases.json\` — phase folder mapping and skill associations
89
89
  - \`.morph/framework/agents.json\` — domain persona definitions (dotnet-senior, ef-modeler, etc.)
90
- - \`.morph/framework/rubrics/\` — eval rubrics for morph-eval scoring
90
+ - \`.morph/framework/evals/rubrics/\` — eval rubrics for morph-eval scoring (architecture/contracts/code-quality/test-coverage)
91
91
  `;
92
92
 
93
93
  /**
@@ -101,3 +101,216 @@ export function aggregateTelemetry(events, opts = {}) {
101
101
  features: [...features],
102
102
  };
103
103
  }
104
+
105
+ // ─────────────────────────────────────────────────────────────────────────
106
+ // Cost aggregation — sibling to aggregateTelemetry (kept separate rather than
107
+ // folded in: aggregateTelemetry's return shape is pinned by
108
+ // test/commands/telemetry.test.js, which spreads it wholesale into --json
109
+ // output — adding fields there would leak into that contract).
110
+ //
111
+ // Turns the same raw event stream into a cost-facing view: how much
112
+ // wall-clock time was spent inside measured tasks, how many context bytes
113
+ // were re-ingested (by kind), and how many sub-agent dispatches happened
114
+ // (and how many prompt chars they cost).
115
+ //
116
+ // "Bytes re-ingested" is a DETERMINISTIC PROXY for cost, not a token count —
117
+ // hooks can only see what tools read off disk, never the model's actual
118
+ // token usage. The `cost` command surfaces that caveat; this module only
119
+ // computes the numbers.
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+
122
+ // Sentinel used to key the open-task map when an event carries no feature
123
+ // (e.g. telemetry-log's getActiveFeature() found none). Without this, a
124
+ // TaskStart with feature: null would silently fail to open (map keyed on
125
+ // undefined/null never matches itself across two different `null`-feature
126
+ // pairs is fine, but `if (e.feature)` truthiness checks would just drop the
127
+ // event) — keying explicitly avoids that class of "counter never increments"
128
+ // bug.
129
+ const NO_FEATURE = '\u0000no-feature';
130
+
131
+ function featureKey(e) {
132
+ return e && e.feature ? e.feature : NO_FEATURE;
133
+ }
134
+
135
+ function tsToMs(ts) {
136
+ const t = Date.parse(ts);
137
+ return Number.isNaN(t) ? null : t;
138
+ }
139
+
140
+ /**
141
+ * Seconds between two ISO timestamps, rounded to millisecond precision to
142
+ * absorb float noise from the division. null if either ts is unparsable.
143
+ */
144
+ function durationSeconds(startTs, endTs) {
145
+ const a = tsToMs(startTs);
146
+ const b = tsToMs(endTs);
147
+ if (a === null || b === null) return null;
148
+ return Math.round(((b - a) / 1000) * 1000) / 1000;
149
+ }
150
+
151
+ function mapToObj(map) {
152
+ const out = {};
153
+ for (const key of [...map.keys()].sort()) out[key] = map.get(key);
154
+ return out;
155
+ }
156
+
157
+ function sumValues(map) {
158
+ let total = 0;
159
+ for (const v of map.values()) total += v;
160
+ return total;
161
+ }
162
+
163
+ function newTaskState(feature, task, ts) {
164
+ return {
165
+ feature: feature ?? null,
166
+ task,
167
+ startTs: ts,
168
+ endTs: null,
169
+ status: null,
170
+ open: true,
171
+ tools: new Map(),
172
+ bytesByKind: new Map(),
173
+ dispatches: { count: 0, promptChars: 0 },
174
+ };
175
+ }
176
+
177
+ function finalizeTask(state) {
178
+ return {
179
+ feature: state.feature,
180
+ task: state.task,
181
+ startTs: state.startTs,
182
+ endTs: state.endTs,
183
+ durationSeconds: state.endTs ? durationSeconds(state.startTs, state.endTs) : null,
184
+ status: state.status,
185
+ open: state.open,
186
+ tools: rank(state.tools),
187
+ bytesByKind: mapToObj(state.bytesByKind),
188
+ bytesTotal: sumValues(state.bytesByKind),
189
+ dispatches: { ...state.dispatches },
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Summarize an event stream for cost purposes: task segmentation (duration +
195
+ * per-task tool/byte/dispatch aggregates), bytes-re-ingested buckets by
196
+ * `kind` (global, per task, per phase), and sub-agent dispatch counts.
197
+ *
198
+ * Segmentation model: a task is "open" for a feature from its TaskStart
199
+ * event until the matching TaskEnd (same feature + task name). Only one task
200
+ * is assumed open per feature at a time — matches how the implement phase
201
+ * actually runs (auto-advance processes tasks sequentially, never two tasks
202
+ * of the same feature concurrently). Events for a feature with no open task
203
+ * simply don't attribute to any task (they still count toward the
204
+ * global/phase totals). A TaskEnd with no matching open TaskStart is ignored
205
+ * — a truncated stream or a legacy event mix never throws. A task left open
206
+ * at the end of the stream (TaskStart with no TaskEnd yet) is still reported,
207
+ * with `open: true` and `durationSeconds: null`, rather than silently
208
+ * dropped.
209
+ *
210
+ * Byte attribution keys off `typeof e.bytes === 'number'` alone (not a tool
211
+ * check) — today only Read events carry `bytes`, but this stays correct if
212
+ * that widens rather than reporting a silent 0.
213
+ *
214
+ * @param {Object[]} events - raw events (from loadTelemetry)
215
+ * @param {{ feature?: string }} [opts] - optional feature filter
216
+ * @returns {{
217
+ * total: number,
218
+ * features: string[],
219
+ * taskDurationSeconds: number,
220
+ * bytesByKind: Object<string, number>,
221
+ * bytesTotal: number,
222
+ * dispatches: {count:number, promptChars:number},
223
+ * tools: Array<{name:string,count:number}>,
224
+ * tasks: Array<Object>,
225
+ * phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
226
+ * }}
227
+ */
228
+ export function aggregateCost(events, opts = {}) {
229
+ const scoped = opts.feature
230
+ ? (events || []).filter(e => e.feature === opts.feature)
231
+ : (events || []);
232
+
233
+ const features = new Set();
234
+ const toolCounts = new Map();
235
+ const bytesByKindGlobal = new Map();
236
+ const dispatches = { count: 0, promptChars: 0 };
237
+ const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
238
+ const activeByFeature = new Map(); // featureKey -> open task state
239
+ const tasks = [];
240
+
241
+ for (const e of scoped) {
242
+ if (!e || typeof e !== 'object') continue;
243
+ if (e.feature) features.add(e.feature);
244
+
245
+ if (e.event === 'TaskStart' && e.task) {
246
+ activeByFeature.set(featureKey(e), newTaskState(e.feature, e.task, e.ts));
247
+ continue;
248
+ }
249
+
250
+ if (e.event === 'TaskEnd' && e.task) {
251
+ const key = featureKey(e);
252
+ const state = activeByFeature.get(key);
253
+ if (state && state.task === e.task) {
254
+ state.endTs = e.ts;
255
+ state.status = e.status ?? null;
256
+ state.open = false;
257
+ activeByFeature.delete(key);
258
+ tasks.push(finalizeTask(state));
259
+ }
260
+ continue;
261
+ }
262
+
263
+ if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) || 0) + 1);
264
+ const active = activeByFeature.get(featureKey(e));
265
+ if (active && e.tool) {
266
+ active.tools.set(e.tool, (active.tools.get(e.tool) || 0) + 1);
267
+ }
268
+
269
+ if (typeof e.bytes === 'number' && Number.isFinite(e.bytes)) {
270
+ const kind = e.kind || 'unknown';
271
+ bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) + e.bytes);
272
+ if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) + e.bytes);
273
+ if (e.phase) {
274
+ if (!phaseBytes.has(e.phase)) phaseBytes.set(e.phase, new Map());
275
+ const m = phaseBytes.get(e.phase);
276
+ m.set(kind, (m.get(kind) || 0) + e.bytes);
277
+ }
278
+ }
279
+
280
+ if ((e.tool === 'Agent' || e.tool === 'Task') && typeof e.prompt === 'string' && e.prompt) {
281
+ dispatches.count += 1;
282
+ dispatches.promptChars += e.prompt.length;
283
+ if (active) {
284
+ active.dispatches.count += 1;
285
+ active.dispatches.promptChars += e.prompt.length;
286
+ }
287
+ }
288
+ }
289
+
290
+ // Tasks still open when the stream ends (no TaskEnd observed yet) are
291
+ // reported, not dropped — see the "open" contract in the docstring above.
292
+ for (const state of activeByFeature.values()) tasks.push(finalizeTask(state));
293
+ tasks.sort((a, b) => (a.startTs || '').localeCompare(b.startTs || ''));
294
+
295
+ const phases = [...phaseBytes.entries()]
296
+ .map(([phase, kindMap]) => ({
297
+ phase,
298
+ bytesByKind: mapToObj(kindMap),
299
+ bytesTotal: sumValues(kindMap),
300
+ }))
301
+ .sort((a, b) => a.phase.localeCompare(b.phase));
302
+
303
+ const taskDurationSeconds = tasks.reduce((sum, t) => sum + (t.durationSeconds || 0), 0);
304
+
305
+ return {
306
+ total: scoped.length,
307
+ features: [...features],
308
+ taskDurationSeconds,
309
+ bytesByKind: mapToObj(bytesByKindGlobal),
310
+ bytesTotal: sumValues(bytesByKindGlobal),
311
+ dispatches,
312
+ tools: rank(toolCounts),
313
+ tasks,
314
+ phases,
315
+ };
316
+ }