vigiles 5.2.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -16
- package/dist/adapters/claude-code/agent-runtime.d.ts +25 -0
- package/dist/adapters/claude-code/agent-runtime.js +43 -0
- package/dist/adapters/claude-code/dialect.d.ts +34 -0
- package/dist/adapters/claude-code/dialect.js +46 -33
- package/dist/adapters/claude-code/skill-runtime.js +0 -8
- package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
- package/dist/adapters/claude-code/typed-spec.js +55 -0
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +8 -1
- package/dist/cli.js +150 -4
- package/dist/core/compile.d.ts +1 -1
- package/dist/core/compile.js +14 -0
- package/dist/core/generate-harness.d.ts +187 -0
- package/dist/core/generate-harness.js +337 -0
- package/dist/core/spec.d.ts +290 -8
- package/dist/core/spec.js +118 -3
- package/dist/scaffold-test.d.ts +28 -0
- package/dist/scaffold-test.js +113 -8
- package/package.json +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vigiles generate-harness — emit ONE typed registry over the whole harness.
|
|
4
|
+
*
|
|
5
|
+
* The third generated artifact beside `generate-types` (`.d.ts`) and
|
|
6
|
+
* `generate-schema` (JSON Schema): a `harness.gen.ts` that imports every
|
|
7
|
+
* `*.spec.ts` in a directory, folds the agents into a `registry`, and asserts
|
|
8
|
+
* the cross-spec invariants at the TYPE level — so a single `tsc --noEmit`
|
|
9
|
+
* checks the WHOLE harness as one program (think TanStack's `routeTree.gen.ts`).
|
|
10
|
+
* See research/whole-harness-codegen.md for the design + the measured perf.
|
|
11
|
+
*
|
|
12
|
+
* The shipped scope (the first increment):
|
|
13
|
+
* 1. DANGLING `delegate` → a `tsc` error. Each `railway()` delegate target is a
|
|
14
|
+
* name the generator reads at codegen time; the gen file emits one shallow
|
|
15
|
+
* per-edge assertion (`KnownAgentName<"target", AgentName>` — O(N), no
|
|
16
|
+
* recursion) that the target resolves to a real agent, else a `tsc` error
|
|
17
|
+
* naming the dangling target + its railway.
|
|
18
|
+
* 2. DUPLICATE agent/skill NAMES → a generator error (this module returns a
|
|
19
|
+
* `duplicate` diagnostic; the CLI exits non-zero). This is the O(N) JS check
|
|
20
|
+
* the encoding rule mandates — a set-uniqueness MAPPED TYPE is the TS2589
|
|
21
|
+
* wall (measured ≈ N=1000), so duplicates are NEVER a type.
|
|
22
|
+
* 3. The whole-harness CAPABILITY LATTICE: the UNION of every agent's
|
|
23
|
+
* `effectSurface(tools, dialect)` — a generator-computed value + type, the
|
|
24
|
+
* substrate the future repo-scale capability-diff reads.
|
|
25
|
+
* 4. CROSS-FILE TYPED COMPOSITION: when a `railway()` success-track step declares
|
|
26
|
+
* what it `needs()`, the gen file emits one shallow per-pair assertion
|
|
27
|
+
* (`Handoff<OkOf<typeof registry[producer]>, needs>` — O(N), no recursion)
|
|
28
|
+
* that the PRIOR step's `result().ok` SUPPLIES it, so a cross-file handoff
|
|
29
|
+
* mismatch (a missing field / wrong type) is a `tsc` error naming the field.
|
|
30
|
+
* The repo-scale generalization of the per-file `pipe`/`Supplies` composition.
|
|
31
|
+
* Scoped to the linear success track; recover/onError (which consume an `err`,
|
|
32
|
+
* not the prior `ok`) are a noted follow-up.
|
|
33
|
+
*
|
|
34
|
+
* Harness-agnostic: the `dialect` (for the capability lattice) is INJECTED by
|
|
35
|
+
* the composition root (the CLI), never hard-coded — mirroring `compileAgent` /
|
|
36
|
+
* `scanPlugin`. The core stays free of any Claude-Code literal.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.HARNESS_GEN_FILENAME = void 0;
|
|
40
|
+
exports.computeHarnessCapabilities = computeHarnessCapabilities;
|
|
41
|
+
exports.findDuplicateName = findDuplicateName;
|
|
42
|
+
exports.generateHarness = generateHarness;
|
|
43
|
+
exports.findHarnessSpecFiles = findHarnessSpecFiles;
|
|
44
|
+
exports.loadHarnessModel = loadHarnessModel;
|
|
45
|
+
exports.labelFor = labelFor;
|
|
46
|
+
exports.readSpecSource = readSpecSource;
|
|
47
|
+
exports.genOutDir = genOutDir;
|
|
48
|
+
const node_fs_1 = require("node:fs");
|
|
49
|
+
const node_path_1 = require("node:path");
|
|
50
|
+
const effects_js_1 = require("./effects.js");
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Capability lattice — the O(N) union over every agent's effect surface
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/** Rank the purity rungs so the harness purity is the LOOSEST agent's. */
|
|
55
|
+
const PURITY_RANK = {
|
|
56
|
+
pure: 0,
|
|
57
|
+
bounded: 1,
|
|
58
|
+
unrestricted: 2,
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Fold every agent's `effectSurface` into one harness-wide lattice: the union of
|
|
62
|
+
* each bucket and the loosest purity. An agent with no `tools` inherits all (a
|
|
63
|
+
* wildcard), so its surface is `unrestricted` — handled by `effectSurface` when
|
|
64
|
+
* we pass `["*"]`. O(N) over the agents; the per-agent legs are fixed-arity.
|
|
65
|
+
*/
|
|
66
|
+
function computeHarnessCapabilities(agents, dialect) {
|
|
67
|
+
const readOnly = new Set();
|
|
68
|
+
const sideEffecting = new Set();
|
|
69
|
+
const unknown = new Set();
|
|
70
|
+
let purityRank = 0;
|
|
71
|
+
for (const a of agents) {
|
|
72
|
+
// No `tools` line means inherits-all → a wildcard surface (unrestricted).
|
|
73
|
+
const tools = a.tools && a.tools.length > 0 ? a.tools : ["*"];
|
|
74
|
+
const surface = (0, effects_js_1.effectSurface)(tools, dialect);
|
|
75
|
+
for (const t of surface.readOnly)
|
|
76
|
+
readOnly.add(t);
|
|
77
|
+
for (const t of surface.sideEffecting)
|
|
78
|
+
sideEffecting.add(t);
|
|
79
|
+
for (const t of surface.unknown)
|
|
80
|
+
unknown.add(t);
|
|
81
|
+
purityRank = Math.max(purityRank, PURITY_RANK[surface.purity]);
|
|
82
|
+
}
|
|
83
|
+
const PURITY_BY_RANK = [
|
|
84
|
+
"pure",
|
|
85
|
+
"bounded",
|
|
86
|
+
"unrestricted",
|
|
87
|
+
];
|
|
88
|
+
const purity = PURITY_BY_RANK[purityRank];
|
|
89
|
+
return {
|
|
90
|
+
readOnly: [...readOnly].sort(),
|
|
91
|
+
sideEffecting: [...sideEffecting].sort(),
|
|
92
|
+
unknown: [...unknown].sort(),
|
|
93
|
+
purity,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Duplicate-name detection — the O(N) JS check (NOT a type; the TS2589-safe path)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Find the FIRST pair of agents that declare the same `name`. O(N) over the
|
|
101
|
+
* agents — the set-cardinality check the encoding rule says must live in the JS
|
|
102
|
+
* generator, never as an N×N mapped type (the measured TS2589 wall). Returns
|
|
103
|
+
* `undefined` when names are unique.
|
|
104
|
+
*/
|
|
105
|
+
function findDuplicateName(agents) {
|
|
106
|
+
const seen = new Map();
|
|
107
|
+
for (const a of agents) {
|
|
108
|
+
const prior = seen.get(a.name);
|
|
109
|
+
if (prior !== undefined) {
|
|
110
|
+
return {
|
|
111
|
+
name: a.name,
|
|
112
|
+
first: prior,
|
|
113
|
+
second: a.file,
|
|
114
|
+
message: `duplicate agent name "${a.name}" — declared in both ${prior} and ${a.file}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
seen.set(a.name, a.file);
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Emission — the pure core (string in, string out, fully testable, no fs read)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** A valid TS identifier derived from a registry key (for the import binding). */
|
|
125
|
+
function identFor(key) {
|
|
126
|
+
const safe = key.replace(/[^A-Za-z0-9_]/g, "_");
|
|
127
|
+
return /^[A-Za-z_]/.test(safe) ? safe : `_${safe}`;
|
|
128
|
+
}
|
|
129
|
+
/** Relativize an import path against the gen file's directory (POSIX `./…`). */
|
|
130
|
+
function relImport(fromDir, toFile) {
|
|
131
|
+
let r = (0, node_path_1.relative)(fromDir, toFile).replace(/\\/g, "/");
|
|
132
|
+
if (!r.startsWith("."))
|
|
133
|
+
r = "./" + r;
|
|
134
|
+
return r;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Emit the cross-file handoff assertions. For each consecutive success-track
|
|
138
|
+
* pair whose CONSUMER declares `needs`, read the PRODUCER's `result().ok` off
|
|
139
|
+
* the registry (`OkOf<typeof registry[from]>`) and assert `Handoff<producerOk,
|
|
140
|
+
* needs>` is `true`. A missing field / wrong type collapses it to
|
|
141
|
+
* `{ __handoff_error: … }`, so `= true` is a tsc error naming the field — across
|
|
142
|
+
* files, at edit time. One shallow assertion per pair (O(N), no recursion).
|
|
143
|
+
*/
|
|
144
|
+
function handoffCheckLines(handoffs) {
|
|
145
|
+
const L = [
|
|
146
|
+
"// CHECK: every declared handoff lines up — the producer's result().ok",
|
|
147
|
+
"// must SUPPLY the consumer's needs. A mismatch makes `Handoff<…>` a",
|
|
148
|
+
"// `{ __handoff_error: … }` object, so `= true` is a tsc error naming the field.",
|
|
149
|
+
];
|
|
150
|
+
handoffs.forEach((h, i) => {
|
|
151
|
+
const needsLiteral = `{ ${Object.entries(h.needs)
|
|
152
|
+
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
153
|
+
.join("; ")} }`;
|
|
154
|
+
L.push(`const _handoff_${String(i)}: Handoff<OkOf<typeof registry[${JSON.stringify(h.from)}]>, ${needsLiteral}> = true;`);
|
|
155
|
+
L.push(`void _handoff_${String(i)}; // ${h.railway}: ${h.from} → ${h.to}`);
|
|
156
|
+
});
|
|
157
|
+
L.push("");
|
|
158
|
+
return L;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Generate the `harness.gen.ts` source over an already-loaded `HarnessModel`.
|
|
162
|
+
*
|
|
163
|
+
* Pure: no filesystem read, no spec loading — just string emission + the two
|
|
164
|
+
* O(N) computations (capability lattice + duplicate check). The fs/scan wrapper
|
|
165
|
+
* (`loadHarnessModel`) feeds this.
|
|
166
|
+
*/
|
|
167
|
+
function generateHarness(model, options) {
|
|
168
|
+
const { dialect, outDir } = options;
|
|
169
|
+
const specImport = options.specImport ?? "vigiles/spec";
|
|
170
|
+
// Stable order: agents sorted by name (deterministic output → clean diffs).
|
|
171
|
+
const agents = [...model.agents].sort((a, b) => a.name.localeCompare(b.name));
|
|
172
|
+
const duplicate = findDuplicateName(agents);
|
|
173
|
+
const capabilities = computeHarnessCapabilities(agents, dialect);
|
|
174
|
+
// Each agent gets a unique import binding. The registry key is the agent name.
|
|
175
|
+
const bindings = agents.map((a) => ({
|
|
176
|
+
...a,
|
|
177
|
+
ident: identFor(a.name),
|
|
178
|
+
import: relImport(outDir, a.file),
|
|
179
|
+
}));
|
|
180
|
+
const L = [];
|
|
181
|
+
L.push("// AUTO-GENERATED by `vigiles generate-harness` — DO NOT EDIT.");
|
|
182
|
+
L.push("// One typed registry over every *.spec.ts in the harness, so a single");
|
|
183
|
+
L.push("// `tsc --noEmit` cross-checks the WHOLE harness as one program.");
|
|
184
|
+
L.push("// Regenerate with `vigiles generate-harness` (wired to a spec guard).");
|
|
185
|
+
L.push("");
|
|
186
|
+
const handoffs = model.handoffs ?? [];
|
|
187
|
+
const specTypeImports = handoffs.length > 0 ? "KnownAgentName, Handoff, OkOf" : "KnownAgentName";
|
|
188
|
+
L.push(`import type { ${specTypeImports} } from ${JSON.stringify(specImport)};`);
|
|
189
|
+
L.push("");
|
|
190
|
+
for (const b of bindings) {
|
|
191
|
+
L.push(`import ${b.ident} from ${JSON.stringify(b.import)};`);
|
|
192
|
+
}
|
|
193
|
+
L.push("");
|
|
194
|
+
// The registry record. `as const` keeps each value's precise type for the
|
|
195
|
+
// future cross-file handoff check; today the registry is keyed by agent name.
|
|
196
|
+
L.push("export const registry = {");
|
|
197
|
+
for (const b of bindings) {
|
|
198
|
+
L.push(` ${JSON.stringify(b.name)}: ${b.ident},`);
|
|
199
|
+
}
|
|
200
|
+
L.push("} as const;");
|
|
201
|
+
L.push("");
|
|
202
|
+
// The literal union of every agent name. The generator emits this (the value's
|
|
203
|
+
// `name` field is `string`, not a literal, so the union can't be recovered
|
|
204
|
+
// from the imported type — the generator KNOWS the names, so it writes them).
|
|
205
|
+
L.push("// The literal union of every agent name in the harness — the set every");
|
|
206
|
+
L.push("// delegate target is checked against (the dangling-delegate basis).");
|
|
207
|
+
if (bindings.length === 0) {
|
|
208
|
+
L.push("export type AgentName = never;");
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
L.push("export type AgentName =");
|
|
212
|
+
bindings.forEach((b, i) => {
|
|
213
|
+
const tail = i === bindings.length - 1 ? ";" : "";
|
|
214
|
+
L.push(` | ${JSON.stringify(b.name)}${tail}`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
L.push("");
|
|
218
|
+
// ---- CHECK: dangling delegate → tsc error (one shallow assertion per edge) --
|
|
219
|
+
if (model.edges.length > 0) {
|
|
220
|
+
L.push("// CHECK: every delegate target resolves to a real agent. A dangling");
|
|
221
|
+
L.push("// target makes `KnownAgentName<target, AgentName>` a `{ __dangling_delegate }`");
|
|
222
|
+
L.push("// object, so assigning `true` to it is a tsc error naming the target.");
|
|
223
|
+
model.edges.forEach((e, i) => {
|
|
224
|
+
L.push(`const _edge_${String(i)}: KnownAgentName<${JSON.stringify(e.target)}, AgentName, ${JSON.stringify(e.from)}> = true;`);
|
|
225
|
+
L.push(`void _edge_${String(i)}; // ${e.from} → ${e.target}`);
|
|
226
|
+
});
|
|
227
|
+
L.push("");
|
|
228
|
+
}
|
|
229
|
+
// ---- CHECK: cross-file handoff → tsc error (one shallow assertion per pair) -
|
|
230
|
+
if (handoffs.length > 0)
|
|
231
|
+
L.push(...handoffCheckLines(handoffs));
|
|
232
|
+
// ---- The whole-harness capability lattice (a generator-computed value) ------
|
|
233
|
+
L.push("// The whole-harness capability lattice — the UNION of every agent's");
|
|
234
|
+
L.push("// effect surface. The substrate a repo-scale capability-diff reads.");
|
|
235
|
+
L.push("export const harnessCapabilities = {");
|
|
236
|
+
L.push(` readOnly: ${JSON.stringify(capabilities.readOnly)},`);
|
|
237
|
+
L.push(` sideEffecting: ${JSON.stringify(capabilities.sideEffecting)},`);
|
|
238
|
+
L.push(` unknown: ${JSON.stringify(capabilities.unknown)},`);
|
|
239
|
+
L.push(` purity: ${JSON.stringify(capabilities.purity)},`);
|
|
240
|
+
L.push("} as const;");
|
|
241
|
+
L.push("");
|
|
242
|
+
return {
|
|
243
|
+
gen: L.join("\n") + "\n",
|
|
244
|
+
capabilities,
|
|
245
|
+
duplicate,
|
|
246
|
+
agentCount: agents.length,
|
|
247
|
+
edgeCount: model.edges.length,
|
|
248
|
+
handoffCount: handoffs.length,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Discover every `*.spec.ts` directly under `dir` (non-recursive, sorted). */
|
|
252
|
+
function findHarnessSpecFiles(dir) {
|
|
253
|
+
return (0, node_fs_1.readdirSync)(dir)
|
|
254
|
+
.filter((f) => f.endsWith(".spec.ts"))
|
|
255
|
+
.sort();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build a `HarnessModel` from `dir`'s spec files using a caller-supplied
|
|
259
|
+
* `load(file) → value` (the CLI injects its `loadSpec`, so this stays
|
|
260
|
+
* fs/runtime-agnostic and unit-testable with fakes). Agents become registry
|
|
261
|
+
* entries; railways contribute delegate edges (steps + recover + onError).
|
|
262
|
+
*/
|
|
263
|
+
async function loadHarnessModel(dir, load) {
|
|
264
|
+
const files = findHarnessSpecFiles(dir);
|
|
265
|
+
const agents = [];
|
|
266
|
+
const edges = [];
|
|
267
|
+
const handoffs = [];
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
const abs = `${dir}/${file}`;
|
|
270
|
+
const spec = await load(abs);
|
|
271
|
+
if (!spec)
|
|
272
|
+
continue;
|
|
273
|
+
if (spec._specType === "agent" && typeof spec.name === "string") {
|
|
274
|
+
agents.push({ name: spec.name, tools: spec.tools, file: abs });
|
|
275
|
+
}
|
|
276
|
+
else if (spec._specType === "railway" && typeof spec.name === "string") {
|
|
277
|
+
edges.push(...railwayEdges(spec.name, spec));
|
|
278
|
+
handoffs.push(...railwayHandoffs(spec.name, spec.steps ?? []));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { agents, edges, handoffs };
|
|
282
|
+
}
|
|
283
|
+
/** The delegate edges a railway contributes (steps + recover + onError). */
|
|
284
|
+
function railwayEdges(from, spec) {
|
|
285
|
+
const out = [];
|
|
286
|
+
const push = (agentName) => {
|
|
287
|
+
if (typeof agentName === "string")
|
|
288
|
+
out.push({ from, target: agentName });
|
|
289
|
+
};
|
|
290
|
+
for (const step of spec.steps ?? [])
|
|
291
|
+
push(step.agent);
|
|
292
|
+
push(spec.recover?.step?.agent);
|
|
293
|
+
push(spec.onError?.agent);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The cross-file handoff edges a railway contributes: each consecutive
|
|
298
|
+
* success-track pair whose CONSUMER declares `needs` asserts the PRODUCER (the
|
|
299
|
+
* prior step) supplies it. Scoped to the LINEAR success track — recover/onError
|
|
300
|
+
* consume an `err`, not the prior `ok`, so they are a noted follow-up.
|
|
301
|
+
*/
|
|
302
|
+
function railwayHandoffs(railwayName, steps) {
|
|
303
|
+
const out = [];
|
|
304
|
+
for (let i = 1; i < steps.length; i++) {
|
|
305
|
+
const producer = steps[i - 1].agent;
|
|
306
|
+
const consumer = steps[i].agent;
|
|
307
|
+
const need = steps[i].needs;
|
|
308
|
+
if (need &&
|
|
309
|
+
Object.keys(need).length > 0 &&
|
|
310
|
+
typeof producer === "string" &&
|
|
311
|
+
typeof consumer === "string") {
|
|
312
|
+
out.push({
|
|
313
|
+
railway: railwayName,
|
|
314
|
+
from: producer,
|
|
315
|
+
to: consumer,
|
|
316
|
+
needs: need,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/** Convenience: the gen file's basename, used by the CLI default out path. */
|
|
323
|
+
exports.HARNESS_GEN_FILENAME = "harness.gen.ts";
|
|
324
|
+
/** Relative label for a path under cwd (CLI-only nicety; pure). */
|
|
325
|
+
function labelFor(cwd, abs) {
|
|
326
|
+
const r = (0, node_path_1.relative)(cwd, abs);
|
|
327
|
+
return r === "" ? (0, node_path_1.basename)(abs) : r;
|
|
328
|
+
}
|
|
329
|
+
/** Read a spec file's raw text (helper exposed for callers that need the source). */
|
|
330
|
+
function readSpecSource(absFile) {
|
|
331
|
+
return (0, node_fs_1.readFileSync)(absFile, "utf-8");
|
|
332
|
+
}
|
|
333
|
+
/** The directory a gen file at `outFile` lives in (helper for the CLI). */
|
|
334
|
+
function genOutDir(outFile) {
|
|
335
|
+
return (0, node_path_1.dirname)(outFile);
|
|
336
|
+
}
|
|
337
|
+
//# sourceMappingURL=generate-harness.js.map
|