@dzhechkov/harness-core 0.8.6 → 0.8.10

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 (105) hide show
  1. package/.dz-manifest.json +257 -53
  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/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.js +1 -1
  15. package/dist/feature-adr-decision-recall.d.ts +167 -0
  16. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  17. package/dist/feature-adr-decision-recall.js +519 -0
  18. package/dist/feature-adr-decision-recall.js.map +1 -0
  19. package/dist/feature-adr-landing.d.ts +37 -0
  20. package/dist/feature-adr-landing.d.ts.map +1 -0
  21. package/dist/feature-adr-landing.js +59 -0
  22. package/dist/feature-adr-landing.js.map +1 -0
  23. package/dist/feature-adr-routing.d.ts +2 -2
  24. package/dist/feature-adr-routing.d.ts.map +1 -1
  25. package/dist/feature-adr-routing.js +7 -11
  26. package/dist/feature-adr-routing.js.map +1 -1
  27. package/dist/guard-promotion.d.ts +41 -0
  28. package/dist/guard-promotion.d.ts.map +1 -1
  29. package/dist/guard-promotion.js +218 -4
  30. package/dist/guard-promotion.js.map +1 -1
  31. package/dist/guard-volume.d.ts +108 -0
  32. package/dist/guard-volume.d.ts.map +1 -0
  33. package/dist/guard-volume.js +536 -0
  34. package/dist/guard-volume.js.map +1 -0
  35. package/dist/guard.d.ts +9 -0
  36. package/dist/guard.d.ts.map +1 -1
  37. package/dist/guard.js +55 -4
  38. package/dist/guard.js.map +1 -1
  39. package/dist/index.d.ts +16 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +11 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/integration-apply.d.ts +25 -0
  44. package/dist/integration-apply.d.ts.map +1 -0
  45. package/dist/integration-apply.js +299 -0
  46. package/dist/integration-apply.js.map +1 -0
  47. package/dist/integration-evidence.d.ts +46 -0
  48. package/dist/integration-evidence.d.ts.map +1 -0
  49. package/dist/integration-evidence.js +44 -0
  50. package/dist/integration-evidence.js.map +1 -0
  51. package/dist/integration-probe-worker.d.ts +22 -0
  52. package/dist/integration-probe-worker.d.ts.map +1 -0
  53. package/dist/integration-probe-worker.js +334 -0
  54. package/dist/integration-probe-worker.js.map +1 -0
  55. package/dist/integrations-verify.d.ts +60 -0
  56. package/dist/integrations-verify.d.ts.map +1 -0
  57. package/dist/integrations-verify.js +194 -0
  58. package/dist/integrations-verify.js.map +1 -0
  59. package/dist/lesson-generalization.d.ts +29 -0
  60. package/dist/lesson-generalization.d.ts.map +1 -0
  61. package/dist/lesson-generalization.js +84 -0
  62. package/dist/lesson-generalization.js.map +1 -0
  63. package/dist/operations.d.ts +27 -0
  64. package/dist/operations.d.ts.map +1 -1
  65. package/dist/operations.js +178 -2
  66. package/dist/operations.js.map +1 -1
  67. package/dist/patterns.d.ts +27 -1
  68. package/dist/patterns.d.ts.map +1 -1
  69. package/dist/patterns.js +211 -45
  70. package/dist/patterns.js.map +1 -1
  71. package/dist/setup.d.ts.map +1 -1
  72. package/dist/setup.js +20 -17
  73. package/dist/setup.js.map +1 -1
  74. package/dist/target-integrations.d.ts +65 -0
  75. package/dist/target-integrations.d.ts.map +1 -0
  76. package/dist/target-integrations.js +152 -0
  77. package/dist/target-integrations.js.map +1 -0
  78. package/dist/vector-tier.d.ts +6 -1
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +32 -7
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +9 -8
  83. package/sbom.json +562 -52
  84. package/src/amendment-trace.ts +12 -1
  85. package/src/compounding.ts +300 -1
  86. package/src/eta.ts +590 -0
  87. package/src/feature-adr-checkpoints.ts +1 -1
  88. package/src/feature-adr-decision-recall.ts +652 -0
  89. package/src/feature-adr-landing.ts +109 -0
  90. package/src/feature-adr-routing.ts +7 -11
  91. package/src/guard-promotion.ts +245 -4
  92. package/src/guard-volume.ts +752 -0
  93. package/src/guard.ts +70 -4
  94. package/src/index.ts +63 -2
  95. package/src/integration-apply.ts +332 -0
  96. package/src/integration-evidence.ts +89 -0
  97. package/src/integration-probe-worker.ts +310 -0
  98. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  99. package/src/integrations-verify.ts +258 -0
  100. package/src/lesson-generalization.ts +115 -0
  101. package/src/operations.ts +199 -2
  102. package/src/patterns.ts +252 -43
  103. package/src/setup.ts +20 -17
  104. package/src/target-integrations.ts +225 -0
  105. package/src/vector-tier.ts +44 -14
@@ -0,0 +1,258 @@
1
+ /** Bounded, non-executing target registration verification. */
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { accessSync, constants, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import type { IntegrationComponent, IntegrationReasonCode } from '@dzhechkov/core';
10
+
11
+ import { CLAUDE_MCP_RECEIPT, assessClaudeMcpEvidence } from './integration-evidence.js';
12
+ import { TARGET_INTEGRATION_POLICY, type RegistrationObservation } from './target-integrations.js';
13
+ import type { TargetName } from './targets.js';
14
+ import type { ProbeWorkerResult } from './integration-probe-worker.js';
15
+
16
+ export const INTEGRATION_PROBE_TIMEOUT_MS = 30_000;
17
+ export const INTEGRATION_PROBE_STREAM_MAX_BYTES = 1024 * 1024;
18
+ export const INTEGRATION_PROBE_AGGREGATE_MAX_BYTES = 2 * 1024 * 1024;
19
+
20
+ export interface IntegrationProcessRequest {
21
+ readonly command: string;
22
+ readonly args: readonly string[];
23
+ readonly cwd: string;
24
+ readonly timeoutMs: number;
25
+ }
26
+
27
+ export interface IntegrationProcessObservation {
28
+ readonly status: number | null;
29
+ readonly signal: string | null;
30
+ readonly stdout: Uint8Array;
31
+ readonly stderr: Uint8Array;
32
+ readonly errorCode?: string;
33
+ }
34
+
35
+ export interface IntegrationProcessPort {
36
+ run(request: IntegrationProcessRequest): IntegrationProcessObservation;
37
+ }
38
+
39
+ export type IntegrationExecutableResolution =
40
+ | { readonly ok: true; readonly path: string }
41
+ | { readonly ok: false; readonly errorCode: 'ENOENT' | 'UNSAFE_EXECUTABLE' };
42
+
43
+ /** Resolve PATH once and reject a project-controlled probe binary before spawning it. */
44
+ export function resolveIntegrationExecutable(
45
+ command: string,
46
+ cwd: string,
47
+ pathValue = process.env['PATH'] ?? '',
48
+ ): IntegrationExecutableResolution {
49
+ const candidates = isAbsolute(command)
50
+ ? [command]
51
+ : command.includes('/') || command.includes('\\')
52
+ ? [resolve(cwd, command)]
53
+ : pathValue.split(delimiter).map((dir) => join(dir.length === 0 ? cwd : dir, command));
54
+ for (const candidate of candidates) {
55
+ if (!existsSync(candidate)) continue;
56
+ try {
57
+ accessSync(candidate, constants.X_OK);
58
+ const actual = realpathSync(candidate);
59
+ const rel = relative(realpathSync(cwd), actual);
60
+ if (rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))) {
61
+ return { ok: false, errorCode: 'UNSAFE_EXECUTABLE' };
62
+ }
63
+ return { ok: true, path: actual };
64
+ } catch {
65
+ continue;
66
+ }
67
+ }
68
+ return { ok: false, errorCode: 'ENOENT' };
69
+ }
70
+
71
+ export const defaultIntegrationProcessPort: IntegrationProcessPort = {
72
+ run(request) {
73
+ const executable = resolveIntegrationExecutable(request.command, request.cwd);
74
+ if (!executable.ok) {
75
+ return { status: null, signal: null, stdout: new Uint8Array(), stderr: new Uint8Array(), errorCode: executable.errorCode };
76
+ }
77
+ const workerPath = fileURLToPath(new URL('./integration-probe-worker.js', import.meta.url));
78
+ const result = spawnSync(process.execPath, [workerPath], {
79
+ input: JSON.stringify({
80
+ ...request,
81
+ command: executable.path,
82
+ streamMaxBytes: INTEGRATION_PROBE_STREAM_MAX_BYTES,
83
+ aggregateMaxBytes: INTEGRATION_PROBE_AGGREGATE_MAX_BYTES,
84
+ }),
85
+ cwd: request.cwd,
86
+ timeout: request.timeoutMs + 5_000,
87
+ killSignal: 'SIGKILL',
88
+ maxBuffer: 4 * 1024 * 1024,
89
+ encoding: 'utf8',
90
+ windowsHide: true,
91
+ });
92
+ if (result.error !== undefined || result.status !== 0) {
93
+ return {
94
+ status: result.status,
95
+ signal: result.signal,
96
+ stdout: new Uint8Array(),
97
+ stderr: new Uint8Array(),
98
+ errorCode: (result.error as NodeJS.ErrnoException | undefined)?.code ?? 'PROCESS_ERROR',
99
+ };
100
+ }
101
+ let observation: ProbeWorkerResult;
102
+ try {
103
+ observation = JSON.parse(result.stdout) as ProbeWorkerResult;
104
+ } catch {
105
+ return { status: null, signal: null, stdout: new Uint8Array(), stderr: new Uint8Array(), errorCode: 'PROCESS_ERROR' };
106
+ }
107
+ return {
108
+ status: observation.status,
109
+ signal: observation.signal,
110
+ stdout: Buffer.from(observation.stdoutBase64, 'base64'),
111
+ stderr: Buffer.from(observation.stderrBase64, 'base64'),
112
+ ...(observation.errorCode !== undefined ? { errorCode: observation.errorCode } : {}),
113
+ };
114
+ },
115
+ };
116
+
117
+ export interface VerifyTargetIntegrationOptions {
118
+ readonly target: TargetName;
119
+ readonly component: IntegrationComponent;
120
+ readonly projectRoot: string;
121
+ readonly registrationId?: string;
122
+ readonly phase?: 'preflight' | 'post-write';
123
+ readonly processPort?: IntegrationProcessPort;
124
+ }
125
+
126
+ export interface IntegrationVerification {
127
+ readonly ok: boolean;
128
+ readonly target: TargetName;
129
+ readonly component: IntegrationComponent;
130
+ readonly runtimeVersion?: string;
131
+ readonly evidenceVersion?: string;
132
+ readonly reasonCode?: IntegrationReasonCode;
133
+ readonly remediation?: string;
134
+ readonly registrations: readonly RegistrationObservation[];
135
+ }
136
+
137
+ function decodeBounded(observation: IntegrationProcessObservation): { stdout: string; stderr: string } | IntegrationReasonCode {
138
+ const stdout = Buffer.from(observation.stdout);
139
+ const stderr = Buffer.from(observation.stderr);
140
+ if (stdout.length > INTEGRATION_PROBE_STREAM_MAX_BYTES || stderr.length > INTEGRATION_PROBE_STREAM_MAX_BYTES ||
141
+ stdout.length + stderr.length > INTEGRATION_PROBE_AGGREGATE_MAX_BYTES) return 'LIVE_PROBE_FAILED';
142
+ try {
143
+ const decoder = new TextDecoder('utf-8', { fatal: true });
144
+ return { stdout: decoder.decode(stdout), stderr: decoder.decode(stderr) };
145
+ } catch {
146
+ return 'LIVE_PROBE_FAILED';
147
+ }
148
+ }
149
+
150
+ function parseClaudeVersion(text: string): string | undefined {
151
+ return /(?:Claude Code\s+)?(\d+\.\d+\.\d+)/i.exec(text)?.[1];
152
+ }
153
+
154
+ function failed(
155
+ options: VerifyTargetIntegrationOptions,
156
+ reasonCode: IntegrationReasonCode,
157
+ remediation: string,
158
+ runtimeVersion?: string,
159
+ ): IntegrationVerification {
160
+ return {
161
+ ok: false,
162
+ target: options.target,
163
+ component: options.component,
164
+ registrations: [],
165
+ reasonCode,
166
+ remediation,
167
+ ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
168
+ };
169
+ }
170
+
171
+ function parseClaudeProjectRegistration(
172
+ options: VerifyTargetIntegrationOptions,
173
+ id: string,
174
+ observation: IntegrationProcessObservation,
175
+ runtimeVersion: string,
176
+ ): IntegrationVerification {
177
+ if (observation.errorCode === 'ETIMEDOUT' || observation.signal === 'SIGKILL') {
178
+ return failed(options, 'LIVE_PROBE_TIMEOUT', `Claude mcp get exceeded ${INTEGRATION_PROBE_TIMEOUT_MS}ms`, runtimeVersion);
179
+ }
180
+ if (observation.errorCode !== undefined) {
181
+ return failed(options, 'LIVE_PROBE_FAILED', `Claude registration probe failed before a trustworthy observation (${observation.errorCode})`, runtimeVersion);
182
+ }
183
+ const text = decodeBounded(observation);
184
+ if (typeof text === 'string') return failed(options, text, 'Claude registration output was oversized or invalid UTF-8', runtimeVersion);
185
+ if (observation.status !== 0) return failed(options, 'POST_WRITE_REGISTRATION_NOT_OBSERVED', `claude mcp get ${id} did not observe the project registration`, runtimeVersion);
186
+ const output = `${text.stdout}\n${text.stderr}`;
187
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ // Claude 2.1.235 prints either `Name: <id>` or an exact `<id>:` heading depending on the
189
+ // installation channel. Require one whole attributable line; a prose mention is not evidence.
190
+ if (!new RegExp(`^(?:Name\\s*:\\s*${escapedId}|${escapedId}:)\\s*$`, 'im').test(output) ||
191
+ !/Scope\s*:\s*Project\b/i.test(output)) {
192
+ return failed(options, 'POST_WRITE_REGISTRATION_NOT_OBSERVED', `claude mcp get ${id} did not attribute the exact id to project scope`, runtimeVersion);
193
+ }
194
+ const pending = /pending approval/i.test(output);
195
+ return {
196
+ ok: true,
197
+ target: options.target,
198
+ component: options.component,
199
+ runtimeVersion,
200
+ evidenceVersion: CLAUDE_MCP_RECEIPT.runtimeVersion,
201
+ registrations: [{ id, scope: 'project', registered: true, approval: pending ? 'pending' : 'unknown', ready: !pending }],
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Verify only code-owned native list/get surfaces. Manifest command/URL fields
207
+ * are intentionally absent from this API, so they cannot be executed here.
208
+ */
209
+ export function verifyTargetIntegration(options: VerifyTargetIntegrationOptions): IntegrationVerification {
210
+ const port = options.processPort ?? defaultIntegrationProcessPort;
211
+ if (options.target !== 'claude-code' || options.component !== 'mcp') {
212
+ const cell = TARGET_INTEGRATION_POLICY[options.target][options.component];
213
+ return failed(options, cell.reasonCode ?? 'NO_QUALIFYING_LIVE_RECEIPT', 'no qualifying exact-version live receipt exists for this target/component');
214
+ }
215
+
216
+ const versionObs = port.run({ command: 'claude', args: ['--version'], cwd: options.projectRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS });
217
+ if (versionObs.errorCode === 'ENOENT') return failed(options, 'TARGET_BINARY_UNAVAILABLE', 'install Claude Code and rerun dz integrations-verify');
218
+ if (versionObs.errorCode === 'ETIMEDOUT' || versionObs.signal === 'SIGKILL') return failed(options, 'LIVE_PROBE_TIMEOUT', `Claude version probe exceeded ${INTEGRATION_PROBE_TIMEOUT_MS}ms`);
219
+ if (versionObs.errorCode !== undefined) return failed(options, 'LIVE_PROBE_FAILED', `Claude version probe failed before a trustworthy observation (${versionObs.errorCode})`);
220
+ const versionText = decodeBounded(versionObs);
221
+ if (typeof versionText === 'string') return failed(options, versionText, 'Claude version output was oversized or invalid UTF-8');
222
+ if (versionObs.status !== 0) return failed(options, 'LIVE_PROBE_FAILED', 'Claude version probe exited non-zero');
223
+ const runtimeVersion = parseClaudeVersion(`${versionText.stdout}\n${versionText.stderr}`);
224
+ if (runtimeVersion === undefined) return failed(options, 'LIVE_PROBE_FAILED', 'Claude version output did not contain an attributable semantic version');
225
+ const evidence = assessClaudeMcpEvidence(runtimeVersion);
226
+ if (!evidence.eligible) return failed(options, evidence.reasonCode ?? 'RECEIPT_STALE', `expected Claude Code ${CLAUDE_MCP_RECEIPT.runtimeVersion}; rerun after a matching receipt is recorded`, runtimeVersion);
227
+
228
+ if ((options.phase ?? 'preflight') === 'preflight') {
229
+ const probeId = 'dz-registration-probe';
230
+ const probeRoot = mkdtempSync(join(tmpdir(), 'dz-claude-mcp-probe-'));
231
+ try {
232
+ writeFileSync(join(probeRoot, '.mcp.json'), `${JSON.stringify({
233
+ mcpServers: { [probeId]: { type: 'stdio', command: 'node', args: ['-e', 'process.exit(0)'] } },
234
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
235
+ const observation = port.run({
236
+ command: 'claude', args: ['mcp', 'get', probeId], cwd: probeRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS,
237
+ });
238
+ const parsed = parseClaudeProjectRegistration(options, probeId, observation, runtimeVersion);
239
+ return parsed.ok ? { ...parsed, registrations: [] } : parsed;
240
+ } finally {
241
+ rmSync(probeRoot, { recursive: true, force: true });
242
+ }
243
+ }
244
+ const id = options.registrationId;
245
+ if (id === undefined) return failed(options, 'LIVE_PROBE_FAILED', 'post-write verification requires an exact registration id', runtimeVersion);
246
+ return parseClaudeProjectRegistration(
247
+ options,
248
+ id,
249
+ port.run({ command: 'claude', args: ['mcp', 'get', id], cwd: options.projectRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS }),
250
+ runtimeVersion,
251
+ );
252
+ }
253
+
254
+ export interface RunIntegrationsVerifyOptions extends VerifyTargetIntegrationOptions {}
255
+
256
+ export function runIntegrationsVerify(options: RunIntegrationsVerifyOptions): IntegrationVerification {
257
+ return verifyTargetIntegration(options);
258
+ }
@@ -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
+ }
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
  // ---------------------------------------------------------------------------