@salaros/ai-harness 0.3.5 → 0.4.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
@@ -38,7 +38,17 @@ From the repository's root:
38
38
  npx @salaros/ai-harness
39
39
  ```
40
40
 
41
- The installer adds the harness files and leaves your own work alone: it never writes `README.md` or `LICENSE`, and it adds a folder README only where one is missing. It records the upstream commit it installed in `harness-lock.json`.
41
+ The installer adds the harness files and leaves your own work alone: it never writes `README.md` or `LICENSE`, and it adds a folder README only where one is missing. It records the upstream commit it installed in `harness-lock.json`, then points Git at the harness's hooks in this clone. Other clones run `node scripts/githooks-init.js` once.
42
+
43
+ If the repository already has agent files of its own, the first install keeps them and adds what the harness needs:
44
+
45
+ | File | What the first install does |
46
+ | --- | --- |
47
+ | `AGENTS.md`, `docs/README.md` | Writes the harness's version and appends yours under `## This project`, for you to fold in |
48
+ | `CLAUDE.md` | Adds `@AGENTS.md` at the top if it's missing |
49
+ | `.claude/settings.json` | Merges by key: replaces the harness's hooks, keeps your permissions and hooks (on every update, too) |
50
+ | `.mcp.json` | Adds the harness's MCP servers; yours win where both define one |
51
+ | `.gitignore` | Appends the harness's patterns you don't have, under a comment |
42
52
 
43
53
  Run the same command again to update. Files you haven't edited take the new version, files you have edited keep your changes and gain the new ones, and a real conflict is written with conflict markers and reported.
44
54
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.3.5",
3
+ "version": "0.4.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"
7
7
  },
8
8
  "files": [
9
9
  "scripts/update-harness.js",
10
+ "scripts/install-policy.js",
10
11
  "scripts/lib.js",
11
12
  "scripts/project-facts.js",
12
13
  "scripts/repo-view.js",
package/scripts/README.md CHANGED
@@ -17,6 +17,11 @@ part of the product.
17
17
 
18
18
  - One task per script, named after what it does (`githooks-init.js`,
19
19
  `build.js`, `release.js`); Node, so they run the same on every OS.
20
+ - The scripts are CommonJS. `package.json` here, and its twin in
21
+ `.agents/hooks/`, says so, so a project whose root `package.json` declares
22
+ `"type": "module"` does not turn them into ES modules that fail on their
23
+ first `require`. A script of the project's own that uses `import` is
24
+ named `.mjs`.
20
25
  - Scripts are safe to run from any working directory — every script and hook
21
26
  asks `lib.root()` which repo it is about, and gets one answer: `--root=<dir>`
22
27
  when given, then the harness's project-dir variable
@@ -0,0 +1,318 @@
1
+ // scripts/install-policy.js
2
+ // What an install does with one path the upstream ships: the policies scripts/harness-files.tsv
3
+ // names, each decided here and nowhere else. scripts/update-harness.js walks the paths, reads what a
4
+ // decision needs and applies what it answers; this module reads nothing and writes nothing.
5
+ // const policy = require("./install-policy");
6
+ // const { policy: name, asked } = policy.policyFor(rows, file, wants);
7
+ // const { outcome, bucket, write, silent } = policy.decide(name, facts, reads);
8
+ // `facts` is what the caller already knows about the path; `reads` holds the things a decision may
9
+ // need and costs something to find out, called only when the decision gets that far:
10
+ // facts exists the target has something at the path
11
+ // theirs the upstream's content at the head: LF text, or a Buffer
12
+ // hasBase the receipt names a commit the upstream still has
13
+ // adopt --adopt was given
14
+ // asked the path is an optional part, and the run asked for it
15
+ // reads held() the target's copy, read the way `theirs` is (text or bytes)
16
+ // base() the upstream's content at the receipt's commit, or null
17
+ // shippedBefore() an earlier run laid this path down, so its absence is a deletion
18
+ // recoverBase(ours) the upstream version nearest the target's copy, or null
19
+ // merge(base, ours, theirs) a three-way merge: { text, conflicts, failed }
20
+ // The answer: `outcome` is the word the run prints, `bucket` the summary list the path joins (null
21
+ // for a path nothing happened to), `write` the content to write when there is any, `silent`
22
+ // when the path is counted in the summary but never printed as a line, and `notice` a sentence the
23
+ // run prints before its summary, for a result someone has to finish by hand.
24
+ const lib = require("./lib");
25
+
26
+ // ---------------------------------------------------------------- the manifest
27
+
28
+ // First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
29
+ // `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
30
+ // the docs site is the case, useful to some projects and dead weight in the rest.
31
+ // `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
32
+ // on the process's own argv and a test can ask what a repo would get either way.
33
+ const rowFor = (rows, file) => rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
34
+ function policyFor(rows, file, wants) {
35
+ const row = rowFor(rows, file);
36
+ if (!row) return { policy: "merge", asked: false }; // anything the upstream ships and nobody classified is harness
37
+ if (!row.policy.startsWith("optional:")) return { policy: row.policy, asked: false };
38
+ const asked = wants(row.policy.slice("optional:".length));
39
+ return { policy: asked ? "seed" : "template", asked };
40
+ }
41
+
42
+ // ---------------------------------------------------------------- merging a file the target has
43
+
44
+ // This script's own conflict label, on a line of its own, so prose about conflict markers is not
45
+ // mistaken for one.
46
+ const MARKED = /^<{7} yours\r?$/m;
47
+
48
+ // A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
49
+ // decides which. A logo the project replaced stays replaced.
50
+ function decideBinary({ held, theirs, hasBase, adopt }, { base }) {
51
+ if (lib.sameContent(held, theirs)) return { outcome: "unchanged", bucket: null };
52
+ const was = hasBase ? base() : null;
53
+ if (adopt || lib.sameContent(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", write: theirs };
54
+ return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept" };
55
+ }
56
+
57
+ // A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
58
+ // always LF. The comparison and the merge happen in LF and the result is written back in the endings
59
+ // the file already had, so a Windows checkout does not read as edited from top to bottom.
60
+ function decideText({ policy, raw, theirs, hasBase, adopt }, { base, recoverBase, merge }) {
61
+ const crlf = lib.isCrlf(raw);
62
+ const ours = lib.toLf(raw);
63
+ const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept" };
64
+
65
+ if (ours === theirs) return { outcome: "unchanged", bucket: null };
66
+ // Before the base logic, not inside it: a repo that needs adopting usually has a receipt
67
+ // already, written by the install that kept the stale files in the first place.
68
+ if (adopt) return { outcome: "adopted", bucket: "written", write: lib.asFound(theirs, crlf) };
69
+ // Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
70
+ // its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
71
+ // harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
72
+ // --adopt above throws it away.
73
+ if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted" };
74
+
75
+ // Claude Code's settings are merged by key on every run, base or none: see mergeSettings.
76
+ if (policy === "settings") return decideSettings(ours, theirs, crlf);
77
+
78
+ // A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
79
+ // and holds the table docs-check parses, and docs/README.md says what the chain puts where.
80
+ // Keeping a stale one leaves a repo that looks installed and behaves like the version it came
81
+ // from, so these are merged even when the receipt is missing. Nothing in the upstream's history
82
+ // matching means this copy was written by hand, for a project that had agent instructions before
83
+ // it had the harness: see appendProject.
84
+ let from = hasBase ? base() : null;
85
+ if (from === null && policy === "reconcile") from = recoverBase(ours);
86
+ // A union table merges by row whether or not there is a base, and even an untouched copy goes
87
+ // through that merge: it may hold a row the upstream dropped and the project still needs.
88
+ if (from === null && policy === "union") from = "";
89
+ if (from === null) {
90
+ const own = WITHOUT_BASE[policy];
91
+ const done = own ? own(ours, theirs) : null;
92
+ if (!done) return keep;
93
+ return { ...done, write: lib.asFound(done.write, crlf) };
94
+ }
95
+
96
+ if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", write: lib.asFound(theirs, crlf) };
97
+ const merged = merge(from, ours, theirs);
98
+ if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept" };
99
+ const result = lib.asFound(merged.text, crlf);
100
+ if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", write: result };
101
+ // A file that keeps a local edit merges cleanly on every later run and comes out the same every
102
+ // time. Reported as merged each run it reads as churn, and the reader goes looking for a change
103
+ // nobody made, so what the run did is decided by the result, not the route.
104
+ if (result === raw) return { outcome: "unchanged", bucket: null };
105
+ return { outcome: "merged", bucket: "merged", write: result };
106
+ }
107
+
108
+ // A union table, merged row by row rather than line by line: a row is keyed by its first
109
+ // tab-separated column, and the table is a set of them, so there is nothing to conflict over.
110
+ // The upstream's comments and order come first. Each of its rows is the project's where only the
111
+ // project changed it, or where both did, and the upstream's otherwise; a row the project deleted
112
+ // stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
113
+ // or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
114
+ // here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
115
+ // receipt, and then the project's copy of a row wins.
116
+ function mergeRows(base, ours, theirs) {
117
+ const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
118
+ const was = rows(base), mine = rows(ours), up = rows(theirs);
119
+ const out = [];
120
+ for (const line of theirs.split("\n")) {
121
+ const key = line.split("\t")[0];
122
+ if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
123
+ const o = mine.get(key), b = was.get(key);
124
+ if (o === undefined) { if (b === undefined) out.push(line); continue; }
125
+ out.push(o === b ? line : o);
126
+ }
127
+ const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
128
+ if (!extra.length) return out.join("\n");
129
+ while (out.length && out[out.length - 1] === "") out.pop();
130
+ return [...out, ...extra, ""].join("\n");
131
+ }
132
+ const unionMerge = (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false });
133
+
134
+ // ---------------------------------------------------------------- a file that has no base
135
+
136
+ // What a policy does with a text file the target already has and no upstream version to merge it
137
+ // against: a first install into a project that set up its own agent files, ignore list or MCP
138
+ // servers before it took the harness. Plain merge keeps the file whole, the project's work being
139
+ // the one thing an install must not lose; these policies also bring in the part of the upstream's
140
+ // copy the harness cannot work without. Each answers { outcome, bucket, write, notice? } in LF, or
141
+ // null to keep the file as it is. A later run has the receipt, so each runs once per file.
142
+ const WITHOUT_BASE = {
143
+ reconcile: appendProject,
144
+ import: addImport,
145
+ ignore: appendPatterns,
146
+ keyed: (ours, theirs) => {
147
+ const merged = mergeJson(ours, theirs, (o, t) => mergeKeys(o, t, 2));
148
+ return merged && { outcome: "merged by key", bucket: "merged", write: merged };
149
+ },
150
+ };
151
+
152
+ // The upstream's copy, then the project's under a heading of its own. A whole-file conflict was the
153
+ // earlier answer, and it left the one file every agent reads full of markers, with the run exiting 1
154
+ // on a first install. The harness's text is what the rest of the harness assumes; the project's is
155
+ // what nobody else knows. Both stay, and the notice asks someone to fold the second into the first.
156
+ // The project's headings move down under the new one, so its title does not compete with the file's.
157
+ const PROJECT_HEADING = "## This project";
158
+ function appendProject(ours, theirs) {
159
+ const note = "<!-- ai-harness: this project's own copy of this file, kept from before the harness was installed. Fold what still applies into the sections above, then delete this section. -->";
160
+ return {
161
+ outcome: "yours appended", bucket: "merged",
162
+ write: [theirs.trimEnd(), "", note, PROJECT_HEADING, "", demoteHeadings(ours).trim(), ""].join("\n"),
163
+ notice: `the harness's copy was written with the project's own appended under "${PROJECT_HEADING}": fold what still applies into it`,
164
+ };
165
+ }
166
+
167
+ // Moves every Markdown heading outside a code fence down, so the shallowest lands one level below
168
+ // PROJECT_HEADING. Six stays six: Markdown has nothing deeper.
169
+ function demoteHeadings(text) {
170
+ const lines = text.split("\n");
171
+ let fence = false;
172
+ const level = lines.map(line => {
173
+ if (/^\s*(```|~~~)/.test(line)) { fence = !fence; return 0; }
174
+ const m = !fence && /^(#{1,6})\s/.exec(line);
175
+ return m ? m[1].length : 0;
176
+ });
177
+ const top = Math.min(...level.filter(Boolean));
178
+ if (!Number.isFinite(top) || top >= 3) return text;
179
+ return lines.map((line, i) => level[i] ? "#".repeat(Math.min(6, level[i] + 3 - top)) + line.slice(level[i]) : line).join("\n");
180
+ }
181
+
182
+ // CLAUDE.md is how Claude Code reaches AGENTS.md. A project's own CLAUDE.md, kept whole, leaves Claude
183
+ // reading instructions that never mention the harness, so the import goes on top and the rest stays.
184
+ const IMPORT = "@AGENTS.md";
185
+ function addImport(ours) {
186
+ if (ours.split("\n").some(l => l.trim() === IMPORT)) return null;
187
+ return { outcome: "import added", bucket: "merged", write: `${IMPORT}\n\n${ours}` };
188
+ }
189
+
190
+ // A .gitignore is a set of patterns, so the upstream's that this one lacks go at the end, under a
191
+ // comment saying where they came from; nothing of the project's moves. A pattern the project
192
+ // negates is its decision, and stays unlisted.
193
+ function appendPatterns(ours, theirs) {
194
+ const have = new Set(ours.split("\n").map(l => l.trim()));
195
+ const missing = [...new Set(theirs.split("\n").map(l => l.trim()))]
196
+ .filter(l => l && !l.startsWith("#") && !have.has(l) && !have.has("!" + l));
197
+ if (!missing.length) return null;
198
+ const note = "# Added by the ai-harness install: the harness's patterns this file did not have.";
199
+ return { outcome: "patterns appended", bucket: "merged", write: [ours.trimEnd(), "", note, ...missing, ""].join("\n") };
200
+ }
201
+
202
+ // ---------------------------------------------------------------- JSON merged by key
203
+
204
+ const isObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
205
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
206
+
207
+ // The project's value wins at every key both have; a key only the upstream has is added. Objects are
208
+ // merged down to `depth` levels, below which the project's value is taken whole: an MCP server the
209
+ // project configured is its own, not a blend of two. Arrays are a union, the project's order first.
210
+ function mergeKeys(ours, theirs, depth = Infinity) {
211
+ if (depth > 0 && isObject(ours) && isObject(theirs)) {
212
+ const out = { ...ours };
213
+ for (const [k, v] of Object.entries(theirs)) out[k] = k in ours ? mergeKeys(ours[k], v, depth - 1) : v;
214
+ return out;
215
+ }
216
+ if (Array.isArray(ours) && Array.isArray(theirs)) return [...ours, ...theirs.filter(t => !ours.some(o => same(o, t)))];
217
+ return ours;
218
+ }
219
+
220
+ // Both texts parsed and merged; null when either is not JSON or the merge adds nothing. The result is
221
+ // the upstream's own text when it comes out equal to it, so the upstream's layout survives.
222
+ function mergeJson(ours, theirs, merge) {
223
+ let o, t;
224
+ try { o = JSON.parse(ours); t = JSON.parse(theirs); } catch { return null; }
225
+ const merged = merge(o, t);
226
+ if (same(merged, o)) return null;
227
+ return same(merged, t) ? theirs : JSON.stringify(merged, null, 2) + "\n";
228
+ }
229
+
230
+ // .claude/settings.json holds the harness's hook launchers beside whatever the project set up: its
231
+ // permissions, its own hooks, its environment. A line merge of that JSON either keeps a stale
232
+ // launcher or conflicts over a brace, so it is merged by key on every run, receipt or none. Every
233
+ // hook whose command runs a script in .agents/hooks/ is the harness's, and is replaced by the
234
+ // upstream's current set; every other key and hook is the project's and stays.
235
+ const HARNESS_HOOK = /\.agents\/hooks\//;
236
+ function mergeSettings(ours, theirs) {
237
+ const merged = mergeKeys(ours, theirs);
238
+ const hooks = {};
239
+ for (const [event, groups] of Object.entries(isObject(ours.hooks) ? ours.hooks : {})) {
240
+ if (!Array.isArray(groups)) { hooks[event] = groups; continue; }
241
+ hooks[event] = groups.map(g => isObject(g) && Array.isArray(g.hooks)
242
+ ? { ...g, hooks: g.hooks.filter(h => !(isObject(h) && typeof h.command === "string" && HARNESS_HOOK.test(h.command))) }
243
+ : g).filter(g => !isObject(g) || !Array.isArray(g.hooks) || g.hooks.length);
244
+ }
245
+ for (const [event, groups] of Object.entries(isObject(theirs.hooks) ? theirs.hooks : {}))
246
+ hooks[event] = [...(Array.isArray(hooks[event]) ? hooks[event] : []), ...groups];
247
+ for (const event of Object.keys(hooks)) if (Array.isArray(hooks[event]) && !hooks[event].length) delete hooks[event];
248
+ if (Object.keys(hooks).length || "hooks" in ours) merged.hooks = hooks;
249
+ return merged;
250
+ }
251
+
252
+ // A project's settings that are not JSON are left for someone to read; the upstream's always are.
253
+ function decideSettings(ours, theirs, crlf) {
254
+ try { JSON.parse(ours); } catch { return { outcome: "yours, not JSON", bucket: "kept" }; }
255
+ const merged = mergeJson(ours, theirs, mergeSettings);
256
+ if (merged === null) return { outcome: "unchanged", bucket: null };
257
+ return { outcome: "merged by key", bucket: "merged", write: lib.asFound(merged, crlf) };
258
+ }
259
+
260
+ // ---------------------------------------------------------------- the merging policies
261
+
262
+ // merge, reconcile, union, import, ignore, keyed and settings: a file the target lacks is written,
263
+ // and one it has is merged.
264
+ function merging(policy) {
265
+ return (facts, reads) => {
266
+ if (!facts.exists) return { outcome: "written", bucket: "written", write: facts.theirs };
267
+ const held = reads.held();
268
+ if (Buffer.isBuffer(facts.theirs)) return decideBinary({ ...facts, held }, reads);
269
+ // A union table has no lines to conflict over, and no base means the project's rows win.
270
+ return decideText({ ...facts, policy, raw: held }, policy === "union" ? { ...reads, merge: unionMerge } : reads);
271
+ };
272
+ }
273
+
274
+ // ---------------------------------------------------------------- laying a file down once
275
+
276
+ // What the project deleted stays deleted. Seed files and skeletons both go down once and are never
277
+ // touched again, so a missing one is either new to this run or one the project removed, and
278
+ // shippedBefore() tells the two apart: for a seed file, the receipt's commit shipped it; for a
279
+ // skeleton, the receipt lists it.
280
+ // TODO: an existing seed file joins the "kept" summary list and an existing skeleton joins none;
281
+ // the two differ only because they grew apart, and unifying them changes what a run reports.
282
+ function layDown(existing) {
283
+ return ({ exists, theirs, asked }, { shippedBefore }) => {
284
+ if (exists) return existing;
285
+ // An optional part is the exception: its flag is the project asking for it now, whatever an
286
+ // earlier run left out.
287
+ if (!asked && shippedBefore()) return { outcome: "deleted here", bucket: null };
288
+ return { outcome: "created", bucket: "seeded", write: theirs };
289
+ };
290
+ }
291
+
292
+ // ---------------------------------------------------------------- the policies
293
+
294
+ const POLICIES = {
295
+ merge: merging("merge"),
296
+ reconcile: merging("reconcile"),
297
+ union: merging("union"),
298
+ import: merging("import"),
299
+ ignore: merging("ignore"),
300
+ keyed: merging("keyed"),
301
+ settings: merging("settings"),
302
+ seed: layDown({ outcome: "yours", bucket: "kept" }),
303
+ skeleton: layDown({ outcome: "yours", bucket: null }),
304
+ // Reported only when the target actually has it: "left alone, yours" about a file the repo does
305
+ // not have names something that was never there.
306
+ skip: ({ exists }) => exists ? { outcome: "yours", bucket: "skipped" } : { outcome: "absent", bucket: null },
307
+ // Not installed anywhere, and named in one line of the summary instead: sixty-five lines saying
308
+ // nothing happened bury the thirty-eight saying something did.
309
+ template: () => ({ outcome: "template", bucket: "template", silent: true }),
310
+ };
311
+
312
+ function decide(policy, facts, reads = {}) {
313
+ const rule = POLICIES[policy];
314
+ if (!rule) throw new Error(`install-policy: no policy named "${policy}"`);
315
+ return rule(facts, reads);
316
+ }
317
+
318
+ module.exports = { policyFor, decide };
package/scripts/lib.js CHANGED
@@ -11,6 +11,8 @@
11
11
  // lib.node(["scripts/skills.js", "missing"]) // run a script with this node; { status, output }
12
12
  // lib.shell("npm install") // run a command through the OS shell
13
13
  // lib.readTsv("scripts/stacks.tsv") // rows as arrays of cells; blank and # lines skipped
14
+ // lib.toLf(text), lib.asFound(text, crlf) // compare in LF, write back in the endings a file had
15
+ // lib.sameContent(a, b) // equal text, or equal bytes; never text against bytes
14
16
  const fs = require("fs");
15
17
  const path = require("path");
16
18
  const { spawnSync } = require("child_process");
@@ -84,4 +86,21 @@ function readTsv(file) {
84
86
  .map(l => l.split("\t"));
85
87
  }
86
88
 
87
- module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv };
89
+ // Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
90
+ // the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
91
+ // touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
92
+ // a comparison or a merge happens in LF, and the result is written back in the endings the file
93
+ // already had.
94
+ const CRLF = /\r\n/g;
95
+ const LF = /\n/g;
96
+ const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
97
+ const toLf = text => text.replace(CRLF, "\n");
98
+ const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
99
+
100
+ // One test for text and bytes, so a caller comparing a blob against what is on disk does not have
101
+ // to know which it got.
102
+ const sameContent = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
103
+ ? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
104
+ : a === b;
105
+
106
+ module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv, isCrlf, toLf, asFound, sameContent };
@@ -34,6 +34,7 @@ const path = require("path");
34
34
  const lib = require("./lib");
35
35
  const projectFacts = require("./project-facts");
36
36
  const repoView = require("./repo-view");
37
+ const installPolicy = require("./install-policy");
37
38
  const { spawnSync } = require("child_process");
38
39
 
39
40
  const TEMPLATE = "https://github.com/salaros/ai-harness.git";
@@ -198,12 +199,6 @@ function blob(dir, commit, file) {
198
199
  return r.stdout.includes(0) ? r.stdout : r.stdout.toString("utf8");
199
200
  }
200
201
 
201
- // One test for both, so a caller comparing what blob returned against what is on disk does not have
202
- // to know which it got.
203
- const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
204
- ? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
205
- : a === b;
206
-
207
202
  // ---------------------------------------------------------------- the adapters
208
203
  //
209
204
  // What plan() reads, and nothing more: the upstream at any commit, and the target as it stands. A
@@ -335,19 +330,6 @@ function policies(templateDir) {
335
330
  return lib.readTsv(path.join(templateDir, MANIFEST)).map(([p, policy]) => ({ path: p, policy }));
336
331
  }
337
332
 
338
- // First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
339
- // `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
340
- // the docs site is the case, useful to some projects and dead weight in the rest.
341
- // `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
342
- // on the process's own argv and a test can ask what a repo would get either way.
343
- const rowFor = (rows, file) => rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
344
- function policyFor(rows, file, wants) {
345
- const row = rowFor(rows, file);
346
- if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
347
- if (!row.policy.startsWith("optional:")) return row.policy;
348
- return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
349
- }
350
-
351
333
  // ---------------------------------------------------------------- skeletons
352
334
 
353
335
  // Three files the upstream does not ship, because there they would be lies: MEMORY.md
@@ -428,108 +410,6 @@ function settleDropped(text) {
428
410
  return { text: out, conflicts: conflicts > 0, failed: false };
429
411
  }
430
412
 
431
- // Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
432
- // the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
433
- // touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
434
- // the comparison and the merge happen in LF, and the result is written back in the endings the file
435
- // already had.
436
- const CRLF = /\r\n/g;
437
- const LF = /\n/g;
438
- // This script's own conflict label, on a line of its own, so prose about conflict markers is not
439
- // mistaken for one.
440
- const MARKED = /^<{7} yours\r?$/m;
441
- const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
442
- const toLf = text => text.replace(CRLF, "\n");
443
- const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
444
-
445
- // ---------------------------------------------------------------- the decision
446
- //
447
- // What happens to one file the target already has, decided apart from doing it. Everything these two
448
- // read is an argument, including the three things they cannot compute -- the base's text, the search
449
- // for a base when the receipt has none, and the three-way merge itself -- so plan() passes them in.
450
- //
451
- // Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
452
- // belongs in (null for a file nothing happened to), and `text` what to write, or null to write
453
- // nothing.
454
-
455
- // A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
456
- // decides which. A logo the project replaced stays replaced.
457
- function decideBinary({ held, theirs, hasBase, adopt }, { baseBytes }) {
458
- if (same(held, theirs)) return { outcome: "unchanged", bucket: null, text: null };
459
- const was = hasBase ? baseBytes() : null;
460
- if (adopt || same(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", text: theirs };
461
- return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept", text: null };
462
- }
463
-
464
- // A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
465
- // always LF. The comparison and the merge happen in LF and the result is written back in the endings
466
- // the file already had, so a Windows checkout does not read as edited from top to bottom.
467
- function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recoverBase, merge }) {
468
- const crlf = isCrlf(raw);
469
- const ours = toLf(raw);
470
- const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept", text: null };
471
-
472
- if (ours === theirs) return { outcome: "unchanged", bucket: null, text: null };
473
- // Before the base logic, not inside it: a repo that needs adopting usually has a receipt
474
- // already, written by the install that kept the stale files in the first place.
475
- if (adopt) return { outcome: "adopted", bucket: "written", text: asFound(theirs, crlf) };
476
- // Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
477
- // its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
478
- // harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
479
- // --adopt above throws it away.
480
- if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted", text: null };
481
-
482
- // A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
483
- // and holds the table docs-check parses, and docs/README.md says what the chain puts where.
484
- // Keeping a stale one leaves a repo that looks installed and behaves like the version it came
485
- // from, so these are merged even when the receipt is missing. Nothing in the upstream's history
486
- // matching means this copy was written by hand, and an empty base makes the whole file one
487
- // conflict -- the honest answer: both versions are there to read, and the run exits 1.
488
- let from = hasBase ? baseText() : null;
489
- if (from === null && policy === "reconcile") from = recoverBase(ours);
490
- if (from === null && policy === "reconcile") from = "";
491
- // A union table merges by row whether or not there is a base, and even an untouched copy goes
492
- // through that merge: it may hold a row the upstream dropped and the project still needs.
493
- if (from === null && policy === "union") from = "";
494
- if (from === null) return keep;
495
-
496
- if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
497
- const merged = merge(from, ours, theirs);
498
- if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
499
- const result = asFound(merged.text, crlf);
500
- if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", text: result };
501
- // A file that keeps a local edit merges cleanly on every later run and comes out the same every
502
- // time. Reported as merged each run it reads as churn, and the reader goes looking for a change
503
- // nobody made, so what the run did is decided by the result, not the route.
504
- if (result === raw) return { outcome: "unchanged", bucket: null, text: null };
505
- return { outcome: "merged", bucket: "merged", text: result };
506
- }
507
-
508
- // A union table, merged row by row rather than line by line: a row is keyed by its first
509
- // tab-separated column, and the table is a set of them, so there is nothing to conflict over.
510
- // The upstream's comments and order come first. Each of its rows is the project's where only the
511
- // project changed it, or where both did, and the upstream's otherwise; a row the project deleted
512
- // stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
513
- // or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
514
- // here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
515
- // receipt, and then the project's copy of a row wins.
516
- function mergeRows(base, ours, theirs) {
517
- const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
518
- const was = rows(base), mine = rows(ours), up = rows(theirs);
519
- const out = [];
520
- for (const line of theirs.split("\n")) {
521
- const key = line.split("\t")[0];
522
- if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
523
- const o = mine.get(key), b = was.get(key);
524
- if (o === undefined) { if (b === undefined) out.push(line); continue; }
525
- out.push(o === b ? line : o);
526
- }
527
- const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
528
- if (!extra.length) return out.join("\n");
529
- while (out.length && out[out.length - 1] === "") out.pop();
530
- return [...out, ...extra, ""].join("\n");
531
- }
532
-
533
413
  // ---------------------------------------------------------------- the plan
534
414
  //
535
415
  // Everything a run will do to the target, decided before anything is written: an install rewrites
@@ -589,7 +469,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
589
469
  add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
590
470
  for (const entry of files) {
591
471
  const { file, link: isLink, exec } = entry;
592
- const policy = policyFor(rows, file, options.wants);
472
+ const { policy, asked } = installPolicy.policyFor(rows, file, options.wants);
593
473
  const m = mode(entry);
594
474
  const theirs = upstream.blob(head, file);
595
475
  // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
@@ -604,10 +484,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
604
484
  const line = (outcome, bucket, act = {}) =>
605
485
  add({ file, policy, mode: m, outcome, bucket, ...act, exec: exec && (exists || act.write !== undefined) });
606
486
 
607
- // Not installed anywhere, and named in one line of the summary instead: sixty-five lines
608
- // saying nothing happened bury the thirty-eight saying something did.
609
- if (policy === "template") { line("template", "template", { silent: true }); continue; }
610
- if (isLink) {
487
+ if (isLink && policy !== "template") {
611
488
  // A skill link is relink's to make, once the directory it lives in exists: it knows which
612
489
  // skills this project actually has, where the upstream only knows its own.
613
490
  if (policy === "skills") { add({ file, mkdir: true, silent: true }); continue; }
@@ -625,34 +502,17 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
625
502
  // Reported one line per skill by planSkills below, not one per reference file: a skill is the
626
503
  // unit a project installs, and its files run to several hundred.
627
504
  if (policy === "skills") { skills.push(entry); continue; }
628
- // Reported only when the target actually has it: "left alone, yours" about a file the repo
629
- // does not have names something that was never there.
630
- if (policy === "skip") { line(exists ? "yours" : "absent", exists ? "skipped" : null); continue; }
631
- if (policy === "seed") {
632
- // A seed file the recorded commit already shipped was laid down then, so its absence is
633
- // the project deleting it, and it stays deleted. An optional part is the exception: its
634
- // flag is the project asking for it now, whatever an earlier run left out.
635
- const asked = (rowFor(rows, file) || { policy: "" }).policy.startsWith("optional:");
636
- if (exists) line("yours", "kept");
637
- else if (!asked && base !== null && upstream.blob(base, file) !== null) line("deleted here", null);
638
- else line("created", "seeded", { write: theirs });
639
- continue;
640
- }
641
- // merge and reconcile
642
- if (!exists) { line("written", "written", { write: theirs }); continue; }
643
- const hasBase = base !== null;
644
- const held = target.read(file, Buffer.isBuffer(theirs));
645
- const { outcome, bucket, text } = Buffer.isBuffer(theirs)
646
- ? decideBinary({ held, theirs, hasBase, adopt: options.adopt }, { baseBytes: () => upstream.blob(base, file) })
647
- : decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
648
- baseText: () => upstream.blob(base, file),
505
+ const { outcome, bucket, notice, ...act } = installPolicy.decide(policy,
506
+ { exists, theirs, hasBase: base !== null, adopt: options.adopt, asked }, {
507
+ held: () => target.read(file, Buffer.isBuffer(theirs)),
508
+ base: () => upstream.blob(base, file),
509
+ // A seed file the recorded commit already shipped was laid down then.
510
+ shippedBefore: () => base !== null && upstream.blob(base, file) !== null,
649
511
  recoverBase: ours => recoverBase(upstream, file, ours),
650
- // A union table has no lines to conflict over, and no base means the project's rows win.
651
- merge: policy === "union"
652
- ? (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false })
653
- : threeWay,
512
+ merge: threeWay,
654
513
  });
655
- line(outcome, bucket, text === null ? {} : { write: text });
514
+ if (notice) notices.push(`${file}: ${notice}`);
515
+ line(outcome, bucket, act);
656
516
  }
657
517
 
658
518
  add({ phase: "skeletons a project starts with" });
@@ -662,15 +522,19 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
662
522
  // list is taken to know them all, which every install since the skeletons began has laid down.
663
523
  const known = new Set(previous && base !== null ? previous.skeletons || Object.keys(SKELETONS) : []);
664
524
  for (const [file, lines] of Object.entries(SKELETONS)) {
665
- if (target.exists(file)) add({ file, policy: "seed", mode: "100644", outcome: "yours", bucket: null });
666
- else if (known.has(file)) add({ file, policy: "seed", mode: "100644", outcome: "deleted here", bucket: null });
667
- else add({ file, policy: "seed", mode: "100644", outcome: "created", bucket: "seeded", write: skeletonLines(file, lines, hasIntent).join("\n") });
525
+ const theirs = skeletonLines(file, lines, hasIntent).join("\n");
526
+ const decision = installPolicy.decide("skeleton", { exists: target.exists(file), theirs, asked: false }, { shippedBefore: () => known.has(file) });
527
+ add({ file, policy: "seed", mode: "100644", ...decision });
668
528
  }
669
529
 
670
530
  add({ phase: "skills, merged by name" });
671
531
  entries.push(...planSkills(upstream, target, head, skills));
672
532
 
673
- add({ file: LOCK, silent: true, write: JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp, skeletons: Object.keys(SKELETONS) }, null, 2) + "\n" });
533
+ // A list of strings on one line, as Prettier writes it: a project formatting its JSON with it
534
+ // would otherwise reject the receipt at every push, and the next update would undo the fix.
535
+ const receipt = JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp, skeletons: Object.keys(SKELETONS) }, null, 2)
536
+ .replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g, (all, items) => `[${items.split(/,\n\s+/).join(", ")}]`);
537
+ add({ file: LOCK, silent: true, write: receipt + "\n" });
674
538
  return { entries, notices, base };
675
539
  }
676
540
 
@@ -706,12 +570,12 @@ function planSkills(upstream, target, head, files) {
706
570
  const held = exists ? target.read(file, true) : null;
707
571
  let write;
708
572
  if (Buffer.isBuffer(text)) {
709
- if (same(held, text)) continue;
573
+ if (lib.sameContent(held, text)) continue;
710
574
  write = text;
711
575
  } else {
712
576
  const ourText = held === null ? null : held.toString("utf8");
713
- if (ourText !== null && toLf(ourText) === text) continue;
714
- write = asFound(text, ourText !== null && isCrlf(ourText));
577
+ if (ourText !== null && lib.toLf(ourText) === text) continue;
578
+ write = lib.asFound(text, ourText !== null && lib.isCrlf(ourText));
715
579
  }
716
580
  if (exists) tally.updated++; else tally.added++;
717
581
  out.push({ file, silent: true, write, bucket: exists ? "merged" : "written", exec: exec && !exists });
@@ -785,18 +649,25 @@ function apply(entries, root, options) {
785
649
 
786
650
  // ---------------------------------------------------------------- after it
787
651
 
788
- // Two files nothing copied: the per-harness skill links, which depend on which skills this project
789
- // has rather than which the upstream ships, and the third-party notice, which must describe this
790
- // project's lock file. Both are generated, so the install leaves a harness that works rather than a
791
- // list of commands to remember. Each run names the target with --root: the shared resolver prefers a
792
- // harness's project-dir variable to the checkout a script sits in, and an install started from a
793
- // session open on another repo would otherwise link and describe that repo instead.
652
+ // First the Git hooks, pointed at .githooks/ before anything else: an install that stopped at a
653
+ // printed reminder left clones whose hooks never ran, and nothing says so -- Git skips a hooks
654
+ // folder it was never told about in silence. Then two files nothing copied: the per-harness skill
655
+ // links, which depend on which skills this project has rather than which the upstream ships, and the
656
+ // third-party notice, which must describe this project's lock file. All three are generated, so the
657
+ // install leaves a harness that works rather than a list of commands to remember. Each run names the
658
+ // target with --root: the shared resolver prefers a harness's project-dir variable to the checkout a
659
+ // script sits in, and an install started from a session open on another repo would otherwise wire,
660
+ // link and describe that repo instead. Returns whether the hooks were wired.
794
661
  function finish(target, options) {
795
- if (!options.quiet) say("\nlinks and notices");
796
- for (const [label, args] of [["links", ["relink"]], ["notices", ["notices"]]]) {
797
- const r = lib.node([path.join(target, "scripts/skills.js"), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
662
+ if (!options.quiet) say("\nGit hooks, links and notices");
663
+ const steps = [["git hooks", "scripts/githooks-init.js", []], ["links", "scripts/skills.js", ["relink"]], ["notices", "scripts/skills.js", ["notices"]]];
664
+ let hooks = true;
665
+ for (const [label, script, args] of steps) {
666
+ const r = lib.node([path.join(target, script), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
798
667
  say(r.status === 0 ? r.output : `${label}: ${r.output}`);
668
+ if (label === "git hooks" && r.status !== 0) hooks = false;
799
669
  }
670
+ return hooks;
800
671
  }
801
672
 
802
673
  // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
@@ -819,7 +690,7 @@ function selfCheck(target, templateDir, options) {
819
690
 
820
691
  // The summary, from the entries as they turned out. Returns the exit code: 1 while anything is left
821
692
  // for the reader to act on.
822
- function report({ entries, base, head, ref, target, check, options }) {
693
+ function report({ entries, base, head, ref, target, check, hooks = true, options }) {
823
694
  const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [] };
824
695
  for (const e of entries) if (e.bucket) notes[e.bucket].push(e.file);
825
696
  // Every path was named as it happened, so repeating the lists here doubles the output; a quiet
@@ -832,7 +703,7 @@ function report({ entries, base, head, ref, target, check, options }) {
832
703
  };
833
704
  say("");
834
705
  say(options.dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
835
- if (!base) say("no merge base: this was an install, so nothing that already existed was changed");
706
+ if (!base) say("no merge base: this was an install, so a file already here was kept, or given only the part the harness needs");
836
707
  list("written", notes.written);
837
708
  list("merged", notes.merged);
838
709
  list("created for the first time", notes.seeded);
@@ -857,11 +728,10 @@ function report({ entries, base, head, ref, target, check, options }) {
857
728
  else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
858
729
  else if (check) say(`\nself check: ${check.summary}`);
859
730
 
860
- if (!options.dryRun) {
861
- say(`\nIn ${target}, point Git at the hooks once per clone, then check the harness:`);
862
- say(` node scripts/githooks-init.js && node scripts/check-harness.js`);
863
- }
864
- return notes.conflicted.length || notes.unreadable.length || (check && check.failed) ? 1 : 0;
731
+ // Git hooks are wired per clone: this one was wired above, and every other clone runs the script once.
732
+ if (!options.dryRun && !hooks) say(`\nGIT HOOKS NOT WIRED: in ${target}, run node scripts/githooks-init.js`);
733
+ if (!options.dryRun) say(`\nEvery other clone of ${target} wires its Git hooks once with: node scripts/githooks-init.js`);
734
+ return notes.conflicted.length || notes.unreadable.length || (check && check.failed) || !hooks ? 1 : 0;
865
735
  }
866
736
 
867
737
  // ---------------------------------------------------------------- the run
@@ -906,13 +776,14 @@ function main(args) {
906
776
  for (const notice of planned.notices) say(notice);
907
777
  const entries = apply(planned.entries, target, options);
908
778
  let check = null;
779
+ let hooks = true;
909
780
  if (!options.dryRun) {
910
781
  // After the plan is applied, because relink needs the skills in place and the invariants
911
782
  // check the links relink has just written.
912
- finish(target, options);
783
+ hooks = finish(target, options);
913
784
  if (options.check) check = selfCheck(target, templateDir, options);
914
785
  }
915
- return report({ entries, base: planned.base, head, ref, target, check, options });
786
+ return report({ entries, base: planned.base, head, ref, target, check, hooks, options });
916
787
  } finally {
917
788
  if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
918
789
  }
@@ -921,7 +792,7 @@ function main(args) {
921
792
  // The plan and the decisions under it, so the suite can put a case in and read the answer out rather
922
793
  // than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
923
794
  // writes nothing; main() writes to somebody's repository and is reached through the command line.
924
- module.exports = { installerStamp, upToDate, mergeRows, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
795
+ module.exports = { installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, lineCounts, overlap, NEAREST, skeletonLines };
925
796
 
926
797
  if (require.main === module) {
927
798
  try { process.exitCode = main(process.argv.slice(2)); }