@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 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.1.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
- if (!existing.isSymbolicLink()) return "kept"; // something of the project's is in the way
146
- if (fs.readlinkSync(full).split(path.sep).join("/") === to) return null;
147
- fs.unlinkSync(full);
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
- notes.seeded.push(file);
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
- for (const { file, link: isLink, exec } of files) {
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
- if (how) notes[how].push(file);
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") { if (exists) notes.skipped.push(file); continue; }
381
+ if (policy === "skip") { step(policy, m, exists ? "yours" : "absent", file, exists && "skipped"); continue; }
330
382
 
331
383
  if (policy === "seed") {
332
- if (exists) { notes.kept.push(file); continue; }
384
+ if (exists) { step(policy, m, "yours", file, "kept"); continue; }
333
385
  write(target, file, theirs, exec);
334
- notes.seeded.push(file);
386
+ step(policy, m, "created", file, "seeded");
335
387
  continue;
336
388
  }
337
389
  // merge
338
- if (!exists) { write(target, file, theirs, exec); notes.written.push(file); continue; }
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; // already current
341
- if (base === null) { notes.kept.push(file); continue; }
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) { notes.kept.push(file); continue; } // new to the upstream, and the target has its own
344
- if (ours === from) { write(target, file, theirs, exec); notes.written.push(file); continue; }
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) { notes.kept.push(file); continue; }
403
+ if (merged.failed) { step(policy, m, "yours, merge failed", file, "kept"); continue; }
347
404
  write(target, file, merged.text, exec);
348
- (merged.conflicts ? notes.conflicted : notes.merged).push(file);
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, updated: new Date().toISOString().slice(0, 10),
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 ? notes.merged : notes.written).push(file);
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
- const list = (label, arr) => { if (arr.length) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`); };
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;