@polymorphism-tech/morph-spec 8.37.1 → 8.38.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 (128) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. package/src/utils/hooks-installer.js +75 -15
@@ -30,22 +30,26 @@
30
30
  * rather than silently destroy the other feature's use case.
31
31
  */
32
32
 
33
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
33
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
34
34
  import { basename, join, relative } from 'path';
35
35
  import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
36
36
  import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
37
37
  import { reservationOwner } from './usecase-registry.js';
38
+ import { SUPERSEDED, useCaseFilesById, useCaseId } from './acervo-format.js';
39
+
40
+ export { useCaseId };
38
41
 
39
42
  const UC = OUTPUT_SCHEMA.usecases;
40
- const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
41
43
 
42
44
  /** Project-relative path of the permanent use-case archive (git pathspec-ready). */
43
45
  export const SPECS_USECASES_PATHSPEC = join('.morph', 'specs', 'usecases');
44
46
 
45
- /** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
46
- export function useCaseId(filename) {
47
- const m = /^(UC-\d+)-/.exec(filename);
48
- return m ? m[1] : null;
47
+ const FRONTMATTER_RE = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/;
48
+
49
+ /** `true` when the document opens with a frontmatter block. */
50
+ export function hasFrontmatter(content) {
51
+ const m = FRONTMATTER_RE.exec(content);
52
+ return !!m && m.index === 0;
49
53
  }
50
54
 
51
55
  /**
@@ -58,33 +62,70 @@ export function useCaseId(filename) {
58
62
  *
59
63
  * @param {string} content
60
64
  * @param {string} key
61
- * @param {string} value
65
+ * @param {string} value - written as-is; quote it first when it is free text
62
66
  * @returns {string}
63
67
  */
64
- function setFrontmatterField(content, key, value) {
65
- const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
68
+ export function setFrontmatterField(content, key, value) {
69
+ const m = FRONTMATTER_RE.exec(content);
66
70
  if (!m || m.index !== 0) return content;
67
71
 
68
72
  const [full, open, body, close] = m;
69
73
  const eol = full.includes('\r\n') ? '\r\n' : '\n';
70
74
  const fieldLine = new RegExp(`^${key}:[ \\t]*.*$`, 'm');
75
+ // Function replacer: a value carrying `$&` or `$1` is text, not a pattern.
71
76
  const next = fieldLine.test(body)
72
- ? body.replace(fieldLine, `${key}: ${value}`)
77
+ ? body.replace(fieldLine, () => `${key}: ${value}`)
73
78
  : `${body}${eol}${key}: ${value}`;
74
79
 
75
80
  return open + next + close + content.slice(full.length);
76
81
  }
77
82
 
78
- /** Read a single top-level frontmatter field. `null` when absent or there is no frontmatter block. */
79
- function readFrontmatterField(content, key) {
80
- const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
83
+ /**
84
+ * Drop a single top-level frontmatter field (its whole line, EOL included).
85
+ * Absent field or no frontmatter → content unchanged.
86
+ *
87
+ * @param {string} content
88
+ * @param {string} key
89
+ * @returns {string}
90
+ */
91
+ export function removeFrontmatterField(content, key) {
92
+ const m = FRONTMATTER_RE.exec(content);
93
+ if (!m || m.index !== 0) return content;
94
+
95
+ const [full, open, body, close] = m;
96
+ const lines = body.split(/(?<=\n)/);
97
+ const kept = lines.filter((line) => !new RegExp(`^${key}:`).test(line));
98
+ if (kept.length === lines.length) return content;
99
+ // The last kept line lost its EOL when the removed one was the final line;
100
+ // the closing delimiter carries its own leading EOL, so trim the dangling one.
101
+ const next = kept.join('').replace(/\r?\n$/, '');
102
+ return open + next + close + content.slice(full.length);
103
+ }
104
+
105
+ /**
106
+ * Read a single top-level frontmatter field. `null` when absent or there is no
107
+ * frontmatter block. A double-quoted value is read as the JSON string it is
108
+ * written as (that is how free text such as `scope:` is stored); a
109
+ * single-quoted one as YAML reads it (`''` is a quote).
110
+ */
111
+ export function readFrontmatterField(content, key) {
112
+ const m = FRONTMATTER_RE.exec(content);
81
113
  if (!m || m.index !== 0) return null;
82
114
  const line = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(m[2]);
83
- return line ? line[1].trim() : null;
115
+ if (!line) return null;
116
+ const raw = line[1].trim();
117
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
118
+ try { return JSON.parse(raw); } catch { return raw; }
119
+ }
120
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1).replace(/''/g, "'");
121
+ return raw;
84
122
  }
85
123
 
86
124
  /**
87
- * Rewrite the frontmatter `status:` to `implemented`.
125
+ * Rewrite the frontmatter `status:` to `implemented` — unless the use case is
126
+ * superseded (its status says so, or it carries a `superseded_by`): a
127
+ * promotion, such as a `bug` fix on the part that still holds, must never
128
+ * hand the rest of it back its authority.
88
129
  *
89
130
  * A file with no frontmatter is copied verbatim: the use case belongs to the
90
131
  * author, and inventing a header would be a silent edit of their document.
@@ -93,7 +134,32 @@ function readFrontmatterField(content, key) {
93
134
  * @returns {string}
94
135
  */
95
136
  export function stampImplemented(content) {
96
- return setFrontmatterField(content, 'status', 'implemented');
137
+ const superseded = readFrontmatterField(content, 'status') === SUPERSEDED
138
+ || readFrontmatterField(content, 'superseded_by') !== null;
139
+ return setFrontmatterField(content, 'status', superseded ? SUPERSEDED : 'implemented');
140
+ }
141
+
142
+ /**
143
+ * Carry the acervo's supersession onto an incoming copy that does not declare
144
+ * one — a copy taken before the revocation landed, or stripped of the fields
145
+ * on the way. Without this the promotion would publish the old use case as
146
+ * `implemented` again.
147
+ *
148
+ * @param {string} content - the incoming copy
149
+ * @param {string|null} previous - the acervo entry it replaces
150
+ * @returns {{ content: string, supersededBy: string|null, scope: string|null }|null} null = nothing to carry
151
+ */
152
+ function inheritSupersession(content, previous) {
153
+ if (!previous || readFrontmatterField(previous, 'status') !== SUPERSEDED) return null;
154
+ if (readFrontmatterField(content, 'status') === SUPERSEDED || readFrontmatterField(content, 'superseded_by') !== null) return null;
155
+ if (!hasFrontmatter(content)) return null;
156
+
157
+ const supersededBy = readFrontmatterField(previous, 'superseded_by');
158
+ const scope = readFrontmatterField(previous, 'scope');
159
+ let next = setFrontmatterField(content, 'status', SUPERSEDED);
160
+ if (supersededBy) next = setFrontmatterField(next, 'superseded_by', supersededBy);
161
+ if (scope !== null) next = setFrontmatterField(next, 'scope', JSON.stringify(scope));
162
+ return { content: next, supersededBy, scope };
97
163
  }
98
164
 
99
165
  /**
@@ -138,27 +204,24 @@ function collisionMessage({ id, incoming, existing, existingFeature, feature, re
138
204
  * @param {string} cwd - project root that owns `.morph/specs/`
139
205
  * @param {string} featureDir - the feature folder holding `2-plan/usecases/`
140
206
  * @returns {{ promoted: string[], replaced: string[], refused: Array<{id: string,
141
- * incoming: string, existing: string, existingFeature: string, message: string}> }}
142
- * filenames, always arrays
207
+ * incoming: string, existing: string, existingFeature: string, message: string}>,
208
+ * keptSuperseded: Array<{id: string, file: string, supersededBy: string|null, scope?: string}> }}
209
+ * always arrays; `keptSuperseded` = promoted copies that stayed superseded
210
+ * because the acervo entry they replaced was
143
211
  */
144
212
  export function promoteUseCases(cwd, featureDir) {
145
- const empty = { promoted: [], replaced: [], refused: [] };
213
+ const empty = { promoted: [], replaced: [], refused: [], keptSuperseded: [] };
146
214
  const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
147
215
  const feature = basename(featureDir);
148
216
 
149
- let names;
150
- try {
151
- if (!existsSync(srcDir)) return empty;
152
- names = readdirSync(srcDir).filter((n) => UC_FILENAME_RE.test(n)).sort();
153
- } catch {
154
- return empty;
155
- }
217
+ const names = [...useCaseFilesById(srcDir).values()].flat().sort();
156
218
  if (!names.length) return empty;
157
219
 
158
220
  const destDir = join(cwd, '.morph', 'specs', 'usecases');
159
221
  const promoted = [];
160
222
  const replaced = [];
161
223
  const refused = [];
224
+ const keptSuperseded = [];
162
225
 
163
226
  try {
164
227
  mkdirSync(destDir, { recursive: true });
@@ -173,18 +236,16 @@ export function promoteUseCases(cwd, featureDir) {
173
236
 
174
237
  // Existing archive entries, indexed by use-case ID — the key the overwrite
175
238
  // rule is written against.
176
- const existingById = new Map();
177
- try {
178
- for (const name of readdirSync(destDir)) {
179
- const id = UC_FILENAME_RE.test(name) ? useCaseId(name) : null;
180
- if (id) existingById.set(id, name);
181
- }
182
- } catch { /* unreadable archive — treat as empty, never fatal */ }
239
+ const existingById = new Map([...useCaseFilesById(destDir)].map(([id, files]) => [id, files[files.length - 1]]));
183
240
 
184
241
  for (const name of names) {
185
242
  try {
186
243
  const id = useCaseId(name);
187
244
  const stale = id ? existingById.get(id) : null;
245
+ let previous = null;
246
+ if (stale) {
247
+ try { previous = readFileSync(join(destDir, stale), 'utf8'); } catch { /* unreadable — no signal */ }
248
+ }
188
249
 
189
250
  // Same ID under a different filename: normally the old file is dropped
190
251
  // (a slug reword, or the `bug` flow correcting a published UC), or the
@@ -202,8 +263,7 @@ export function promoteUseCases(cwd, featureDir) {
202
263
  // (no git common dir, or a legacy archive entry predating this stamp) is
203
264
  // fail-open: nothing to compare against, so nothing is refused.
204
265
  if (stale && stale !== name) {
205
- let staleFeature = null;
206
- try { staleFeature = readFrontmatterField(readFileSync(join(destDir, stale), 'utf8'), 'sourceFeature'); } catch { /* unreadable — no signal */ }
266
+ const staleFeature = previous === null ? null : readFrontmatterField(previous, 'sourceFeature');
207
267
  const incomingOwner = id ? reservationOwner(id, { cwd, gitCommonDir }) : null;
208
268
 
209
269
  if (staleFeature && staleFeature !== feature && incomingOwner === feature) {
@@ -224,13 +284,18 @@ export function promoteUseCases(cwd, featureDir) {
224
284
  replaced.push(stale);
225
285
  }
226
286
 
227
- let content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
228
- content = stampSourceFeature(content, feature);
287
+ let content = readFileSync(join(srcDir, name), 'utf8');
288
+ const kept = inheritSupersession(content, previous);
289
+ if (kept) {
290
+ content = kept.content;
291
+ keptSuperseded.push({ id, file: name, supersededBy: kept.supersededBy, ...(kept.scope !== null ? { scope: kept.scope } : {}) });
292
+ }
293
+ content = stampSourceFeature(stampImplemented(content), feature);
229
294
  writeFileSync(join(destDir, name), content, 'utf8');
230
295
  promoted.push(name);
231
296
  if (id) existingById.set(id, name);
232
297
  } catch { /* one unreadable use case must not sink the rest */ }
233
298
  }
234
299
 
235
- return { promoted, replaced, refused };
300
+ return { promoted, replaced, refused, keptSuperseded };
236
301
  }
@@ -40,10 +40,9 @@ import {
40
40
  } from 'fs';
41
41
  import { join } from 'path';
42
42
 
43
- /** A UC filename's leading `UC-{nn}` — deliberately NOT imported from
44
- * `usecase-promoter.js`: that module will come to depend on THIS one (to look
45
- * up who reserved a given number), and a two-way import would be circular. */
46
- const UC_ID_RE = /^(UC-(\d+))-/;
43
+ // From acervo-format.js, not usecase-promoter.js: the promoter depends on THIS
44
+ // module (to look up who reserved a number), and a two-way import is circular.
45
+ import { useCaseId } from './acervo-format.js';
47
46
 
48
47
  /** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
49
48
  function formatUcId(n) {
@@ -138,9 +137,10 @@ function maxUcNumberInDir(dir) {
138
137
  try {
139
138
  if (!existsSync(dir)) return 0;
140
139
  for (const name of readdirSync(dir)) {
141
- const m = UC_ID_RE.exec(name);
142
- if (!m) continue;
143
- const n = parseInt(m[2], 10);
140
+ // Any `UC-{nn}-` prefix counts, canonical or not: a number already on disk is never handed out again.
141
+ const id = useCaseId(name);
142
+ if (!id) continue;
143
+ const n = parseInt(id.slice('UC-'.length), 10);
144
144
  if (Number.isFinite(n) && n > max) max = n;
145
145
  }
146
146
  } catch { /* unreadable dir — treat as empty, never fatal */ }
@@ -12,6 +12,7 @@ import fs from 'fs-extra';
12
12
  import { readFileSync, existsSync } from 'fs';
13
13
  import { join, dirname } from 'path';
14
14
  import { fileURLToPath } from 'url';
15
+ import { UC_ID_RE } from '../specs/acervo-format.js';
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
  const SCHEMA_PATH = join(__dirname, '..', '..', '..', 'framework', 'schemas', 'tasks.schema.json');
@@ -26,8 +27,6 @@ export function getSchema() {
26
27
  const VALID_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
27
28
  const VALID_EFFORT = ['S', 'M', 'L', 'XL'];
28
29
  const VALID_GROUP = ['backend', 'frontend', 'tests', 'infra', 'docs'];
29
- /** Use-case reference: the ID only (`UC-07`), never the filename. */
30
- const USECASE_RE = /^UC-\d+$/;
31
30
  /** Veredito de reuso por conceito — ver framework/schemas/tasks.schema.json. */
32
31
  const VALID_REUSE_VERDICT = ['REUSAR', 'ESTENDER', 'CRIAR'];
33
32
 
@@ -86,7 +85,7 @@ export function validateTasksJson(data) {
86
85
  // Optional: a task with no observable behaviour references no use case.
87
86
  // When present it must be the bare ID — `morph-spec dag` is the gate that
88
87
  // catches "UC-07-listar.md" or "UC7" before the plan reaches Gate 2.
89
- if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !USECASE_RE.test(task.usecase))) {
88
+ if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !UC_ID_RE.test(task.usecase))) {
90
89
  errors.push(`${ctx}.usecase must match /^UC-\\d+$/ (the ID alone, e.g. "UC-07")`);
91
90
  }
92
91
 
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Loop measurements over the harness event stream: turns, tool calls split by
3
+ * orchestrator and sub-agent, time per command class, identical reruns, file
4
+ * rereads, feature-scope verify count, git calls per task and whether the
5
+ * reuse validator ran. Computed the same way on every window so a later
6
+ * window compares with an earlier one.
7
+ *
8
+ * Every number degrades to null ("not measurable here") when the stream lacks
9
+ * the field it needs — a v1 event never counts as a zero.
10
+ *
11
+ * A rerun is the same producer command (what runs before the output filter:
12
+ * `dotnet test | tail -25` then `| tail -20` is one test run twice) in the same
13
+ * feature within 10 minutes of its previous run. `rate` counts the repeats;
14
+ * `burstRate` counts every run that belongs to a burst, the first included.
15
+ */
16
+
17
+ import { ingestedBytes } from './telemetry-reader.js';
18
+ import { producerOf } from '../../../framework/hooks/shared/command-tokens.js';
19
+ import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
20
+
21
+ export const RERUN_WINDOW_MS = 10 * 60 * 1000;
22
+
23
+ const RERUN_CLASSES = new Set(['test', 'build', 'verify']);
24
+ const READ_ONLY = new Set(['grep', 'rg', 'sed', 'ls', 'cat', 'find', 'head', 'tail', 'wc', 'tree', 'awk']);
25
+ const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
26
+ const NO_FEATURE = '\u0000';
27
+
28
+ /**
29
+ * The command without quoted text and heredoc bodies: `morph-spec verify`
30
+ * inside a commit message is not a verify run.
31
+ */
32
+ function executedText(command) {
33
+ const beforeHeredoc = command.split('<<', 1)[0];
34
+ return beforeHeredoc.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, (q) => q[0] + ' '.repeat(q.length - 2) + q[0]);
35
+ }
36
+
37
+ /**
38
+ * Kind (fine) and class (coarse) of a shell command. Searched anywhere in the
39
+ * command, most specific first, so `cd x && dotnet test` is a test.
40
+ *
41
+ * @param {string} command
42
+ * @returns {{ kind: string, klass: string }}
43
+ */
44
+ export function classifyCommand(command) {
45
+ const cmd = String(command || '');
46
+ const verify = /\bmorph-spec(?:\.js)?\s+verify\s+[^\s;&|)]+(\s+T\d+\b)?/.exec(executedText(cmd));
47
+ if (verify) return { kind: verify[1] ? 'verify-task' : 'verify-feature', klass: 'verify' };
48
+ if (/\bdotnet\s+test\b/.test(cmd)) {
49
+ if (/--list-tests\b/.test(cmd)) return { kind: 'dotnet-test-list', klass: 'test' };
50
+ return { kind: /--filter\b/.test(cmd) ? 'dotnet-test-filtered' : 'dotnet-test-full', klass: 'test' };
51
+ }
52
+ if (/\b(?:(?:npm|pnpm|yarn)\s+(?:run\s+)?test|vitest|jest|node\s+--test|run-tests\.mjs)\b/.test(cmd)) {
53
+ return { kind: 'js-test', klass: 'test' };
54
+ }
55
+ if (/\b(?:dotnet\s+build|(?:npm|pnpm|yarn)\s+(?:run\s+)?build|next\s+build|tsc)\b/.test(cmd)) {
56
+ return { kind: 'build', klass: 'build' };
57
+ }
58
+ if (/\b(?:morph-spec\s+e2e|playwright\s+test|docker\s+compose\s+up)\b/.test(cmd)) return { kind: 'e2e', klass: 'e2e' };
59
+ const git = /\bgit\s+(?:-C\s+(?:"[^"]*"|\S+)\s+)?([a-z][a-z-]*)/.exec(cmd);
60
+ if (git) return { kind: `git-${git[1]}`, klass: 'git' };
61
+ const morph = /\bmorph-spec\s+([a-z][a-z-]*)/.exec(cmd);
62
+ if (morph) return { kind: `morph-spec-${morph[1]}`, klass: 'morph-spec' };
63
+ const first = cmd.trim().split(/\s+/, 1)[0];
64
+ if (READ_ONLY.has(first)) return { kind: first, klass: 'read' };
65
+ return { kind: 'other', klass: 'other' };
66
+ }
67
+
68
+ /** `git status` and `git diff` occurrences — `git status && git diff` is two calls. */
69
+ function gitCalls(command) {
70
+ const text = executedText(String(command || ''));
71
+ const count = (sub) => (text.match(new RegExp(`\\bgit\\s+(?:-C\\s+(?:"[^"]*"|\\S+)\\s+)?${sub}\\b`, 'g')) || []).length;
72
+ return { status: count('status'), diff: count('diff') };
73
+ }
74
+
75
+ function median(values) {
76
+ const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
77
+ if (!v.length) return null;
78
+ const mid = Math.floor(v.length / 2);
79
+ return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
80
+ }
81
+
82
+ function rate(part, whole) {
83
+ return whole ? Math.round((part / whole) * 1000) / 1000 : null;
84
+ }
85
+
86
+ function isV2(e) {
87
+ return Number.isInteger(e.schemaVersion) && e.schemaVersion >= 2;
88
+ }
89
+
90
+ function whoOf(e) {
91
+ if (e.agentId) return 'subagent';
92
+ return isV2(e) ? 'orchestrator' : 'unknown';
93
+ }
94
+
95
+ function newCounters() {
96
+ return {
97
+ toolCalls: { total: 0, orchestrator: 0, subagent: 0, unknown: 0 },
98
+ turns: { total: 0, orchestrator: 0, subagent: 0, unknown: 0 },
99
+ timeByClass: {},
100
+ reruns: { runs: 0, reruns: 0, inBursts: 0 },
101
+ git: { status: 0, diff: 0 },
102
+ reads: { reads: 0, bytes: 0, redundantBytes: 0, windowed: 0 },
103
+ };
104
+ }
105
+
106
+ function addTime(counters, klass, durationMs) {
107
+ const slot = counters.timeByClass[klass] ||= { runs: 0, measuredMs: 0, unmeasured: 0 };
108
+ slot.runs += 1;
109
+ if (Number.isFinite(durationMs)) slot.measuredMs += durationMs;
110
+ else slot.unmeasured += 1;
111
+ }
112
+
113
+ function finishCounters(c, turnsObserved) {
114
+ return {
115
+ toolCalls: c.toolCalls,
116
+ turns: turnsObserved ? c.turns : null,
117
+ timeByClass: c.timeByClass,
118
+ reruns: { ...c.reruns, rate: rate(c.reruns.reruns, c.reruns.runs), burstRate: rate(c.reruns.inBursts, c.reruns.runs) },
119
+ git: c.git,
120
+ rereads: { ...c.reads, rate: rate(c.reads.redundantBytes, c.reads.bytes) },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Why a command ran again within the window. Edits in between and a changed
126
+ * output filter need no exit code; the pass/fail split does, so a masked or v1
127
+ * exit code falls back to "after an edit or not".
128
+ */
129
+ function rerunClass(prev, current, editsBetween) {
130
+ if (isV2(prev) && prev.outcome === 'timeout') return 'timeout';
131
+ if (editsBetween === 0 && prev.command !== current.command) return 'refilter';
132
+ if (!isV2(prev) || !isV2(current) || prev.exitMasked || current.exitMasked) {
133
+ return editsBetween > 0 ? 'unknown-after-edit' : 'unknown-no-edit';
134
+ }
135
+ const prevFailed = prev.outcome === 'error' || (Number.isInteger(prev.exitCode) && prev.exitCode !== 0);
136
+ const nowFailed = current.outcome === 'error' || (Number.isInteger(current.exitCode) && current.exitCode !== 0);
137
+ if (prevFailed) {
138
+ if (editsBetween > 0) return 'fix-loop';
139
+ return nowFailed ? 'retry' : 'flake';
140
+ }
141
+ return editsBetween > 0 ? 're-verify' : 'redundant';
142
+ }
143
+
144
+ function reuseStateOf(e) {
145
+ const skipped = (e.nodes?.validators?.skipped || []).find((s) => s?.validator === 'reuse');
146
+ if (skipped) return { state: 'skipped', reason: skipped.reason || null };
147
+ if ((e.nodes?.validators?.ran || []).includes('reuse')) return { state: 'ran', reason: null };
148
+ return { state: 'absent', reason: null };
149
+ }
150
+
151
+ /**
152
+ * @param {Object[]} events - deduplicated, time-ordered (loadTelemetry)
153
+ * @param {{ feature?: string, top?: number }} [opts]
154
+ * @returns {Object} see framework/docs/CLI.md, "cost --metrics"
155
+ */
156
+ export function computeMetrics(events, opts = {}) {
157
+ const top = Number.isInteger(opts.top) && opts.top > 0 ? opts.top : 10;
158
+ const scoped = (events || []).filter((e) => e && typeof e === 'object' && (!opts.feature || e.feature === opts.feature));
159
+
160
+ const global = newCounters();
161
+ const byFeature = new Map();
162
+ const openTask = new Map();
163
+ const tasks = [];
164
+ const lastRun = new Map();
165
+ const editCount = new Map();
166
+ const readsSeen = new Set();
167
+ const files = new Map();
168
+ const rerunClasses = {};
169
+ const rerunByKind = {};
170
+ const verifyByFeature = {};
171
+ const fullSuite = { runs: 0, withBuild: 0, byPhase: {} };
172
+ const lastFingerprint = new Map();
173
+ const unchangedRepeats = {};
174
+ const reuse = { runs: 0, byTree: {}, skipReasons: {} };
175
+ const tasksOf = new Map();
176
+ let callsInTaskWindows = 0;
177
+ let v2 = 0;
178
+ let legacy = 0;
179
+ let turnsObserved = false;
180
+ let from = null;
181
+ let to = null;
182
+
183
+ const featureCounters = (key) => {
184
+ if (!byFeature.has(key)) byFeature.set(key, newCounters());
185
+ return byFeature.get(key);
186
+ };
187
+ const closeTask = (key, endTs, status) => {
188
+ const task = openTask.get(key);
189
+ if (!task) return;
190
+ openTask.delete(key);
191
+ task.endTs = endTs;
192
+ task.status = status;
193
+ task.open = endTs === null;
194
+ tasks.push(task);
195
+ };
196
+
197
+ for (const e of scoped) {
198
+ if (isV2(e)) v2 += 1; else legacy += 1;
199
+ if (e.ts) {
200
+ from ??= e.ts;
201
+ to = e.ts;
202
+ }
203
+ const fkey = e.feature || NO_FEATURE;
204
+
205
+ if (e.event === 'TaskStart' && e.task) {
206
+ if (!tasksOf.has(fkey)) tasksOf.set(fkey, new Set());
207
+ tasksOf.get(fkey).add(e.task);
208
+ closeTask(fkey, null, null);
209
+ openTask.set(fkey, { feature: e.feature ?? null, task: e.task, startTs: e.ts, endTs: null, status: null, open: true, c: newCounters() });
210
+ continue;
211
+ }
212
+ if (e.event === 'TaskEnd' && e.task) {
213
+ if (openTask.get(fkey)?.task === e.task) closeTask(fkey, e.ts, e.status ?? null);
214
+ continue;
215
+ }
216
+
217
+ const sinks = [global, featureCounters(fkey)];
218
+ const task = openTask.get(fkey);
219
+ if (task) sinks.push(task.c);
220
+
221
+ if (e.event === 'ToolBatch') {
222
+ turnsObserved = true;
223
+ for (const c of sinks) { c.turns.total += 1; c.turns[whoOf(e)] += 1; }
224
+ continue;
225
+ }
226
+
227
+ if (e.event === 'Verify') {
228
+ if (e.scope === 'feature' && e.fingerprint && e.persisted !== false) {
229
+ if (lastFingerprint.get(fkey) === e.fingerprint) {
230
+ unchangedRepeats[e.feature || '(sem feature)'] = (unchangedRepeats[e.feature || '(sem feature)'] || 0) + 1;
231
+ }
232
+ lastFingerprint.set(fkey, e.fingerprint);
233
+ }
234
+ reuse.runs += 1;
235
+ const tree = e.tree || 'unknown';
236
+ const { state, reason } = reuseStateOf(e);
237
+ const slot = reuse.byTree[tree] ||= { ran: 0, skipped: 0, absent: 0 };
238
+ slot[state] += 1;
239
+ if (reason) reuse.skipReasons[reason] = (reuse.skipReasons[reason] || 0) + 1;
240
+ continue;
241
+ }
242
+
243
+ if (!TOOL_EVENTS.has(e.event) || !e.tool) continue;
244
+ for (const c of sinks) { c.toolCalls.total += 1; c.toolCalls[whoOf(e)] += 1; }
245
+ if (task) callsInTaskWindows += 1;
246
+
247
+ if (e.tool === 'Write' || e.tool === 'Edit') {
248
+ if (!isFailedToolCall(e)) editCount.set(fkey, (editCount.get(fkey) || 0) + 1);
249
+ continue;
250
+ }
251
+
252
+ if (e.tool === 'Read') {
253
+ const bytes = ingestedBytes(e);
254
+ if (bytes === null || !e.target) continue;
255
+ const window = `${fkey}\u0000${e.target}\u0000${e.offset || 1}\u0000${e.limit || 0}`;
256
+ const redundant = readsSeen.has(window) ? bytes : 0;
257
+ readsSeen.add(window);
258
+ for (const c of sinks) {
259
+ c.reads.reads += 1;
260
+ c.reads.bytes += bytes;
261
+ c.reads.redundantBytes += redundant;
262
+ if (Number.isFinite(e.bytesRead)) c.reads.windowed += 1;
263
+ }
264
+ const fileKey = `${fkey}\u0000${e.target}`;
265
+ const file = files.get(fileKey) || { feature: e.feature ?? null, file: e.target, reads: 0, bytes: 0, redundantBytes: 0 };
266
+ file.reads += 1;
267
+ file.bytes += bytes;
268
+ file.redundantBytes += redundant;
269
+ files.set(fileKey, file);
270
+ continue;
271
+ }
272
+
273
+ if (e.tool !== 'Bash' && e.tool !== 'PowerShell') continue;
274
+ const { kind, klass } = classifyCommand(e.command);
275
+ const git = gitCalls(e.command);
276
+ for (const c of sinks) {
277
+ addTime(c, klass, e.durationMs);
278
+ c.git.status += git.status;
279
+ c.git.diff += git.diff;
280
+ }
281
+
282
+ if (kind === 'dotnet-test-full') {
283
+ const rebuilds = !/--no-build\b/.test(e.command);
284
+ const phase = fullSuite.byPhase[e.phase || '-'] ||= { runs: 0, withBuild: 0 };
285
+ fullSuite.runs += 1;
286
+ phase.runs += 1;
287
+ if (rebuilds) { fullSuite.withBuild += 1; phase.withBuild += 1; }
288
+ }
289
+
290
+ if (klass === 'verify') {
291
+ const slot = verifyByFeature[e.feature || '(sem feature)'] ||= { feature: 0, task: 0 };
292
+ slot[kind === 'verify-feature' ? 'feature' : 'task'] += 1;
293
+ }
294
+
295
+ if (!RERUN_CLASSES.has(klass) || typeof e.command !== 'string') continue;
296
+ const runKey = `${fkey}\u0000${producerOf(e.command)}`;
297
+ const prev = lastRun.get(runKey);
298
+ const edits = editCount.get(fkey) || 0;
299
+ const t = Date.parse(e.ts ?? '');
300
+ const isRerun = Boolean(prev) && Number.isFinite(t) && t - prev.t <= RERUN_WINDOW_MS;
301
+ const kindSlot = rerunByKind[kind] ||= { runs: 0, reruns: 0, inBursts: 0 };
302
+ kindSlot.runs += 1;
303
+ for (const c of sinks) c.reruns.runs += 1;
304
+ const run = { t: Number.isFinite(t) ? t : 0, edits, event: e, sinks, kindSlot, inBurst: false };
305
+ if (isRerun) {
306
+ kindSlot.reruns += 1;
307
+ for (const c of sinks) c.reruns.reruns += 1;
308
+ // A burst counts its first run too; that one is only known once the
309
+ // second arrives, so it is credited to where it ran, late.
310
+ for (const r of prev.inBurst ? [run] : [prev, run]) {
311
+ r.inBurst = true;
312
+ r.kindSlot.inBursts += 1;
313
+ for (const c of r.sinks) c.reruns.inBursts += 1;
314
+ }
315
+ const why = rerunClass(prev.event, e, edits - prev.edits);
316
+ rerunClasses[why] = (rerunClasses[why] || 0) + 1;
317
+ }
318
+ lastRun.set(runKey, run);
319
+ }
320
+
321
+ for (const key of [...openTask.keys()]) closeTask(key, null, null);
322
+ tasks.sort((a, b) => String(a.startTs).localeCompare(String(b.startTs)));
323
+
324
+ const taskRows = tasks.map((t) => ({
325
+ feature: t.feature,
326
+ task: t.task,
327
+ startTs: t.startTs,
328
+ endTs: t.endTs,
329
+ open: t.open,
330
+ status: t.status,
331
+ ...finishCounters(t.c, turnsObserved),
332
+ }));
333
+
334
+ const featureRows = [...byFeature.entries()]
335
+ .filter(([key]) => key !== NO_FEATURE)
336
+ .map(([feature, c]) => ({
337
+ feature,
338
+ ...finishCounters(c, turnsObserved),
339
+ verify: verifyByFeature[feature] || { feature: 0, task: 0 },
340
+ }))
341
+ .sort((a, b) => b.toolCalls.total - a.toolCalls.total || a.feature.localeCompare(b.feature));
342
+
343
+ // A task window only holds what ran between its TaskStart and TaskEnd, and
344
+ // a parallel batch or a late `in_progress` leaves most calls outside any
345
+ // window. Dividing each feature's totals by its task count needs no window.
346
+ const perTaskOfFeature = (pick) => median(featureRows
347
+ .filter((f) => tasksOf.has(f.feature))
348
+ .map((f) => Math.round((pick(f) / tasksOf.get(f.feature).size) * 10) / 10));
349
+ const byKind = {};
350
+ for (const [kind, slot] of Object.entries(rerunByKind)) {
351
+ byKind[kind] = { ...slot, rate: rate(slot.reruns, slot.runs), burstRate: rate(slot.inBursts, slot.runs) };
352
+ }
353
+
354
+ return {
355
+ scope: { feature: opts.feature ?? null },
356
+ window: { from, to, events: scoped.length },
357
+ coverage: { v2, legacy },
358
+ ...finishCounters(global, turnsObserved),
359
+ rerunsByKind: byKind,
360
+ rerunClasses,
361
+ topRereads: [...files.values()]
362
+ .filter((f) => f.redundantBytes > 0)
363
+ .sort((a, b) => b.redundantBytes - a.redundantBytes || a.file.localeCompare(b.file))
364
+ .slice(0, top),
365
+ verify: {
366
+ byFeature: verifyByFeature,
367
+ featureScopeMedian: median(featureRows.map((f) => f.verify.feature).filter((n) => n > 0)),
368
+ unchangedRepeats,
369
+ },
370
+ gitPerTaskMedian: {
371
+ status: median(taskRows.map((t) => t.git.status)),
372
+ diff: median(taskRows.map((t) => t.git.diff)),
373
+ },
374
+ turnsPerTaskMedian: turnsObserved ? median(taskRows.map((t) => t.turns.total)) : null,
375
+ taskWindowCoverage: rate(callsInTaskWindows, global.toolCalls.total),
376
+ perTaskByFeature: {
377
+ git: perTaskOfFeature((f) => f.git.status + f.git.diff),
378
+ toolCalls: perTaskOfFeature((f) => f.toolCalls.total),
379
+ turns: turnsObserved ? perTaskOfFeature((f) => f.turns.total) : null,
380
+ },
381
+ dotnetFullSuite: fullSuite,
382
+ reuse,
383
+ features: featureRows,
384
+ tasks: taskRows,
385
+ };
386
+ }