@salaros/ai-harness 0.2.10 → 0.3.1

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.
@@ -32,6 +32,8 @@ const fs = require("fs");
32
32
  const os = require("os");
33
33
  const path = require("path");
34
34
  const lib = require("./lib");
35
+ const projectFacts = require("./project-facts");
36
+ const repoView = require("./repo-view");
35
37
  const { spawnSync } = require("child_process");
36
38
 
37
39
  const TEMPLATE = "https://github.com/salaros/ai-harness.git";
@@ -39,9 +41,6 @@ const LOCK = "harness-lock.json";
39
41
  const MANIFEST = "scripts/harness-files.tsv";
40
42
  const DEFAULT_REF = "master";
41
43
 
42
- const argv = process.argv.slice(2);
43
- const flag = name => argv.includes(name);
44
- const value = (name, fallback) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
45
44
  // Every argument the installer knows. An optional part of the harness adds its own flag through the
46
45
  // manifest (optional:<flag>), so those are checked once the upstream checkout is read. Anything else
47
46
  // stops the run before a file is written: this script installs when it is run, so a mistyped
@@ -86,8 +85,31 @@ function usage() {
86
85
  for (let i = start + 1; i < lines.length && lines[i].startsWith("// "); i++) out.push(lines[i].slice(3).replace("node scripts/update-harness.js", "npx @salaros/ai-harness"));
87
86
  return ["Installs or updates the agent harness in the current git repository.", "", "Usage:", ...out].join("\n");
88
87
  }
89
- const dryRun = flag("--dry-run");
90
- const adopt = flag("--adopt");
88
+
89
+ // The arguments, read once. Everything below takes this object rather than the process's argv, so a
90
+ // test can ask what an --adopt run would plan without being one. `wants` answers whether the run
91
+ // asked for an optional part of the harness by its flag.
92
+ function parseOptions(args) {
93
+ const flag = name => args.includes(name);
94
+ const value = name => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : null; };
95
+ return {
96
+ help: flag("--help") || flag("-h"),
97
+ dryRun: flag("--dry-run"),
98
+ adopt: flag("--adopt"),
99
+ quiet: flag("--quiet"),
100
+ check: !flag("--no-check"),
101
+ wants: name => flag(`--${name}`),
102
+ ref: value("--ref"),
103
+ target: value("--target"),
104
+ from: value("--from"),
105
+ };
106
+ }
107
+
108
+ // A reason to stop. Thrown rather than exiting, so main() is the one place the process ends, and a
109
+ // temporary clone is still removed on the way out.
110
+ class Stop extends Error {}
111
+ const fail = m => { throw new Stop(m); };
112
+ const say = m => console.log(m);
91
113
 
92
114
  // ---------------------------------------------------------------- the target
93
115
 
@@ -95,9 +117,8 @@ const adopt = flag("--adopt");
95
117
  // its scripts/ folder. Run through npx, the package is an extracted tarball with no .git of its own,
96
118
  // so the answer is the directory the user is standing in. One rule covers both, and --target covers
97
119
  // installing into a checkout from somewhere else entirely.
98
- function targetRoot() {
99
- const given = value("--target", null);
100
- if (given) return path.resolve(given);
120
+ function targetRoot(options) {
121
+ if (options.target) return path.resolve(options.target);
101
122
  const beside = path.resolve(__dirname, "..");
102
123
  return fs.existsSync(path.join(beside, ".git")) ? beside : process.cwd();
103
124
  }
@@ -106,22 +127,21 @@ function targetRoot() {
106
127
 
107
128
  // A clone deep enough to read the recorded commit: an update needs that commit's version of a file
108
129
  // as the merge base, and --depth 1 would not have it. Removed again unless the caller supplied one.
109
- function templateCheckout(ref) {
130
+ function templateCheckout(ref, options) {
110
131
  // The manifest is what makes a checkout usable here, so both routes are held to it: a --from
111
132
  // that points somewhere else, and a --ref naming a branch or tag from before the table existed,
112
133
  // fail the same way. Without this the run reaches readTsv and dies in a stack trace naming a
113
134
  // temporary directory the reader has never heard of.
114
135
  const usable = dir => fs.existsSync(path.join(dir, MANIFEST));
115
- const given = value("--from", null);
116
- if (given) {
117
- const dir = path.resolve(given);
136
+ if (options.from) {
137
+ const dir = path.resolve(options.from);
118
138
  if (!usable(dir)) fail(`${dir} does not look like the upstream harness: no ${MANIFEST}`);
119
139
  return { dir, temporary: false };
120
140
  }
121
141
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
122
142
  say(`cloning ${TEMPLATE} at ${ref}`);
123
143
  const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
124
- if (r.status !== 0) fail(`could not clone the upstream at ${ref}\n${r.output}`);
144
+ if (r.status !== 0) { fs.rmSync(dir, { recursive: true, force: true }); fail(`could not clone the upstream at ${ref}\n${r.output}`); }
125
145
  if (!usable(dir)) {
126
146
  fs.rmSync(dir, { recursive: true, force: true });
127
147
  fail(`${TEMPLATE} at ${ref} carries no ${MANIFEST}, so there is nothing to install from; try another --ref`);
@@ -129,17 +149,29 @@ function templateCheckout(ref) {
129
149
  return { dir, temporary: true };
130
150
  }
131
151
 
132
- // The installer's own name and version, read from the package it ships inside rather than written
133
- // down here, so a release cannot forget to update it. It answers what the upstream commit cannot:
134
- // which released tool wrote this tree. Both routes land on the right file, because the script always
135
- // sits in the scripts/ folder of either the npm package or a checkout of the upstream. Omitted
136
- // rather than recorded as null when it cannot be read, so the receipt never claims a version it
137
- // does not know.
152
+ // The installer's own name and version: which released tool wrote this tree, the one thing the
153
+ // upstream commit cannot answer. package.json holds 0.0.0 on every branch and only the release job
154
+ // writes the real version into it, just before the publish, so its number means something only
155
+ // inside the npm package. A checkout of the upstream reads the release tag on its own HEAD instead.
156
+ // A checkout on no tag is not a release, and the receipt names no package rather than a version
157
+ // that was never published. Omitted, never null, whenever it cannot be read, so the receipt never
158
+ // claims a version it does not know.
159
+ const PLACEHOLDER_VERSION = "0.0.0";
160
+ const RELEASE_TAG = /^\d+\.\d+\.\d+$/;
161
+ function installerStamp({ name, version, tag }) {
162
+ if (!name) return {};
163
+ if (version && version !== PLACEHOLDER_VERSION) return { installer: `${name}@${version}` };
164
+ return tag && RELEASE_TAG.test(tag) ? { installer: `${name}@${tag}` } : {};
165
+ }
166
+ // The reads behind installerStamp. The script sits in the scripts/ folder of either the npm package
167
+ // or a checkout of the upstream, so both files are one folder up; the package has no .git, and there
168
+ // git answers nothing.
138
169
  function installer() {
139
- try {
140
- const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8"));
141
- return pkg.name && pkg.version ? { installer: `${pkg.name}@${pkg.version}` } : {};
142
- } catch { return {}; }
170
+ const home = path.resolve(__dirname, "..");
171
+ let pkg = {};
172
+ try { pkg = JSON.parse(fs.readFileSync(path.join(home, "package.json"), "utf8")); } catch { return {}; }
173
+ const described = at(home, ["describe", "--tags", "--exact-match", "HEAD"]);
174
+ return installerStamp({ name: pkg.name, version: pkg.version, tag: described.status === 0 ? described.output.trim() : null });
143
175
  }
144
176
 
145
177
  // Windows stops at 260 characters for a path, and the harness ships skill files nested deep enough
@@ -151,8 +183,7 @@ function installer() {
151
183
  const GIT = ["-c", "core.longpaths=true"];
152
184
  const at = (dir, args) => lib.run("git", [...GIT, "-C", dir, ...args]);
153
185
 
154
- // The upstream's version of a path at a commit, or null when the file did not exist there. Also how
155
- // a missing base is detected: a rewritten history no longer holds the recorded commit.
186
+ // The upstream's version of a path at a commit, or null when the file did not exist there.
156
187
  // Read raw rather than through lib.run, which trims trailing whitespace: that is right for the
157
188
  // plumbing whose output is a hash or a status line, and wrong for a file. Trimmed, every installed
158
189
  // file lost its final newline, no copy was ever byte-identical to the upstream, and so every later
@@ -173,6 +204,82 @@ const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
173
204
  ? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
174
205
  : a === b;
175
206
 
207
+ // ---------------------------------------------------------------- the adapters
208
+ //
209
+ // What plan() reads, and nothing more: the upstream at any commit, and the target as it stands. A
210
+ // real run backs them with the upstream's git checkout and the target's directory; the suite backs
211
+ // them with maps, so a whole install is a table row rather than a clone and a temp tree.
212
+
213
+ // Every blob in one commit, read in two calls rather than one `git show` per path: an install reads
214
+ // every file at the head, and a process per file made a first install take most of a minute on
215
+ // Windows. Keyed by path, holding what blob() would return; a submodule entry is not a blob and is
216
+ // left out, as `git show` would fail on it too.
217
+ function treeBlobs(dir, commit) {
218
+ const listing = spawnSync("git", [...GIT, "-C", dir, "ls-tree", "-r", "-z", commit], { maxBuffer: 64 * 1024 * 1024 });
219
+ if (listing.status !== 0) return null;
220
+ const entries = listing.stdout.toString("utf8").split("\0").filter(Boolean)
221
+ .map(line => { const tab = line.indexOf("\t"); const [, type, oid] = line.slice(0, tab).split(" "); return { type, oid, file: line.slice(tab + 1) }; })
222
+ .filter(e => e.type === "blob");
223
+ const r = spawnSync("git", [...GIT, "-C", dir, "cat-file", "--batch"],
224
+ { input: entries.map(e => e.oid).join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
225
+ if (r.status !== 0) return null;
226
+ const blobs = new Map();
227
+ let at = 0;
228
+ for (const { file } of entries) {
229
+ const eol = r.stdout.indexOf(10, at);
230
+ const size = Number(r.stdout.toString("utf8", at, eol).split(" ")[2]);
231
+ const bytes = r.stdout.subarray(eol + 1, eol + 1 + size);
232
+ blobs.set(file, bytes.includes(0) ? Buffer.from(bytes) : bytes.toString("utf8"));
233
+ at = eol + 1 + size + 1;
234
+ }
235
+ return blobs;
236
+ }
237
+
238
+ function gitUpstream(dir, head) {
239
+ let atHead;
240
+ return {
241
+ // Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and
242
+ // the harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill
243
+ // under .claude/skills. Written as ordinary files they become text files holding a path,
244
+ // which is how a harness ends up looking installed while the agent sees no skills at all.
245
+ files() {
246
+ const rows = repoView.indexModes(dir);
247
+ if (!rows) fail(`could not list the upstream's files in ${dir}`);
248
+ return rows.map(({ file, link, exec }) => ({ file, link, exec }));
249
+ },
250
+ // The head is read whole on first use; any other commit, which only a base or a base search
251
+ // asks for, one path at a time.
252
+ blob(commit, file) {
253
+ if (commit !== head) return blob(dir, commit, file);
254
+ if (atHead === undefined) atHead = treeBlobs(dir, head);
255
+ if (atHead === null) return blob(dir, commit, file);
256
+ return atHead.has(file) ? atHead.get(file) : null;
257
+ },
258
+ // A rewritten history no longer holds the recorded commit, which leaves the run without a base.
259
+ hasCommit: commit => at(dir, ["cat-file", "-e", `${commit}^{commit}`]).status === 0,
260
+ // The commits that touched a path, newest first.
261
+ history(file) {
262
+ const r = at(dir, ["log", "--format=%H", "--", file]);
263
+ return r.status === 0 ? r.output.split(/\r?\n/).filter(Boolean) : [];
264
+ },
265
+ };
266
+ }
267
+
268
+ function fsTarget(root) {
269
+ const full = file => path.join(root, file);
270
+ return {
271
+ exists: file => fs.existsSync(full(file)),
272
+ // A Buffer when asked for bytes, text otherwise.
273
+ read: (file, binary) => binary ? fs.readFileSync(full(file)) : fs.readFileSync(full(file), "utf8"),
274
+ // null when nothing is there; otherwise whether it is a symlink, and where it points.
275
+ lstat(file) {
276
+ let s;
277
+ try { s = fs.lstatSync(full(file)); } catch { return null; }
278
+ return s.isSymbolicLink() ? { link: fs.readlinkSync(full(file)).split(path.sep).join("/") } : { link: null };
279
+ },
280
+ };
281
+ }
282
+
176
283
  // The receipt is missing, so the base is found instead: the upstream version this copy is closest to
177
284
  // is where the project forked from, whatever a receipt would have said. An exact match is the clean
178
285
  // case, an older copy nobody touched; a project that has since edited its own file matches nothing
@@ -183,17 +290,15 @@ const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
183
290
  // Under half the lines in common is a different file, not an older one, and merging against it would
184
291
  // invent a diff the project never made.
185
292
  const NEAREST = 0.5;
186
- function recoverBase(dir, file, ours) {
187
- const r = at(dir, ["log", "--format=%H", "--", file]);
188
- if (r.status !== 0) return null;
293
+ function recoverBase(upstream, file, ours) {
189
294
  const want = lineCounts(ours);
190
295
  let best = null;
191
296
  let nearest = NEAREST;
192
297
  // Oldest first, and a tie goes to the first seen: two upstream versions one line apart score the
193
298
  // same against a copy that has neither, and the older of them is the one whose merge puts that
194
299
  // line back. The newer would drop it silently, which is the failure this policy exists to stop.
195
- for (const commit of r.output.split(/\r?\n/).filter(Boolean).reverse()) {
196
- const text = blob(dir, commit, file);
300
+ for (const commit of upstream.history(file).reverse()) {
301
+ const text = upstream.blob(commit, file);
197
302
  if (typeof text !== "string") continue;
198
303
  if (text === ours) return text;
199
304
  const shared = overlap(want, lineCounts(text));
@@ -242,59 +347,6 @@ function policyFor(rows, file, wants) {
242
347
  return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
243
348
  }
244
349
 
245
- // Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and the
246
- // harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill under
247
- // .claude/skills. Written as ordinary files they become text files holding a path, which is how a
248
- // harness ends up looking installed while the agent sees no skills and no agents at all.
249
- function templateFiles(dir) {
250
- const r = at(dir, ["ls-files", "-s"]);
251
- if (r.status !== 0) fail(`could not list the upstream's files\n${r.output}`);
252
- return r.output.split(/\r?\n/).filter(Boolean).map(line => {
253
- const [meta, file] = line.split("\t");
254
- return { file, link: meta.startsWith("120000"), exec: meta.startsWith("100755") };
255
- });
256
- }
257
-
258
- // Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
259
- // whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
260
- // that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
261
- // core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
262
- // hooks would run for whoever installed them and silently never run for anyone else. `git add
263
- // --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
264
- // install stages these few files rather than leaving them for the project's own `git add`.
265
- function carryMode(target, file) {
266
- try { fs.chmodSync(path.join(target, file), 0o755); } catch { /* the filesystem does not do modes */ }
267
- const r = lib.run("git", [...GIT, "-C", target, "add", "--chmod=+x", "--", file]);
268
- if (r.status !== 0) say(`could not mark ${file} executable: ${r.output}`);
269
- }
270
-
271
- // A symlink recorded in Git is a blob holding its target. Windows needs Developer Mode and
272
- // core.symlinks=true for this to work at all, so a refusal is reported rather than thrown: the
273
- // harness still functions with the links missing, it is just invisible to the harnesses that read
274
- // them, and README says how to turn them on.
275
- function link(target, file, to) {
276
- const full = path.join(target, file);
277
- if (dryRun) return "written";
278
- fs.mkdirSync(path.dirname(full), { recursive: true });
279
- let existing = null;
280
- try { existing = fs.lstatSync(full); } catch { /* absent */ }
281
- if (existing) {
282
- // Something of the project's is in the way -- or, in a repo whose harness predates the lock
283
- // file, the link itself checked out as a text file holding a path, which is the failure that
284
- // leaves an agent seeing no skills at all. --adopt is the only thing that replaces it.
285
- if (!existing.isSymbolicLink()) { if (!adopt) return "kept"; fs.unlinkSync(full); }
286
- else if (fs.readlinkSync(full).split(path.sep).join("/") === to) return null;
287
- else fs.unlinkSync(full);
288
- }
289
- try {
290
- fs.symlinkSync(to.split("/").join(path.sep), full, "dir");
291
- return existing ? "merged" : "written";
292
- } catch (e) {
293
- say(`could not create the symlink ${file} -> ${to}: ${e.code || e.message}`);
294
- return "kept";
295
- }
296
- }
297
-
298
350
  // ---------------------------------------------------------------- skeletons
299
351
 
300
352
  // Three files the upstream does not ship, because there they would be lies: MEMORY.md
@@ -311,14 +363,6 @@ const SKELETONS = {
311
363
  "`pre-commit` and `pre-push` hooks refuse to let work leave a clone while any value is still a",
312
364
  "`<placeholder>`.",
313
365
  "",
314
- "- **Name:** <name>",
315
- "- **Purpose:** <purpose>",
316
- "- **Prose language:** <prose language>",
317
- "- **Requirements:** <requirements>",
318
- "- **Unit type:** <unit type>",
319
- "- **Language:** <language>",
320
- "- **Runtime / package manager:** <runtime>",
321
- "",
322
366
  ],
323
367
  "CONTEXT.md": [
324
368
  "# Context",
@@ -338,21 +382,13 @@ const SKELETONS = {
338
382
  ],
339
383
  };
340
384
 
341
- // A repo that already has an INTENT.md names the product and its purpose there, so the MEMORY.md
342
- // laid down beside it leaves those two out rather than asking for them a second time.
343
- function skeletonLines(target, file, lines) {
344
- if (file !== "MEMORY.md" || !fs.existsSync(path.join(target, "INTENT.md"))) return lines;
345
- const facts = lines.filter(l => !/^- \*\*(?:Name|Purpose):\*\*/.test(l));
346
- const first = facts.findIndex(l => l.startsWith("- **"));
347
- return [...facts.slice(0, first), "The name and purpose are in `INTENT.md`, under `## Product`.", "", ...facts.slice(first)];
348
- }
349
-
350
- function skeletons(target) {
351
- for (const [file, lines] of Object.entries(SKELETONS)) {
352
- if (fs.existsSync(path.join(target, file))) { step("seed", "100644", "yours", file); continue; }
353
- write(target, file, skeletonLines(target, file, lines).join("\n"));
354
- step("seed", "100644", "created", file, "seeded");
355
- }
385
+ // MEMORY.md's fact lines come from scripts/project-facts.js, the table the gate checks them against,
386
+ // so a skeleton never asks for a fact the gate does not know or leaves out one it requires. A repo
387
+ // that already has an INTENT.md names the product and its purpose there, so the MEMORY.md laid down
388
+ // beside it leaves those two out rather than asking for them a second time.
389
+ function skeletonLines(file, lines, hasIntent) {
390
+ if (file !== projectFacts.MEMORY) return lines;
391
+ return [...lines, ...projectFacts.skeleton(hasIntent), ""];
356
392
  }
357
393
 
358
394
  // ---------------------------------------------------------------- merging
@@ -372,25 +408,6 @@ function threeWay(base, ours, theirs) {
372
408
  } finally { fs.rmSync(dir, { recursive: true, force: true }); }
373
409
  }
374
410
 
375
- // ---------------------------------------------------------------- reporting
376
-
377
- const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [], check: null };
378
- const say = m => console.log(m);
379
- function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
380
-
381
- // An install rewrites someone else's repository, so it says what it did to every path while it does
382
- // it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
383
- // lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
384
- // skills, and both look installed. One call records the outcome and prints the line, so the running
385
- // commentary and the summary below cannot drift apart.
386
- const quiet = flag("--quiet");
387
- const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
388
- function step(policy, m, outcome, file, bucket) {
389
- if (bucket) notes[bucket].push(file);
390
- if (!quiet) say(` ${policy.padEnd(9)}${m} ${outcome.padEnd(12)}${file}`);
391
- }
392
- const phase = m => { if (!quiet) say(`\n${m}`); };
393
-
394
411
  // Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
395
412
  // the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
396
413
  // touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
@@ -409,14 +426,11 @@ const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
409
426
  //
410
427
  // What happens to one file the target already has, decided apart from doing it. Everything these two
411
428
  // read is an argument, including the three things they cannot compute -- the base's text, the search
412
- // for a base when the receipt has none, and the three-way merge itself -- so the caller passes them
413
- // in and the loop below is left reading, writing and reporting.
429
+ // for a base when the receipt has none, and the three-way merge itself -- so plan() passes them in.
414
430
  //
415
431
  // Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
416
432
  // belongs in (null for a file nothing happened to), and `text` what to write, or null to write
417
- // nothing. Splitting them this way is what makes the table of cases testable: an install rewrites
418
- // someone else's repository, and every branch below used to need a git checkout and a temp tree to
419
- // reach even once.
433
+ // nothing.
420
434
 
421
435
  // A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
422
436
  // decides which. A logo the project replaced stays replaced.
@@ -468,246 +482,290 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
468
482
  return { outcome: "merged", bucket: "merged", text: result };
469
483
  }
470
484
 
471
- function write(target, file, text, exec) {
472
- const full = path.join(target, file);
473
- if (dryRun) return;
474
- fs.mkdirSync(path.dirname(full), { recursive: true });
475
- fs.writeFileSync(full, text);
476
- if (exec) carryMode(target, file);
477
- }
478
-
479
- // ---------------------------------------------------------------- the self check
480
-
481
- // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
482
- // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
483
- // this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
484
- // harness invariants, and scripts/check-harness.js holds them as functions of a root.
485
+ // ---------------------------------------------------------------- the plan
485
486
  //
486
- // So they run from the upstream checkout against the target, and nothing is written into the target
487
- // to run them. The upstream's copy rather than the one just installed, so the check is the one that
488
- // matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
489
- // scripts, which the upstream's own CI has already done.
490
- function selfCheck(target, templateDir) {
491
- const script = path.join(templateDir, "scripts", "check-harness.js");
492
- if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
493
- phase("self check: the harness invariants, run from the upstream against this repo");
494
- const harness = require(script);
495
- const r = harness.check(target);
496
- return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
497
- }
498
-
499
- // ---------------------------------------------------------------- the run
500
-
501
- function main() {
502
- if (flag("--help") || flag("-h")) { console.log(usage()); return; }
503
- const mistyped = mistypedArgs(argv);
504
- if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
505
- const target = targetRoot();
506
- if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
507
-
508
- const lockPath = path.join(target, LOCK);
509
- const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
510
- const ref = value("--ref", previous ? previous.ref : DEFAULT_REF);
511
- const { dir: templateDir, temporary } = templateCheckout(ref);
487
+ // Everything a run will do to the target, decided before anything is written: an install rewrites
488
+ // someone else's repository, and deciding and writing in the same loop left every branch but the
489
+ // per-file decision reachable only through a git checkout and a temp tree. A dry run prints the plan;
490
+ // a real run applies it. Each entry is one line of the run's output and at most one thing done to
491
+ // one path:
492
+ // file, policy, mode, outcome, bucket what the run prints, and the summary list the path joins
493
+ // write the path's new content, text or a Buffer
494
+ // link, replace a symlink to `link`, replacing what is there when `replace`
495
+ // exec mark the path executable, whether or not it is written
496
+ // mkdir create the path's folder and nothing else
497
+ // silent counted in the summary, never printed as a line
498
+ // and a { phase } entry heads each section of the output.
499
+ const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
500
+ const SKILLS = ".agents/skills/";
501
+
502
+ // `previous` is the target's harness-lock.json, or null; `stamp` is what the receipt records about
503
+ // this run besides the upstream commit, passed in so a plan is the same whenever it is made.
504
+ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {} }) {
505
+ const entries = [];
506
+ const notices = [];
507
+ const add = e => entries.push(e);
508
+
509
+ // A base is what makes this an update rather than an overwrite. Without one -- a first install,
510
+ // or an upstream whose history was rewritten -- an existing file is left alone instead of being
511
+ // guessed at, and the run says so.
512
+ let base = previous ? previous.commit : null;
513
+ if (base && !upstream.hasCommit(base)) {
514
+ notices.push(`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`);
515
+ base = null;
516
+ }
517
+ // --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest way to
518
+ // reach that state is an install that wrote the receipt and kept a stale harness. So the run stays
519
+ // open at the recorded commit, and main() answers "nothing to update" only without --adopt.
520
+ if (previous && base === head) notices.push(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
521
+
522
+ // A repo carrying a harness from before harness-lock.json existed. Without a base the rule below
523
+ // keeps every file that is already there, which protects the project's work and also preserves
524
+ // the old harness: its checks then run against the new skills and agents and fail, naming rules
525
+ // this version dropped. Worth saying out loud, because the run otherwise looks like a success.
526
+ const MARKERS = [".agents/hooks/lib.js", "scripts/lib.js", ".githooks/pre-commit"];
527
+ const stale = previous ? [] : MARKERS.filter(f => target.exists(f));
528
+ if (stale.length) {
529
+ notices.push(options.adopt
530
+ ? `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`
531
+ : `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.`);
532
+ }
512
533
 
513
- try {
514
- // Checked before anything is said about the target, so a bad argument is the only message.
515
- const rows = policies(templateDir);
516
- const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
517
- const unknown = unknownArgs(argv, optional);
518
- if (unknown.length) {
519
- if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
520
- fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
521
- }
522
- const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
523
- // A base is what makes this an update rather than an overwrite. Without one -- a first
524
- // install, or an upstream whose history was rewritten -- an existing file is left alone
525
- // instead of being guessed at, and the run says so.
526
- let base = previous ? previous.commit : null;
527
- if (base && at(templateDir, ["cat-file", "-e", `${base}^{commit}`]).status !== 0) {
528
- 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`);
529
- base = null;
530
- }
531
- // --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest
532
- // way to reach that state is an install that wrote the receipt and kept a stale harness. So
533
- // the run has to stay open at the recorded commit: short-circuiting here would answer the
534
- // one command that fixes it with "nothing to update".
535
- if (previous && base === head && !adopt) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
536
- if (previous && base === head) say(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
537
-
538
- // A repo carrying a harness from before harness-lock.json existed. Without a base the rule
539
- // below keeps every file that is already there, which protects the project's work and also
540
- // preserves the old harness: its checks then run against the new skills and agents and fail,
541
- // naming rules this version dropped. Worth saying out loud, because the run otherwise looks
542
- // like a success.
543
- const MARKERS = [".agents/hooks/lib.js", "scripts/lib.js", ".githooks/pre-commit"];
544
- const stale = !previous && MARKERS.filter(f => fs.existsSync(path.join(target, f)));
545
- if (stale && stale.length) {
546
- 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`);
547
- 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.`);
534
+ const files = upstream.files();
535
+ const skills = [];
536
+ add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
537
+ for (const entry of files) {
538
+ const { file, link: isLink, exec } = entry;
539
+ const policy = policyFor(rows, file, options.wants);
540
+ const m = mode(entry);
541
+ const theirs = upstream.blob(head, file);
542
+ // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
543
+ // rather than the file being absent. Said out loud: skipped quietly, the run reports a clean
544
+ // install of a harness missing whichever files the reader was never told about.
545
+ if (theirs === null) { add({ file, policy, mode: m, outcome: "UNREADABLE", bucket: "unreadable" }); continue; }
546
+ const exists = target.exists(file);
547
+ // The executable bit is not the project's content, so a file kept for its content still has
548
+ // its mode corrected. Git runs a hook only if it is executable and says nothing when it is
549
+ // not, so a hook kept at 100644 by an install that had no merge base looks installed and
550
+ // gates nothing at all -- the failure the mode column exists to catch.
551
+ const line = (outcome, bucket, act = {}) =>
552
+ add({ file, policy, mode: m, outcome, bucket, ...act, exec: exec && (exists || act.write !== undefined) });
553
+
554
+ // Not installed anywhere, and named in one line of the summary instead: sixty-five lines
555
+ // saying nothing happened bury the thirty-eight saying something did.
556
+ if (policy === "template") { line("template", "template", { silent: true }); continue; }
557
+ if (isLink) {
558
+ // A skill link is relink's to make, once the directory it lives in exists: it knows which
559
+ // skills this project actually has, where the upstream only knows its own.
560
+ if (policy === "skills") { add({ file, mkdir: true, silent: true }); continue; }
561
+ const to = theirs.trim();
562
+ const found = target.lstat(file);
563
+ // Something of the project's in the way -- or, in a repo whose harness predates the lock
564
+ // file, the link itself checked out as a text file holding a path, which is the failure
565
+ // that leaves an agent seeing no skills at all. --adopt is the only thing that replaces it.
566
+ if (!found) line("written", "written", { link: to });
567
+ else if (!found.link) line(options.adopt ? "merged" : "yours", options.adopt ? "merged" : "kept", options.adopt ? { link: to, replace: true } : {});
568
+ else if (found.link === to) line("unchanged", null);
569
+ else line("merged", "merged", { link: to, replace: true });
570
+ continue;
548
571
  }
549
-
550
- const wants = name => flag(`--${name}`);
551
- const files = templateFiles(templateDir);
552
- const skills = [];
553
-
554
- phase(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`);
555
- for (const entry of files) {
556
- const { file, link: isLink, exec } = entry;
557
- const policy = policyFor(rows, file, wants);
558
- const m = mode(entry);
559
- const theirs = blob(templateDir, head, file);
560
- // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
561
- // rather than the file being absent. Said out loud: skipped quietly, the run reports a
562
- // clean install of a harness missing whichever files the reader was never told about.
563
- if (theirs === null) { step(policy, m, "UNREADABLE", file, "unreadable"); continue; }
564
- const full = path.join(target, file);
565
- const exists = fs.existsSync(full);
566
-
567
- // The executable bit is not the project's content, so a file kept for its content still
568
- // has its mode corrected. Git runs a hook only if it is executable and says nothing when
569
- // it is not, so a hook kept at 100644 by an install that had no merge base looks
570
- // installed and gates nothing at all -- the failure this whole column exists to catch.
571
- if (exec && exists && !dryRun) carryMode(target, file);
572
-
573
- // Not installed anywhere, and named in one line of the summary instead: sixty-five
574
- // lines saying nothing happened bury the thirty-eight saying something did.
575
- if (policy === "template") { notes.template.push(file); continue; }
576
- if (isLink) {
577
- // A skill link is relink's to make, once the directory it lives in exists: it knows
578
- // which skills this project actually has, where the upstream only knows its own.
579
- if (policy === "skills") {
580
- if (!dryRun) fs.mkdirSync(path.dirname(full), { recursive: true });
581
- continue;
582
- }
583
- const how = link(target, file, theirs.trim());
584
- step(policy, m, how === "kept" ? "yours" : how || "unchanged", file, how);
585
- continue;
586
- }
587
- // Reported one line per skill by mergeSkills below, not one per reference file: a skill
588
- // is the unit a project installs, and its files run to several hundred.
589
- if (policy === "skills") { skills.push(file); continue; }
590
- // Reported only when the target actually has it: "left alone, yours" about a file the
591
- // repo does not have names something that was never there.
592
- if (policy === "skip") { step(policy, m, exists ? "yours" : "absent", file, exists && "skipped"); continue; }
593
-
594
- if (policy === "seed") {
595
- if (exists) { step(policy, m, "yours", file, "kept"); continue; }
596
- write(target, file, theirs, exec);
597
- step(policy, m, "created", file, "seeded");
598
- continue;
599
- }
600
- // merge
601
- if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
602
-
603
- // Everything the decision needs, read here; what to do with its answer, done here. The
604
- // decision itself is decideBinary/decideText, which touch neither git nor the disk.
605
- const hasBase = base !== null;
606
- const held = Buffer.isBuffer(theirs) ? fs.readFileSync(full) : fs.readFileSync(full, "utf8");
607
- const { outcome, bucket, text } = Buffer.isBuffer(theirs)
608
- ? decideBinary({ held, theirs, hasBase, adopt }, { baseBytes: () => blob(templateDir, base, file) })
609
- : decideText({ policy, raw: held, theirs, hasBase, adopt }, {
610
- baseText: () => blob(templateDir, base, file),
611
- recoverBase: ours => recoverBase(templateDir, file, ours),
612
- merge: threeWay,
613
- });
614
- if (text !== null) write(target, file, text, exec);
615
- step(policy, m, outcome, file, bucket);
572
+ // Reported one line per skill by planSkills below, not one per reference file: a skill is the
573
+ // unit a project installs, and its files run to several hundred.
574
+ if (policy === "skills") { skills.push(entry); continue; }
575
+ // Reported only when the target actually has it: "left alone, yours" about a file the repo
576
+ // does not have names something that was never there.
577
+ if (policy === "skip") { line(exists ? "yours" : "absent", exists ? "skipped" : null); continue; }
578
+ if (policy === "seed") {
579
+ if (exists) line("yours", "kept");
580
+ else line("created", "seeded", { write: theirs });
581
+ continue;
616
582
  }
583
+ // merge and reconcile
584
+ if (!exists) { line("written", "written", { write: theirs }); continue; }
585
+ const hasBase = base !== null;
586
+ const held = target.read(file, Buffer.isBuffer(theirs));
587
+ const { outcome, bucket, text } = Buffer.isBuffer(theirs)
588
+ ? decideBinary({ held, theirs, hasBase, adopt: options.adopt }, { baseBytes: () => upstream.blob(base, file) })
589
+ : decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
590
+ baseText: () => upstream.blob(base, file),
591
+ recoverBase: ours => recoverBase(upstream, file, ours),
592
+ merge: threeWay,
593
+ });
594
+ line(outcome, bucket, text === null ? {} : { write: text });
595
+ }
617
596
 
618
- phase("skeletons a project starts with");
619
- skeletons(target);
620
- phase("skills, merged by name");
621
- mergeSkills(target, templateDir, head, skills);
622
-
623
- if (!dryRun) {
624
- fs.writeFileSync(lockPath, JSON.stringify({
625
- template: TEMPLATE, ref, commit: head, ...installer(),
626
- updated: new Date().toISOString().slice(0, 10),
627
- }, null, 2) + "\n");
628
- finish(target);
629
- // After finish(), because the invariants check the links relink has just written.
630
- if (!flag("--no-check")) notes.check = selfCheck(target, templateDir);
631
- }
632
- report(target, head, ref, base);
633
- } finally {
634
- if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
597
+ add({ phase: "skeletons a project starts with" });
598
+ const hasIntent = target.exists(projectFacts.INTENT);
599
+ for (const [file, lines] of Object.entries(SKELETONS)) {
600
+ if (target.exists(file)) add({ file, policy: "seed", mode: "100644", outcome: "yours", bucket: null });
601
+ else add({ file, policy: "seed", mode: "100644", outcome: "created", bucket: "seeded", write: skeletonLines(file, lines, hasIntent).join("\n") });
635
602
  }
603
+
604
+ add({ phase: "skills, merged by name" });
605
+ entries.push(...planSkills(upstream, target, head, skills));
606
+
607
+ add({ file: LOCK, silent: true, write: JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp }, null, 2) + "\n" });
608
+ return { entries, notices, base };
636
609
  }
637
610
 
638
611
  // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
639
612
  // project vendored itself is never removed. skills-lock.json is the union, the project's entry
640
613
  // winning where both name the same skill, so a project that pinned a different source keeps it.
641
- function mergeSkills(target, templateDir, head, files) {
642
- const SKILLS = ".agents/skills/";
643
- const ours = path.join(target, "skills-lock.json");
644
- const theirLock = JSON.parse(blob(templateDir, head, "skills-lock.json") || '{"skills":{}}');
645
- const ourLock = fs.existsSync(ours) ? JSON.parse(fs.readFileSync(ours, "utf8")) : { skills: {} };
614
+ function planSkills(upstream, target, head, files) {
615
+ const LOCKFILE = "skills-lock.json";
616
+ const theirLock = JSON.parse(upstream.blob(head, LOCKFILE) || '{"skills":{}}');
617
+ const ourLock = target.exists(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
646
618
  ourLock.skills = ourLock.skills || {};
647
-
648
619
  const mine = new Set(Object.keys(ourLock.skills));
649
- // One line per skill, not per file. Outcome is decided across the whole folder: a skill counts
650
- // as changed the moment any file in it did, and only an untouched folder reads "unchanged".
620
+
621
+ const out = [];
622
+ // One line per skill, not per file. Outcome is decided across the whole folder: a skill counts as
623
+ // changed the moment any file in it did, and only an untouched folder reads "unchanged".
651
624
  const outcomes = new Map();
652
625
  const seen = name => outcomes.get(name) || outcomes.set(name, { added: 0, updated: 0, files: 0 }).get(name);
653
- for (const file of files) {
626
+ for (const { file, exec } of files) {
654
627
  if (!file.startsWith(SKILLS)) continue; // .claude/skills links are rebuilt, not copied
655
628
  const name = file.slice(SKILLS.length).split("/")[0];
656
629
  const tally = seen(name);
657
630
  tally.files++;
631
+ const exists = target.exists(file);
632
+ // A script the skill runs keeps its executable bit whoever owns the content, as a hook does.
633
+ if (exec && exists) out.push({ file, silent: true, exec: true });
658
634
  // A skill the project installed under a name the upstream also uses stays the project's.
659
635
  if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
660
- const text = blob(templateDir, head, file);
636
+ const text = upstream.blob(head, file);
661
637
  if (text === null) continue;
662
- const full = path.join(target, file);
663
- const exists = fs.existsSync(full);
664
638
  // A vendored file the project has not touched still differs byte-for-byte on Windows, where
665
639
  // Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
666
- const held = exists ? fs.readFileSync(full) : null;
640
+ const held = exists ? target.read(file, true) : null;
641
+ let write;
667
642
  if (Buffer.isBuffer(text)) {
668
643
  if (same(held, text)) continue;
669
- write(target, file, text);
644
+ write = text;
670
645
  } else {
671
646
  const ourText = held === null ? null : held.toString("utf8");
672
647
  if (ourText !== null && toLf(ourText) === text) continue;
673
- write(target, file, asFound(text, ourText !== null && isCrlf(ourText)));
648
+ write = asFound(text, ourText !== null && isCrlf(ourText));
674
649
  }
675
- if (exists) { tally.updated++; notes.merged.push(file); }
676
- else { tally.added++; notes.written.push(file); }
650
+ if (exists) tally.updated++; else tally.added++;
651
+ out.push({ file, silent: true, write, bucket: exists ? "merged" : "written", exec: exec && !exists });
677
652
  }
678
653
  for (const [name, t] of [...outcomes].sort()) {
679
654
  const what = t.yours ? "yours" : t.added ? "added" : t.updated ? "updated" : "unchanged";
680
- step("skills", "100644", what, `${SKILLS}${name} (${t.files} file(s))`);
655
+ out.push({ file: `${SKILLS}${name} (${t.files} file(s))`, policy: "skills", mode: "100644", outcome: what, bucket: null });
681
656
  }
682
657
  for (const [name, entry] of Object.entries(theirLock.skills)) {
683
658
  if (!ourLock.skills[name]) ourLock.skills[name] = entry;
684
659
  }
685
- if (!dryRun) fs.writeFileSync(ours, JSON.stringify(ourLock, null, 2) + "\n");
660
+ out.push({ file: LOCKFILE, silent: true, write: JSON.stringify(ourLock, null, 2) + "\n" });
661
+ return out;
686
662
  }
687
663
 
664
+ // ---------------------------------------------------------------- applying it
665
+
666
+ // Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
667
+ // whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
668
+ // that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
669
+ // core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
670
+ // hooks would run for whoever installed them and silently never run for anyone else. `git add
671
+ // --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
672
+ // install stages these few files rather than leaving them for the project's own `git add`.
673
+ // Returns why it failed, or null.
674
+ function carryMode(root, file) {
675
+ try { fs.chmodSync(path.join(root, file), 0o755); } catch { /* the filesystem does not do modes */ }
676
+ const r = lib.run("git", [...GIT, "-C", root, "add", "--chmod=+x", "--", file]);
677
+ return r.status === 0 ? null : `could not mark ${file} executable: ${r.output}`;
678
+ }
679
+
680
+ // Carries out one entry. Returns null when it went as planned, or what happened instead: `why` to
681
+ // print, and the `outcome` and `bucket` the run reports in place of the plan's.
682
+ function perform(root, e) {
683
+ const full = path.join(root, e.file);
684
+ if (e.mkdir || e.link !== undefined || e.write !== undefined) fs.mkdirSync(path.dirname(full), { recursive: true });
685
+ if (e.link !== undefined) {
686
+ if (e.replace) fs.unlinkSync(full);
687
+ // Windows needs Developer Mode and core.symlinks=true for this to work at all, so a refusal
688
+ // is reported rather than thrown: the harness still functions with the link missing, it is
689
+ // just invisible to the harnesses that read it, and README says how to turn them on.
690
+ try { fs.symlinkSync(e.link.split("/").join(path.sep), full, "dir"); }
691
+ catch (err) { return { outcome: "yours", bucket: "kept", why: `could not create the symlink ${e.file} -> ${e.link}: ${err.code || err.message}` }; }
692
+ return null;
693
+ }
694
+ if (e.write !== undefined) fs.writeFileSync(full, e.write);
695
+ if (e.exec) { const why = carryMode(root, e.file); if (why) return { why }; }
696
+ return null;
697
+ }
698
+
699
+ // An install rewrites someone else's repository, so it says what it did to every path while it does
700
+ // it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
701
+ // lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
702
+ // skills, and both look installed. A dry run prints the same lines straight from the plan.
703
+ // Returns the entries as they turned out, which the summary is built from.
704
+ function apply(entries, root, options) {
705
+ const done = [];
706
+ for (const e of entries) {
707
+ if (e.phase) { if (!options.quiet) say(`\n${e.phase}`); continue; }
708
+ let shown = e;
709
+ if (!options.dryRun) {
710
+ const fix = perform(root, e);
711
+ if (fix && fix.why) say(fix.why);
712
+ if (fix && fix.outcome) shown = { ...e, outcome: fix.outcome, bucket: fix.bucket };
713
+ }
714
+ if (!shown.silent && !options.quiet) say(` ${shown.policy.padEnd(9)}${shown.mode} ${shown.outcome.padEnd(12)}${shown.file}`);
715
+ done.push(shown);
716
+ }
717
+ return done;
718
+ }
719
+
720
+ // ---------------------------------------------------------------- after it
721
+
688
722
  // Two files nothing copied: the per-harness skill links, which depend on which skills this project
689
723
  // has rather than which the upstream ships, and the third-party notice, which must describe this
690
724
  // project's lock file. Both are generated, so the install leaves a harness that works rather than a
691
- // list of commands to remember.
692
- function finish(target) {
693
- phase("links and notices");
725
+ // list of commands to remember. Each run names the target with --root: the shared resolver prefers a
726
+ // harness's project-dir variable to the checkout a script sits in, and an install started from a
727
+ // session open on another repo would otherwise link and describe that repo instead.
728
+ function finish(target, options) {
729
+ if (!options.quiet) say("\nlinks and notices");
694
730
  for (const [label, args] of [["links", ["relink"]], ["notices", ["notices"]]]) {
695
- const r = lib.node([path.join(target, "scripts/skills.js"), ...args], { cwd: target });
731
+ const r = lib.node([path.join(target, "scripts/skills.js"), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
696
732
  say(r.status === 0 ? r.output : `${label}: ${r.output}`);
697
733
  }
698
734
  }
699
735
 
700
- function report(target, head, ref, base) {
736
+ // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
737
+ // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
738
+ // this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
739
+ // harness invariants, and scripts/check-harness.js holds them as functions of a root.
740
+ //
741
+ // So they run from the upstream checkout against the target, and nothing is written into the target
742
+ // to run them. The upstream's copy rather than the one just installed, so the check is the one that
743
+ // matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
744
+ // scripts, which the upstream's own CI has already done.
745
+ function selfCheck(target, templateDir, options) {
746
+ const script = path.join(templateDir, "scripts", "check-harness.js");
747
+ if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
748
+ if (!options.quiet) say("\nself check: the harness invariants, run from the upstream against this repo");
749
+ const harness = require(script);
750
+ const r = harness.check(target);
751
+ return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
752
+ }
753
+
754
+ // The summary, from the entries as they turned out. Returns the exit code: 1 while anything is left
755
+ // for the reader to act on.
756
+ function report({ entries, base, head, ref, target, check, options }) {
757
+ const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [] };
758
+ for (const e of entries) if (e.bucket) notes[e.bucket].push(e.file);
701
759
  // Every path was named as it happened, so repeating the lists here doubles the output; a quiet
702
760
  // run never saw them and gets them in full. Conflicts are listed either way: they are what the
703
761
  // reader has to act on, and they belong beside the instructions for acting on them.
704
762
  const list = (label, arr, always) => {
705
763
  if (!arr.length) return;
706
- if (quiet || always) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`);
764
+ if (options.quiet || always) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`);
707
765
  else say(`\n${label}: ${arr.length}`);
708
766
  };
709
767
  say("");
710
- say(dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
768
+ say(options.dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
711
769
  if (!base) say("no merge base: this was an install, so nothing that already existed was changed");
712
770
  list("added", notes.written);
713
771
  list("merged", notes.merged);
@@ -729,21 +787,74 @@ function report(target, head, ref, base) {
729
787
  list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
730
788
  say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then check the harness:\n node scripts/check-harness.js`);
731
789
  }
732
- const check = notes.check;
733
790
  if (check && check.skipped) say(`\nself check skipped: ${check.skipped}`);
734
791
  else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
735
792
  else if (check) say(`\nself check: ${check.summary}`);
736
793
 
737
- if (!dryRun) {
794
+ if (!options.dryRun) {
738
795
  say(`\nIn ${target}, point Git at the hooks once per clone, then check the harness:`);
739
796
  say(` node scripts/githooks-init.js && node scripts/check-harness.js`);
740
797
  }
741
- if (notes.conflicted.length || notes.unreadable.length || (check && check.failed)) process.exit(1);
798
+ return notes.conflicted.length || notes.unreadable.length || (check && check.failed) ? 1 : 0;
742
799
  }
743
800
 
744
- // The decision, and the two pure helpers under it, so the suite can put a case in and read the
745
- // answer out rather than building a git checkout to reach one branch. Everything else here writes to
746
- // somebody's repository and stays behind main().
747
- module.exports = { unknownArgs, mistypedArgs, usage, policyFor, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
801
+ // ---------------------------------------------------------------- the run
802
+
803
+ // Returns the exit code, and throws Stop for a run that could not start.
804
+ function main(args) {
805
+ const options = parseOptions(args);
806
+ if (options.help) { console.log(usage()); return 0; }
807
+ const mistyped = mistypedArgs(args);
808
+ if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
809
+ const target = targetRoot(options);
810
+ if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
811
+
812
+ const lockPath = path.join(target, LOCK);
813
+ const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
814
+ const ref = options.ref || (previous ? previous.ref : DEFAULT_REF);
815
+ const { dir: templateDir, temporary } = templateCheckout(ref, options);
748
816
 
749
- if (require.main === module) main();
817
+ try {
818
+ // Checked before anything is said about the target, so a bad argument is the only message.
819
+ const rows = policies(templateDir);
820
+ const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
821
+ const unknown = unknownArgs(args, optional);
822
+ if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
823
+ const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
824
+ if (previous && previous.commit === head && !options.adopt) {
825
+ say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
826
+ return 0;
827
+ }
828
+
829
+ const planned = plan({
830
+ upstream: gitUpstream(templateDir, head), target: fsTarget(target), rows, head, ref, previous, options,
831
+ stamp: { ...installer(), updated: new Date().toISOString().slice(0, 10) },
832
+ });
833
+ for (const notice of planned.notices) say(notice);
834
+ const entries = apply(planned.entries, target, options);
835
+ let check = null;
836
+ if (!options.dryRun) {
837
+ // After the plan is applied, because relink needs the skills in place and the invariants
838
+ // check the links relink has just written.
839
+ finish(target, options);
840
+ if (options.check) check = selfCheck(target, templateDir, options);
841
+ }
842
+ return report({ entries, base: planned.base, head, ref, target, check, options });
843
+ } finally {
844
+ if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
845
+ }
846
+ }
847
+
848
+ // The plan and the decisions under it, so the suite can put a case in and read the answer out rather
849
+ // than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
850
+ // writes nothing; main() writes to somebody's repository and is reached through the command line.
851
+ module.exports = { installerStamp, unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
852
+
853
+ if (require.main === module) {
854
+ try { process.exitCode = main(process.argv.slice(2)); }
855
+ catch (e) {
856
+ if (!(e instanceof Stop)) throw e;
857
+ console.error(`update-harness: ${e.message}`);
858
+ process.exitCode = 1;
859
+ }
860
+ }