mandrel 2.58.0 → 2.60.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -1,418 +0,0 @@
1
- /**
2
- * model-attribution.js — per-ticket model attribution comments + rollup
3
- * (Story #2813).
4
- *
5
- * Records which Claude model executed a given ticket as a structured comment
6
- * on that ticket. Epic-level mixes are computed at query time by walking the
7
- * child tickets' comments — no Epic-scope emission is written.
8
- *
9
- * Surface:
10
- *
11
- * - {@link resolveModelIdentity} — pure resolver that picks an identity
12
- * from the fallback chain (SDK metadata → env var → 'unknown'
13
- * sentinel) and tags the source. Pure: no IO, no Date.now().
14
- * - {@link buildModelAttributionPayload} — shape the canonical payload
15
- * from a resolved identity + ticketId + timestamp.
16
- * - {@link validateModelAttributionPayload} — hand-rolled validator
17
- * matching `.agents/schemas/model-attribution.schema.json`. Returns
18
- * `{ ok: true }` or `{ ok: false, errors: string[] }`.
19
- * - {@link renderModelAttributionBody} — markdown body + fenced JSON
20
- * block (same fence convention as the other structured comments in
21
- * this repo so the shared `parseFencedJsonComment` can read it back).
22
- * - {@link emitModelAttribution} — idempotent upsert against a Task
23
- * ticket. Throws on validator failure (no comment written).
24
- * - {@link parseModelAttributionComment} — readback helper for the
25
- * rollup walker; returns `null` on missing/malformed payloads.
26
- * - {@link rollupModelAttribution} — given a Story or Epic ticket id,
27
- * walks `getSubTickets`, collects attribution comments, returns
28
- * `{ totalChildren, byModel: {...}, missing: N }`.
29
- */
30
-
31
- import { parseFencedJsonComment } from './structured-comment-parser.js';
32
- import { findStructuredComment, upsertStructuredComment } from './ticketing.js';
33
-
34
- export const MODEL_ATTRIBUTION_TYPE = 'model-attribution';
35
-
36
- /**
37
- * Sentinel returned by {@link resolveModelIdentity} when neither the SDK
38
- * metadata nor the runtime env var supplied a model id. Exported so tests
39
- * and downstream consumers can compare without re-spelling the literal.
40
- */
41
- export const UNKNOWN_MODEL_ID = 'unknown';
42
-
43
- /**
44
- * Env vars consulted as the second-priority resolver source. Listed in
45
- * priority order — the first one set wins.
46
- */
47
- const ENV_VAR_CANDIDATES = Object.freeze(['CLAUDE_MODEL', 'ANTHROPIC_MODEL']);
48
-
49
- /**
50
- * Derive the coarse family label from a canonical model id. Used for the
51
- * rollup-friendly `family` field. Returns `null` when the id does not
52
- * match a recognised family pattern (the rollup helper falls back to the
53
- * full id in that case so unknown models still aggregate distinctly).
54
- *
55
- * Recognises Anthropic Claude families: Opus, Sonnet, Haiku.
56
- *
57
- * @param {string} id
58
- * @returns {string | null}
59
- */
60
- export function deriveFamily(id) {
61
- if (typeof id !== 'string') return null;
62
- const lower = id.toLowerCase();
63
- if (lower.includes('opus')) return 'Opus';
64
- if (lower.includes('sonnet')) return 'Sonnet';
65
- if (lower.includes('haiku')) return 'Haiku';
66
- return null;
67
- }
68
-
69
- /**
70
- * Resolve the active model identity using the documented fallback chain:
71
- *
72
- * 1. `sdkMetadata.modelId` (or `.model`) when present and non-empty.
73
- * 2. `CLAUDE_MODEL` / `ANTHROPIC_MODEL` env vars (first non-empty wins).
74
- * 3. The `UNKNOWN_MODEL_ID` sentinel.
75
- *
76
- * Pure: accepts the env bag as an injectable argument so tests can pin
77
- * the resolver without mutating `process.env`. Production callers default
78
- * to `process.env`.
79
- *
80
- * @param {object} [opts]
81
- * @param {object|null} [opts.sdkMetadata] — SDK response metadata.
82
- * @param {Record<string, string|undefined>} [opts.env=process.env]
83
- * @returns {{ id: string, family: string|null, source: 'sdk-metadata'|'env'|'unknown', sdkMetadata?: object }}
84
- */
85
- export function resolveModelIdentity(opts = {}) {
86
- const { sdkMetadata = null, env = process.env } = opts;
87
-
88
- const sdkId =
89
- sdkMetadata && typeof sdkMetadata === 'object'
90
- ? (typeof sdkMetadata.modelId === 'string' && sdkMetadata.modelId) ||
91
- (typeof sdkMetadata.model === 'string' && sdkMetadata.model) ||
92
- null
93
- : null;
94
- if (sdkId) {
95
- return {
96
- id: sdkId,
97
- family: deriveFamily(sdkId),
98
- source: 'sdk-metadata',
99
- sdkMetadata,
100
- };
101
- }
102
-
103
- for (const name of ENV_VAR_CANDIDATES) {
104
- const value = env?.[name];
105
- if (typeof value === 'string' && value.length > 0) {
106
- return {
107
- id: value,
108
- family: deriveFamily(value),
109
- source: 'env',
110
- };
111
- }
112
- }
113
-
114
- return {
115
- id: UNKNOWN_MODEL_ID,
116
- family: null,
117
- source: 'unknown',
118
- };
119
- }
120
-
121
- /**
122
- * Build the canonical payload object from a resolved identity. Separated
123
- * from {@link emitModelAttribution} so callers (and tests) can shape the
124
- * payload without triggering the upsert side-effect.
125
- *
126
- * @param {{
127
- * ticketId: number,
128
- * identity: ReturnType<typeof resolveModelIdentity>,
129
- * recordedAt?: string,
130
- * }} args
131
- * @returns {object}
132
- */
133
- export function buildModelAttributionPayload(args) {
134
- const { ticketId, identity, recordedAt } = args ?? {};
135
- const payload = {
136
- kind: MODEL_ATTRIBUTION_TYPE,
137
- ticketId,
138
- model: identity?.family
139
- ? { id: identity.id, family: identity.family }
140
- : { id: identity?.id },
141
- source: identity?.source,
142
- recordedAt: recordedAt ?? new Date().toISOString(),
143
- };
144
- if (identity?.sdkMetadata && typeof identity.sdkMetadata === 'object') {
145
- payload.sdkMetadata = identity.sdkMetadata;
146
- }
147
- return payload;
148
- }
149
-
150
- const VALID_SOURCES = new Set(['sdk-metadata', 'env', 'unknown']);
151
- const ISO_8601_RE =
152
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
153
-
154
- /**
155
- * Per-field validators for {@link validateModelAttributionPayload}. Each
156
- * returns an array of error strings for its field (empty when the field is
157
- * valid), so the top-level validator collapses to a flat-map over the field
158
- * list — no per-field branching in the orchestrating body. Story #4075
159
- * (CLI-orchestration CC reduction): keeps each validator pure and
160
- * single-responsibility.
161
- */
162
- function validateKind(payload) {
163
- return payload.kind === MODEL_ATTRIBUTION_TYPE
164
- ? []
165
- : [`kind must be "${MODEL_ATTRIBUTION_TYPE}"`];
166
- }
167
-
168
- function validateTicketId(payload) {
169
- return Number.isInteger(payload.ticketId) && payload.ticketId > 0
170
- ? []
171
- : ['ticketId must be a positive integer'];
172
- }
173
-
174
- function validateModel(payload) {
175
- const { model } = payload;
176
- if (!model || typeof model !== 'object' || Array.isArray(model)) {
177
- return ['model must be an object'];
178
- }
179
- const errors = [];
180
- if (typeof model.id !== 'string' || model.id.length === 0) {
181
- errors.push('model.id must be a non-empty string');
182
- }
183
- if (
184
- model.family !== undefined &&
185
- (typeof model.family !== 'string' || model.family.length === 0)
186
- ) {
187
- errors.push('model.family, when present, must be a non-empty string');
188
- }
189
- return errors;
190
- }
191
-
192
- function validateSource(payload) {
193
- return typeof payload.source === 'string' && VALID_SOURCES.has(payload.source)
194
- ? []
195
- : [`source must be one of: ${[...VALID_SOURCES].join(', ')}`];
196
- }
197
-
198
- function validateRecordedAt(payload) {
199
- return typeof payload.recordedAt === 'string' &&
200
- ISO_8601_RE.test(payload.recordedAt)
201
- ? []
202
- : ['recordedAt must be an ISO-8601 timestamp string'];
203
- }
204
-
205
- function validateSdkMetadata(payload) {
206
- const { sdkMetadata } = payload;
207
- if (sdkMetadata === undefined) return [];
208
- const valid =
209
- sdkMetadata !== null &&
210
- typeof sdkMetadata === 'object' &&
211
- !Array.isArray(sdkMetadata);
212
- return valid ? [] : ['sdkMetadata, when present, must be an object'];
213
- }
214
-
215
- const PAYLOAD_FIELD_VALIDATORS = Object.freeze([
216
- validateKind,
217
- validateTicketId,
218
- validateModel,
219
- validateSource,
220
- validateRecordedAt,
221
- validateSdkMetadata,
222
- ]);
223
-
224
- /**
225
- * Hand-rolled validator matching
226
- * `.agents/schemas/model-attribution.schema.json`. Returns
227
- * `{ ok: true }` on success, `{ ok: false, errors: string[] }` on
228
- * failure. The error strings are stable enough to assert against in
229
- * tests.
230
- *
231
- * The codebase does not pull in `ajv` for runtime schema validation
232
- * (signals + structured comments use hand-rolled shape guards — see
233
- * `lib/signals/schema.js`). We follow the same pattern here so the
234
- * schema file stays the documented SSOT and the validator is the
235
- * runtime gate.
236
- *
237
- * @param {unknown} payload
238
- * @returns {{ ok: true } | { ok: false, errors: string[] }}
239
- */
240
- export function validateModelAttributionPayload(payload) {
241
- if (
242
- payload === null ||
243
- typeof payload !== 'object' ||
244
- Array.isArray(payload)
245
- ) {
246
- return { ok: false, errors: ['payload must be a plain object'] };
247
- }
248
- const errors = PAYLOAD_FIELD_VALIDATORS.flatMap((validate) =>
249
- validate(payload),
250
- );
251
- return errors.length ? { ok: false, errors } : { ok: true };
252
- }
253
-
254
- /**
255
- * Render the markdown body that gets upserted as the structured comment.
256
- * Body is a one-line summary followed by the canonical payload inside a
257
- * fenced ```json``` block so downstream readers can use the shared
258
- * `parseFencedJsonComment` helper without bespoke parsing.
259
- *
260
- * @param {object} payload — already validated.
261
- * @returns {string}
262
- */
263
- export function renderModelAttributionBody(payload) {
264
- const id = payload.model?.id ?? UNKNOWN_MODEL_ID;
265
- const family = payload.model?.family ? ` (${payload.model.family})` : '';
266
- const sourceLabel =
267
- payload.source === 'sdk-metadata'
268
- ? 'SDK metadata'
269
- : payload.source === 'env'
270
- ? 'env var'
271
- : 'unknown source';
272
- const header = `🤖 Model attribution: \`${id}\`${family} · via ${sourceLabel}`;
273
- return [header, '', '```json', JSON.stringify(payload, null, 2), '```'].join(
274
- '\n',
275
- );
276
- }
277
-
278
- /**
279
- * Emit (idempotently) the model-attribution comment onto a Task ticket.
280
- * Validates the payload before any IO — a malformed payload throws and
281
- * writes nothing to the provider.
282
- *
283
- * @param {{
284
- * provider: import('../ITicketingProvider.js').ITicketingProvider,
285
- * ticketId: number,
286
- * sdkMetadata?: object|null,
287
- * env?: Record<string, string|undefined>,
288
- * recordedAt?: string,
289
- * }} args
290
- * @returns {Promise<{ payload: object, body: string }>}
291
- */
292
- export async function emitModelAttribution(args) {
293
- const { provider, ticketId, sdkMetadata, env, recordedAt } = args ?? {};
294
- if (!provider || typeof provider.postComment !== 'function') {
295
- throw new TypeError(
296
- 'emitModelAttribution requires a provider with postComment',
297
- );
298
- }
299
- if (!Number.isInteger(ticketId) || ticketId <= 0) {
300
- throw new TypeError(
301
- 'emitModelAttribution requires a positive integer ticketId',
302
- );
303
- }
304
- const identity = resolveModelIdentity({ sdkMetadata, env });
305
- const payload = buildModelAttributionPayload({
306
- ticketId,
307
- identity,
308
- recordedAt,
309
- });
310
- const result = validateModelAttributionPayload(payload);
311
- if (!result.ok) {
312
- throw new Error(
313
- `model-attribution payload failed validation: ${result.errors.join('; ')}`,
314
- );
315
- }
316
- const body = renderModelAttributionBody(payload);
317
- await upsertStructuredComment(
318
- provider,
319
- ticketId,
320
- MODEL_ATTRIBUTION_TYPE,
321
- body,
322
- );
323
- return { payload, body };
324
- }
325
-
326
- /**
327
- * Read back a model-attribution comment from a ticket. Returns the
328
- * parsed payload, or `null` when the comment is missing or its payload
329
- * fails validation (malformed payloads are treated as "no attribution"
330
- * so a single corrupt comment does not poison the whole rollup).
331
- *
332
- * @param {{
333
- * provider: import('../ITicketingProvider.js').ITicketingProvider,
334
- * ticketId: number,
335
- * }} args
336
- * @returns {Promise<object|null>}
337
- */
338
- export async function parseModelAttributionComment(args) {
339
- const { provider, ticketId } = args ?? {};
340
- const comment = await findStructuredComment(
341
- provider,
342
- ticketId,
343
- MODEL_ATTRIBUTION_TYPE,
344
- );
345
- if (!comment) return null;
346
- const parsed = parseFencedJsonComment(comment);
347
- if (!parsed) return null;
348
- const result = validateModelAttributionPayload(parsed);
349
- return result.ok ? parsed : null;
350
- }
351
-
352
- /**
353
- * Walk the immediate child tickets of a parent (e.g. an Epic's Stories),
354
- * read each child's model-attribution comment (when present), and
355
- * aggregate per-model counts. Children with no attribution comment are
356
- * counted under `missing`.
357
- *
358
- * Rollup is keyed by `model.family` when present, otherwise by
359
- * `model.id` so unknown families still aggregate distinctly. The
360
- * envelope also exposes the per-id breakdown for callers that want a
361
- * finer-grained view.
362
- *
363
- * @param {{
364
- * provider: import('../ITicketingProvider.js').ITicketingProvider,
365
- * parentId: number,
366
- * }} args
367
- * @returns {Promise<{
368
- * parentId: number,
369
- * totalChildren: number,
370
- * missing: number,
371
- * byModel: Record<string, number>,
372
- * byId: Record<string, number>,
373
- * }>}
374
- */
375
- export async function rollupModelAttribution(args) {
376
- const { provider, parentId } = args ?? {};
377
- if (!provider || typeof provider.getSubTickets !== 'function') {
378
- throw new TypeError(
379
- 'rollupModelAttribution requires a provider with getSubTickets',
380
- );
381
- }
382
- if (!Number.isInteger(parentId) || parentId <= 0) {
383
- throw new TypeError(
384
- 'rollupModelAttribution requires a positive integer parentId',
385
- );
386
- }
387
- const children = (await provider.getSubTickets(parentId)) ?? [];
388
- const byModel = {};
389
- const byId = {};
390
- let missing = 0;
391
- for (const child of children) {
392
- const childId = Number(child?.id);
393
- if (!Number.isInteger(childId) || childId <= 0) {
394
- missing += 1;
395
- continue;
396
- }
397
- const payload = await parseModelAttributionComment({
398
- provider,
399
- ticketId: childId,
400
- });
401
- if (!payload) {
402
- missing += 1;
403
- continue;
404
- }
405
- const familyKey =
406
- payload.model?.family ?? payload.model?.id ?? UNKNOWN_MODEL_ID;
407
- const idKey = payload.model?.id ?? UNKNOWN_MODEL_ID;
408
- byModel[familyKey] = (byModel[familyKey] ?? 0) + 1;
409
- byId[idKey] = (byId[idKey] ?? 0) + 1;
410
- }
411
- return {
412
- parentId,
413
- totalChildren: children.length,
414
- missing,
415
- byModel,
416
- byId,
417
- };
418
- }
@@ -1,188 +0,0 @@
1
- /**
2
- * v2 split-policy validator — the plan-time "one-owner-AC" split rejector.
3
- *
4
- * Under the v2 default-single split policy (`docs/roadmap.md` § v2.0.0), a
5
- * plan authors **one Story by default**; it splits into N>1 Stories only when
6
- * the pieces have near-zero overlap or sit across an architectural seam. This
7
- * validator is the deterministic guardrail on that policy: **every acceptance
8
- * criterion must belong to exactly one Story.** An identical AC appearing in
9
- * two Stories is evidence the split coupled what should have stayed one Story,
10
- * so the plan is refused rather than reconciled at delivery time (there is no
11
- * epic-level acceptance reconcile in v2).
12
- *
13
- * Scope of the deterministic check:
14
- * - **Cross-Story duplication** (always): the same normalized AC text must
15
- * not appear in more than one Story.
16
- * - **Full coverage** (optional, when a plan-level `acceptance` manifest is
17
- * supplied): every manifest AC is claimed by exactly one Story, and no
18
- * Story claims an AC absent from the manifest.
19
- *
20
- * Semantic overlap between differently-worded ACs is **not** caught here — it
21
- * is a gate-#2 review call. This validator only sees identical (normalized)
22
- * text, keeping it deterministic and false-positive-free.
23
- *
24
- * Normalization for comparison: trim, collapse internal whitespace, and
25
- * lower-case. Reporting always uses the first-seen original text.
26
- */
27
-
28
- /**
29
- * Normalize an acceptance string for equality comparison. Trims, collapses
30
- * runs of whitespace to a single space, and lower-cases. Non-strings and
31
- * empty/whitespace-only strings normalize to `null` (ignored).
32
- *
33
- * @param {unknown} ac
34
- * @returns {string | null}
35
- */
36
- export function normalizeAcceptance(ac) {
37
- if (typeof ac !== 'string') return null;
38
- const norm = ac.trim().replace(/\s+/g, ' ').toLowerCase();
39
- return norm === '' ? null : norm;
40
- }
41
-
42
- /**
43
- * @typedef {object} StorySlice
44
- * @property {string} [id] Story identifier for reporting (slug or #id).
45
- * @property {string} [slug] Alternate identifier (used when `id` absent).
46
- * @property {string[]} acceptance Acceptance criteria this Story claims.
47
- */
48
-
49
- /**
50
- * @typedef {object} SplitPolicyViolation
51
- * @property {'cross-story-duplicate'|'orphan-ac'|'unclaimed-manifest-ac'} kind
52
- * @property {string} acceptance The original (first-seen) AC text.
53
- * @property {string[]} [stories] Story ids sharing a duplicated AC (`cross-story-duplicate`).
54
- * @property {string} [story] Story id owning an AC absent from the manifest (`orphan-ac`).
55
- */
56
-
57
- /**
58
- * Resolve a Story's reporting id.
59
- *
60
- * @param {StorySlice} story
61
- * @param {number} index
62
- * @returns {string}
63
- */
64
- function storyId(story, index) {
65
- if (story && typeof story.id === 'string' && story.id.trim() !== '') {
66
- return story.id;
67
- }
68
- if (story && typeof story.slug === 'string' && story.slug.trim() !== '') {
69
- return story.slug;
70
- }
71
- return `story[${index}]`;
72
- }
73
-
74
- /**
75
- * Validate that acceptance criteria partition cleanly across Stories.
76
- *
77
- * @param {StorySlice[]} stories The plan's Stories, each with `acceptance[]`.
78
- * @param {object} [opts]
79
- * @param {string[]} [opts.planAcceptance] Optional plan-level acceptance
80
- * manifest. When supplied, coverage is enforced (every manifest AC claimed
81
- * exactly once; no Story claims an off-manifest AC).
82
- * @returns {{ ok: boolean, violations: SplitPolicyViolation[] }}
83
- */
84
- export function validateAcceptancePartition(stories, opts = {}) {
85
- const violations = [];
86
- const list = Array.isArray(stories) ? stories : [];
87
-
88
- // normalized AC → { original, owners: Set<storyId> }
89
- const owners = new Map();
90
- list.forEach((story, index) => {
91
- const id = storyId(story, index);
92
- const acceptance = Array.isArray(story?.acceptance) ? story.acceptance : [];
93
- for (const ac of acceptance) {
94
- const norm = normalizeAcceptance(ac);
95
- if (norm === null) continue;
96
- const existing = owners.get(norm);
97
- if (existing) {
98
- existing.owners.add(id);
99
- } else {
100
- owners.set(norm, { original: ac.trim(), owners: new Set([id]) });
101
- }
102
- }
103
- });
104
-
105
- // Cross-Story duplication: any AC owned by more than one Story.
106
- for (const { original, owners: set } of owners.values()) {
107
- if (set.size > 1) {
108
- violations.push({
109
- kind: 'cross-story-duplicate',
110
- acceptance: original,
111
- stories: [...set],
112
- });
113
- }
114
- }
115
-
116
- // Optional coverage check against a plan-level manifest.
117
- const manifest = Array.isArray(opts.planAcceptance)
118
- ? opts.planAcceptance
119
- : null;
120
- if (manifest !== null) {
121
- const manifestNorms = new Map();
122
- for (const ac of manifest) {
123
- const norm = normalizeAcceptance(ac);
124
- if (norm !== null && !manifestNorms.has(norm)) {
125
- manifestNorms.set(norm, ac.trim());
126
- }
127
- }
128
- // Every manifest AC must be claimed by exactly one Story.
129
- for (const [norm, original] of manifestNorms) {
130
- if (!owners.has(norm)) {
131
- violations.push({
132
- kind: 'unclaimed-manifest-ac',
133
- acceptance: original,
134
- });
135
- }
136
- }
137
- // No Story may claim an AC absent from the manifest.
138
- for (const [norm, { original, owners: set }] of owners) {
139
- if (!manifestNorms.has(norm)) {
140
- violations.push({
141
- kind: 'orphan-ac',
142
- acceptance: original,
143
- story: [...set][0],
144
- });
145
- }
146
- }
147
- }
148
-
149
- return { ok: violations.length === 0, violations };
150
- }
151
-
152
- /**
153
- * Render a single violation as a human-readable line.
154
- *
155
- * @param {SplitPolicyViolation} v
156
- * @returns {string}
157
- */
158
- function formatViolation(v) {
159
- switch (v.kind) {
160
- case 'cross-story-duplicate':
161
- return `acceptance criterion appears in ${v.stories.length} Stories (${v.stories.join(', ')}) — a coupled split; keep it one Story: "${v.acceptance}"`;
162
- case 'unclaimed-manifest-ac':
163
- return `plan acceptance criterion is claimed by no Story: "${v.acceptance}"`;
164
- case 'orphan-ac':
165
- return `Story ${v.story} claims an acceptance criterion absent from the plan manifest: "${v.acceptance}"`;
166
- default:
167
- return `unknown split-policy violation: "${v.acceptance}"`;
168
- }
169
- }
170
-
171
- /**
172
- * Throwing wrapper for the persist path: throws a single batched error when
173
- * the acceptance criteria do not partition cleanly, otherwise returns
174
- * `stories` unchanged. Wired into `plan-persist` in Stage 3.
175
- *
176
- * @param {StorySlice[]} stories
177
- * @param {object} [opts] See {@link validateAcceptancePartition}.
178
- * @returns {StorySlice[]}
179
- */
180
- export function assertAcceptancePartition(stories, opts = {}) {
181
- const { ok, violations } = validateAcceptancePartition(stories, opts);
182
- if (ok) return stories;
183
- throw new Error(
184
- `[split-policy] ${violations.length} acceptance-partition violation(s) — the plan splits coupled work; refuse:\n${violations
185
- .map((v) => ` - ${formatViolation(v)}`)
186
- .join('\n')}`,
187
- );
188
- }
@@ -1,33 +0,0 @@
1
- /**
2
- * story-plan-state.js — read the v2 Story planning checkpoint.
3
- *
4
- * `plan-persist.js` upserts one `story-plan-state` structured comment per
5
- * created Story carrying the persist receipt (when the plan completed, how many
6
- * Stories it created, and their ids). Story #4542 removed the risk fields it
7
- * used to carry: the planner-authored verdict, the envelope derived from it,
8
- * and the review routing computed from that envelope. Nothing read any of them
9
- * back — review depth is now derived from the diff at close time
10
- * (`review-depth.js#deriveChangeLevel`), so no checkpoint read sits on the
11
- * delivery path at all.
12
- */
13
-
14
- import { parseFencedJsonComment } from './structured-comment-parser.js';
15
- import { findStructuredComment } from './ticketing.js';
16
-
17
- /**
18
- * Read the v2 Story planning checkpoint. Missing/malformed comments degrade
19
- * to null — an unplanned Story simply has no persist receipt.
20
- */
21
- export async function readStoryPlanState({
22
- provider,
23
- storyId,
24
- findCommentFn = findStructuredComment,
25
- }) {
26
- const comment = await findCommentFn(
27
- provider,
28
- Number(storyId),
29
- 'story-plan-state',
30
- );
31
- const state = parseFencedJsonComment(comment);
32
- return state && typeof state === 'object' ? state : null;
33
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * structured-comment-parser.js — shared parser for fenced-JSON structured
3
- * comments.
4
- *
5
- * Several callers (the deleted epic-runner Checkpointer/ProgressReporter,
6
- * `parseStoryRunProgressComment` / `parsePhaseTimingsComment`, wave-gate's
7
- * local `extractJsonBlock`) need to extract the `{...}` payload from the
8
- * fenced ```json``` block of a structured comment body. They had each open-
9
- * coded the same `/```json\s*\n([\s\S]*?)\n```/` regex + `JSON.parse` +
10
- * try/catch dance — small enough to copy, large enough to drift.
11
- *
12
- * This helper centralizes that single regex so a future change to the
13
- * fence format (e.g. tolerating CRLF, surrounding whitespace, alternate
14
- * fence languages) lives in exactly one place. The functions are
15
- * deliberately permissive about input shape: anything that isn't the
16
- * expected type returns `null` rather than throwing, matching what the
17
- * existing callers already do — they all treat parse failure as "no
18
- * payload available" and fall back to a default state.
19
- *
20
- * Acceptance contract for this helper:
21
- * - returns the parsed JSON value when the body contains a valid fenced
22
- * ```json``` block;
23
- * - returns `null` for missing comment, missing body, missing fence, or
24
- * malformed JSON inside the fence;
25
- * - never throws.
26
- */
27
-
28
- const JSON_FENCE_RE = /```json\s*\n([\s\S]*?)\n```/;
29
-
30
- /**
31
- * Extract the parsed JSON object from the first fenced ```json``` block in
32
- * a raw string. Returns `null` for any malformed or missing input — callers
33
- * treat that as "no payload" and fall back.
34
- *
35
- * Use this variant when the caller already holds the raw comment body as a
36
- * string (e.g. after extracting `.body` themselves). Use
37
- * `parseFencedJsonComment` when working with a comment-like object.
38
- *
39
- * @param {unknown} text — the raw string to scan.
40
- * @returns {unknown | null} the parsed JSON value (typically an object),
41
- * or `null` when extraction fails.
42
- */
43
- export function parseFencedJson(text) {
44
- if (typeof text !== 'string') return null;
45
- const match = text.match(JSON_FENCE_RE);
46
- if (!match) return null;
47
- try {
48
- return JSON.parse(match[1]);
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- /**
55
- * Extract the parsed JSON object from the first fenced ```json``` block in
56
- * a structured comment's `body`. Returns `null` for any malformed or
57
- * missing input — callers treat that as "no payload" and fall back.
58
- *
59
- * @param {{ body?: unknown } | null | undefined} comment — a comment-like
60
- * object. Only the string `body` field is consulted.
61
- * @returns {unknown | null} the parsed JSON value (typically an object),
62
- * or `null` when extraction fails.
63
- */
64
- export function parseFencedJsonComment(comment) {
65
- if (!comment || typeof comment.body !== 'string') return null;
66
- return parseFencedJson(comment.body);
67
- }