@salaros/ai-harness 0.1.0 → 0.2.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 +5 -1
- package/package.json +1 -1
- package/scripts/update-harness.js +102 -22
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@ Running it again updates. The first run writes `harness-lock.json` naming the up
|
|
|
26
26
|
|
|
27
27
|
Skills are added and updated, never removed: one you vendored yourself survives every update, and `skills-lock.json` is merged as a union.
|
|
28
28
|
|
|
29
|
+
A repo whose harness predates `harness-lock.json` is the one case where "never touch what is already there" works against you: with no receipt there is no merge base, so the old harness stays and its checks then run against the new skills and fail, naming rules this version dropped. The run says so and names `--adopt`, which replaces every harness file with the upstream's — hooks, agents, scripts, `AGENTS.md`, and a `.claude/agents` that checked out as a text file rather than a symlink. It stays opt-in because it discards your edits to those files. `--dry-run --quiet` lists them first.
|
|
30
|
+
|
|
31
|
+
The run reports each path as it works on it: the policy, the mode Git records, what happened, and the path, with the skills tree collapsed to one line per skill. `--quiet` gives the summary alone.
|
|
32
|
+
|
|
29
33
|
## Getting started
|
|
30
34
|
|
|
31
35
|
1. **Windows only, before cloning:** enable Developer Mode (Settings → System → For developers) and run `git config --global core.symlinks true`. The `.claude/` folder is tracked as symlinks; without this, Git checks them out as text files and Claude Code sees no skills.
|
|
@@ -48,7 +52,7 @@ Skills are added and updated, never removed: one you vendored yourself survives
|
|
|
48
52
|
| `.agents/skills/<name>/` | The canonical, vendored copy of each skill (`SKILL.md` plus its reference files). |
|
|
49
53
|
| `.agents/hooks/` | Harness-neutral hook scripts (see [Hooks](#hooks)). |
|
|
50
54
|
| `.agents/agents/` | Agent definitions (see [Agents](#agents)). |
|
|
51
|
-
| `scripts/update-harness.js`, `scripts/harness-files.tsv`, `harness-lock.json` | The installer, the table of what travels and what does not, and the receipt naming the upstream commit a repo last took. The first two are the upstream’s own and are never installed; the receipt is written into the repo being updated. |
|
|
55
|
+
| `scripts/update-harness.js`, `scripts/harness-files.tsv`, `harness-lock.json` | The installer, the table of what travels and what does not, and the receipt naming the upstream commit a repo last took and the released installer that wrote it. The first two are the upstream’s own and are never installed; the receipt is written into the repo being updated. |
|
|
52
56
|
| `.agents/routing.md` | Route rows shared by some agents but not all, in a section per audience, plus the rule for what earns a row. What every agent needs is in `AGENTS.md` ("Working here") instead, since every session loads that. Above `agents/` because `.claude/agents` is a symlink to that folder and a harness reads everything in it as an agent. |
|
|
53
57
|
| `.claude/` | Claude Code wiring: `skills/*` and `agents` are symlinks into `.agents/`, `settings.json` wires the hooks. |
|
|
54
58
|
| `.githooks/`, `scripts/githooks-init.js` | Git hooks, run through `core.hooksPath` after `githooks-init.js` is run once per clone. They are the only shell scripts left, because Git runs them through its own `sh` on every OS; each is a two-line wrapper piping changed paths into a Node script. `post-merge` feeds `scripts/on-manifest-change.js`, which restores what `scripts/stacks.tsv` says (skills, npm, pnpm, yarn, NuGet, uv). `pre-commit` and `pre-push` both start with `scripts/check-initialised.js`, which refuses an unconfigured clone: `MEMORY.md` must record the six facts that describe the project itself (name, purpose, requirements, unit type, language, runtime), none of them left as a `<placeholder>`. The `Jira` line is not among them: a tracker is a choice, not a property of the code. `pre-commit` then pipes the staged `TODO.md` into `scripts/check-todo.js`, which checks the shape of the loose-ends ledger against the todo-md standard: the `# TODO` header, and an entry carrying a known tag (`#question`, `#assumption`, `#deferred`) and a source the chain would accept. Only checkbox lines are entries, every other line is prose, and an indented subtask inherits its parent's tag and source. It never asks for entries to exist, and it rejects `[x]` and `[-]`, because a settled entry is deleted rather than kept. `pre-commit` then feeds `scripts/check-staged-docs.js`, which blocks a commit that would break the documentation chain. `commit-msg` feeds `scripts/check-commit-msg.js`, which blocks a commit whose message is not a conventional commit (`<type>(<scope>)?!?: <description>`, subject at most 72 characters, a description of at least four words, and a body of real prose after a blank line — trailers such as `Refs:` are metadata and do not count as one); messages Git writes itself for a merge, revert, fixup or squash are left alone. A message citing no Jira issue key is warned about rather than rejected, to encourage the habit without blocking a change nobody has raised a ticket for; the key comes from `docs/agents/issue-tracker.md`, and any `PROJ-123` shape counts until `project-init` records the real one. A project whose `MEMORY.md` records `Issue tracker: none` is not warned: it has no key to cite. A tracker whose references are not `KEY-123` sets `Key format:` in `docs/agents/issue-tracker.md`, so a GitHub-Issues project is warned about `#42` instead. `pre-push` feeds `scripts/format-changed.js`, which checks that the files a push publishes are formatted, using the stack's own formatter from `scripts/stacks.tsv` (`prettier`, `dotnet format` or a project's Husky.NET task runner, `ruff`). It never rewrites files: it reports and blocks, and skips silently when the stack's formatter is not installed. Formatting is checked on push rather than on commit because some formatters load the whole project and cost seconds. The restore, chain and format scripts take `--dry-run`; `check-commit-msg.js` takes the message file, or reads stdin. `git commit --no-verify` and `git push --no-verify` skip them. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Installs and updates the agent harness from its upstream repository: hooks, skills, agents and the documentation chain, merged into an existing repository without touching its own work.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai-harness": "scripts/update-harness.js"
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// node scripts/update-harness.js --from ../ai-harness use a checkout you already have, no clone
|
|
23
23
|
// node scripts/update-harness.js --astro-docs add tools/docs-site, the Astro renderer for the chain
|
|
24
24
|
// node scripts/update-harness.js --no-check install without proving it afterwards
|
|
25
|
+
// node scripts/update-harness.js --quiet the summary alone, no line per path
|
|
26
|
+
// node scripts/update-harness.js --adopt replace a harness that predates harness-lock.json
|
|
25
27
|
// Installing into a repo that has no harness yet, from anywhere:
|
|
26
28
|
// git clone https://github.com/salaros/ai-harness .harness && \
|
|
27
29
|
// node .harness/scripts/update-harness.js --from .harness --target . && rm -rf .harness
|
|
@@ -39,6 +41,7 @@ const argv = process.argv.slice(2);
|
|
|
39
41
|
const flag = name => argv.includes(name);
|
|
40
42
|
const value = (name, fallback) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
|
|
41
43
|
const dryRun = flag("--dry-run");
|
|
44
|
+
const adopt = flag("--adopt");
|
|
42
45
|
|
|
43
46
|
// ---------------------------------------------------------------- the target
|
|
44
47
|
|
|
@@ -80,6 +83,19 @@ function templateCheckout(ref) {
|
|
|
80
83
|
return { dir, temporary: true };
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
// The installer's own name and version, read from the package it ships inside rather than written
|
|
87
|
+
// down here, so a release cannot forget to update it. It answers what the upstream commit cannot:
|
|
88
|
+
// which released tool wrote this tree. Both routes land on the right file, because the script always
|
|
89
|
+
// sits in the scripts/ folder of either the npm package or a checkout of the upstream. Omitted
|
|
90
|
+
// rather than recorded as null when it cannot be read, so the receipt never claims a version it
|
|
91
|
+
// does not know.
|
|
92
|
+
function installer() {
|
|
93
|
+
try {
|
|
94
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8"));
|
|
95
|
+
return pkg.name && pkg.version ? { installer: `${pkg.name}@${pkg.version}` } : {};
|
|
96
|
+
} catch { return {}; }
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
const at = (dir, args) => lib.run("git", ["-C", dir, ...args]);
|
|
84
100
|
|
|
85
101
|
// The upstream's version of a path at a commit, or null when the file did not exist there. Also how
|
|
@@ -142,9 +158,12 @@ function link(target, file, to) {
|
|
|
142
158
|
let existing = null;
|
|
143
159
|
try { existing = fs.lstatSync(full); } catch { /* absent */ }
|
|
144
160
|
if (existing) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
// Something of the project's is in the way -- or, in a repo whose harness predates the lock
|
|
162
|
+
// file, the link itself checked out as a text file holding a path, which is the failure that
|
|
163
|
+
// leaves an agent seeing no skills at all. --adopt is the only thing that replaces it.
|
|
164
|
+
if (!existing.isSymbolicLink()) { if (!adopt) return "kept"; fs.unlinkSync(full); }
|
|
165
|
+
else if (fs.readlinkSync(full).split(path.sep).join("/") === to) return null;
|
|
166
|
+
else fs.unlinkSync(full);
|
|
148
167
|
}
|
|
149
168
|
try {
|
|
150
169
|
fs.symlinkSync(to.split("/").join(path.sep), full, "dir");
|
|
@@ -199,9 +218,9 @@ const SKELETONS = {
|
|
|
199
218
|
|
|
200
219
|
function skeletons(target) {
|
|
201
220
|
for (const [file, lines] of Object.entries(SKELETONS)) {
|
|
202
|
-
if (fs.existsSync(path.join(target, file))) continue;
|
|
221
|
+
if (fs.existsSync(path.join(target, file))) { step("seed", "100644", "yours", file); continue; }
|
|
203
222
|
write(target, file, lines.join("\n"));
|
|
204
|
-
|
|
223
|
+
step("seed", "100644", "created", file, "seeded");
|
|
205
224
|
}
|
|
206
225
|
}
|
|
207
226
|
|
|
@@ -228,6 +247,19 @@ const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], s
|
|
|
228
247
|
const say = m => console.log(m);
|
|
229
248
|
function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
|
|
230
249
|
|
|
250
|
+
// An install rewrites someone else's repository, so it says what it did to every path while it does
|
|
251
|
+
// it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
|
|
252
|
+
// lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
|
|
253
|
+
// skills, and both look installed. One call records the outcome and prints the line, so the running
|
|
254
|
+
// commentary and the summary below cannot drift apart.
|
|
255
|
+
const quiet = flag("--quiet");
|
|
256
|
+
const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
|
|
257
|
+
function step(policy, m, outcome, file, bucket) {
|
|
258
|
+
if (bucket) notes[bucket].push(file);
|
|
259
|
+
if (!quiet) say(` ${policy.padEnd(9)}${m} ${outcome.padEnd(12)}${file}`);
|
|
260
|
+
}
|
|
261
|
+
const phase = m => { if (!quiet) say(`\n${m}`); };
|
|
262
|
+
|
|
231
263
|
function write(target, file, text, exec) {
|
|
232
264
|
const full = path.join(target, file);
|
|
233
265
|
if (dryRun) return;
|
|
@@ -263,6 +295,7 @@ function selfCheck(target, templateDir, head, files) {
|
|
|
263
295
|
write(target, file, text);
|
|
264
296
|
borrowed.push(file);
|
|
265
297
|
}
|
|
298
|
+
phase(`self check: ${borrowed.length} file(s) borrowed from the upstream suite`);
|
|
266
299
|
if (!borrowed.length) return { skipped: "the suite is already in this repo; run it yourself with node .agents/hooks/test.js" };
|
|
267
300
|
|
|
268
301
|
try {
|
|
@@ -300,17 +333,34 @@ function main() {
|
|
|
300
333
|
}
|
|
301
334
|
if (previous && base === head) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
|
|
302
335
|
|
|
336
|
+
// A repo carrying a harness from before harness-lock.json existed. Without a base the rule
|
|
337
|
+
// below keeps every file that is already there, which protects the project's work and also
|
|
338
|
+
// preserves the old harness: its checks then run against the new skills and agents and fail,
|
|
339
|
+
// naming rules this version dropped. Worth saying out loud, because the run otherwise looks
|
|
340
|
+
// like a success.
|
|
341
|
+
const MARKERS = [".agents/hooks/lib.js", "scripts/lib.js", ".githooks/pre-commit"];
|
|
342
|
+
const stale = !previous && MARKERS.filter(f => fs.existsSync(path.join(target, f)));
|
|
343
|
+
if (stale && stale.length) {
|
|
344
|
+
if (adopt) say(`this repo has a harness but no ${LOCK}, and --adopt was given: harness files are replaced with ${ref}'s, and edits to them are lost`);
|
|
345
|
+
else say(`this repo has a harness (${stale.join(", ")}) but no ${LOCK}, so it predates the receipt and there is no merge base.\nEvery harness file already here is kept, which leaves old checks running against new skills. Re-run with --adopt to replace them, or --dry-run --quiet to list them first.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
303
348
|
const rows = policies(templateDir);
|
|
304
349
|
const files = templateFiles(templateDir);
|
|
305
350
|
const skills = [];
|
|
306
351
|
|
|
307
|
-
|
|
352
|
+
phase(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`);
|
|
353
|
+
for (const entry of files) {
|
|
354
|
+
const { file, link: isLink, exec } = entry;
|
|
308
355
|
const policy = policyFor(rows, file);
|
|
356
|
+
const m = mode(entry);
|
|
309
357
|
const theirs = blob(templateDir, head, file);
|
|
310
358
|
if (theirs === null) continue;
|
|
311
359
|
const full = path.join(target, file);
|
|
312
360
|
const exists = fs.existsSync(full);
|
|
313
361
|
|
|
362
|
+
// Not installed anywhere, and named in one line of the summary instead: sixty-five
|
|
363
|
+
// lines saying nothing happened bury the thirty-eight saying something did.
|
|
314
364
|
if (policy === "template") { notes.template.push(file); continue; }
|
|
315
365
|
if (isLink) {
|
|
316
366
|
// A skill link is relink's to make, once the directory it lives in exists: it knows
|
|
@@ -320,40 +370,51 @@ function main() {
|
|
|
320
370
|
continue;
|
|
321
371
|
}
|
|
322
372
|
const how = link(target, file, theirs.trim());
|
|
323
|
-
|
|
373
|
+
step(policy, m, how === "kept" ? "yours" : how || "unchanged", file, how);
|
|
324
374
|
continue;
|
|
325
375
|
}
|
|
376
|
+
// Reported one line per skill by mergeSkills below, not one per reference file: a skill
|
|
377
|
+
// is the unit a project installs, and its files run to several hundred.
|
|
326
378
|
if (policy === "skills") { skills.push(file); continue; }
|
|
327
379
|
// Reported only when the target actually has it: "left alone, yours" about a file the
|
|
328
380
|
// repo does not have names something that was never there.
|
|
329
|
-
if (policy === "skip") {
|
|
381
|
+
if (policy === "skip") { step(policy, m, exists ? "yours" : "absent", file, exists && "skipped"); continue; }
|
|
330
382
|
|
|
331
383
|
if (policy === "seed") {
|
|
332
|
-
if (exists) {
|
|
384
|
+
if (exists) { step(policy, m, "yours", file, "kept"); continue; }
|
|
333
385
|
write(target, file, theirs, exec);
|
|
334
|
-
|
|
386
|
+
step(policy, m, "created", file, "seeded");
|
|
335
387
|
continue;
|
|
336
388
|
}
|
|
337
389
|
// merge
|
|
338
|
-
if (!exists) { write(target, file, theirs, exec);
|
|
390
|
+
if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
|
|
339
391
|
const ours = fs.readFileSync(full, "utf8");
|
|
340
|
-
if (ours === theirs) continue;
|
|
341
|
-
if (base === null) {
|
|
392
|
+
if (ours === theirs) { step(policy, m, "unchanged", file); continue; }
|
|
393
|
+
if (base === null) {
|
|
394
|
+
if (!adopt) { step(policy, m, "yours, no base", file, "kept"); continue; }
|
|
395
|
+
write(target, file, theirs, exec);
|
|
396
|
+
step(policy, m, "adopted", file, "written");
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
342
399
|
const from = blob(templateDir, base, file);
|
|
343
|
-
if (from === null) {
|
|
344
|
-
if (ours === from) { write(target, file, theirs, exec);
|
|
400
|
+
if (from === null) { step(policy, m, "yours, new here", file, "kept"); continue; }
|
|
401
|
+
if (ours === from) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
|
|
345
402
|
const merged = threeWay(from, ours, theirs);
|
|
346
|
-
if (merged.failed) {
|
|
403
|
+
if (merged.failed) { step(policy, m, "yours, merge failed", file, "kept"); continue; }
|
|
347
404
|
write(target, file, merged.text, exec);
|
|
348
|
-
(merged.conflicts
|
|
405
|
+
if (merged.conflicts) step(policy, m, "CONFLICT", file, "conflicted");
|
|
406
|
+
else step(policy, m, "merged", file, "merged");
|
|
349
407
|
}
|
|
350
408
|
|
|
409
|
+
phase("skeletons a project starts with");
|
|
351
410
|
skeletons(target);
|
|
411
|
+
phase("skills, merged by name");
|
|
352
412
|
mergeSkills(target, templateDir, head, skills);
|
|
353
413
|
|
|
354
414
|
if (!dryRun) {
|
|
355
415
|
fs.writeFileSync(lockPath, JSON.stringify({
|
|
356
|
-
template: TEMPLATE, ref, commit: head,
|
|
416
|
+
template: TEMPLATE, ref, commit: head, ...installer(),
|
|
417
|
+
updated: new Date().toISOString().slice(0, 10),
|
|
357
418
|
}, null, 2) + "\n");
|
|
358
419
|
finish(target);
|
|
359
420
|
// After finish(), because the suite checks the links relink has just written.
|
|
@@ -376,18 +437,29 @@ function mergeSkills(target, templateDir, head, files) {
|
|
|
376
437
|
ourLock.skills = ourLock.skills || {};
|
|
377
438
|
|
|
378
439
|
const mine = new Set(Object.keys(ourLock.skills));
|
|
440
|
+
// One line per skill, not per file. Outcome is decided across the whole folder: a skill counts
|
|
441
|
+
// as changed the moment any file in it did, and only an untouched folder reads "unchanged".
|
|
442
|
+
const outcomes = new Map();
|
|
443
|
+
const seen = name => outcomes.get(name) || outcomes.set(name, { added: 0, updated: 0, files: 0 }).get(name);
|
|
379
444
|
for (const file of files) {
|
|
380
445
|
if (!file.startsWith(SKILLS)) continue; // .claude/skills links are rebuilt, not copied
|
|
381
446
|
const name = file.slice(SKILLS.length).split("/")[0];
|
|
447
|
+
const tally = seen(name);
|
|
448
|
+
tally.files++;
|
|
382
449
|
// A skill the project installed under a name the upstream also uses stays the project's.
|
|
383
|
-
if (mine.has(name) && !theirLock.skills[name]) continue;
|
|
450
|
+
if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
|
|
384
451
|
const text = blob(templateDir, head, file);
|
|
385
452
|
if (text === null) continue;
|
|
386
453
|
const full = path.join(target, file);
|
|
387
454
|
const exists = fs.existsSync(full);
|
|
388
455
|
if (exists && fs.readFileSync(full, "utf8") === text) continue;
|
|
389
456
|
write(target, file, text);
|
|
390
|
-
(exists
|
|
457
|
+
if (exists) { tally.updated++; notes.merged.push(file); }
|
|
458
|
+
else { tally.added++; notes.written.push(file); }
|
|
459
|
+
}
|
|
460
|
+
for (const [name, t] of [...outcomes].sort()) {
|
|
461
|
+
const what = t.yours ? "yours" : t.added ? "added" : t.updated ? "updated" : "unchanged";
|
|
462
|
+
step("skills", "100644", what, `${SKILLS}${name} (${t.files} file(s))`);
|
|
391
463
|
}
|
|
392
464
|
for (const [name, entry] of Object.entries(theirLock.skills)) {
|
|
393
465
|
if (!ourLock.skills[name]) ourLock.skills[name] = entry;
|
|
@@ -400,6 +472,7 @@ function mergeSkills(target, templateDir, head, files) {
|
|
|
400
472
|
// project's lock file. Both are generated, so the install leaves a harness that works rather than a
|
|
401
473
|
// list of commands to remember.
|
|
402
474
|
function finish(target) {
|
|
475
|
+
phase("links and notices");
|
|
403
476
|
for (const [label, args] of [["links", ["relink"]], ["notices", ["notices"]]]) {
|
|
404
477
|
const r = lib.node([path.join(target, "scripts/skills.js"), ...args], { cwd: target });
|
|
405
478
|
say(r.status === 0 ? r.output : `${label}: ${r.output}`);
|
|
@@ -407,7 +480,14 @@ function finish(target) {
|
|
|
407
480
|
}
|
|
408
481
|
|
|
409
482
|
function report(target, head, ref, base) {
|
|
410
|
-
|
|
483
|
+
// Every path was named as it happened, so repeating the lists here doubles the output; a quiet
|
|
484
|
+
// run never saw them and gets them in full. Conflicts are listed either way: they are what the
|
|
485
|
+
// reader has to act on, and they belong beside the instructions for acting on them.
|
|
486
|
+
const list = (label, arr, always) => {
|
|
487
|
+
if (!arr.length) return;
|
|
488
|
+
if (quiet || always) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`);
|
|
489
|
+
else say(`\n${label}: ${arr.length}`);
|
|
490
|
+
};
|
|
411
491
|
say("");
|
|
412
492
|
say(dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
|
|
413
493
|
if (!base) say("no merge base: this was an install, so nothing that already existed was changed");
|
|
@@ -422,7 +502,7 @@ function report(target, head, ref, base) {
|
|
|
422
502
|
say(`\nnot installed, the upstream's own (${notes.template.length}): ${named.join(", ")}, and the suite's fixtures`);
|
|
423
503
|
}
|
|
424
504
|
if (notes.conflicted.length) {
|
|
425
|
-
list("CONFLICTED, resolve the markers by hand", notes.conflicted);
|
|
505
|
+
list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
|
|
426
506
|
say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then run the suite:\n node .agents/hooks/test.js`);
|
|
427
507
|
}
|
|
428
508
|
const check = notes.check;
|