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.
- package/.agents/README.md +17 -12
- package/.agents/agents/acceptance-critic.md +24 -43
- package/.agents/agents/story-worker.md +18 -19
- package/.agents/docs/SDLC.md +12 -13
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +29 -46
- package/.agents/docs/quality-gates.md +9 -5
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +5 -7
- package/.agents/rules/ci-remediation.md +41 -8
- package/.agents/rules/known-tooling-behavior.md +65 -15
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -11
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
- package/.agents/scripts/README.md +11 -1
- package/.agents/scripts/acceptance-eval.js +25 -27
- package/.agents/scripts/ceremony-derive.js +15 -10
- package/.agents/scripts/check-context-budget.js +148 -228
- package/.agents/scripts/check-schema-references.js +5 -3
- package/.agents/scripts/check-workflow-citations.js +33 -147
- package/.agents/scripts/coverage-capture.js +7 -4
- package/.agents/scripts/deliver-light.js +41 -100
- package/.agents/scripts/deliver-run.js +631 -0
- package/.agents/scripts/file-ci-gap.js +59 -11
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/changed-files.js +30 -0
- package/.agents/scripts/lib/config/delivery-routing.js +5 -4
- package/.agents/scripts/lib/config/explain.js +1 -3
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
- package/.agents/scripts/lib/config-resolver.js +1 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
- package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/doc-tiers.js +4 -2
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/gh-exec.js +160 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
- package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
- package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
- package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
- package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
- package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
- package/.agents/scripts/lib/story-body/story-body.js +83 -29
- package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
- package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
- package/.agents/scripts/merge-baseline.js +4 -5
- package/.agents/scripts/plan-context.js +117 -28
- package/.agents/scripts/plan-persist.js +79 -39
- package/.agents/scripts/plan-run-epilogue.js +11 -8
- package/.agents/scripts/pr-watch-with-update.js +9 -2
- package/.agents/scripts/run-verify.js +13 -6
- package/.agents/scripts/single-story-init.js +7 -57
- package/.agents/scripts/stories-wave-tick.js +160 -26
- package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
- package/.agents/workflows/helpers/code-review.md +4 -2
- package/.agents/workflows/helpers/deliver-digest.md +31 -24
- package/.agents/workflows/helpers/deliver-light.md +92 -101
- package/.agents/workflows/helpers/deliver-reference.md +116 -100
- package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
- package/.agents/workflows/helpers/deliver-story.md +17 -18
- package/.agents/workflows/helpers/plan-reference.md +82 -60
- package/.agents/workflows/mandrel-deliver.md +47 -31
- package/.agents/workflows/mandrel-plan.md +32 -30
- package/.agents/workflows/mandrel-update.md +36 -21
- package/README.md +3 -3
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +45 -25
- package/lib/cli/update.js +376 -17
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
- package/package.json +8 -2
- package/.agents/schemas/model-attribution.schema.json +0 -53
- package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
- package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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
|
-
}
|