@deftai/directive-core 0.98.1 → 0.99.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 (55) hide show
  1. package/dist/authz/classify.js +265 -73
  2. package/dist/consumer-check-contract/evaluate.d.ts +40 -0
  3. package/dist/consumer-check-contract/evaluate.js +188 -3
  4. package/dist/consumer-check-contract/index.d.ts +1 -1
  5. package/dist/consumer-check-contract/index.js +1 -1
  6. package/dist/content-contracts/skills/greptile-detector.d.ts +42 -0
  7. package/dist/content-contracts/skills/greptile-detector.js +202 -4
  8. package/dist/decision/index.d.ts +17 -0
  9. package/dist/decision/index.js +35 -0
  10. package/dist/decision/list.d.ts +47 -0
  11. package/dist/decision/list.js +250 -0
  12. package/dist/decision/schema.d.ts +88 -0
  13. package/dist/decision/schema.js +293 -0
  14. package/dist/decision/write.d.ts +82 -0
  15. package/dist/decision/write.js +427 -0
  16. package/dist/eval/report.d.ts +29 -0
  17. package/dist/eval/report.js +69 -0
  18. package/dist/eval/run.d.ts +9 -0
  19. package/dist/eval/run.js +40 -4
  20. package/dist/eval/version-pin.d.ts +99 -0
  21. package/dist/eval/version-pin.js +181 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/platform/host-content-surface.d.ts +74 -0
  25. package/dist/platform/host-content-surface.js +214 -0
  26. package/dist/platform/index.d.ts +1 -0
  27. package/dist/platform/index.js +1 -0
  28. package/dist/policy/ceremony-dial.d.ts +233 -0
  29. package/dist/policy/ceremony-dial.js +829 -0
  30. package/dist/policy/deft-directive-disable.js +12 -2
  31. package/dist/policy/index.d.ts +1 -0
  32. package/dist/policy/index.js +15 -1
  33. package/dist/pr-merge-readiness/evaluate.js +10 -0
  34. package/dist/pr-merge-readiness/mergeability.js +5 -0
  35. package/dist/pr-merge-readiness/output.js +2 -0
  36. package/dist/pr-merge-readiness/parse.js +4 -0
  37. package/dist/pr-merge-readiness/types.d.ts +6 -0
  38. package/dist/scope/effort-activate-gate.d.ts +28 -0
  39. package/dist/scope/effort-activate-gate.js +64 -0
  40. package/dist/scope/index.d.ts +1 -0
  41. package/dist/scope/index.js +1 -0
  42. package/dist/scope/transition.js +8 -0
  43. package/dist/session/session-start.d.ts +24 -1
  44. package/dist/session/session-start.js +183 -26
  45. package/dist/swarm/index.d.ts +2 -0
  46. package/dist/swarm/index.js +2 -0
  47. package/dist/swarm/pre-dispatch-cli.d.ts +19 -0
  48. package/dist/swarm/pre-dispatch-cli.js +143 -0
  49. package/dist/swarm/pre-dispatch.d.ts +87 -0
  50. package/dist/swarm/pre-dispatch.js +373 -0
  51. package/dist/vbrief-activate/activate.js +6 -0
  52. package/dist/vbrief-validate/constants.d.ts +2 -0
  53. package/dist/vbrief-validate/constants.js +2 -0
  54. package/dist/vbrief-validate/schema.js +4 -1
  55. package/package.json +15 -3
@@ -0,0 +1,82 @@
1
+ /**
2
+ * decision:write — create a lightweight structured decision record (#1396).
3
+ */
4
+ import { type DecisionRecord, slugifyDecision } from "./schema.js";
5
+ export type DecisionWriteOutcome = "written" | "error-bad-args" | "error-config" | "error-io";
6
+ export interface DecisionWriteInput {
7
+ readonly decision?: string;
8
+ readonly governingRule?: string | {
9
+ description: string;
10
+ path?: string;
11
+ rfc2119?: string;
12
+ };
13
+ readonly alternatives?: readonly (string | {
14
+ option: string;
15
+ whyNot?: string;
16
+ })[];
17
+ readonly whyWinner?: string;
18
+ readonly confidence?: string;
19
+ readonly scope?: string | readonly string[];
20
+ readonly revisitTrigger?: string;
21
+ readonly id?: string;
22
+ readonly timestamp?: string;
23
+ readonly tags?: readonly string[];
24
+ readonly relatedIssues?: readonly number[];
25
+ /** Absolute or project-relative path to a JSON body file (full or partial record). */
26
+ readonly bodyFile?: string;
27
+ /** Force standalone under xbrief/decisions/ even when --scope is set (still links). */
28
+ readonly standalone?: boolean;
29
+ /** When true, replace an existing decision file at the same path. Default: fail if exists. */
30
+ readonly force?: boolean;
31
+ readonly projectRoot?: string | null;
32
+ readonly dryRun?: boolean;
33
+ readonly json?: boolean;
34
+ }
35
+ export interface DecisionWriteResult {
36
+ readonly outcome: DecisionWriteOutcome;
37
+ readonly exitCode: 0 | 1 | 2;
38
+ readonly path: string | null;
39
+ readonly scopePath: string | null;
40
+ readonly record: DecisionRecord | null;
41
+ readonly message: string;
42
+ }
43
+ /**
44
+ * Append a pointer line to plan.narratives.Decisions on a scope xBRIEF without
45
+ * removing existing narratives (validators accept unknown narrative keys as strings).
46
+ *
47
+ * Concurrent writers: read-modify-replace with post-write verify + retry so a
48
+ * later attach does not silently drop an earlier decision pointer.
49
+ */
50
+ export declare function appendScopeDecisionPointer(projectRoot: string, scopeRelPath: string, decisionRelPath: string, decisionSummary: string): void;
51
+ /** Write a validated decision record to disk (standalone + optional scope pointer). */
52
+ export declare function runDecisionWrite(options: DecisionWriteInput): DecisionWriteResult;
53
+ export interface DecisionWriteCliArgs {
54
+ decision?: string;
55
+ governingRule?: string;
56
+ governingPath?: string;
57
+ governingRfc?: string;
58
+ alternatives?: string[];
59
+ whyWinner?: string;
60
+ confidence?: string;
61
+ scope?: string[];
62
+ revisitTrigger?: string;
63
+ id?: string;
64
+ timestamp?: string;
65
+ tags?: string[];
66
+ relatedIssues?: number[];
67
+ bodyFile?: string;
68
+ standalone?: boolean;
69
+ force?: boolean;
70
+ dryRun?: boolean;
71
+ json?: boolean;
72
+ projectRoot?: string;
73
+ error?: string;
74
+ }
75
+ /** Parse argv for decision:write. */
76
+ export declare function parseDecisionWriteArgs(argv: readonly string[]): DecisionWriteCliArgs;
77
+ /** CLI entry for decision:write. */
78
+ export declare function decisionWriteMain(argv: readonly string[]): number;
79
+ /** Relative path helper for tests. */
80
+ export declare function relativeToProject(projectRoot: string, absPath: string): string;
81
+ export { slugifyDecision };
82
+ //# sourceMappingURL=write.d.ts.map
@@ -0,0 +1,427 @@
1
+ /**
2
+ * decision:write — create a lightweight structured decision record (#1396).
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { ContainedWriteError, containedWrite } from "../fs/contained-write.js";
7
+ import { ProjectionContainmentError } from "../fs/projection-containment.js";
8
+ import { resolveProjectRoot } from "../scope/project-context.js";
9
+ import { DECISIONS_DIR_REL, decisionFilename, formatDecisionValidationErrors, normalizeTimestamp, slugifyDecision, validateDecisionRecord, } from "./schema.js";
10
+ function loadBodyFile(bodyFile, projectRoot) {
11
+ const abs = resolve(bodyFile.startsWith(".") ? join(projectRoot, bodyFile) : bodyFile);
12
+ const text = readFileSync(abs, "utf8");
13
+ const parsed = JSON.parse(text);
14
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
15
+ throw new Error("body file must contain a JSON object");
16
+ }
17
+ return parsed;
18
+ }
19
+ function mergeInput(options, projectRoot) {
20
+ let base = {};
21
+ if (options.bodyFile !== undefined && options.bodyFile.trim().length > 0) {
22
+ base = loadBodyFile(options.bodyFile.trim(), projectRoot);
23
+ }
24
+ const out = { ...base };
25
+ if (options.decision !== undefined)
26
+ out.decision = options.decision;
27
+ if (options.governingRule !== undefined)
28
+ out.governingRule = options.governingRule;
29
+ if (options.alternatives !== undefined)
30
+ out.alternativesConsidered = options.alternatives;
31
+ if (options.whyWinner !== undefined)
32
+ out.whyWinner = options.whyWinner;
33
+ if (options.confidence !== undefined)
34
+ out.confidence = options.confidence;
35
+ if (options.revisitTrigger !== undefined)
36
+ out.revisitTrigger = options.revisitTrigger;
37
+ if (options.id !== undefined)
38
+ out.id = options.id;
39
+ if (options.timestamp !== undefined)
40
+ out.timestamp = options.timestamp;
41
+ if (options.tags !== undefined)
42
+ out.tags = options.tags;
43
+ if (options.relatedIssues !== undefined)
44
+ out.relatedIssues = options.relatedIssues;
45
+ if (options.scope !== undefined) {
46
+ const scopes = Array.isArray(options.scope) ? options.scope : [options.scope];
47
+ out.activeScopeRefs = scopes.map((s) => s.replace(/\\/g, "/"));
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Append a pointer line to plan.narratives.Decisions on a scope xBRIEF without
53
+ * removing existing narratives (validators accept unknown narrative keys as strings).
54
+ *
55
+ * Concurrent writers: read-modify-replace with post-write verify + retry so a
56
+ * later attach does not silently drop an earlier decision pointer.
57
+ */
58
+ export function appendScopeDecisionPointer(projectRoot, scopeRelPath, decisionRelPath, decisionSummary) {
59
+ const scopeAbs = resolve(projectRoot, scopeRelPath);
60
+ if (!existsSync(scopeAbs)) {
61
+ throw new Error(`scope xBRIEF not found: ${scopeRelPath}`);
62
+ }
63
+ const pointerNeedle = decisionRelPath;
64
+ const maxAttempts = 4;
65
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
66
+ const raw = readFileSync(scopeAbs, "utf8");
67
+ const doc = JSON.parse(raw);
68
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
69
+ throw new Error(`invalid scope xBRIEF JSON: ${scopeRelPath}`);
70
+ }
71
+ const plan = doc.plan;
72
+ if (plan === null || typeof plan !== "object" || Array.isArray(plan)) {
73
+ throw new Error(`scope xBRIEF missing plan: ${scopeRelPath}`);
74
+ }
75
+ const narrativesRaw = plan.narratives;
76
+ const narratives = narrativesRaw !== null && typeof narrativesRaw === "object" && !Array.isArray(narrativesRaw)
77
+ ? { ...narrativesRaw }
78
+ : {};
79
+ const pointer = `- ${decisionRelPath} — ${decisionSummary}`;
80
+ const existing = typeof narratives.Decisions === "string" ? narratives.Decisions.trim() : "";
81
+ if (existing.includes(pointerNeedle)) {
82
+ return;
83
+ }
84
+ narratives.Decisions = existing.length > 0 ? `${existing}\n${pointer}` : pointer;
85
+ plan.narratives = narratives;
86
+ const data = `${JSON.stringify(doc, null, 2)}\n`;
87
+ containedWrite({
88
+ root: resolve(projectRoot),
89
+ target: scopeAbs,
90
+ data,
91
+ mode: "replace",
92
+ });
93
+ // Verify pointer survived concurrent replace.
94
+ try {
95
+ const verifyRaw = readFileSync(scopeAbs, "utf8");
96
+ const verifyDoc = JSON.parse(verifyRaw);
97
+ const vPlan = verifyDoc?.plan;
98
+ const vNarr = vPlan?.narratives;
99
+ const vDec = typeof vNarr?.Decisions === "string" ? vNarr.Decisions : "";
100
+ if (vDec.includes(pointerNeedle)) {
101
+ return;
102
+ }
103
+ }
104
+ catch {
105
+ // retry
106
+ }
107
+ }
108
+ throw new Error(`failed to attach decision pointer to ${scopeRelPath} after ${maxAttempts} attempts ` +
109
+ `(concurrent writers may be contending on plan.narratives.Decisions)`);
110
+ }
111
+ /** Write a validated decision record to disk (standalone + optional scope pointer). */
112
+ export function runDecisionWrite(options) {
113
+ const projectRootRaw = resolveProjectRoot(options.projectRoot ?? undefined);
114
+ if (projectRootRaw === null) {
115
+ return {
116
+ outcome: "error-config",
117
+ exitCode: 2,
118
+ path: null,
119
+ scopePath: null,
120
+ record: null,
121
+ message: "Error: could not resolve project root. Pass --project-root or run from a directive repo.\n",
122
+ };
123
+ }
124
+ const projectRoot = resolve(projectRootRaw);
125
+ let merged;
126
+ try {
127
+ merged = mergeInput(options, projectRoot);
128
+ }
129
+ catch (err) {
130
+ return {
131
+ outcome: "error-bad-args",
132
+ exitCode: 2,
133
+ path: null,
134
+ scopePath: null,
135
+ record: null,
136
+ message: `Error: failed to load body file: ${err instanceof Error ? err.message : String(err)}\n`,
137
+ };
138
+ }
139
+ const validated = validateDecisionRecord(merged);
140
+ if (!validated.ok || validated.record === undefined) {
141
+ return {
142
+ outcome: "error-bad-args",
143
+ exitCode: 2,
144
+ path: null,
145
+ scopePath: null,
146
+ record: null,
147
+ message: `Error: invalid decision record:\n${formatDecisionValidationErrors(validated.errors)}\n`,
148
+ };
149
+ }
150
+ const record = validated.record;
151
+ const filename = decisionFilename(record.id, record.timestamp);
152
+ const relPath = join(DECISIONS_DIR_REL, filename).replace(/\\/g, "/");
153
+ const absPath = resolve(projectRoot, relPath);
154
+ const recordOut = {
155
+ ...record,
156
+ path: relPath,
157
+ };
158
+ if (options.dryRun) {
159
+ return {
160
+ outcome: "written",
161
+ exitCode: 0,
162
+ path: relPath,
163
+ scopePath: record.activeScopeRefs[0] ?? null,
164
+ record: recordOut,
165
+ message: `[dry-run] would write ${relPath}\n`,
166
+ };
167
+ }
168
+ try {
169
+ mkdirSync(dirname(absPath), { recursive: true });
170
+ if (existsSync(absPath) && options.force !== true) {
171
+ return {
172
+ outcome: "error-io",
173
+ exitCode: 2,
174
+ path: relPath,
175
+ scopePath: null,
176
+ record: recordOut,
177
+ message: `Error: decision file already exists: ${relPath}\n` +
178
+ " Refusing to overwrite (would destroy prior rationale). " +
179
+ "Pass --force to replace, or use a distinct --id / decision text.\n",
180
+ };
181
+ }
182
+ const data = `${JSON.stringify({
183
+ schemaVersion: recordOut.schemaVersion,
184
+ id: recordOut.id,
185
+ decision: recordOut.decision,
186
+ governingRule: recordOut.governingRule,
187
+ alternativesConsidered: recordOut.alternativesConsidered,
188
+ whyWinner: recordOut.whyWinner,
189
+ confidence: recordOut.confidence,
190
+ activeScopeRefs: recordOut.activeScopeRefs,
191
+ timestamp: recordOut.timestamp,
192
+ revisitTrigger: recordOut.revisitTrigger,
193
+ ...(recordOut.tags !== undefined ? { tags: recordOut.tags } : {}),
194
+ ...(recordOut.relatedIssues !== undefined
195
+ ? { relatedIssues: recordOut.relatedIssues }
196
+ : {}),
197
+ }, null, 2)}\n`;
198
+ // Prefer exclusive create unless --force. Concurrent writers that both
199
+ // pass the pre-check still fail closed via create/EXISTS instead of replace.
200
+ containedWrite({
201
+ root: projectRoot,
202
+ target: absPath,
203
+ data,
204
+ mode: options.force === true ? "replace" : "create",
205
+ });
206
+ let scopePath = null;
207
+ if (record.activeScopeRefs.length > 0 && options.standalone !== true) {
208
+ const scopeRel = record.activeScopeRefs[0];
209
+ try {
210
+ appendScopeDecisionPointer(projectRoot, scopeRel, relPath, record.decision);
211
+ scopePath = scopeRel;
212
+ }
213
+ catch (err) {
214
+ return {
215
+ outcome: "error-io",
216
+ exitCode: 2,
217
+ path: relPath,
218
+ scopePath: null,
219
+ record: recordOut,
220
+ message: `[deft decision] Wrote ${relPath} but failed to attach scope pointer: ` +
221
+ `${err instanceof Error ? err.message : String(err)}\n`,
222
+ };
223
+ }
224
+ }
225
+ const scopeNote = scopePath !== null ? ` (linked from ${scopePath})` : "";
226
+ return {
227
+ outcome: "written",
228
+ exitCode: 0,
229
+ path: relPath,
230
+ scopePath,
231
+ record: recordOut,
232
+ message: `[deft decision] Wrote ${relPath}${scopeNote}\n`,
233
+ };
234
+ }
235
+ catch (err) {
236
+ if (err instanceof ContainedWriteError || err instanceof ProjectionContainmentError) {
237
+ return {
238
+ outcome: "error-io",
239
+ exitCode: 2,
240
+ path: null,
241
+ scopePath: null,
242
+ record: recordOut,
243
+ message: `Error: contained write refused: ${err.message}\n`,
244
+ };
245
+ }
246
+ return {
247
+ outcome: "error-io",
248
+ exitCode: 2,
249
+ path: null,
250
+ scopePath: null,
251
+ record: recordOut,
252
+ message: `Error: write failed: ${err instanceof Error ? err.message : String(err)}\n`,
253
+ };
254
+ }
255
+ }
256
+ /** Parse argv for decision:write. */
257
+ export function parseDecisionWriteArgs(argv) {
258
+ const out = {};
259
+ const positionals = [];
260
+ const takeValue = (i, flag, eqPrefix) => {
261
+ const arg = argv[i];
262
+ if (arg === flag) {
263
+ return [argv[i + 1], i + 1];
264
+ }
265
+ if (arg.startsWith(eqPrefix)) {
266
+ return [arg.slice(eqPrefix.length), i];
267
+ }
268
+ return [undefined, i];
269
+ };
270
+ for (let i = 0; i < argv.length; i += 1) {
271
+ const arg = argv[i];
272
+ if (arg === "--dry-run")
273
+ out.dryRun = true;
274
+ else if (arg === "--json")
275
+ out.json = true;
276
+ else if (arg === "--standalone")
277
+ out.standalone = true;
278
+ else if (arg === "--force")
279
+ out.force = true;
280
+ else if (arg === "--decision" || arg.startsWith("--decision=")) {
281
+ const [v, ni] = takeValue(i, "--decision", "--decision=");
282
+ out.decision = v;
283
+ i = ni;
284
+ }
285
+ else if (arg === "--governing-rule" || arg.startsWith("--governing-rule=")) {
286
+ const [v, ni] = takeValue(i, "--governing-rule", "--governing-rule=");
287
+ out.governingRule = v;
288
+ i = ni;
289
+ }
290
+ else if (arg === "--governing-path" || arg.startsWith("--governing-path=")) {
291
+ const [v, ni] = takeValue(i, "--governing-path", "--governing-path=");
292
+ out.governingPath = v;
293
+ i = ni;
294
+ }
295
+ else if (arg === "--governing-rfc" || arg.startsWith("--governing-rfc=")) {
296
+ const [v, ni] = takeValue(i, "--governing-rfc", "--governing-rfc=");
297
+ out.governingRfc = v;
298
+ i = ni;
299
+ }
300
+ else if (arg === "--alternative" || arg.startsWith("--alternative=")) {
301
+ const [v, ni] = takeValue(i, "--alternative", "--alternative=");
302
+ if (v !== undefined) {
303
+ out.alternatives = [...(out.alternatives ?? []), v];
304
+ }
305
+ i = ni;
306
+ }
307
+ else if (arg === "--why-winner" || arg.startsWith("--why-winner=")) {
308
+ const [v, ni] = takeValue(i, "--why-winner", "--why-winner=");
309
+ out.whyWinner = v;
310
+ i = ni;
311
+ }
312
+ else if (arg === "--confidence" || arg.startsWith("--confidence=")) {
313
+ const [v, ni] = takeValue(i, "--confidence", "--confidence=");
314
+ out.confidence = v;
315
+ i = ni;
316
+ }
317
+ else if (arg === "--scope" || arg.startsWith("--scope=")) {
318
+ const [v, ni] = takeValue(i, "--scope", "--scope=");
319
+ if (v !== undefined) {
320
+ out.scope = [...(out.scope ?? []), v];
321
+ }
322
+ i = ni;
323
+ }
324
+ else if (arg === "--revisit-trigger" || arg.startsWith("--revisit-trigger=")) {
325
+ const [v, ni] = takeValue(i, "--revisit-trigger", "--revisit-trigger=");
326
+ out.revisitTrigger = v;
327
+ i = ni;
328
+ }
329
+ else if (arg === "--id" || arg.startsWith("--id=")) {
330
+ const [v, ni] = takeValue(i, "--id", "--id=");
331
+ out.id = v;
332
+ i = ni;
333
+ }
334
+ else if (arg === "--timestamp" || arg.startsWith("--timestamp=")) {
335
+ const [v, ni] = takeValue(i, "--timestamp", "--timestamp=");
336
+ out.timestamp = v;
337
+ i = ni;
338
+ }
339
+ else if (arg === "--tag" || arg.startsWith("--tag=")) {
340
+ const [v, ni] = takeValue(i, "--tag", "--tag=");
341
+ if (v !== undefined)
342
+ out.tags = [...(out.tags ?? []), v];
343
+ i = ni;
344
+ }
345
+ else if (arg === "--related-issue" || arg.startsWith("--related-issue=")) {
346
+ const [v, ni] = takeValue(i, "--related-issue", "--related-issue=");
347
+ if (v !== undefined && /^\d+$/.test(v.trim())) {
348
+ out.relatedIssues = [...(out.relatedIssues ?? []), Number(v.trim())];
349
+ }
350
+ i = ni;
351
+ }
352
+ else if (arg === "--body-file" || arg.startsWith("--body-file=")) {
353
+ const [v, ni] = takeValue(i, "--body-file", "--body-file=");
354
+ out.bodyFile = v;
355
+ i = ni;
356
+ }
357
+ else if (arg === "--project-root" || arg.startsWith("--project-root=")) {
358
+ const [v, ni] = takeValue(i, "--project-root", "--project-root=");
359
+ out.projectRoot = v;
360
+ i = ni;
361
+ }
362
+ else if (arg.startsWith("-")) {
363
+ return { ...out, error: `unrecognized argument: ${arg}` };
364
+ }
365
+ else {
366
+ positionals.push(arg);
367
+ }
368
+ }
369
+ if ((out.decision === undefined || out.decision.trim().length === 0) && positionals.length > 0) {
370
+ out.decision = positionals.join(" ");
371
+ }
372
+ return out;
373
+ }
374
+ /** CLI entry for decision:write. */
375
+ export function decisionWriteMain(argv) {
376
+ const args = parseDecisionWriteArgs(argv);
377
+ if (args.error !== undefined) {
378
+ process.stderr.write(`decision:write: ${args.error}\n`);
379
+ return 2;
380
+ }
381
+ const governingRule = args.governingRule !== undefined
382
+ ? {
383
+ description: args.governingRule,
384
+ path: args.governingPath,
385
+ rfc2119: args.governingRfc,
386
+ }
387
+ : undefined;
388
+ const result = runDecisionWrite({
389
+ decision: args.decision,
390
+ governingRule,
391
+ alternatives: args.alternatives,
392
+ whyWinner: args.whyWinner,
393
+ confidence: args.confidence,
394
+ scope: args.scope,
395
+ revisitTrigger: args.revisitTrigger,
396
+ id: args.id,
397
+ timestamp: args.timestamp ?? normalizeTimestamp(),
398
+ tags: args.tags,
399
+ relatedIssues: args.relatedIssues,
400
+ bodyFile: args.bodyFile,
401
+ standalone: args.standalone,
402
+ force: args.force,
403
+ dryRun: args.dryRun,
404
+ json: args.json,
405
+ projectRoot: args.projectRoot,
406
+ });
407
+ if (args.json) {
408
+ process.stdout.write(`${JSON.stringify({
409
+ outcome: result.outcome,
410
+ exit_code: result.exitCode,
411
+ path: result.path,
412
+ scope_path: result.scopePath,
413
+ record: result.record,
414
+ message: result.message.trim(),
415
+ }, null, 2)}\n`);
416
+ }
417
+ else {
418
+ process.stdout.write(result.message);
419
+ }
420
+ return result.exitCode;
421
+ }
422
+ /** Relative path helper for tests. */
423
+ export function relativeToProject(projectRoot, absPath) {
424
+ return relative(projectRoot, absPath).replace(/\\/g, "/");
425
+ }
426
+ export { slugifyDecision };
427
+ //# sourceMappingURL=write.js.map
@@ -1,4 +1,5 @@
1
1
  import { type GoldenRunRecord } from "./run.js";
2
+ import { type CellVersionPurity, type MixedVersionPolicy } from "./version-pin.js";
2
3
  export declare const REPORT_SCHEMA_VERSION: 1;
3
4
  /** Metric delta with a simple two-proportion significance test (#896). */
4
5
  export interface MetricDelta {
@@ -17,6 +18,15 @@ export interface HoldoutTripwire {
17
18
  readonly primaryDelta: number;
18
19
  readonly holdoutDelta: number;
19
20
  }
21
+ /** Version purity evidence for report consumers (#3215). */
22
+ export interface VersionPurityEvidence {
23
+ readonly pure: boolean;
24
+ readonly summary: string;
25
+ readonly cells: readonly CellVersionPurity[];
26
+ readonly championCellAllowed: boolean;
27
+ readonly challengerCellAllowed: boolean;
28
+ readonly policy: MixedVersionPolicy;
29
+ }
20
30
  /** Version-diff report between champion and challenger golden runs. */
21
31
  export interface GoldenEvalReport {
22
32
  readonly schemaVersion: typeof REPORT_SCHEMA_VERSION;
@@ -27,12 +37,19 @@ export interface GoldenEvalReport {
27
37
  readonly challengerRunId: string;
28
38
  readonly deltas: readonly MetricDelta[];
29
39
  readonly holdoutTripwire: HoldoutTripwire;
40
+ /** Cell-level framework version purity for the reported model (#3215). */
41
+ readonly versionPurity: VersionPurityEvidence;
30
42
  }
31
43
  export interface ReportGoldenEvalOptions {
32
44
  readonly projectRoot?: string;
33
45
  readonly championVersion: string;
34
46
  readonly challengerVersion: string;
35
47
  readonly model: string;
48
+ /**
49
+ * Mixed-version cell policy (#3215). Default `refuse` fails the report when
50
+ * ledger runs for this model disagree on framework version within a treatment.
51
+ */
52
+ readonly mixedVersionPolicy?: MixedVersionPolicy;
36
53
  }
37
54
  export interface ReportGoldenEvalResult {
38
55
  readonly code: 0 | 1 | 2;
@@ -48,6 +65,18 @@ export declare function twoProportionZTest(passedA: number, totalA: number, pass
48
65
  };
49
66
  /** Detect holdout tripwire: primary improves while holdout regresses (#1703). */
50
67
  export declare function evaluateHoldoutTripwire(champion: GoldenRunRecord, challenger: GoldenRunRecord): HoldoutTripwire;
68
+ /**
69
+ * Map a golden-run ledger row to the #3215 versioned-run identity.
70
+ * Treatment is model@harness@version so cross-version champion/challenger
71
+ * comparisons stay distinct cells; pin disagreements under one version refuse.
72
+ */
73
+ export declare function goldenRunToVersionedRun(record: GoldenRunRecord): {
74
+ frameworkVersion: string;
75
+ treatment: string;
76
+ model: string;
77
+ harness: string;
78
+ runId: string;
79
+ };
51
80
  /** Diff two directive versions with metric deltas and significance (#1703 Tier 2). */
52
81
  export declare function reportGoldenEval(options: ReportGoldenEvalOptions): ReportGoldenEvalResult;
53
82
  //# sourceMappingURL=report.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolveEvalPath } from "../layout/resolve.js";
3
3
  import { GOLDEN_RUNS_HISTORY_REL } from "./run.js";
4
+ import { aggregateCellWithVersionPurity, evaluateLedgerVersionPurity, } from "./version-pin.js";
4
5
  export const REPORT_SCHEMA_VERSION = 1;
5
6
  function readGoldenRuns(projectRoot) {
6
7
  const path = resolveEvalPath(projectRoot, GOLDEN_RUNS_HISTORY_REL);
@@ -89,6 +90,21 @@ export function evaluateHoldoutTripwire(champion, challenger) {
89
90
  function formatPercent(value) {
90
91
  return `${(value * 100).toFixed(1)}%`;
91
92
  }
93
+ /**
94
+ * Map a golden-run ledger row to the #3215 versioned-run identity.
95
+ * Treatment is model@harness@version so cross-version champion/challenger
96
+ * comparisons stay distinct cells; pin disagreements under one version refuse.
97
+ */
98
+ export function goldenRunToVersionedRun(record) {
99
+ const pin = record.frameworkVersionPin?.frameworkVersion ?? record.directiveVersion;
100
+ return {
101
+ frameworkVersion: pin,
102
+ treatment: `${record.model}@${record.harness}@${record.directiveVersion}`,
103
+ model: record.model,
104
+ harness: record.harness,
105
+ runId: record.runId,
106
+ };
107
+ }
92
108
  /** Diff two directive versions with metric deltas and significance (#1703 Tier 2). */
93
109
  export function reportGoldenEval(options) {
94
110
  if (!options.championVersion.trim() || !options.challengerVersion.trim()) {
@@ -102,7 +118,48 @@ export function reportGoldenEval(options) {
102
118
  return { code: 2, report: null, message: "eval:report: --model is required" };
103
119
  }
104
120
  const projectRoot = options.projectRoot ?? process.cwd();
121
+ const policy = options.mixedVersionPolicy ?? "refuse";
105
122
  const records = readGoldenRuns(projectRoot);
123
+ const modelRecords = records.filter((r) => r.model === options.model);
124
+ // Within each version×model×harness cell, refuse/flag if pins disagree (#3215).
125
+ // Cross-version champion vs challenger is intentional and uses distinct cells.
126
+ const championCellRuns = modelRecords
127
+ .filter((r) => r.directiveVersion === options.championVersion)
128
+ .map(goldenRunToVersionedRun);
129
+ const challengerCellRuns = modelRecords
130
+ .filter((r) => r.directiveVersion === options.challengerVersion)
131
+ .map(goldenRunToVersionedRun);
132
+ const championAgg = aggregateCellWithVersionPurity({
133
+ runs: championCellRuns,
134
+ treatment: `champion@${options.model}@${options.championVersion}`,
135
+ policy,
136
+ });
137
+ const challengerAgg = aggregateCellWithVersionPurity({
138
+ runs: challengerCellRuns,
139
+ treatment: `challenger@${options.model}@${options.challengerVersion}`,
140
+ policy,
141
+ });
142
+ const ledgerPurity = evaluateLedgerVersionPurity(modelRecords.map(goldenRunToVersionedRun));
143
+ if (!championAgg.allowed || !challengerAgg.allowed) {
144
+ const versionPurity = {
145
+ pure: false,
146
+ summary: [
147
+ !championAgg.allowed ? championAgg.purity.message : null,
148
+ !challengerAgg.allowed ? challengerAgg.purity.message : null,
149
+ ]
150
+ .filter((line) => line !== null)
151
+ .join(" "),
152
+ cells: [championAgg.purity, challengerAgg.purity],
153
+ championCellAllowed: championAgg.allowed,
154
+ challengerCellAllowed: challengerAgg.allowed,
155
+ policy,
156
+ };
157
+ return {
158
+ code: 1,
159
+ report: null,
160
+ message: `eval:report: mixed framework versions in treatment cell(s) — aggregation refused (#3215)\n ${versionPurity.summary}`,
161
+ };
162
+ }
106
163
  const champion = findLatestGoldenRun(records, options.championVersion, options.model);
107
164
  const challenger = findLatestGoldenRun(records, options.challengerVersion, options.model);
108
165
  if (champion === null) {
@@ -133,6 +190,14 @@ export function reportGoldenEval(options) {
133
190
  metricDelta("overallPassRate", champion.summary.passRate, challenger.summary.passRate, countPasses(champion.results).passed, countPasses(champion.results).total, countPasses(challenger.results).passed, countPasses(challenger.results).total),
134
191
  ];
135
192
  const holdoutTripwire = evaluateHoldoutTripwire(champion, challenger);
193
+ const versionPurity = {
194
+ pure: championAgg.purity.pure && challengerAgg.purity.pure && ledgerPurity.pure,
195
+ summary: `${championAgg.purity.message} ${challengerAgg.purity.message}`,
196
+ cells: [championAgg.purity, challengerAgg.purity, ...ledgerPurity.cells],
197
+ championCellAllowed: championAgg.allowed,
198
+ challengerCellAllowed: challengerAgg.allowed,
199
+ policy,
200
+ };
136
201
  const report = {
137
202
  schemaVersion: REPORT_SCHEMA_VERSION,
138
203
  championVersion: options.championVersion,
@@ -142,6 +207,7 @@ export function reportGoldenEval(options) {
142
207
  challengerRunId: challenger.runId,
143
208
  deltas,
144
209
  holdoutTripwire,
210
+ versionPurity,
145
211
  };
146
212
  const lines = [
147
213
  `eval:report champion=v${options.championVersion} challenger=v${options.challengerVersion} model=${options.model}`,
@@ -154,6 +220,9 @@ export function reportGoldenEval(options) {
154
220
  return ` ${d.metric}: ${formatPercent(d.champion)} -> ${formatPercent(d.challenger)} (delta ${(d.delta * 100).toFixed(1)}pp, ${sig})`;
155
221
  }),
156
222
  ` ${holdoutTripwire.summary}`,
223
+ ` version purity: ${versionPurity.pure ? "ok" : "mixed"} — ${versionPurity.summary}`,
224
+ ` champion cell pin: v${champion.frameworkVersionPin?.frameworkVersion ?? champion.directiveVersion}`,
225
+ ` challenger cell pin: v${challenger.frameworkVersionPin?.frameworkVersion ?? challenger.directiveVersion}`,
157
226
  ];
158
227
  const code = holdoutTripwire.triggered ? 1 : 0;
159
228
  return { code, report, message: lines.join("\n") };