@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.
- package/.dz-manifest.json +257 -53
- package/README.md +48 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +12 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/compounding.d.ts +54 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +221 -1
- package/dist/compounding.js.map +1 -1
- package/dist/eta.d.ts +92 -0
- package/dist/eta.d.ts.map +1 -0
- package/dist/eta.js +488 -0
- package/dist/eta.js.map +1 -0
- package/dist/feature-adr-checkpoints.js +1 -1
- package/dist/feature-adr-decision-recall.d.ts +167 -0
- package/dist/feature-adr-decision-recall.d.ts.map +1 -0
- package/dist/feature-adr-decision-recall.js +519 -0
- package/dist/feature-adr-decision-recall.js.map +1 -0
- package/dist/feature-adr-landing.d.ts +37 -0
- package/dist/feature-adr-landing.d.ts.map +1 -0
- package/dist/feature-adr-landing.js +59 -0
- package/dist/feature-adr-landing.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +2 -2
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +7 -11
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard-promotion.d.ts +41 -0
- package/dist/guard-promotion.d.ts.map +1 -1
- package/dist/guard-promotion.js +218 -4
- package/dist/guard-promotion.js.map +1 -1
- package/dist/guard-volume.d.ts +108 -0
- package/dist/guard-volume.d.ts.map +1 -0
- package/dist/guard-volume.js +536 -0
- package/dist/guard-volume.js.map +1 -0
- package/dist/guard.d.ts +9 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +55 -4
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.d.ts +25 -0
- package/dist/integration-apply.d.ts.map +1 -0
- package/dist/integration-apply.js +299 -0
- package/dist/integration-apply.js.map +1 -0
- package/dist/integration-evidence.d.ts +46 -0
- package/dist/integration-evidence.d.ts.map +1 -0
- package/dist/integration-evidence.js +44 -0
- package/dist/integration-evidence.js.map +1 -0
- package/dist/integration-probe-worker.d.ts +22 -0
- package/dist/integration-probe-worker.d.ts.map +1 -0
- package/dist/integration-probe-worker.js +334 -0
- package/dist/integration-probe-worker.js.map +1 -0
- package/dist/integrations-verify.d.ts +60 -0
- package/dist/integrations-verify.d.ts.map +1 -0
- package/dist/integrations-verify.js +194 -0
- package/dist/integrations-verify.js.map +1 -0
- package/dist/lesson-generalization.d.ts +29 -0
- package/dist/lesson-generalization.d.ts.map +1 -0
- package/dist/lesson-generalization.js +84 -0
- package/dist/lesson-generalization.js.map +1 -0
- package/dist/operations.d.ts +27 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +178 -2
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +27 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +211 -45
- package/dist/patterns.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +20 -17
- package/dist/setup.js.map +1 -1
- package/dist/target-integrations.d.ts +65 -0
- package/dist/target-integrations.d.ts.map +1 -0
- package/dist/target-integrations.js +152 -0
- package/dist/target-integrations.js.map +1 -0
- package/dist/vector-tier.d.ts +6 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +32 -7
- package/dist/vector-tier.js.map +1 -1
- package/package.json +9 -8
- package/sbom.json +562 -52
- package/src/amendment-trace.ts +12 -1
- package/src/compounding.ts +300 -1
- package/src/eta.ts +590 -0
- package/src/feature-adr-checkpoints.ts +1 -1
- package/src/feature-adr-decision-recall.ts +652 -0
- package/src/feature-adr-landing.ts +109 -0
- package/src/feature-adr-routing.ts +7 -11
- package/src/guard-promotion.ts +245 -4
- package/src/guard-volume.ts +752 -0
- package/src/guard.ts +70 -4
- package/src/index.ts +63 -2
- package/src/integration-apply.ts +332 -0
- package/src/integration-evidence.ts +89 -0
- package/src/integration-probe-worker.ts +310 -0
- package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
- package/src/integrations-verify.ts +258 -0
- package/src/lesson-generalization.ts +115 -0
- package/src/operations.ts +199 -2
- package/src/patterns.ts +252 -43
- package/src/setup.ts +20 -17
- package/src/target-integrations.ts +225 -0
- 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 {
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|