@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.
- package/CLAUDE.md +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +54 -17
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- 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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
228
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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' || !
|
|
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
|
+
}
|