@dzhechkov/harness-core 0.8.6 → 0.8.11

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 (164) hide show
  1. package/.dz-manifest.json +361 -97
  2. package/README.md +48 -1
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/codex-invoke.d.ts +73 -0
  7. package/dist/codex-invoke.d.ts.map +1 -0
  8. package/dist/codex-invoke.js +80 -0
  9. package/dist/codex-invoke.js.map +1 -0
  10. package/dist/compounding.d.ts +54 -0
  11. package/dist/compounding.d.ts.map +1 -1
  12. package/dist/compounding.js +221 -1
  13. package/dist/compounding.js.map +1 -1
  14. package/dist/discrimination-gate.d.ts +63 -3
  15. package/dist/discrimination-gate.d.ts.map +1 -1
  16. package/dist/discrimination-gate.js +113 -16
  17. package/dist/discrimination-gate.js.map +1 -1
  18. package/dist/eta.d.ts +92 -0
  19. package/dist/eta.d.ts.map +1 -0
  20. package/dist/eta.js +488 -0
  21. package/dist/eta.js.map +1 -0
  22. package/dist/event-chain.d.ts +30 -0
  23. package/dist/event-chain.d.ts.map +1 -1
  24. package/dist/event-chain.js +24 -0
  25. package/dist/event-chain.js.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +1 -1
  27. package/dist/feature-adr-decision-recall.d.ts +167 -0
  28. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  29. package/dist/feature-adr-decision-recall.js +519 -0
  30. package/dist/feature-adr-decision-recall.js.map +1 -0
  31. package/dist/feature-adr-landing.d.ts +37 -0
  32. package/dist/feature-adr-landing.d.ts.map +1 -0
  33. package/dist/feature-adr-landing.js +59 -0
  34. package/dist/feature-adr-landing.js.map +1 -0
  35. package/dist/feature-adr-routing.d.ts +2 -2
  36. package/dist/feature-adr-routing.d.ts.map +1 -1
  37. package/dist/feature-adr-routing.js +7 -11
  38. package/dist/feature-adr-routing.js.map +1 -1
  39. package/dist/guard-promotion.d.ts +41 -0
  40. package/dist/guard-promotion.d.ts.map +1 -1
  41. package/dist/guard-promotion.js +218 -4
  42. package/dist/guard-promotion.js.map +1 -1
  43. package/dist/guard-volume.d.ts +108 -0
  44. package/dist/guard-volume.d.ts.map +1 -0
  45. package/dist/guard-volume.js +536 -0
  46. package/dist/guard-volume.js.map +1 -0
  47. package/dist/guard.d.ts +17 -0
  48. package/dist/guard.d.ts.map +1 -1
  49. package/dist/guard.js +92 -4
  50. package/dist/guard.js.map +1 -1
  51. package/dist/index.d.ts +24 -7
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +19 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/integration-apply.d.ts +25 -0
  56. package/dist/integration-apply.d.ts.map +1 -0
  57. package/dist/integration-apply.js +299 -0
  58. package/dist/integration-apply.js.map +1 -0
  59. package/dist/integration-evidence.d.ts +46 -0
  60. package/dist/integration-evidence.d.ts.map +1 -0
  61. package/dist/integration-evidence.js +44 -0
  62. package/dist/integration-evidence.js.map +1 -0
  63. package/dist/integration-probe-worker.d.ts +22 -0
  64. package/dist/integration-probe-worker.d.ts.map +1 -0
  65. package/dist/integration-probe-worker.js +334 -0
  66. package/dist/integration-probe-worker.js.map +1 -0
  67. package/dist/integrations-verify.d.ts +60 -0
  68. package/dist/integrations-verify.d.ts.map +1 -0
  69. package/dist/integrations-verify.js +194 -0
  70. package/dist/integrations-verify.js.map +1 -0
  71. package/dist/lesson-generalization.d.ts +29 -0
  72. package/dist/lesson-generalization.d.ts.map +1 -0
  73. package/dist/lesson-generalization.js +84 -0
  74. package/dist/lesson-generalization.js.map +1 -0
  75. package/dist/mutation-gate.d.ts +39 -36
  76. package/dist/mutation-gate.d.ts.map +1 -1
  77. package/dist/mutation-gate.js +111 -5
  78. package/dist/mutation-gate.js.map +1 -1
  79. package/dist/operations.d.ts +27 -0
  80. package/dist/operations.d.ts.map +1 -1
  81. package/dist/operations.js +186 -7
  82. package/dist/operations.js.map +1 -1
  83. package/dist/patterns.d.ts +27 -1
  84. package/dist/patterns.d.ts.map +1 -1
  85. package/dist/patterns.js +211 -45
  86. package/dist/patterns.js.map +1 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/plugin.js +27 -5
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/recommend.d.ts +4 -5
  91. package/dist/recommend.d.ts.map +1 -1
  92. package/dist/recommend.js +110 -45
  93. package/dist/recommend.js.map +1 -1
  94. package/dist/registry.d.ts +32 -1
  95. package/dist/registry.d.ts.map +1 -1
  96. package/dist/registry.js +165 -9
  97. package/dist/registry.js.map +1 -1
  98. package/dist/run-records.d.ts +3 -0
  99. package/dist/run-records.d.ts.map +1 -1
  100. package/dist/run-records.js +18 -0
  101. package/dist/run-records.js.map +1 -1
  102. package/dist/score.d.ts +95 -0
  103. package/dist/score.d.ts.map +1 -1
  104. package/dist/score.js +274 -2
  105. package/dist/score.js.map +1 -1
  106. package/dist/setup.d.ts.map +1 -1
  107. package/dist/setup.js +20 -17
  108. package/dist/setup.js.map +1 -1
  109. package/dist/skill-selection.d.ts +72 -0
  110. package/dist/skill-selection.d.ts.map +1 -0
  111. package/dist/skill-selection.js +76 -0
  112. package/dist/skill-selection.js.map +1 -0
  113. package/dist/stem.d.ts +12 -0
  114. package/dist/stem.d.ts.map +1 -0
  115. package/dist/stem.js +89 -0
  116. package/dist/stem.js.map +1 -0
  117. package/dist/target-integrations.d.ts +65 -0
  118. package/dist/target-integrations.d.ts.map +1 -0
  119. package/dist/target-integrations.js +152 -0
  120. package/dist/target-integrations.js.map +1 -0
  121. package/dist/telemetry-vocabulary.d.ts +7 -0
  122. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  123. package/dist/telemetry-vocabulary.js +29 -0
  124. package/dist/telemetry-vocabulary.js.map +1 -1
  125. package/dist/vector-tier.d.ts +6 -1
  126. package/dist/vector-tier.d.ts.map +1 -1
  127. package/dist/vector-tier.js +32 -7
  128. package/dist/vector-tier.js.map +1 -1
  129. package/package.json +7 -6
  130. package/sbom.json +772 -112
  131. package/src/amendment-trace.ts +12 -1
  132. package/src/codex-invoke.ts +138 -0
  133. package/src/compounding.ts +300 -1
  134. package/src/discrimination-gate.ts +183 -19
  135. package/src/eta.ts +590 -0
  136. package/src/event-chain.ts +41 -0
  137. package/src/feature-adr-checkpoints.ts +1 -1
  138. package/src/feature-adr-decision-recall.ts +652 -0
  139. package/src/feature-adr-landing.ts +109 -0
  140. package/src/feature-adr-routing.ts +7 -11
  141. package/src/guard-promotion.ts +245 -4
  142. package/src/guard-volume.ts +752 -0
  143. package/src/guard.ts +110 -4
  144. package/src/index.ts +73 -6
  145. package/src/integration-apply.ts +332 -0
  146. package/src/integration-evidence.ts +89 -0
  147. package/src/integration-probe-worker.ts +310 -0
  148. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  149. package/src/integrations-verify.ts +258 -0
  150. package/src/lesson-generalization.ts +115 -0
  151. package/src/mutation-gate.ts +165 -5
  152. package/src/operations.ts +207 -7
  153. package/src/patterns.ts +252 -43
  154. package/src/plugin.ts +27 -5
  155. package/src/recommend.ts +116 -46
  156. package/src/registry.ts +144 -11
  157. package/src/run-records.ts +23 -0
  158. package/src/score.ts +361 -3
  159. package/src/setup.ts +20 -17
  160. package/src/skill-selection.ts +111 -0
  161. package/src/stem.ts +87 -0
  162. package/src/target-integrations.ts +225 -0
  163. package/src/telemetry-vocabulary.ts +36 -0
  164. package/src/vector-tier.ts +44 -14
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export type LessonForm = 'specific' | 'class';
4
+ export type LessonMatchedForm = 'specific' | 'class' | 'both';
5
+
6
+ export interface LessonFormsInput {
7
+ readonly specific: string;
8
+ readonly classForm?: string;
9
+ readonly classAdvisory?: string;
10
+ }
11
+
12
+ export interface LessonFormRankedHit<T> {
13
+ readonly key: string;
14
+ readonly value: T;
15
+ readonly matchedForm: LessonForm;
16
+ }
17
+
18
+ export interface MergedLessonFormHit<T> {
19
+ readonly key: string;
20
+ readonly value: T;
21
+ readonly matchedForm: LessonMatchedForm;
22
+ readonly score: number;
23
+ }
24
+
25
+ export function normalizeLessonForms(specific: string, classInput?: unknown): LessonFormsInput {
26
+ if (classInput === undefined || (typeof classInput === 'string' && classInput.trim() === '')) {
27
+ return { specific };
28
+ }
29
+ if (typeof classInput !== 'string') {
30
+ return { specific, classAdvisory: 'class form must be text; the specific lesson was kept' };
31
+ }
32
+ return { specific, classForm: classInput.trim() };
33
+ }
34
+
35
+ function normalizedText(value: string): string {
36
+ return value.normalize('NFC').toLocaleLowerCase('und').replace(/\s+/gu, ' ').trim();
37
+ }
38
+
39
+ function escapedLiteral(value: string): string {
40
+ return normalizedText(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
41
+ }
42
+
43
+ export function validateClassTemplate(
44
+ specific: string,
45
+ classTemplate: string,
46
+ ): { readonly ok: true } | { readonly ok: false; readonly reason: string } {
47
+ const slot = /:([A-Za-z][A-Za-z0-9_-]*)/g;
48
+ const parts: string[] = [];
49
+ let slots = 0;
50
+ let cursor = 0;
51
+ for (const match of classTemplate.matchAll(slot)) {
52
+ const index = match.index ?? 0;
53
+ const literal = classTemplate.slice(cursor, index);
54
+ if (literal.includes(':')) return { ok: false, reason: 'invalid :slot name' };
55
+ parts.push(escapedLiteral(literal), '(.+?)');
56
+ slots += 1;
57
+ cursor = index + match[0].length;
58
+ }
59
+ const tail = classTemplate.slice(cursor);
60
+ if (tail.includes(':')) return { ok: false, reason: 'invalid :slot name' };
61
+ if (slots === 0) return { ok: false, reason: 'class form must contain at least one :slot' };
62
+ parts.push(escapedLiteral(tail));
63
+ try {
64
+ return new RegExp(`^${parts.join('')}$`, 'u').test(normalizedText(specific))
65
+ ? { ok: true }
66
+ : { ok: false, reason: 'class form does not syntactically cover the specific lesson' };
67
+ } catch {
68
+ return { ok: false, reason: 'class form contains an invalid template' };
69
+ }
70
+ }
71
+
72
+ export function lessonPairIdOf(specific: string, classTemplate: string, ts: string): string {
73
+ const digest = createHash('sha256')
74
+ .update(`${specific}\u0000${classTemplate}\u0000${ts}`)
75
+ .digest('hex')
76
+ .slice(0, 16);
77
+ return `lesson-pair:${digest}`;
78
+ }
79
+
80
+ export function mergeLessonMatchedForms(
81
+ left: LessonMatchedForm | undefined,
82
+ right: LessonMatchedForm | undefined,
83
+ ): LessonMatchedForm | undefined {
84
+ if (left === undefined) return right;
85
+ if (right === undefined || left === right) return left;
86
+ return 'both';
87
+ }
88
+
89
+ export function mergeLessonFormHits<T>(
90
+ specificHits: readonly LessonFormRankedHit<T>[],
91
+ classHits: readonly LessonFormRankedHit<T>[],
92
+ limit: number,
93
+ ): MergedLessonFormHit<T>[] {
94
+ interface Acc { value: T; specific: boolean; classForm: boolean; score: number }
95
+ const merged = new Map<string, Acc>();
96
+ const add = (hit: LessonFormRankedHit<T>, rank: number): void => {
97
+ const current = merged.get(hit.key) ?? { value: hit.value, specific: false, classForm: false, score: 0 };
98
+ if (hit.matchedForm === 'specific') current.specific = true;
99
+ else current.classForm = true;
100
+ current.score += 1 / (60 + rank + 1);
101
+ merged.set(hit.key, current);
102
+ };
103
+ specificHits.forEach(add);
104
+ classHits.forEach(add);
105
+ const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : merged.size;
106
+ return [...merged.entries()]
107
+ .sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0]))
108
+ .slice(0, safeLimit)
109
+ .map(([key, hit]) => ({
110
+ key,
111
+ value: hit.value,
112
+ matchedForm: hit.specific && hit.classForm ? 'both' : hit.classForm ? 'class' : 'specific',
113
+ score: hit.score,
114
+ }));
115
+ }
@@ -108,6 +108,10 @@ export interface MutationObservation {
108
108
  readonly rebaselineExitCode?: number | null;
109
109
  /** named no-exit reason for the restored-tree attribution run, when it produced none. */
110
110
  readonly rebaselineFailureReason?: string;
111
+ /** parsed failing files from a RED restored-tree run; absent when no red rebaseline ran. */
112
+ readonly rebaselineAttribution?: BaselineAttribution;
113
+ /** bounded log proving an internal runner failure received at most one retry. */
114
+ readonly internalAttemptLog?: string;
111
115
  }
112
116
 
113
117
  export interface MutationEntryResult {
@@ -135,6 +139,60 @@ export interface MutationEntryResult {
135
139
  readonly detail: string;
136
140
  }
137
141
 
142
+ // ── Internal runner crash containment — exactly one retry, both attempts observable ───────────
143
+
144
+ export type InternalRunnerAttemptOutcome = 'completed' | 'runner-internal-error';
145
+
146
+ export interface InternalRunnerAttempt {
147
+ readonly attempt: 1 | 2;
148
+ readonly outcome: InternalRunnerAttemptOutcome;
149
+ readonly detail: string;
150
+ }
151
+
152
+ export interface InternalRunnerRetryResult<T> {
153
+ /** The completed attempt's value. null means both attempts threw internally. */
154
+ readonly value: T | null;
155
+ readonly attempts: readonly InternalRunnerAttempt[];
156
+ /** Closed by construction: a runner receives either zero retries or exactly one. */
157
+ readonly internalRetries: 0 | 1;
158
+ /** Named reason consumed by the existing no-exit → INCONCLUSIVE arm. */
159
+ readonly failureReason?: `runner-internal-error: ${string}`;
160
+ }
161
+
162
+ function internalRunnerErrorHead(error: unknown): string {
163
+ const raw = error instanceof Error ? error.message : String(error);
164
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal runner error';
165
+ return Array.from(firstLine).slice(0, 160).join('');
166
+ }
167
+
168
+ /**
169
+ * Run one internal runner invocation. Only a THROWN internal error is retried; normal green/red
170
+ * observations and ordinary no-exit observations are values and therefore never retried.
171
+ */
172
+ export function runWithOneInternalRetry<T>(runner: () => T): InternalRunnerRetryResult<T> {
173
+ const attempts: InternalRunnerAttempt[] = [];
174
+ for (const attempt of [1, 2] as const) {
175
+ try {
176
+ const value = runner();
177
+ attempts.push({ attempt, outcome: 'completed', detail: `attempt ${attempt}: completed` });
178
+ return { value, attempts, internalRetries: attempt === 1 ? 0 : 1 };
179
+ } catch (error) {
180
+ const head = internalRunnerErrorHead(error);
181
+ attempts.push({
182
+ attempt,
183
+ outcome: 'runner-internal-error',
184
+ detail: `attempt ${attempt}: runner-internal-error: ${head}`,
185
+ });
186
+ }
187
+ }
188
+ return {
189
+ value: null,
190
+ attempts,
191
+ internalRetries: 1,
192
+ failureReason: 'runner-internal-error: persistent after 2/2 attempts',
193
+ };
194
+ }
195
+
138
196
  // ── Registry parsing — declarative DATA, validated loudly ─────────────────────────────────────
139
197
 
140
198
  const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -495,21 +553,114 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
495
553
 
496
554
  // ── Baseline (rule 3's runnability half) ──────────────────────────────────────────────────────
497
555
 
556
+ export type BaselineAttributionSource = 'node-test' | 'vitest' | 'unparseable';
557
+
558
+ export interface BaselineAttribution {
559
+ readonly parsedFrom: BaselineAttributionSource;
560
+ /** Package-relative failing paths, in first-seen order. */
561
+ readonly failingFiles: readonly string[];
562
+ /** Failing paths that match a registry file exactly (or by package-relative suffix). */
563
+ readonly covered: readonly string[];
564
+ /** Failing paths with no matching registry file. */
565
+ readonly extraneous: readonly string[];
566
+ }
567
+
568
+ function normaliseReportedFile(raw: string): string | null {
569
+ let value = raw.trim().replace(/^['"]|['"]$/g, '').replace(/:\d+(?::\d+)?$/, '');
570
+ value = value.replace(/\\/g, '/');
571
+ const testSegment = value.lastIndexOf('/test/');
572
+ if (testSegment >= 0) value = value.slice(testSegment + 1);
573
+ if (!value.includes('/') || !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(value)) return null;
574
+ return value;
575
+ }
576
+
577
+ /** Parse the failing FILE paths already exposed by supported node --test and vitest shapes. */
578
+ export function attributeBaselineRedness(
579
+ rawOutput: string,
580
+ registryFiles: readonly string[],
581
+ ): BaselineAttribution {
582
+ const output = stripSgr(rawOutput);
583
+ const files: string[] = [];
584
+ const add = (raw: string): void => {
585
+ const file = normaliseReportedFile(raw);
586
+ if (file !== null && !files.includes(file)) files.push(file);
587
+ };
588
+
589
+ const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(\S+)/gm)];
590
+ for (const match of vitestMatches) add(match[1] ?? '');
591
+
592
+ const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
593
+ for (const match of tapMatches) add(match[1] ?? '');
594
+ for (const match of output.matchAll(/^\s*location:\s*['"]([^'"]+)['"]\s*$/gm)) {
595
+ add(match[1] ?? '');
596
+ }
597
+
598
+ const parsedFrom: BaselineAttributionSource = files.length === 0
599
+ ? 'unparseable'
600
+ : vitestMatches.length > 0 ? 'vitest' : 'node-test';
601
+ if (parsedFrom === 'unparseable') {
602
+ return { parsedFrom, failingFiles: [], covered: [], extraneous: [] };
603
+ }
604
+
605
+ const normalisedRegistry = registryFiles
606
+ .map((file) => normaliseReportedFile(file) ?? file.replace(/\\/g, '/'));
607
+ const covered = files.filter((file) => normalisedRegistry.some((registryFile) =>
608
+ file === registryFile || file.endsWith(`/${registryFile}`) || registryFile.endsWith(`/${file}`)));
609
+ const extraneous = files.filter((file) => !covered.includes(file));
610
+ return { parsedFrom, failingFiles: files, covered, extraneous };
611
+ }
612
+
613
+ export type BaselineFailureReason =
614
+ | 'runner-internal-error'
615
+ | 'runner-no-exit'
616
+ | 'extraneous-red-in-allowlist'
617
+ | 'baseline-red-covered-files'
618
+ | 'baseline-red-files-unparseable';
619
+
498
620
  export interface BaselineResult {
499
621
  readonly ok: boolean;
500
622
  readonly detail: string;
623
+ readonly reason?: BaselineFailureReason;
501
624
  }
502
625
 
503
626
  /**
504
627
  * A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
505
628
  * "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
506
629
  */
507
- export function classifyBaseline(exitCode: number | null, runFailureReason?: string): BaselineResult {
630
+ export function classifyBaseline(
631
+ exitCode: number | null,
632
+ runFailureReason?: string,
633
+ attribution?: BaselineAttribution,
634
+ ): BaselineResult {
508
635
  if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
509
636
  if (exitCode === null) {
510
- return { ok: false, detail: `baseline suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) the copy is not runnable; fix the copy, do not read this as a mutation result` };
637
+ const internal = runFailureReason?.startsWith('runner-internal-error:') === true;
638
+ return {
639
+ ok: false,
640
+ reason: internal ? 'runner-internal-error' : 'runner-no-exit',
641
+ detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
642
+ };
511
643
  }
512
- return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
644
+ if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
645
+ return {
646
+ ok: false,
647
+ reason: 'baseline-red-files-unparseable',
648
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — failing files: unparseable from runner output — the broken copy cannot prove anything`,
649
+ };
650
+ }
651
+ const failing = `failing files: ${attribution.failingFiles.join(', ')}`;
652
+ if (attribution.extraneous.length > 0 && attribution.covered.length === 0) {
653
+ return {
654
+ ok: false,
655
+ reason: 'extraneous-red-in-allowlist',
656
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — extraneous red in the testCommand allowlist; the registry entries themselves are not disproven`,
657
+ };
658
+ }
659
+ return {
660
+ ok: false,
661
+ reason: 'baseline-red-covered-files',
662
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — broken-copy baseline redness touches registry-covered files; fix the copy before evaluating mutations`,
663
+ };
513
664
  }
514
665
 
515
666
  // ── Classification (rules 1 + 2, and the drop decision) ───────────────────────────────────────
@@ -549,7 +700,7 @@ export function classifyBaseline(exitCode: number | null, runFailureReason?: str
549
700
  * while the contract still holds is the early warning, reported loudly so a human re-pins
550
701
  * `observed` or investigates — silently normalising it would erase the signal.
551
702
  */
552
- export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
703
+ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): MutationEntryResult {
553
704
  const e = obs.entry;
554
705
  const minFailing = e.minFailing ?? 1;
555
706
  const base = {
@@ -653,12 +804,15 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
653
804
  // not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
654
805
  // Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
655
806
  if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
807
+ const failing = obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
808
+ ? 'failing files: unparseable from runner output'
809
+ : `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
656
810
  return {
657
811
  ...base,
658
812
  applied: true,
659
813
  verdict: 'INCONCLUSIVE',
660
814
  drop: false,
661
- detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
815
+ detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — mutation did not revert / flaky restored-tree route; ${failing}; the redness is not attributable to the protection`,
662
816
  };
663
817
  }
664
818
 
@@ -698,6 +852,12 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
698
852
  };
699
853
  }
700
854
 
855
+ export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
856
+ const result = classifyMutationOutcomeWithoutAttemptLog(obs);
857
+ if (obs.internalAttemptLog === undefined) return result;
858
+ return { ...result, detail: `${result.detail}; ${obs.internalAttemptLog}` };
859
+ }
860
+
701
861
  /** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
702
862
  const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
703
863
 
package/src/operations.ts CHANGED
@@ -57,6 +57,17 @@ import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './
57
57
  import type { SkillApplyFailure, SkillLoadFailure } from './skills.js';
58
58
  import { TARGETS } from './targets.js';
59
59
  import type { TargetName } from './targets.js';
60
+ import {
61
+ TARGET_INTEGRATIONS,
62
+ aggregateIntegrationManifests,
63
+ notRequestedOutcomes,
64
+ refusedOutcome,
65
+ staticPolicyOutcomes,
66
+ type IntegrationOutcome,
67
+ type IntegrationManifestSource,
68
+ } from './target-integrations.js';
69
+ import { applyIntegrationFragments, IntegrationApplyError, type IntegrationApplyFault } from './integration-apply.js';
70
+ import { verifyTargetIntegration, type IntegrationProcessPort } from './integrations-verify.js';
60
71
  import {
61
72
  AGENTS_MD_BUDGET_WARN_FRACTION,
62
73
  CODEX_PROJECT_DOC_MAX_BYTES,
@@ -87,6 +98,24 @@ export interface InitOptions {
87
98
  readonly select?: readonly string[];
88
99
  /** When set, generate platform-specific enrichment files alongside SKILL.md. */
89
100
  readonly enrich?: boolean;
101
+ /** Exact content-bound digest printed by the first integration-aware run. */
102
+ readonly allowIntegrations?: string;
103
+ /** Explicit hook opt-out; maps hooks to not-requested. */
104
+ readonly noHooks?: boolean;
105
+ /** Explicit skills-only opt-out, even when selected skills carry manifests. */
106
+ readonly noIntegrations?: boolean;
107
+ /** Live verification is load-bearing; requesting this flag makes requested integrations refuse. */
108
+ readonly noVerify?: boolean;
109
+ /** Injectable process boundary for deterministic integration tests. */
110
+ readonly integrationProcessPort?: IntegrationProcessPort;
111
+ /** Test-only fault at the carrier/ownership-journal durability boundary. */
112
+ readonly integrationApplyFault?: IntegrationApplyFault;
113
+ /**
114
+ * Install-level manifest sources. The CLI supplies this only on the first
115
+ * per-directory run so companion planning, authorization, and probing happen
116
+ * exactly once across every discovered skill pack.
117
+ */
118
+ readonly integrationManifestSources?: readonly IntegrationManifestSource[];
90
119
  }
91
120
 
92
121
  /** Per-skill outcome of {@link runInit}. */
@@ -130,6 +159,10 @@ export interface InitReport {
130
159
  * any per-skill loop.
131
160
  */
132
161
  readonly applyFailures: readonly SkillApplyFailure[];
162
+ /** Exactly two ordered companion outcomes: MCP, then hooks. */
163
+ readonly integrations: readonly [IntegrationOutcome, IntegrationOutcome];
164
+ /** Safe digest used by `--allow-integrations`; absent when no manifest exists. */
165
+ readonly integrationDigest?: string;
133
166
  }
134
167
 
135
168
  // ---------------------------------------------------------------------------
@@ -266,6 +299,8 @@ export interface AgentsMdInitOptions {
266
299
  readonly projectRoot: string;
267
300
  /** When set, install only these skill ids (a preset selection). */
268
301
  readonly select?: readonly string[] | undefined;
302
+ readonly noHooks?: boolean;
303
+ readonly noIntegrations?: boolean;
269
304
  }
270
305
 
271
306
  /** The parts of a single-file managed-Markdown target that vary by filename. */
@@ -367,6 +402,28 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
367
402
  }
368
403
  const missing = selection === undefined ? [] : selection.filter((id) => !discovered.has(id));
369
404
 
405
+ let aggregate = { manifest: undefined, digest: undefined } as ReturnType<typeof aggregateIntegrationManifests>;
406
+ try {
407
+ if (options.noIntegrations !== true) {
408
+ aggregate = aggregateIntegrationManifests(picked.map(({ id, skillsDir }) => ({ skillId: id, skillDir: skillsDir })));
409
+ }
410
+ } catch (error) {
411
+ const remediation = error instanceof Error ? error.message : String(error);
412
+ return {
413
+ target: config.target,
414
+ skillsDir: joinedDir,
415
+ projectRoot: options.projectRoot,
416
+ skills: [],
417
+ missing,
418
+ failures: [],
419
+ applyFailures: [],
420
+ integrations: [
421
+ refusedOutcome(config.target, 'mcp', 'MANIFEST_INVALID', remediation),
422
+ refusedOutcome(config.target, 'hooks', 'MANIFEST_INVALID', remediation),
423
+ ],
424
+ };
425
+ }
426
+
370
427
  // Skip-and-collect: one unloadable skill must not discard the whole aggregation.
371
428
  const failures: SkillLoadFailure[] = [];
372
429
  const loaded: { id: string; section: string }[] = [];
@@ -397,7 +454,19 @@ function runInitSingleFileMd(options: AgentsMdInitOptions, config: SingleFileMdC
397
454
 
398
455
  // No apply failures are possible here: the single write is outside every per-skill
399
456
  // loop, so a write error propagates as itself rather than being attributed to a skill.
400
- return { target: config.target, skillsDir: joinedDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures: [] };
457
+ return {
458
+ target: config.target,
459
+ skillsDir: joinedDir,
460
+ projectRoot: options.projectRoot,
461
+ skills,
462
+ missing,
463
+ failures,
464
+ applyFailures: [],
465
+ integrations: options.noIntegrations === true
466
+ ? notRequestedOutcomes(config.target)
467
+ : staticPolicyOutcomes(config.target, aggregate.manifest, options.noHooks === true),
468
+ ...(aggregate.digest !== undefined ? { integrationDigest: aggregate.digest } : {}),
469
+ };
401
470
  }
402
471
 
403
472
  /**
@@ -546,6 +615,8 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
546
615
  skillsDirs: [options.skillsDir],
547
616
  projectRoot: options.projectRoot,
548
617
  select: options.select,
618
+ ...(options.noHooks !== undefined ? { noHooks: options.noHooks } : {}),
619
+ ...(options.noIntegrations !== undefined ? { noIntegrations: options.noIntegrations } : {}),
549
620
  });
550
621
  }
551
622
  // gemini is likewise a flattening single-file target — aggregate into ONE root
@@ -555,6 +626,8 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
555
626
  skillsDirs: [options.skillsDir],
556
627
  projectRoot: options.projectRoot,
557
628
  select: options.select,
629
+ ...(options.noHooks !== undefined ? { noHooks: options.noHooks } : {}),
630
+ ...(options.noIntegrations !== undefined ? { noIntegrations: options.noIntegrations } : {}),
558
631
  });
559
632
  }
560
633
 
@@ -568,6 +641,78 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
568
641
  const missing = selection === undefined
569
642
  ? []
570
643
  : selection.filter((id) => !discovered.includes(id));
644
+ const manifestSources: readonly IntegrationManifestSource[] = options.integrationManifestSources
645
+ ?? ids.map((id) => ({ skillId: id, skillDir: options.skillsDir }));
646
+ let aggregate = { manifest: undefined, digest: undefined } as ReturnType<typeof aggregateIntegrationManifests>;
647
+ try {
648
+ if (options.noIntegrations !== true) aggregate = aggregateIntegrationManifests(manifestSources);
649
+ } catch (error) {
650
+ const remediation = error instanceof Error ? error.message : String(error);
651
+ return {
652
+ target: options.target,
653
+ skillsDir: options.skillsDir,
654
+ projectRoot: options.projectRoot,
655
+ skills: [],
656
+ missing,
657
+ failures: [],
658
+ applyFailures: [],
659
+ integrations: [
660
+ refusedOutcome(options.target, 'mcp', 'MANIFEST_INVALID', remediation),
661
+ refusedOutcome(options.target, 'hooks', 'MANIFEST_INVALID', remediation),
662
+ ],
663
+ };
664
+ }
665
+
666
+ let integrations: readonly [IntegrationOutcome, IntegrationOutcome] = notRequestedOutcomes(options.target);
667
+ let eligibleClaudePlan: ReturnType<(typeof TARGET_INTEGRATIONS)['claude-code']['plan']> | undefined;
668
+ if (aggregate.manifest !== undefined && options.noIntegrations !== true) {
669
+ if (options.target !== 'claude-code') {
670
+ integrations = staticPolicyOutcomes(options.target, aggregate.manifest, options.noHooks === true);
671
+ } else {
672
+ const mcpRequested = Object.keys(aggregate.manifest.mcpServers ?? {}).length > 0;
673
+ const hooksRequested = (aggregate.manifest.hooks?.length ?? 0) > 0 && options.noHooks !== true;
674
+ let mcpOutcome: IntegrationOutcome = { target: options.target, component: 'mcp', status: 'not-requested', registrations: [] };
675
+ if (mcpRequested) {
676
+ if (options.noVerify === true) {
677
+ mcpOutcome = refusedOutcome(options.target, 'mcp', 'NO_QUALIFYING_LIVE_RECEIPT', '--no-verify cannot authorize integration emission; rerun with live verification enabled');
678
+ } else if (aggregate.digest === undefined || options.allowIntegrations !== aggregate.digest) {
679
+ mcpOutcome = refusedOutcome(options.target, 'mcp', 'INTEGRATION_AUTHORIZATION_REQUIRED', `rerun with --allow-integrations ${aggregate.digest ?? '<missing-digest>'}`);
680
+ } else {
681
+ const plan = TARGET_INTEGRATIONS['claude-code'].plan(aggregate.manifest, { target: 'claude-code' });
682
+ const refusal = plan.refusals.find((row) => row.component === 'mcp');
683
+ if (refusal !== undefined) {
684
+ mcpOutcome = refusedOutcome(options.target, 'mcp', refusal.reasonCode, refusal.remediation);
685
+ } else {
686
+ const preflight = verifyTargetIntegration({
687
+ target: 'claude-code',
688
+ component: 'mcp',
689
+ projectRoot: options.projectRoot,
690
+ phase: 'preflight',
691
+ ...(options.integrationProcessPort !== undefined ? { processPort: options.integrationProcessPort } : {}),
692
+ });
693
+ if (!preflight.ok) {
694
+ mcpOutcome = refusedOutcome(options.target, 'mcp', preflight.reasonCode ?? 'LIVE_PROBE_FAILED', preflight.remediation ?? 'live preflight did not qualify');
695
+ } else {
696
+ eligibleClaudePlan = plan;
697
+ mcpOutcome = {
698
+ target: options.target,
699
+ component: 'mcp',
700
+ status: 'emitted',
701
+ registrations: [],
702
+ carrier: { scope: 'project', path: '.mcp.json' },
703
+ ...(preflight.runtimeVersion !== undefined ? { runtimeVersion: preflight.runtimeVersion } : {}),
704
+ ...(preflight.evidenceVersion !== undefined ? { evidenceVersion: preflight.evidenceVersion } : {}),
705
+ };
706
+ }
707
+ }
708
+ }
709
+ }
710
+ const hookOutcome: IntegrationOutcome = hooksRequested
711
+ ? refusedOutcome(options.target, 'hooks', 'NO_ACTIVATION_RECEIPT', 'record a nonce canary and negative-control activation receipt')
712
+ : { target: options.target, component: 'hooks', status: 'not-requested', registrations: [] };
713
+ integrations = [mcpOutcome, hookOutcome];
714
+ }
715
+ }
571
716
  // Skip-and-collect (D1): one unparseable SKILL.md must not discard the whole install.
572
717
  // Same shape as `runVerify`'s long-standing per-id try/catch below.
573
718
  //
@@ -602,7 +747,59 @@ export async function runInit(options: InitOptions): Promise<InitReport> {
602
747
  applyFailures.push({ id, reason: error instanceof Error ? error.message : String(error) });
603
748
  }
604
749
  }
605
- return { target: options.target, skillsDir: options.skillsDir, projectRoot: options.projectRoot, skills, missing, failures, applyFailures };
750
+ if (eligibleClaudePlan !== undefined && integrations[0].status === 'emitted') {
751
+ try {
752
+ applyIntegrationFragments({
753
+ projectRoot: options.projectRoot,
754
+ fragments: eligibleClaudePlan.fragments,
755
+ ...(options.integrationApplyFault !== undefined ? { injectFault: options.integrationApplyFault } : {}),
756
+ });
757
+ const registrations = Object.keys(aggregate.manifest?.mcpServers ?? {});
758
+ const observed = [];
759
+ let failedObservation: ReturnType<typeof verifyTargetIntegration> | undefined;
760
+ for (const registrationId of registrations) {
761
+ const result = verifyTargetIntegration({
762
+ target: 'claude-code',
763
+ component: 'mcp',
764
+ projectRoot: options.projectRoot,
765
+ registrationId,
766
+ phase: 'post-write',
767
+ ...(options.integrationProcessPort !== undefined ? { processPort: options.integrationProcessPort } : {}),
768
+ });
769
+ if (!result.ok) { failedObservation = result; break; }
770
+ observed.push(...result.registrations);
771
+ }
772
+ if (failedObservation !== undefined) {
773
+ integrations = [
774
+ {
775
+ ...refusedOutcome(options.target, 'mcp', failedObservation.reasonCode ?? 'POST_WRITE_REGISTRATION_NOT_OBSERVED', failedObservation.remediation ?? 'registration was written but not observed'),
776
+ carrier: { scope: 'project', path: '.mcp.json' },
777
+ applied: true,
778
+ },
779
+ integrations[1],
780
+ ];
781
+ } else {
782
+ integrations = [{ ...integrations[0], registrations: observed }, integrations[1]];
783
+ }
784
+ } catch (error) {
785
+ const reasonCode = error instanceof IntegrationApplyError ? error.reasonCode : 'APPLY_FAILED';
786
+ integrations = [{
787
+ ...refusedOutcome(options.target, 'mcp', reasonCode, error instanceof Error ? error.message : String(error)),
788
+ ...(error instanceof IntegrationApplyError && error.applied ? { applied: true } : {}),
789
+ }, integrations[1]];
790
+ }
791
+ }
792
+ return {
793
+ target: options.target,
794
+ skillsDir: options.skillsDir,
795
+ projectRoot: options.projectRoot,
796
+ skills,
797
+ missing,
798
+ failures,
799
+ applyFailures,
800
+ integrations,
801
+ ...(aggregate.digest !== undefined ? { integrationDigest: aggregate.digest } : {}),
802
+ };
606
803
  }
607
804
 
608
805
  // ---------------------------------------------------------------------------
@@ -1186,9 +1383,12 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1186
1383
  // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
1187
1384
  // fault, and reporting it would train the reader to ignore this line.
1188
1385
  try {
1189
- const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
1190
- for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
1191
- const p = join(root, '.dz', rel);
1386
+ const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE, CHAINED_JOURNALS } = await import('./event-chain.js');
1387
+ // W0-chain (bc4ee35c): enumerated from THE registry, never from a list kept here. The inline
1388
+ // array this replaces is why a journal could be given a chain and still be checked by nobody —
1389
+ // the mechanism present, the coverage absent, and no red anywhere to say so.
1390
+ for (const journal of CHAINED_JOURNALS) {
1391
+ const p = join(root, journal.rel);
1192
1392
  if (!existsSync(p)) continue;
1193
1393
  const text = readFileSync(p, 'utf-8');
1194
1394
  const v = verifyEventChainText(text);
@@ -1203,7 +1403,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1203
1403
  // file and false of the present, and a red nobody can act on is a red nobody reads.
1204
1404
  if (age.inRun.length === 0 && age.runRecords > 0) {
1205
1405
  checks.push({
1206
- name: `evidence chain (.dz/${rel})`,
1406
+ name: `evidence chain (${journal.rel})`,
1207
1407
  ok: true,
1208
1408
  // The COUNT carries the meaning, and is printed first for that reason: "1 record forms an
1209
1409
  // unbroken run" is true and says almost nothing, while 998 says a great deal. Naming the
@@ -1216,7 +1416,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1216
1416
  continue;
1217
1417
  }
1218
1418
  checks.push({
1219
- name: `evidence chain (.dz/${rel})`,
1419
+ name: `evidence chain (${journal.rel})`,
1220
1420
  ok: false,
1221
1421
  detail: `${named} — with NO sound records after them: learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
1222
1422
  });