@salaros/ai-harness 0.2.1 → 0.2.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A starting point that does not assume a language or framework: `.gitignore`, `.gitattributes`, Git hooks, a folder layout with a README in every folder, and an AI-agnostic **agent harness** (skills, hook scripts, four agents) that works the same in Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, and any other tool that reads `AGENTS.md` and `.agents/skills`.
4
4
 
5
- Requires **Node 22 or newer** (the only runtime the harness needs) and Git; nothing else. The [harness CI workflow](.github/workflows/harness.yml) runs the suite on Ubuntu and Windows. A second workflow, [skills-update](.github/workflows/skills-update.yml), runs `npx skills update` weekly, relinks, regenerates the third-party notice, and commits the result to `development` only if the suite and the chain check still pass; it then opens a pull request promoting `development` to `master`, because a skill is a prompt an agent runs with full permissions and its diff wants a reader.
5
+ Requires **Node 22 or newer** (the only runtime the harness needs) and Git; nothing else. The [harness CI workflow](.github/workflows/harness.yml) runs the suite on Ubuntu and Windows. A second workflow, [skills-update](.github/workflows/skills-update.yml), runs `npx skills update` weekly, relinks, regenerates the third-party notice, and commits the result to `development` only if the suite and the chain check still pass; it then opens a pull request promoting `development` to `master`, because a skill is a prompt an agent runs with full permissions and its diff wants a reader. A third, [release](.github/workflows/release.yml), publishes this package to npm when a `v*` tag is pushed, over npm trusted publishing rather than a stored token: it proves the tag and `package.json` agree, runs the suite and the chain check, and refuses to publish a tarball missing the installer.
6
6
 
7
7
  ## Adding the harness to a repository you already have
8
8
 
@@ -24,10 +24,14 @@ Every run proves itself before it finishes. Merging is not checking: the install
24
24
 
25
25
  Running it again updates. The first run writes `harness-lock.json` naming the upstream commit it took, which gives a later run a merge base: a harness file nobody edited takes the new version, an edited one keeps its edits and gains the changes around them, and only a real collision is written with conflict markers, reported, and exits non-zero. That is how `AGENTS.md` gains a new section while keeping your own rules, and how `docs/agents/issue-tracker.md` keeps your project key.
26
26
 
27
+ Two files are merged even on a first install, where there is no receipt to take a base from: `AGENTS.md`, the map every agent reads and the table `docs-check` parses, and `docs/README.md`, which says what the chain puts where. Keeping a stale copy of either leaves a repo that looks installed and behaves like the version it came from, so the installer finds the base instead — the upstream version yours is closest to is where you forked from, whatever a receipt would have said. A copy nobody edited merges cleanly; an edited one keeps its edits and gains the rest. A file written from scratch matches nothing in the upstream's history and is written with conflict markers, so both versions are there to read.
28
+
27
29
  Skills are added and updated, never removed: one you vendored yourself survives every update, and `skills-lock.json` is merged as a union.
28
30
 
29
31
  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
32
 
33
+ That first install writes a receipt even though it kept the old files, so `--adopt` has to keep working afterwards — it is normally the self check that tells you the harness is stale, and by then the receipt exists. A run at the recorded commit says so and takes every harness file again, rather than answering the one command that fixes it with "nothing to update".
34
+
31
35
  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
36
 
33
37
  ## Getting started
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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"
@@ -23,7 +23,7 @@
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
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
26
+ // node scripts/update-harness.js --adopt take every harness file from the upstream, losing local edits
27
27
  // Installing into a repo that has no harness yet, from anywhere:
28
28
  // git clone https://github.com/salaros/ai-harness .harness && \
29
29
  // node .harness/scripts/update-harness.js --from .harness --target . && rm -rf .harness
@@ -31,6 +31,7 @@ const fs = require("fs");
31
31
  const os = require("os");
32
32
  const path = require("path");
33
33
  const lib = require("./lib");
34
+ const { spawnSync } = require("child_process");
34
35
 
35
36
  const TEMPLATE = "https://github.com/salaros/ai-harness.git";
36
37
  const LOCK = "harness-lock.json";
@@ -74,7 +75,7 @@ function templateCheckout(ref) {
74
75
  }
75
76
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
76
77
  say(`cloning ${TEMPLATE} at ${ref}`);
77
- const r = lib.run("git", ["clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
78
+ const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
78
79
  if (r.status !== 0) fail(`could not clone the upstream at ${ref}\n${r.output}`);
79
80
  if (!usable(dir)) {
80
81
  fs.rmSync(dir, { recursive: true, force: true });
@@ -96,13 +97,86 @@ function installer() {
96
97
  } catch { return {}; }
97
98
  }
98
99
 
99
- const at = (dir, args) => lib.run("git", ["-C", dir, ...args]);
100
+ // Windows stops at 260 characters for a path, and the harness ships skill files nested deep enough
101
+ // that a project a few folders down the drive crosses it. Git then fails to stat the working copy
102
+ // while resolving <commit>:<path>, so `git show` says the file is not there and the installer skips
103
+ // it -- a skill missing seven of its reference files, and not a word said about it. Setting
104
+ // core.longpaths on every call is what makes those paths reachable, and it costs nothing anywhere
105
+ // else.
106
+ const GIT = ["-c", "core.longpaths=true"];
107
+ const at = (dir, args) => lib.run("git", [...GIT, "-C", dir, ...args]);
100
108
 
101
109
  // The upstream's version of a path at a commit, or null when the file did not exist there. Also how
102
110
  // a missing base is detected: a rewritten history no longer holds the recorded commit.
111
+ // Read raw rather than through lib.run, which trims trailing whitespace: that is right for the
112
+ // plumbing whose output is a hash or a status line, and wrong for a file. Trimmed, every installed
113
+ // file lost its final newline, no copy was ever byte-identical to the upstream, and so every later
114
+ // run re-merged files nobody had touched.
115
+ // Text comes back as a string and anything holding a NUL byte as the Buffer it arrived in, which is
116
+ // how Git itself tells the two apart. Decoded as UTF-8 and written back, every byte a PNG holds
117
+ // outside ASCII becomes U+FFFD: the skill's logo installs as a broken image, and no later run ever
118
+ // agrees with the upstream about it. Nothing merges a Buffer; it is written whole or kept whole.
103
119
  function blob(dir, commit, file) {
104
- const r = at(dir, ["show", `${commit}:${file}`]);
105
- return r.status === 0 ? r.output : null;
120
+ const r = spawnSync("git", [...GIT, "-C", dir, "show", `${commit}:${file}`], { maxBuffer: 256 * 1024 * 1024 });
121
+ if (r.status !== 0) return null;
122
+ return r.stdout.includes(0) ? r.stdout : r.stdout.toString("utf8");
123
+ }
124
+
125
+ // One test for both, so a caller comparing what blob returned against what is on disk does not have
126
+ // to know which it got.
127
+ const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
128
+ ? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
129
+ : a === b;
130
+
131
+ // The receipt is missing, so the base is found instead: the upstream version this copy is closest to
132
+ // is where the project forked from, whatever a receipt would have said. An exact match is the clean
133
+ // case, an older copy nobody touched; a project that has since edited its own file matches nothing
134
+ // exactly, so the nearest version by shared lines stands in as the base. That turns a first install
135
+ // into a real three-way merge for the files that need one, rather than one whole-file conflict.
136
+ // Only reconcile-policy files pay for the search: one git log, then a blob read per commit that
137
+ // touched the path.
138
+ // Under half the lines in common is a different file, not an older one, and merging against it would
139
+ // invent a diff the project never made.
140
+ const NEAREST = 0.5;
141
+ function recoverBase(dir, file, ours) {
142
+ const r = at(dir, ["log", "--format=%H", "--", file]);
143
+ if (r.status !== 0) return null;
144
+ const want = lineCounts(ours);
145
+ let best = null;
146
+ let nearest = NEAREST;
147
+ // Oldest first, and a tie goes to the first seen: two upstream versions one line apart score the
148
+ // same against a copy that has neither, and the older of them is the one whose merge puts that
149
+ // line back. The newer would drop it silently, which is the failure this policy exists to stop.
150
+ for (const commit of r.output.split(/\r?\n/).filter(Boolean).reverse()) {
151
+ const text = blob(dir, commit, file);
152
+ if (typeof text !== "string") continue;
153
+ if (text === ours) return text;
154
+ const shared = overlap(want, lineCounts(text));
155
+ if (shared > nearest) { best = text; nearest = shared; }
156
+ }
157
+ return best;
158
+ }
159
+
160
+ // Lines to counts, blank ones left out: they carry no content and every version of a markdown file
161
+ // has plenty, so counting them would score two unrelated documents as half the same.
162
+ function lineCounts(text) {
163
+ const counts = new Map();
164
+ for (const line of text.split(/\r?\n/)) {
165
+ if (!line.trim()) continue;
166
+ counts.set(line, (counts.get(line) || 0) + 1);
167
+ }
168
+ return counts;
169
+ }
170
+
171
+ // The share of the longer file the two have in common, so neither a version that added a section nor
172
+ // one that cut it scores as a perfect match.
173
+ function overlap(ours, theirs) {
174
+ let shared = 0;
175
+ let mine = 0;
176
+ for (const [line, n] of ours) { mine += n; shared += Math.min(n, theirs.get(line) || 0); }
177
+ let other = 0;
178
+ for (const n of theirs.values()) other += n;
179
+ return shared / Math.max(mine, other, 1);
106
180
  }
107
181
 
108
182
  // ---------------------------------------------------------------- the manifest
@@ -143,7 +217,7 @@ function templateFiles(dir) {
143
217
  // install stages these few files rather than leaving them for the project's own `git add`.
144
218
  function carryMode(target, file) {
145
219
  try { fs.chmodSync(path.join(target, file), 0o755); } catch { /* the filesystem does not do modes */ }
146
- const r = lib.run("git", ["-C", target, "add", "--chmod=+x", "--", file]);
220
+ const r = lib.run("git", [...GIT, "-C", target, "add", "--chmod=+x", "--", file]);
147
221
  if (r.status !== 0) say(`could not mark ${file} executable: ${r.output}`);
148
222
  }
149
223
 
@@ -244,7 +318,7 @@ function threeWay(base, ours, theirs) {
244
318
 
245
319
  // ---------------------------------------------------------------- reporting
246
320
 
247
- const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], check: null };
321
+ const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [], check: null };
248
322
  const say = m => console.log(m);
249
323
  function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
250
324
 
@@ -261,6 +335,20 @@ function step(policy, m, outcome, file, bucket) {
261
335
  }
262
336
  const phase = m => { if (!quiet) say(`\n${m}`); };
263
337
 
338
+ // Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
339
+ // the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
340
+ // touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
341
+ // the comparison and the merge happen in LF, and the result is written back in the endings the file
342
+ // already had.
343
+ const CRLF = /\r\n/g;
344
+ const LF = /\n/g;
345
+ // This script's own conflict label, on a line of its own, so prose about conflict markers is not
346
+ // mistaken for one.
347
+ const MARKED = /^<{7} yours\r?$/m;
348
+ const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
349
+ const toLf = text => text.replace(CRLF, "\n");
350
+ const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
351
+
264
352
  function write(target, file, text, exec) {
265
353
  const full = path.join(target, file);
266
354
  if (dryRun) return;
@@ -332,7 +420,12 @@ function main() {
332
420
  say(`the recorded upstream commit ${base.slice(0, 8)} is not in ${TEMPLATE} any more, so this run has no merge base: existing files are left alone`);
333
421
  base = null;
334
422
  }
335
- if (previous && base === head) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
423
+ // --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest
424
+ // way to reach that state is an install that wrote the receipt and kept a stale harness. So
425
+ // the run has to stay open at the recorded commit: short-circuiting here would answer the
426
+ // one command that fixes it with "nothing to update".
427
+ if (previous && base === head && !adopt) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
428
+ if (previous && base === head) say(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
336
429
 
337
430
  // A repo carrying a harness from before harness-lock.json existed. Without a base the rule
338
431
  // below keeps every file that is already there, which protects the project's work and also
@@ -356,10 +449,19 @@ function main() {
356
449
  const policy = policyFor(rows, file);
357
450
  const m = mode(entry);
358
451
  const theirs = blob(templateDir, head, file);
359
- if (theirs === null) continue;
452
+ // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
453
+ // rather than the file being absent. Said out loud: skipped quietly, the run reports a
454
+ // clean install of a harness missing whichever files the reader was never told about.
455
+ if (theirs === null) { step(policy, m, "UNREADABLE", file, "unreadable"); continue; }
360
456
  const full = path.join(target, file);
361
457
  const exists = fs.existsSync(full);
362
458
 
459
+ // The executable bit is not the project's content, so a file kept for its content still
460
+ // has its mode corrected. Git runs a hook only if it is executable and says nothing when
461
+ // it is not, so a hook kept at 100644 by an install that had no merge base looks
462
+ // installed and gates nothing at all -- the failure this whole column exists to catch.
463
+ if (exec && exists && !dryRun) carryMode(target, file);
464
+
363
465
  // Not installed anywhere, and named in one line of the summary instead: sixty-five
364
466
  // lines saying nothing happened bury the thirty-eight saying something did.
365
467
  if (policy === "template") { notes.template.push(file); continue; }
@@ -389,22 +491,54 @@ function main() {
389
491
  }
390
492
  // merge
391
493
  if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
392
- const ours = fs.readFileSync(full, "utf8");
393
- if (ours === theirs) { step(policy, m, "unchanged", file); continue; }
394
- if (base === null) {
395
- if (!adopt) { step(policy, m, "yours, no base", file, "kept"); continue; }
396
- write(target, file, theirs, exec);
397
- step(policy, m, "adopted", file, "written");
494
+ // Binary: there are no lines to merge, so it is the upstream's copy or it is the
495
+ // project's, and the base decides which. A logo the project replaced stays replaced.
496
+ if (Buffer.isBuffer(theirs)) {
497
+ const held = fs.readFileSync(full);
498
+ if (same(held, theirs)) { step(policy, m, "unchanged", file); continue; }
499
+ const was = base === null ? null : blob(templateDir, base, file);
500
+ if (adopt || same(held, was)) {
501
+ write(target, file, theirs, exec);
502
+ step(policy, m, adopt ? "adopted" : "written", file, "written");
503
+ } else step(policy, m, base === null ? "yours, no base" : "yours, binary", file, "kept");
398
504
  continue;
399
505
  }
400
- const from = blob(templateDir, base, file);
401
- if (from === null) { step(policy, m, "yours, new here", file, "kept"); continue; }
402
- if (ours === from) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
506
+ const raw = fs.readFileSync(full, "utf8");
507
+ const crlf = isCrlf(raw);
508
+ const ours = toLf(raw);
509
+ if (ours === theirs) { step(policy, m, "unchanged", file); continue; }
510
+ // Before the base logic, not inside it: a repo that needs adopting usually has a
511
+ // receipt already, written by the install that kept the stale files in the first place.
512
+ if (adopt) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "adopted", file, "written"); continue; }
513
+ // Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file
514
+ // is now its own nearest base, so the merge takes it whole, the run says "unchanged" and
515
+ // a half-merged harness passes as settled. Named instead, and the run exits 1 until
516
+ // someone resolves it or --adopt above throws it away.
517
+ if (MARKED.test(ours)) { step(policy, m, "STILL OPEN", file, "conflicted"); continue; }
518
+ // A reconcile file is one the harness cannot work around: AGENTS.md is the map every
519
+ // agent reads and holds the table docs-check parses, and docs/README.md says what the
520
+ // chain puts where. Keeping a stale one leaves a repo that looks installed and behaves
521
+ // like the version it came from, so these are merged even when the receipt is missing.
522
+ let from = base === null ? null : blob(templateDir, base, file);
523
+ if (from === null && policy === "reconcile") from = recoverBase(templateDir, file, ours);
524
+ if (from === null && policy === "reconcile") {
525
+ // Nothing in the upstream's history matches, so this copy was written by hand. An
526
+ // empty base makes the whole file one conflict, which is the honest answer: both
527
+ // versions are there to read, and the run exits 1 rather than pretending.
528
+ from = "";
529
+ }
530
+ if (from === null) { step(policy, m, base === null ? "yours, no base" : "yours, new here", file, "kept"); continue; }
531
+ if (ours === from) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "written", file, "written"); continue; }
403
532
  const merged = threeWay(from, ours, theirs);
404
533
  if (merged.failed) { step(policy, m, "yours, merge failed", file, "kept"); continue; }
405
- write(target, file, merged.text, exec);
406
- if (merged.conflicts) step(policy, m, "CONFLICT", file, "conflicted");
407
- else step(policy, m, "merged", file, "merged");
534
+ const result = asFound(merged.text, crlf);
535
+ if (merged.conflicts) { write(target, file, result, exec); step(policy, m, "CONFLICT", file, "conflicted"); continue; }
536
+ // A file that keeps a local edit merges cleanly on every later run and comes out the same
537
+ // every time. Reported as merged each run it reads as churn, and the reader goes looking
538
+ // for a change nobody made, so what the run did is decided by the result, not the route.
539
+ if (result === raw) { step(policy, m, "unchanged", file); continue; }
540
+ write(target, file, result, exec);
541
+ step(policy, m, "merged", file, "merged");
408
542
  }
409
543
 
410
544
  phase("skeletons a project starts with");
@@ -453,8 +587,17 @@ function mergeSkills(target, templateDir, head, files) {
453
587
  if (text === null) continue;
454
588
  const full = path.join(target, file);
455
589
  const exists = fs.existsSync(full);
456
- if (exists && fs.readFileSync(full, "utf8") === text) continue;
457
- write(target, file, text);
590
+ // A vendored file the project has not touched still differs byte-for-byte on Windows, where
591
+ // Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
592
+ const held = exists ? fs.readFileSync(full) : null;
593
+ if (Buffer.isBuffer(text)) {
594
+ if (same(held, text)) continue;
595
+ write(target, file, text);
596
+ } else {
597
+ const ourText = held === null ? null : held.toString("utf8");
598
+ if (ourText !== null && toLf(ourText) === text) continue;
599
+ write(target, file, asFound(text, ourText !== null && isCrlf(ourText)));
600
+ }
458
601
  if (exists) { tally.updated++; notes.merged.push(file); }
459
602
  else { tally.added++; notes.written.push(file); }
460
603
  }
@@ -502,6 +645,12 @@ function report(target, head, ref, base) {
502
645
  const named = notes.template.filter(f => !f.startsWith(".agents/hooks/tests/"));
503
646
  say(`\nnot installed, the upstream's own (${notes.template.length}): ${named.join(", ")}, and the suite's fixtures`);
504
647
  }
648
+ // Listed whatever the verbosity, like a conflict: a file the upstream ships and this run could
649
+ // not read is missing from the install, and the reader is the only one who can say why.
650
+ if (notes.unreadable.length) {
651
+ list("UNREADABLE in the upstream checkout, so not installed", notes.unreadable, true);
652
+ say(`\nGit could not read these out of the upstream checkout. On Windows a path over 260 characters is\nthe usual cause: install into a shorter path, or set core.longpaths=true globally.`);
653
+ }
505
654
  if (notes.conflicted.length) {
506
655
  list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
507
656
  say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then run the suite:\n node .agents/hooks/test.js`);
@@ -516,7 +665,7 @@ function report(target, head, ref, base) {
516
665
  say(`\nIn ${target}, point Git at the hooks once per clone:`);
517
666
  say(` node scripts/githooks-init.js${suite ? " && node .agents/hooks/test.js" : " && node scripts/docs-check.js"}`);
518
667
  }
519
- if (notes.conflicted.length || (check && check.failed)) process.exit(1);
668
+ if (notes.conflicted.length || notes.unreadable.length || (check && check.failed)) process.exit(1);
520
669
  }
521
670
 
522
671
  main();