@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
@@ -9,52 +9,134 @@
9
9
  * the same split fleet-scanner uses. CLI-side; runs in the target project cwd.
10
10
  */
11
11
 
12
- import { readFileSync, existsSync } from 'fs';
12
+ import { readFileSync, existsSync, readdirSync } from 'fs';
13
13
  import { join, basename } from 'path';
14
14
  import { parseWorktreeList, resolvePrimaryRoot } from '../../commands/project/worktree.js';
15
+ import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
16
+ import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
15
17
 
16
- const EVENTS_FILE = join('.morph', 'logs', 'events.jsonl');
18
+ const LOGS_DIR = join('.morph', 'logs');
19
+ const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
20
+ const EVENTS_LOG = /^events(-.+)?\.jsonl$/;
21
+ // Caches a finish backup may hold; none of them contains a log.
22
+ const BACKUP_SKIP_DIRS = new Set(['node_modules', '.next', 'bin', 'obj', 'TestResults', 'graphify-out', '.git']);
17
23
 
18
24
  /**
19
- * Read the raw event stream from a project root. A corrupt/truncated line is
20
- * skipped, not fatal. Returns [] when there is no log.
25
+ * The same line reaches the reader through the live log, its rotated archives
26
+ * and every finish backup that copied it. v2 events carry an `eventId`; a v1
27
+ * event is identical only to a byte-identical copy of itself.
28
+ */
29
+ function dedupeKey(event) {
30
+ return event.eventId ? `id:${event.eventId}` : `raw:${JSON.stringify(event)}`;
31
+ }
32
+
33
+ function eventTime(event) {
34
+ const t = Date.parse(event.ts ?? '');
35
+ return Number.isNaN(t) ? 0 : t;
36
+ }
37
+
38
+ /**
39
+ * Collapse copies of the same event and restore time order across sources.
40
+ * The sort is stable, so events sharing a timestamp keep their write order.
21
41
  *
22
- * @param {string} [projectPath]
42
+ * @param {Object[]} events
23
43
  * @returns {Object[]}
24
44
  */
25
- export function loadTelemetry(projectPath) {
45
+ export function dedupeEvents(events) {
46
+ const seen = new Set();
47
+ const out = [];
48
+ for (const e of events || []) {
49
+ if (!e || typeof e !== 'object') continue;
50
+ const key = dedupeKey(e);
51
+ if (seen.has(key)) continue;
52
+ seen.add(key);
53
+ out.push(e);
54
+ }
55
+ return out.sort((a, b) => eventTime(a) - eventTime(b));
56
+ }
57
+
58
+ function readJsonlFile(path, out) {
26
59
  try {
27
- const p = join(projectPath || process.cwd(), EVENTS_FILE);
28
- if (!existsSync(p)) return [];
29
- const events = [];
30
- for (const line of readFileSync(p, 'utf-8').split('\n')) {
60
+ if (!existsSync(path)) return;
61
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
31
62
  const trimmed = line.trim();
32
63
  if (!trimmed) continue;
33
- try { events.push(JSON.parse(trimmed)); } catch { /* skip corrupt line */ }
64
+ try { out.push(JSON.parse(trimmed)); } catch { /* skip corrupt line */ }
34
65
  }
35
- return events;
66
+ } catch { /* unreadable file contributes nothing */ }
67
+ }
68
+
69
+ function archiveFiles(sessionsDir) {
70
+ try {
71
+ return readdirSync(sessionsDir)
72
+ .filter((name) => name.startsWith('events-') && EVENTS_LOG.test(name))
73
+ .sort()
74
+ .map((name) => join(sessionsDir, name));
36
75
  } catch {
37
76
  return [];
38
77
  }
39
78
  }
40
79
 
41
80
  /**
42
- * Enumerate every git worktree that has its OWN `.morph/logs/events.jsonl` —
43
- * the data source for `cost --all-trees` (Furo 3). Reuses the SAME worktree
44
- * enumeration `doctor --worktrees`/`fleet` already use (`parseWorktreeList` +
45
- * `resolvePrimaryRoot` from commands/project/worktree.js — see
46
- * src/lib/worktree/fleet-scanner.js and worktree-audit.js for the sibling
47
- * consumers) instead of re-walking `git worktree list` here.
81
+ * Read the event stream of a project root: the live log plus, by default, the
82
+ * archives SessionStart rotated into `.morph/logs/sessions/` — reading only the
83
+ * live file loses everything older than the last 5 MB.
48
84
  *
49
- * `git worktree list --porcelain` always lists the PRIMARY worktree first —
50
- * that entry is labeled `'root'`; every other entry is labeled by its
51
- * directory basename (typically the feature slug, e.g. `worktrees/{f}` →
52
- * `f`). Only trees whose events.jsonl actually exists are returned — a
53
- * worktree that never ran the harness contributes nothing, silently.
85
+ * @param {string} [projectPath]
86
+ * @param {{ archives?: boolean }} [options]
87
+ * @returns {Object[]} deduplicated, in time order; [] when there is no log
88
+ */
89
+ export function loadTelemetry(projectPath, { archives = true } = {}) {
90
+ const root = projectPath || process.cwd();
91
+ const events = [];
92
+ if (archives) for (const file of archiveFiles(join(root, LOGS_DIR, 'sessions'))) readJsonlFile(file, events);
93
+ readJsonlFile(join(root, EVENTS_FILE), events);
94
+ return dedupeEvents(events);
95
+ }
96
+
97
+ function collectBackupLogs(dir, depth, out) {
98
+ if (depth < 0) return;
99
+ let entries;
100
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
101
+ for (const entry of entries) {
102
+ const full = join(dir, entry.name);
103
+ if (entry.isDirectory()) {
104
+ if (!BACKUP_SKIP_DIRS.has(entry.name)) collectBackupLogs(full, depth - 1, out);
105
+ } else if (EVENTS_LOG.test(entry.name) && /[\\/]\.morph[\\/]logs([\\/]sessions)?$/.test(dir)) {
106
+ out.push(full);
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Event logs that `finish` saved under `<git-common-dir>/morph-spec/finish-backup/`
113
+ * before tearing a worktree down — once the worktree is gone, a feature built
114
+ * there leaves its telemetry nowhere else.
115
+ *
116
+ * @param {string} [cwd]
117
+ * @returns {{ path: string, events: Object[] }|null}
118
+ */
119
+ export function loadFinishBackupTelemetry(cwd = process.cwd()) {
120
+ const common = resolveGitCommonDir(cwd);
121
+ if (!common) return null;
122
+ const base = join(common, 'morph-spec', 'finish-backup');
123
+ const files = [];
124
+ collectBackupLogs(base, 6, files);
125
+ if (!files.length) return null;
126
+ const events = [];
127
+ for (const file of files.sort()) readJsonlFile(file, events);
128
+ return { path: base, events: dedupeEvents(events) };
129
+ }
130
+
131
+ /**
132
+ * Every git worktree that has its OWN `.morph/logs/events.jsonl` — the source
133
+ * of `cost --all-trees` — plus a `finish-backup` entry for worktrees already
134
+ * torn down. Reuses the worktree enumeration of `doctor --worktrees`/`fleet`.
54
135
  *
55
- * Fail-open, like `loadTelemetry`: outside a git repo (or on any git error)
56
- * `resolvePrimaryRoot`/`parseWorktreeList` degrade to a single root entry —
57
- * never throws.
136
+ * The PRIMARY worktree is labeled `'root'`; every other entry by its directory
137
+ * basename (typically the feature slug). A tree that never ran the harness
138
+ * contributes nothing. Outside a git repo this degrades to a single root
139
+ * entry — never throws.
58
140
  *
59
141
  * @param {string} [cwd]
60
142
  * @returns {Array<{ tree: string, path: string, events: Object[] }>}
@@ -74,6 +156,8 @@ export function loadTelemetryAllTrees(cwd = process.cwd()) {
74
156
  events: loadTelemetry(t.path),
75
157
  });
76
158
  }
159
+ const backup = loadFinishBackupTelemetry(root);
160
+ if (backup) out.push({ tree: 'finish-backup', path: backup.path, events: backup.events });
77
161
  return out;
78
162
  }
79
163
 
@@ -247,9 +331,9 @@ function finalizeTask(state) {
247
331
  * with `open: true` and `durationSeconds: null`, rather than silently
248
332
  * dropped.
249
333
  *
250
- * Byte attribution keys off `typeof e.bytes === 'number'` alone (not a tool
251
- * check) — today only Read events carry `bytes`, but this stays correct if
252
- * that widens rather than reporting a silent 0.
334
+ * Byte attribution prefers `bytesRead` (the line window a Read put in context)
335
+ * and falls back to `bytes` (the file size — all v1 events have) — keyed off
336
+ * the number alone, not the tool.
253
337
  *
254
338
  * Dispatch prompt size (Furo 3 — contract with the hooks-side telemetry-log
255
339
  * rewrite): a dispatch event's `prompt` field is now TRUNCATED to 2k chars on
@@ -259,17 +343,13 @@ function finalizeTask(state) {
259
343
  * `prompt.length`, unchanged from before — every existing byte count stays
260
344
  * exact against old logs.
261
345
  *
262
- * SessionUsage events (`{ event: 'SessionUsage', inputTokens, outputTokens,
263
- * cacheReadTokens, cacheCreationTokens }`) are a session-level rollup, not a
264
- * per-tool event — they don't touch toolCounts/bytesByKind/dispatches, only
265
- * sum into `sessionUsage` (real, hook-observed token counts — NOT the bytes
266
- * proxy). They participate in the `--feature` filter exactly like every
267
- * other event (via `scoped`, computed once at the top): a rollup with no
268
- * `feature` field is silently excluded by a feature-scoped call, same as any
269
- * other feature-less event would be — no special-casing. `sessionUsage` is
270
- * `null` when the stream carries none (nothing to show), never a zeroed
271
- * object, so a caller can tell "observed, all zero" apart from "never
272
- * observed" the same way `evalScore` does in gate-check's signals.
346
+ * SessionUsage (main session) and AgentUsage (each sub-agent) are real token
347
+ * counts, not the bytes proxy, and never touch toolCounts/bytesByKind/
348
+ * dispatches. Only their `delta` is summed — the top-level numbers are a
349
+ * running total repeated on every sample — so a v1 sample (no `delta`) is
350
+ * counted in `sessionUsageLegacySamples` and never added in. Like any event
351
+ * they obey the `--feature` filter. `sessionUsage`/`agentUsage` are `null`
352
+ * when nothing summable was observed, never a zeroed object.
273
353
  *
274
354
  * @param {Object[]} events - raw events (from loadTelemetry)
275
355
  * @param {{ feature?: string }} [opts] - optional feature filter
@@ -280,7 +360,9 @@ function finalizeTask(state) {
280
360
  * bytesByKind: Object<string, number>,
281
361
  * bytesTotal: number,
282
362
  * dispatches: {count:number, promptChars:number},
283
- * sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number}|null,
363
+ * sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number, samples:number}|null,
364
+ * sessionUsageLegacySamples: number,
365
+ * agentUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, agents:number, samples:number}|null,
284
366
  * tools: Array<{name:string,count:number}>,
285
367
  * tasks: Array<Object>,
286
368
  * phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
@@ -288,6 +370,14 @@ function finalizeTask(state) {
288
370
  */
289
371
  const SESSION_USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
290
372
 
373
+ /** Bytes a Read put in context: its window when known, else the file size. */
374
+ export function ingestedBytes(e) {
375
+ if (isFailedToolCall(e)) return null;
376
+ if (Number.isFinite(e.bytesRead)) return e.bytesRead;
377
+ if (Number.isFinite(e.bytes)) return e.bytes;
378
+ return null;
379
+ }
380
+
291
381
  export function aggregateCost(events, opts = {}) {
292
382
  const scoped = opts.feature
293
383
  ? (events || []).filter(e => e.feature === opts.feature)
@@ -300,8 +390,13 @@ export function aggregateCost(events, opts = {}) {
300
390
  const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
301
391
  const activeByFeature = new Map(); // featureKey -> open task state
302
392
  const tasks = [];
303
- const sessionUsageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
304
- let sessionUsageCount = 0;
393
+ const newUsageBucket = () => ({
394
+ totals: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 },
395
+ ids: new Set(),
396
+ samples: 0,
397
+ legacy: 0,
398
+ });
399
+ const usage = { session: newUsageBucket(), agent: newUsageBucket() };
305
400
 
306
401
  for (const e of scoped) {
307
402
  if (!e || typeof e !== 'object') continue;
@@ -325,10 +420,18 @@ export function aggregateCost(events, opts = {}) {
325
420
  continue;
326
421
  }
327
422
 
328
- if (e.event === 'SessionUsage') {
329
- sessionUsageCount += 1;
423
+ if (e.event === 'SessionUsage' || e.event === 'AgentUsage') {
424
+ const bucket = e.event === 'SessionUsage' ? usage.session : usage.agent;
425
+ // Only a delta can be summed: the top-level numbers are the transcript's
426
+ // running total, and v1 samples (no delta) repeat it on every Stop.
427
+ if (!e.delta || typeof e.delta !== 'object') {
428
+ bucket.legacy += 1;
429
+ continue;
430
+ }
431
+ bucket.samples += 1;
432
+ bucket.ids.add(e.event === 'SessionUsage' ? (e.sessionId ?? 'default') : e.agentId);
330
433
  for (const key of SESSION_USAGE_KEYS) {
331
- if (typeof e[key] === 'number' && Number.isFinite(e[key])) sessionUsageTotals[key] += e[key];
434
+ if (Number.isFinite(e.delta[key])) bucket.totals[key] += e.delta[key];
332
435
  }
333
436
  continue;
334
437
  }
@@ -339,14 +442,15 @@ export function aggregateCost(events, opts = {}) {
339
442
  active.tools.set(e.tool, (active.tools.get(e.tool) || 0) + 1);
340
443
  }
341
444
 
342
- if (typeof e.bytes === 'number' && Number.isFinite(e.bytes)) {
445
+ const read = ingestedBytes(e);
446
+ if (read !== null) {
343
447
  const kind = e.kind || 'unknown';
344
- bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) + e.bytes);
345
- if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) + e.bytes);
448
+ bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) + read);
449
+ if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) + read);
346
450
  if (e.phase) {
347
451
  if (!phaseBytes.has(e.phase)) phaseBytes.set(e.phase, new Map());
348
452
  const m = phaseBytes.get(e.phase);
349
- m.set(kind, (m.get(kind) || 0) + e.bytes);
453
+ m.set(kind, (m.get(kind) || 0) + read);
350
454
  }
351
455
  }
352
456
 
@@ -384,7 +488,13 @@ export function aggregateCost(events, opts = {}) {
384
488
  bytesByKind: mapToObj(bytesByKindGlobal),
385
489
  bytesTotal: sumValues(bytesByKindGlobal),
386
490
  dispatches,
387
- sessionUsage: sessionUsageCount > 0 ? { ...sessionUsageTotals, sessions: sessionUsageCount } : null,
491
+ sessionUsage: usage.session.samples > 0
492
+ ? { ...usage.session.totals, sessions: usage.session.ids.size, samples: usage.session.samples }
493
+ : null,
494
+ sessionUsageLegacySamples: usage.session.legacy,
495
+ agentUsage: usage.agent.samples > 0
496
+ ? { ...usage.agent.totals, agents: usage.agent.ids.size, samples: usage.agent.samples }
497
+ : null,
388
498
  tools: rank(toolCounts),
389
499
  tasks,
390
500
  phases,
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The `Verify` telemetry event: one line per `morph-spec verify` run with the
3
+ * scope, per-node status and duration, the commit it covered and whether the
4
+ * reuse validator could run. The Bash event of the same command says who
5
+ * called it and how long the whole command took; this one says what it did.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { appendTelemetryEvent, readTelemetryEvents } from '../../../framework/hooks/shared/telemetry-logger.js';
11
+
12
+ const NODE_NAMES = ['build', 'tests', 'validators', 'e2e'];
13
+
14
+ /**
15
+ * @param {Object} result - runVerify() output
16
+ * @param {{ sha: string|null, tree: 'root'|'worktree', persisted: boolean, reason: string|null,
17
+ * fingerprint?: string|null }} context
18
+ * @returns {Object}
19
+ */
20
+ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null }) {
21
+ const nodes = {};
22
+ for (const name of NODE_NAMES) {
23
+ const node = result.nodes?.[name];
24
+ if (!node) continue;
25
+ nodes[name] = { status: node.status, durationMs: Number.isFinite(node.durationMs) ? node.durationMs : null };
26
+ }
27
+ if (nodes.validators) {
28
+ nodes.validators.ran = [...(result.nodes.validators.ran || [])];
29
+ nodes.validators.skipped = (result.nodes.validators.skipped || [])
30
+ .map(({ validator, reason: why, unavailable }) => ({ validator, reason: why, unavailable: unavailable === true }));
31
+ }
32
+ return {
33
+ event: 'Verify',
34
+ feature: result.feature,
35
+ task: result.task ?? null,
36
+ scope: result.scope,
37
+ status: result.status,
38
+ sha,
39
+ fingerprint,
40
+ tree,
41
+ persisted,
42
+ reason: persisted ? null : (reason ?? null),
43
+ nodes,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The last complete run of the same verify (feature, task, scope) over the
49
+ * same tree, or null. A partial run (`--filter`, `--skip-*`, `--no-persist`)
50
+ * is not the proof a complete one replaces, so it is passed over. Only the
51
+ * live log is read: a repeat worth warning about is recent.
52
+ *
53
+ * @param {string} projectPath
54
+ * @param {{ feature: string, task: string|null, scope: string, fingerprint: string|null }} run
55
+ * @returns {{ ts: string, status: string, sha: string|null }|null}
56
+ */
57
+ export function findRepeatedVerify(projectPath, { feature, task, scope, fingerprint }) {
58
+ if (!fingerprint) return null;
59
+ const events = readTelemetryEvents(projectPath);
60
+ for (let i = events.length - 1; i >= 0; i--) {
61
+ const e = events[i];
62
+ if (e.event !== 'Verify' || e.feature !== feature || e.scope !== scope || (e.task ?? null) !== (task ?? null)) continue;
63
+ if (e.persisted === false) continue;
64
+ return e.fingerprint === fingerprint ? { ts: e.ts, status: e.status, sha: e.sha ?? null } : null;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Appends only where a morph project already exists: this command must never
71
+ * be the one that creates `.morph/`.
72
+ *
73
+ * @param {string} projectPath
74
+ * @param {Object} event
75
+ */
76
+ export function recordVerifyEvent(projectPath, event) {
77
+ if (!existsSync(join(projectPath, '.morph'))) return;
78
+ appendTelemetryEvent(event, projectPath);
79
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Code Budget Validator — o que a feature adicionou além do código.
3
+ *
4
+ * Mede, só nas linhas que esta árvore adicionou contra a branch default:
5
+ * (a) linhas de comentário que citam processo (task, UC, ADR, Gate...), que o
6
+ * standard `architecture-code-comments` manda para o commit;
7
+ * (b) linhas de teste ÷ linhas de produção, contra o orçamento por task do
8
+ * `morph-plan` (um teste por pós-condição + um de regressão por bug).
9
+ *
10
+ * Sempre `warning`: é medida de volume, e o excesso pode estar justificado na
11
+ * task. Um aviso por medida, não por linha, porque cada aviso custa 1 ponto no
12
+ * score do validador. Qualquer falha de medição vira skip com razão.
13
+ *
14
+ * @module code-budget-validator
15
+ */
16
+
17
+ import { readFileSync } from 'fs';
18
+ import { join } from 'path';
19
+
20
+ import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
21
+ import { resolveBaseRef } from '../../git/branch-classifier.js';
22
+ import { isProjectSource, isTestFile } from '../reuse/symbol-scanner.js';
23
+ import { citesProcess, commentLines } from './comment-scanner.js';
24
+
25
+ /** Razão teste ÷ produção acima da qual se avisa. */
26
+ export const MAX_TEST_RATIO = 1.5;
27
+
28
+ /** Abaixo disto a razão não é julgada: a regressão de um bug de 2 linhas cabe aqui. */
29
+ export const MIN_TEST_LINES = 40;
30
+
31
+ /** Quantos locais citar no aviso de comentário antes de resumir com "(+N)". */
32
+ const MAX_CITED = 4;
33
+
34
+ /** Código gerado não é escrito por ninguém, então não entra em nenhum dos lados. */
35
+ const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
36
+
37
+ const decimal = (n) => n.toFixed(1).replace('.', ',');
38
+
39
+ /**
40
+ * Puro, sem I/O.
41
+ *
42
+ * @param {Array<{path: string, content: string, added: number[]|null}>} files -
43
+ * `added` são as linhas adicionadas; `null` é o arquivo inteiro (novo)
44
+ * @returns {{commentLines: number, processLines: Array<{file: string, line: number, text: string}>,
45
+ * testLines: number, productionLines: number}}
46
+ */
47
+ export function measureCodeBudget(files) {
48
+ const measured = { commentLines: 0, processLines: [], testLines: 0, productionLines: 0 };
49
+
50
+ for (const { path, content, added } of files) {
51
+ if (!isProjectSource(path) || GENERATED.test(path)) continue;
52
+ const addedSet = added ? new Set(added) : null;
53
+ const isAdded = (n) => addedSet === null || addedSet.has(n);
54
+
55
+ const nonBlank = String(content).split('\n')
56
+ .filter((row, i) => isAdded(i + 1) && row.trim()).length;
57
+ if (isTestFile(path)) measured.testLines += nonBlank;
58
+ else measured.productionLines += nonBlank;
59
+
60
+ for (const [line, text] of commentLines(content, path)) {
61
+ if (!isAdded(line)) continue;
62
+ measured.commentLines++;
63
+ if (citesProcess(text)) measured.processLines.push({ file: path, line, text });
64
+ }
65
+ }
66
+
67
+ return measured;
68
+ }
69
+
70
+ /**
71
+ * @param {ReturnType<typeof measureCodeBudget>} measured
72
+ * @returns {Array<object>} avisos no formato do validation-runner
73
+ */
74
+ export function budgetIssues(measured) {
75
+ const issues = [];
76
+ const { commentLines: comments, processLines, testLines, productionLines } = measured;
77
+
78
+ if (processLines.length > 0) {
79
+ const shown = processLines.slice(0, MAX_CITED).map((p) => `${p.file}:${p.line}`);
80
+ const more = processLines.length - shown.length;
81
+ issues.push({
82
+ level: 'warning',
83
+ message:
84
+ `${processLines.length} de ${comments} linhas de comentário adicionadas citam processo ` +
85
+ `(UC, ADR, T{n}, FR, Gate, spec.md, mockup): ${shown.join(', ')}${more > 0 ? ` (+${more})` : ''}`,
86
+ file: processLines[0].file,
87
+ line: processLines[0].line,
88
+ solution:
89
+ 'A história vai na mensagem de commit e no decisions.md; no código fica só o porquê que ' +
90
+ 'ele não mostra (standard architecture-code-comments).',
91
+ });
92
+ }
93
+
94
+ if (testLines > MIN_TEST_LINES && testLines > MAX_TEST_RATIO * productionLines) {
95
+ const ratio = productionLines > 0 ? ` (${decimal(testLines / productionLines)}×)` : '';
96
+ issues.push({
97
+ level: 'warning',
98
+ message:
99
+ `linhas de teste adicionadas passam de ${decimal(MAX_TEST_RATIO)}× as de produção: ` +
100
+ `${testLines} de teste para ${productionLines} de produção${ratio}`,
101
+ solution:
102
+ 'Orçamento por task: um teste por pós-condição + um de regressão por bug, cenários do mesmo ' +
103
+ 'comportamento numa tabela, nenhum teste que só fixa texto. Excesso justificado fica escrito na task.',
104
+ });
105
+ }
106
+
107
+ return issues;
108
+ }
109
+
110
+ /**
111
+ * Validador plugado no `validation-runner` (e por ele no nó `validators` do
112
+ * `morph-spec verify`).
113
+ *
114
+ * @param {string} projectPath
115
+ * @param {string|null} [featureName] - não usado: a medida é da árvore inteira contra a default
116
+ * @param {object} [options]
117
+ * @param {Map<string, number[]|null>} [options.addedLines] - override da coleta (testes)
118
+ * @returns {Promise<{errors: number, warnings: number, issues: object[], _skipReason?: string}>}
119
+ */
120
+ export async function validateCodeBudget(projectPath, featureName = null, options = {}) {
121
+ const result = { errors: 0, warnings: 0, issues: [] };
122
+
123
+ try {
124
+ const added = options.addedLines
125
+ ?? addedLinesAgainst(resolveBaseRef(detectDefaultBranch(projectPath), projectPath), projectPath);
126
+ if (!added) {
127
+ result._skipReason = 'sem base git para medir o que a feature adicionou';
128
+ result._skipUnavailable = true;
129
+ return result;
130
+ }
131
+
132
+ const files = [];
133
+ // Todo path do diff existe no disco; um que não se lê é medida faltando, e isso se diz.
134
+ const unreadable = [];
135
+ for (const [path, lines] of added) {
136
+ if (!isProjectSource(path)) continue;
137
+ try {
138
+ files.push({ path, content: readFileSync(join(projectPath, path), 'utf8'), added: lines });
139
+ } catch {
140
+ unreadable.push(path);
141
+ }
142
+ }
143
+ const named = unreadable.slice(0, MAX_CITED).join(', ') +
144
+ (unreadable.length > MAX_CITED ? ` (+${unreadable.length - MAX_CITED})` : '');
145
+
146
+ const measured = measureCodeBudget(files);
147
+ if (measured.testLines + measured.productionLines === 0) {
148
+ result._skipReason = unreadable.length > 0
149
+ ? `nenhum arquivo adicionado pôde ser lido (${named})`
150
+ : 'nenhuma linha de código adicionada nesta árvore';
151
+ if (unreadable.length > 0) result._skipUnavailable = true;
152
+ return result;
153
+ }
154
+
155
+ result.issues = budgetIssues(measured);
156
+ if (unreadable.length > 0) {
157
+ result.issues.push({
158
+ level: 'warning',
159
+ message: `${unreadable.length} arquivo(s) adicionado(s) não puderam ser lidos, a medida é parcial: ${named}`,
160
+ solution: 'Confira se o verify roda na raiz do projeto e se os arquivos têm permissão de leitura.',
161
+ });
162
+ }
163
+ result.warnings = result.issues.length;
164
+ } catch (err) {
165
+ result.issues = [];
166
+ result.warnings = 0;
167
+ result._skipReason = `medição indisponível: ${err.message}`;
168
+ result._skipUnavailable = true;
169
+ }
170
+
171
+ return result;
172
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Linhas de comentário de um arquivo e se elas contam história de processo.
3
+ *
4
+ * O comentário é o que `stripComments` apaga: o mesmo varredor que já separa
5
+ * comentário de string para o `doctor --ai`, com a sintaxe de TS/JS ligada
6
+ * fora do C#. Um regex por linha confundiria `https://` com comentário.
7
+ *
8
+ * @module code-budget-comment-scanner
9
+ */
10
+
11
+ import { stripComments } from '../../ai/comment-stripper.js';
12
+
13
+ /** Vocabulário de processo do morph-spec, fora o id de task (abaixo). */
14
+ const PROCESS_CITATION = [
15
+ /\bUC[-\s]?\d+/,
16
+ /\bADR[-\s]?\d+/,
17
+ /\bFR[-\s]?\d+/,
18
+ /\bgate\s?[1-3]\b/i,
19
+ /\b(?:spec|mandate)\.md\b/i,
20
+ /\bmockups?\b/i,
21
+ ];
22
+
23
+ /**
24
+ * `T{n}` também é nome de parâmetro genérico. Não casa depois de `template`/
25
+ * `typename`/`generic`, nem colado a letra ou dígito (a hora ISO `24T10`).
26
+ */
27
+ const TASK_ID = /(?<!\b(?:[Tt]emplate|[Tt]ypename|[Gg]eneric)\s+)(?<![\w"])T\d{1,3}\b(?!")/;
28
+
29
+ /** Lista de tipos `<…>` e `{…}` (`Func<T1, T2>`, `cref="Func{T1,T2}"`, XML doc), de dentro para fora. */
30
+ const TYPE_LIST = /<[^<>]*>|\{[^{}]*\}/g;
31
+
32
+ function withoutTypeLists(text) {
33
+ let previous;
34
+ let current = text;
35
+ do {
36
+ previous = current;
37
+ current = current.replace(TYPE_LIST, ' ');
38
+ } while (current !== previous);
39
+ return current;
40
+ }
41
+
42
+ /**
43
+ * @param {string} commentText
44
+ * @returns {boolean} o comentário usa o vocabulário de processo acima
45
+ */
46
+ export function citesProcess(commentText) {
47
+ return PROCESS_CITATION.some((re) => re.test(commentText))
48
+ || TASK_ID.test(withoutTypeLists(commentText));
49
+ }
50
+
51
+ /**
52
+ * @param {string} content - conteúdo do arquivo
53
+ * @param {string} file - path, só para escolher a sintaxe
54
+ * @returns {Map<number, string>} nº da linha (1-based) → texto do comentário nela
55
+ */
56
+ export function commentLines(content, file) {
57
+ const text = String(content || '');
58
+ const stripped = stripComments(text, { js: !/\.cs$/i.test(file) });
59
+ const out = new Map();
60
+ let start = 0;
61
+ text.split('\n').forEach((row, index) => {
62
+ let first = -1;
63
+ let last = -1;
64
+ for (let k = 0; k < row.length; k++) {
65
+ if (stripped[start + k] === row[k]) continue;
66
+ if (first < 0) first = k;
67
+ last = k;
68
+ }
69
+ // O espaço dentro do comentário não muda ao ser apagado; o trecho vai do
70
+ // primeiro ao último caractere apagado.
71
+ if (first >= 0) out.set(index + 1, row.slice(first, last + 1).trim());
72
+ start += row.length + 1;
73
+ });
74
+ return out;
75
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Code Budget Validators
3
+ *
4
+ * Volume que a feature adicionou além do código: comentário que conta a
5
+ * história do processo e teste acima do orçamento (ver code-budget-validator.js).
6
+ */
7
+
8
+ export * from './code-budget-validator.js';
9
+ export * from './comment-scanner.js';
@@ -114,8 +114,8 @@ export function findDuplicateSymbols(declarations, existing, opts = {}) {
114
114
  const language = languageOf(decls[0].file);
115
115
 
116
116
  // Onde mais esse nome vive: no grafo (código que já existia) e nos OUTROS
117
- // arquivos desta mesma leva (código novo que o grafo ainda não viu — o
118
- // rebuild é assíncrono, e é exatamente o caso SimpleBuilding).
117
+ // arquivos desta mesma leva (código novo que o grafo ainda não viu — ele
118
+ // só é refeito depois do commit, e é exatamente o caso SimpleBuilding).
119
119
  const fromGraph = (existing.get(key) ?? []).filter(
120
120
  (e) => e.language === language && !declaredFiles.has(e.file),
121
121
  );
@@ -280,6 +280,8 @@ export async function validateReuse(projectPath, featureName = null, options = {
280
280
  // segue em frente. Um validador que reprovasse aqui transformaria uma
281
281
  // dependência opcional em pré-requisito.
282
282
  result._skipReason = 'sem grafo de codebase — `morph-spec graph build` habilita esta verificação';
283
+ // "Could not check", unlike "nothing to check": verify reports it aloud.
284
+ result._skipUnavailable = true;
283
285
  return result;
284
286
  }
285
287