@salaros/ai-harness 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ If the repository already has agent files of its own, the first install keeps th
45
45
  | File | What the first install does |
46
46
  | --- | --- |
47
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 |
48
+ | `CLAUDE.md` | Adds `@AGENTS.md` at the top if it's missing (on every update, too) |
49
49
  | `.claude/settings.json` | Merges by key: replaces the harness's hooks, keeps your permissions and hooks (on every update, too) |
50
50
  | `.mcp.json` | Adds the harness's MCP servers; yours win where both define one |
51
51
  | `.gitignore` | Appends the harness's patterns you don't have, under a comment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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"
@@ -11,6 +11,7 @@
11
11
  "scripts/lib.js",
12
12
  "scripts/project-facts.js",
13
13
  "scripts/repo-view.js",
14
+ "scripts/repo-edit.js",
14
15
  "README.md",
15
16
  "LICENSE"
16
17
  ],
@@ -141,7 +141,6 @@ const unionMerge = (from, ours, up) => ({ text: mergeRows(from, ours, up), confl
141
141
  // null to keep the file as it is. A later run has the receipt, so each runs once per file.
142
142
  const WITHOUT_BASE = {
143
143
  reconcile: appendProject,
144
- import: addImport,
145
144
  ignore: appendPatterns,
146
145
  keyed: (ours, theirs) => {
147
146
  const merged = mergeJson(ours, theirs, (o, t) => mergeKeys(o, t, 2));
@@ -179,12 +178,18 @@ function demoteHeadings(text) {
179
178
  return lines.map((line, i) => level[i] ? "#".repeat(Math.min(6, level[i] + 3 - top)) + line.slice(level[i]) : line).join("\n");
180
179
  }
181
180
 
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.
181
+ // CLAUDE.md is how Claude Code reaches AGENTS.md, so its import line is not something to merge: it is
182
+ // there or the harness is unreachable, whatever else the file says. The line is added on top of
183
+ // whatever the rest of the decision left, which covers both ways it goes missing: a project that
184
+ // wrote its own CLAUDE.md before it had the harness, and one that has a receipt but dropped the line
185
+ // since. Adding it to a file left full of conflict markers helps nobody, so that one is passed
186
+ // through; the run is already exiting 1 over it.
184
187
  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
+ function ensureImport(done, raw, crlf) {
189
+ if (done.bucket === "conflicted") return done;
190
+ const text = lib.toLf(done.write === undefined ? raw : done.write);
191
+ if (text.split("\n").some(l => l.trim() === IMPORT)) return done;
192
+ return { outcome: "import added", bucket: "merged", write: lib.asFound(`${IMPORT}\n\n${text}`, crlf) };
188
193
  }
189
194
 
190
195
  // A .gitignore is a set of patterns, so the upstream's that this one lacks go at the end, under a
@@ -267,7 +272,8 @@ function merging(policy) {
267
272
  const held = reads.held();
268
273
  if (Buffer.isBuffer(facts.theirs)) return decideBinary({ ...facts, held }, reads);
269
274
  // 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);
275
+ const done = decideText({ ...facts, policy, raw: held }, policy === "union" ? { ...reads, merge: unionMerge } : reads);
276
+ return policy === "import" ? ensureImport(done, held, lib.isCrlf(held)) : done;
271
277
  };
272
278
  }
273
279
 
package/scripts/lib.js CHANGED
@@ -11,6 +11,7 @@
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.parseTsv(text) // the same, for a caller that already has the text
14
15
  // lib.toLf(text), lib.asFound(text, crlf) // compare in LF, write back in the endings a file had
15
16
  // lib.sameContent(a, b) // equal text, or equal bytes; never text against bytes
16
17
  const fs = require("fs");
@@ -80,11 +81,13 @@ function run(cmd, args, opts = {}) {
80
81
  const node = (args, opts) => run(process.execPath, args, opts);
81
82
  const shell = (cmd, opts) => run(cmd, [], { shell: true, ...opts });
82
83
 
83
- function readTsv(file) {
84
- return fs.readFileSync(file, "utf8").split(/\r?\n/)
85
- .filter(l => l.trim() && !l.startsWith("#"))
86
- .map(l => l.split("\t"));
87
- }
84
+ // The rows of a tab-separated table, blank and # lines skipped. parseTsv takes the text, for a
85
+ // caller that already has it -- one reading through a repo-view, which hands out text rather than
86
+ // paths -- and readTsv is the same thing for a caller holding a filename.
87
+ const parseTsv = text => text.split(/\r?\n/)
88
+ .filter(l => l.trim() && !l.startsWith("#"))
89
+ .map(l => l.split("\t"));
90
+ const readTsv = file => parseTsv(fs.readFileSync(file, "utf8"));
88
91
 
89
92
  // Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
90
93
  // the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
@@ -103,4 +106,4 @@ const sameContent = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
103
106
  ? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
104
107
  : a === b;
105
108
 
106
- module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv, isCrlf, toLf, asFound, sameContent };
109
+ module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv, parseTsv, isCrlf, toLf, asFound, sameContent };
@@ -0,0 +1,140 @@
1
+ // scripts/repo-edit.js
2
+ // A repo as a run changes it: the mirror of repo-view.js. An install is files in and files out, and
3
+ // this is the "out" -- every write the installer makes to somebody else's repository, in one
4
+ // implementation behind one substitutable adapter.
5
+ // apply(entries) carry out a finished plan, and return what became of it
6
+ // Each entry that asks for work gets one result:
7
+ // { file, kind, done, why }
8
+ // `kind` is write, link, mkdir or mark; `done` is whether the tree now holds what the entry asked
9
+ // for; `why` is the mechanical reason it does not, or null. No outcome word and no summary bucket
10
+ // appears here: those are the install policy's, and the run translates these results into them.
11
+ // Nothing here prints or exits, so a refusal is a value the caller reads rather than a message a
12
+ // user has to be watching for.
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { spawnSync } = require("child_process");
16
+ const repoView = require("./repo-view");
17
+
18
+ // core.longpaths, because a vendored skill tree nests deeper than Windows' default limit.
19
+ const git = (root, args) => spawnSync("git", ["-c", "core.longpaths=true", "-C", root, ...args], { encoding: "utf8" });
20
+
21
+ const result = (file, kind, why) => ({ file, kind, done: !why, why: why || null });
22
+
23
+ // What an entry asks to have done, or null when it asks for nothing: a phase heading, or a path the
24
+ // plan decided to leave exactly as it found it.
25
+ function work(e) {
26
+ if (e.link !== undefined) return "link";
27
+ if (e.write !== undefined) return "write";
28
+ if (e.mkdir) return "mkdir";
29
+ if (e.exec) return "mark";
30
+ return null;
31
+ }
32
+
33
+ // The common body: the ordering and the reporting, over whatever actually touches the repo.
34
+ // An entry may ask for two things at once -- the harness's hooks are written and then marked
35
+ // executable -- so the mark is part of whatever the entry chiefly asked for rather than a result of
36
+ // its own, and a caller still gets one line per entry to print. A write that failed is not then
37
+ // marked: there is nothing there to mark.
38
+ function editing(act) {
39
+ return {
40
+ apply(entries) {
41
+ const out = [];
42
+ for (const e of entries) {
43
+ const kind = work(e);
44
+ if (!kind) continue;
45
+ let why = kind === "mark" ? null : act[kind](e);
46
+ if (!why && e.exec) why = act.mark(e);
47
+ out.push(result(e.file, kind, why));
48
+ }
49
+ return out;
50
+ },
51
+ };
52
+ }
53
+
54
+ // The same entry, as a map holds it once it is executable.
55
+ const asExec = held => {
56
+ const spec = held && typeof held === "object" && !Buffer.isBuffer(held) ? held : { bytes: held };
57
+ return { ...spec, exec: true };
58
+ };
59
+
60
+ // The entries applied to a map, with the resulting tree readable as a view. A check says what the
61
+ // repo held before and asserts on what it holds after, with no directory in between.
62
+ // `links: false` stands for a target the platform will not let anyone make a symlink in, which is
63
+ // every Windows checkout without Developer Mode. It is a shape a target really has, not a lever for
64
+ // a test: the case it makes reachable is the one the suite could otherwise only run on a machine
65
+ // that happens to refuse.
66
+ function mapEdit(files = {}, { links = true } = {}) {
67
+ const held = { ...files };
68
+ const marked = [];
69
+ const view = () => repoView.fromMap(held);
70
+ return {
71
+ ...editing({
72
+ write: e => { held[e.file] = e.write; return null; },
73
+ link: e => {
74
+ if (!links) return `could not create the symlink ${e.file} -> ${e.link}: EPERM`;
75
+ held[e.file] = { link: e.link };
76
+ return null;
77
+ },
78
+ // A map holds files, and a folder in it is whatever a path implies, so there is nothing
79
+ // to make: the entry is satisfied the moment anything is written under it.
80
+ mkdir: () => null,
81
+ mark: e => {
82
+ const row = view().modes().find(r => r.file === e.file);
83
+ if (!row) return `nothing at ${e.file} to mark executable`;
84
+ if (row.exec) return null;
85
+ held[e.file] = asExec(held[e.file]);
86
+ marked.push(e.file);
87
+ return null;
88
+ },
89
+ }),
90
+ view,
91
+ marked: () => [...marked],
92
+ };
93
+ }
94
+
95
+ // The files on disk under `root`. W-2 is this adapter's whole reason for existing: a parent folder
96
+ // is made before anything is written into it and whatever is in a link's way is removed before the
97
+ // link is made, so a caller orders its entries for a reader rather than for the filesystem.
98
+ function worktreeEdit(root) {
99
+ const at = rel => path.resolve(root, String(rel));
100
+ const parent = rel => fs.mkdirSync(path.dirname(at(rel)), { recursive: true });
101
+ const clear = rel => { try { fs.unlinkSync(at(rel)); } catch { /* nothing was in the way */ } };
102
+ const marked = [];
103
+ // Git runs a hook only if it is executable and says nothing when it is not, so an installed
104
+ // harness whose hooks are 644 looks installed and gates nothing. The upstream records them
105
+ // 100755 and only Git can carry that: `chmod` alone is not enough, because on Windows
106
+ // core.fileMode is false and the call does nothing, leaving the file to be staged 100644 later
107
+ // and the hooks to run for whoever installed them and silently never for anyone else.
108
+ // Which makes the index, not the disk, where "already marked" is true or false. Asking the disk
109
+ // on Windows -- where the filesystem has no executable bit at all -- would mark every hook on
110
+ // every run. A root with no index to read answers no, which marks: the safe way round.
111
+ const alreadyExec = file => (repoView.indexModes(root, [file]) || []).some(r => r.file === file && r.exec);
112
+ return {
113
+ ...editing({
114
+ write: e => { parent(e.file); fs.writeFileSync(at(e.file), e.write); return null; },
115
+ mkdir: e => { fs.mkdirSync(at(e.file), { recursive: true }); return null; },
116
+ link: e => {
117
+ parent(e.file);
118
+ clear(e.file);
119
+ // Windows needs Developer Mode and core.symlinks=true for this to work at all, so a
120
+ // refusal is a result rather than a throw: the harness still functions with the link
121
+ // missing, it is just invisible to the agent harnesses that read it.
122
+ try { fs.symlinkSync(e.link.split("/").join(path.sep), at(e.file), "dir"); }
123
+ catch (err) { return `could not create the symlink ${e.file} -> ${e.link}: ${err.code || err.message}`; }
124
+ return null;
125
+ },
126
+ mark: e => {
127
+ if (alreadyExec(e.file)) return null;
128
+ try { fs.chmodSync(at(e.file), 0o755); } catch { /* the filesystem does not do modes */ }
129
+ const r = git(root, ["add", "--chmod=+x", "--", e.file]);
130
+ if (r.status !== 0) return `could not mark ${e.file} executable: ${(r.stderr || "").trim()}`;
131
+ marked.push(e.file);
132
+ return null;
133
+ },
134
+ }),
135
+ view: () => repoView.worktree(root),
136
+ marked: () => [...marked],
137
+ };
138
+ }
139
+
140
+ module.exports = { worktreeEdit, mapEdit };
@@ -1,12 +1,24 @@
1
1
  // scripts/repo-view.js
2
- // A repo as a check reads it: four questions about repo-relative paths, answered from the working
2
+ // A repo as a check reads it: a handful of questions about repo-relative paths, answered from the working
3
3
  // tree, from Git's index, or from a map a test builds. A check that takes a view reads what a
4
4
  // commit will record as easily as what is on disk, and a test hands it the files it is about
5
5
  // instead of building a tree or pointing it at a file that does not exist.
6
6
  // exists(rel) a file, or a folder holding one
7
7
  // isFile(rel) a file
8
8
  // read(rel) its text, or null when there is none
9
+ // bytes(rel) its bytes, or null when there is none
9
10
  // list(rel) the names directly inside a folder, sorted; [] when there is none
11
+ // lstat(rel) { link } for whatever is at the path, or null when nothing is: `link` is where a
12
+ // symlink points, with forward slashes, and null for anything that is not one
13
+ // modes() every path the view holds, sorted, as { file, mode, object, link, exec }
14
+ // recorded(paths) the same rows, for what a commit would record under `paths`, or null when
15
+ // nothing records them. The disk asks Git's index, because Windows has no executable
16
+ // bit and a hook's mode is only ever a fact about the index; a view that is already
17
+ // a record -- a map, an index, a commit -- answers with its own rows.
18
+ // bytes() is a question of its own rather than a flag on read(), so that a view holding text answers
19
+ // read() honestly and has to be handed real bytes to answer bytes(). A flag is what the installer's
20
+ // own stand-in had, and it satisfied the flag by re-encoding its text: every branch production takes
21
+ // for a blob with a zero byte in it was unreachable from the suite for as long as that lasted.
10
22
  // Paths are repo-relative with forward slashes. Nothing here writes, prints or exits.
11
23
  // indexModes() is the one reader of `git ls-files -s`: the mode Git records is how the harness
12
24
  // tells a symlink (120000) and an executable (100755) from a plain file.
@@ -14,8 +26,16 @@ const fs = require("fs");
14
26
  const path = require("path");
15
27
  const { spawnSync } = require("child_process");
16
28
 
29
+ // quotepath, so a path outside ASCII comes back as itself rather than as escapes. longpaths, because
30
+ // Windows stops at 260 characters and a vendored skill tree in a repo a few folders down the drive
31
+ // crosses it: `git show <commit>:<path>` stats the working copy on the way past, and without this it
32
+ // answers that a file the tree plainly holds is not there.
33
+ const CONFIG = ["-c", "core.quotepath=off", "-c", "core.longpaths=true"];
17
34
  // stdout alone and untrimmed, since a blob's trailing newline is part of its text.
18
- const git = (dir, args) => spawnSync("git", ["-c", "core.quotepath=off", ...args], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
35
+ const git = (dir, args) => spawnSync("git", [...CONFIG, ...args], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
36
+ // The same, with stdout left as bytes: a blob is read this way and decoded only if someone asks for
37
+ // its text, since a decode a file never had is not recoverable afterwards.
38
+ const gitBytes = (dir, args) => spawnSync("git", [...CONFIG, ...args], { cwd: dir, maxBuffer: 256 * 1024 * 1024 });
19
39
 
20
40
  const norm = rel => path.posix.normalize(String(rel).split(path.sep).join("/")).replace(/^\.\/?$|\/+$/g, "");
21
41
 
@@ -31,26 +51,50 @@ function indexModes(dir, paths = []) {
31
51
  });
32
52
  }
33
53
 
34
- // A view over a set of files known up front, each read on first use. Folders are whatever the
35
- // paths imply, which is all an index or a map has of them.
36
- function filesView(loaders) {
54
+ // One entry of such a set: the mode Git records, the blob it recorded when there is one, and a
55
+ // loader for the content, called on first use. Written this way round because modes() has to answer
56
+ // for every path without reading a single blob: an index knows a file's mode from `ls-files -s`
57
+ // long before anyone asks what is in it, and a listing that read them all would make the cheapest
58
+ // question in the module the most expensive.
59
+ const entry = (mode, object, load) => ({ mode, object, load });
60
+
61
+ // A view over a set of entries known up front. Folders are whatever the paths imply, which is all
62
+ // an index or a map has of them. A loader gives text or bytes, whichever its source holds; read()
63
+ // and bytes() convert what they were given rather than what they wish for. A 120000 entry loads the
64
+ // path it points at, which is what Git keeps in its blob, so a source with no filesystem still
65
+ // answers the question the installer asks of a link.
66
+ function filesView(entries) {
37
67
  const cache = new Map();
38
68
  const under = rel => rel ? rel + "/" : "";
39
- return {
40
- exists: rel => { rel = norm(rel); return loaders.has(rel) || [...loaders.keys()].some(f => f.startsWith(under(rel))); },
41
- isFile: rel => loaders.has(norm(rel)),
42
- read: rel => {
43
- rel = norm(rel);
44
- if (!loaders.has(rel)) return null;
45
- if (!cache.has(rel)) cache.set(rel, loaders.get(rel)());
46
- return cache.get(rel);
69
+ const held = rel => {
70
+ rel = norm(rel);
71
+ if (!entries.has(rel)) return null;
72
+ if (!cache.has(rel)) cache.set(rel, entries.get(rel).load());
73
+ return cache.get(rel);
74
+ };
75
+ const view = {
76
+ exists: rel => { rel = norm(rel); return entries.has(rel) || [...entries.keys()].some(f => f.startsWith(under(rel))); },
77
+ isFile: rel => entries.has(norm(rel)),
78
+ read: rel => { const v = held(rel); return v === null ? null : Buffer.isBuffer(v) ? v.toString("utf8") : v; },
79
+ bytes: rel => { const v = held(rel); return v === null ? null : Buffer.isBuffer(v) ? v : Buffer.from(v, "utf8"); },
80
+ lstat: rel => {
81
+ if (!view.exists(rel)) return null;
82
+ const e = entries.get(norm(rel));
83
+ return { link: e && e.mode === "120000" ? view.read(rel) : null };
47
84
  },
48
85
  list: rel => {
49
86
  const prefix = under(norm(rel));
50
- const names = new Set([...loaders.keys()].filter(f => f.startsWith(prefix)).map(f => f.slice(prefix.length).split("/")[0]));
87
+ const names = new Set([...entries.keys()].filter(f => f.startsWith(prefix)).map(f => f.slice(prefix.length).split("/")[0]));
51
88
  return [...names].sort();
52
89
  },
90
+ modes: () => [...entries.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
91
+ .map(([file, e]) => ({ file, mode: e.mode, object: e.object, link: e.mode === "120000", exec: e.mode === "100755" })),
92
+ // A set of entries is already a record of itself -- a map, an index, a commit -- so there is
93
+ // nothing to go and ask. Scoped to `paths`, which is all the caller wanted the index for.
94
+ recorded: (paths = []) => view.modes().filter(r => !paths.length
95
+ || paths.map(norm).some(p => r.file === p || r.file.startsWith(p + "/"))),
53
96
  };
97
+ return view;
54
98
  }
55
99
 
56
100
  // The files on disk under `root`. A path outside it resolves as it stands, which is what a cited
@@ -62,7 +106,33 @@ function worktree(root) {
62
106
  exists: rel => !!stat(rel),
63
107
  isFile: rel => !!(stat(rel) || { isFile: () => false }).isFile(),
64
108
  read: rel => { const s = stat(rel); return s && s.isFile() ? fs.readFileSync(at(rel), "utf8") : null; },
109
+ bytes: rel => { const s = stat(rel); return s && s.isFile() ? fs.readFileSync(at(rel)) : null; },
65
110
  list: rel => { const s = stat(rel); return s && s.isDirectory() ? fs.readdirSync(at(rel)).sort() : []; },
111
+ // lstatSync, not statSync: a link pointing nowhere is still something in the way, and a link
112
+ // pointing somewhere must not be mistaken for what it points at.
113
+ lstat(rel) {
114
+ let s;
115
+ try { s = fs.lstatSync(at(rel)); } catch { return null; }
116
+ return { link: s.isSymbolicLink() ? fs.readlinkSync(at(rel)).split(path.sep).join("/") : null };
117
+ },
118
+ // The tree walked from the root, .git excepted. There is no blob to name, because nothing
119
+ // here has been recorded yet; on Windows there is no executable bit either, which is why
120
+ // the harness asks the index and not the disk what mode a hook was committed with.
121
+ modes() {
122
+ const walk = (rel) => fs.readdirSync(at(rel) || root, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)
123
+ .flatMap(d => {
124
+ const file = rel ? `${rel}/${d.name}` : d.name;
125
+ if (d.isDirectory()) return d.name === ".git" ? [] : walk(file);
126
+ const mode = d.isSymbolicLink() ? "120000"
127
+ : ((fs.lstatSync(at(file)).mode & 0o111) ? "100755" : "100644");
128
+ return [{ file, mode, object: null, link: mode === "120000", exec: mode === "100755" }];
129
+ });
130
+ return fs.existsSync(root) ? walk("") : [];
131
+ },
132
+ // The disk records nothing itself, so this is Git's index and not the walk above: the two
133
+ // differ exactly where it matters, since Windows has no executable bit and stages one all
134
+ // the same. null when there is no index to ask, which is not an empty one.
135
+ recorded: (paths = []) => indexModes(root, paths),
66
136
  };
67
137
  }
68
138
 
@@ -73,11 +143,55 @@ function index(root, paths = []) {
73
143
  const rows = indexModes(root, paths);
74
144
  if (!rows) throw new Error(`could not read the git index in ${root}`);
75
145
  const blob = object => {
76
- const r = git(root, ["cat-file", "blob", object]);
77
- if (r.status !== 0) throw new Error(`could not read blob ${object}: ${r.stderr}`);
146
+ const r = gitBytes(root, ["cat-file", "blob", object]);
147
+ if (r.status !== 0) throw new Error(`could not read blob ${object}: ${r.stderr.toString("utf8")}`);
148
+ return r.stdout;
149
+ };
150
+ return filesView(new Map(rows.map(row =>
151
+ [row.file, entry(row.mode, row.object, () => row.link ? blob(row.object).toString("utf8") : blob(row.object))])));
152
+ }
153
+
154
+ // Every blob of one tree, read in two calls rather than a `git show` per path. An install reads
155
+ // every file of the upstream's head, and a process per file made a first install take most of a
156
+ // minute on Windows. null when the batch could not be read, which leaves each path to the fallback.
157
+ function treeBlobs(root, rows) {
158
+ const r = spawnSync("git", ["-c", "core.quotepath=off", "cat-file", "--batch"],
159
+ { cwd: root, input: rows.map(e => e.object).join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
160
+ if (r.status !== 0) return null;
161
+ const blobs = new Map();
162
+ let at = 0;
163
+ for (const { file } of rows) {
164
+ const eol = r.stdout.indexOf(10, at);
165
+ const size = Number(r.stdout.toString("utf8", at, eol).split(" ")[2]);
166
+ blobs.set(file, Buffer.from(r.stdout.subarray(eol + 1, eol + 1 + size)));
167
+ at = eol + 1 + size + 1;
168
+ }
169
+ return blobs;
170
+ }
171
+
172
+ // One commit of the checkout at `root`: what it recorded, whatever the working tree has done since.
173
+ // This is how the upstream is read during an install -- the commit the receipt names, not the
174
+ // checkout that happens to be on disk. Throws when the commit cannot be read, for the same reason
175
+ // index() does: a commit nobody could read is not an empty one.
176
+ // A submodule is not a blob, and is left out, as `git show` would fail on it too.
177
+ function commit(root, sha) {
178
+ const listing = gitBytes(root, ["ls-tree", "-r", "-z", sha]);
179
+ if (listing.status !== 0) throw new Error(`could not read the commit ${sha} in ${root}`);
180
+ const rows = listing.stdout.toString("utf8").split("\0").filter(Boolean).map(line => {
181
+ const tab = line.indexOf("\t");
182
+ const [mode, type, object] = line.slice(0, tab).split(" ");
183
+ return { mode, type, object, file: line.slice(tab + 1) };
184
+ }).filter(e => e.type === "blob");
185
+
186
+ let batch; // read whole on first use, and only if anyone asks
187
+ const content = row => {
188
+ if (batch === undefined) batch = treeBlobs(root, rows);
189
+ if (batch && batch.has(row.file)) return batch.get(row.file);
190
+ const r = gitBytes(root, ["show", `${sha}:${row.file}`]);
191
+ if (r.status !== 0) throw new Error(`could not read ${row.file} at ${sha}: ${r.stderr.toString("utf8")}`);
78
192
  return r.stdout;
79
193
  };
80
- return filesView(new Map(rows.map(row => [row.file, () => blob(row.object)])));
194
+ return filesView(new Map(rows.map(row => [row.file, entry(row.mode, row.object, () => content(row))])));
81
195
  }
82
196
 
83
197
  // What a commit will record for `paths`, and the working tree for everything else. Each of `paths`
@@ -91,11 +205,27 @@ function staged(root, paths) {
91
205
  exists: rel => pick(rel).exists(rel),
92
206
  isFile: rel => pick(rel).isFile(rel),
93
207
  read: rel => pick(rel).read(rel),
208
+ bytes: rel => pick(rel).bytes(rel),
94
209
  list: rel => pick(rel).list(rel),
210
+ lstat: rel => pick(rel).lstat(rel),
211
+ // The index's, not a mix of the two: a mode is a question about what a commit will record,
212
+ // and the disk has no answer to it that this view would rather give.
213
+ modes: () => idx.modes(),
214
+ recorded: (within = []) => idx.recorded(within),
95
215
  };
96
216
  }
97
217
 
98
- // The files a test names, path -> text, and nothing else.
99
- const fromMap = files => filesView(new Map(Object.entries(files).map(([f, text]) => [norm(f), () => text])));
218
+ // The files a test names, and nothing else. An entry is the content -- text or bytes -- or, when the
219
+ // case is about a mode rather than a content, `{ link }` for a symlink and `{ text|bytes, exec }`
220
+ // for an executable. That is the whole of what a map has to say, so a case about a link in the way
221
+ // or a mode already correct needs no checkout and no platform that has an executable bit.
222
+ function fromMap(files) {
223
+ return filesView(new Map(Object.entries(files).map(([f, held]) => {
224
+ const spec = held && typeof held === "object" && !Buffer.isBuffer(held) ? held : { bytes: held };
225
+ const content = "link" in spec ? spec.link : "text" in spec ? spec.text : spec.bytes;
226
+ const mode = "link" in spec ? "120000" : spec.exec ? "100755" : "100644";
227
+ return [norm(f), entry(mode, null, () => content)];
228
+ })));
229
+ }
100
230
 
101
- module.exports = { worktree, index, staged, fromMap, indexModes };
231
+ module.exports = { worktree, index, commit, staged, fromMap, indexModes };
@@ -34,8 +34,8 @@ 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 repoEdit = require("./repo-edit");
37
38
  const installPolicy = require("./install-policy");
38
- const { spawnSync } = require("child_process");
39
39
 
40
40
  const TEMPLATE = "https://github.com/salaros/ai-harness.git";
41
41
  const LOCK = "harness-lock.json";
@@ -121,7 +121,7 @@ const say = m => console.log(m);
121
121
  function targetRoot(options) {
122
122
  if (options.target) return path.resolve(options.target);
123
123
  const beside = path.resolve(__dirname, "..");
124
- return fs.existsSync(path.join(beside, ".git")) ? beside : process.cwd();
124
+ return repoView.worktree(beside).exists(".git") ? beside : process.cwd();
125
125
  }
126
126
 
127
127
  // ---------------------------------------------------------------- the upstream
@@ -171,117 +171,68 @@ function installer() {
171
171
  const home = path.resolve(__dirname, "..");
172
172
  let pkg = {};
173
173
  try { pkg = JSON.parse(fs.readFileSync(path.join(home, "package.json"), "utf8")); } catch { return {}; }
174
- const described = at(home, ["describe", "--tags", "--exact-match", "HEAD"]);
174
+ const described = git(home, ["describe", "--tags", "--exact-match", "HEAD"]);
175
175
  return installerStamp({ name: pkg.name, version: pkg.version, tag: described.status === 0 ? described.output.trim() : null });
176
176
  }
177
177
 
178
- // Windows stops at 260 characters for a path, and the harness ships skill files nested deep enough
179
- // that a project a few folders down the drive crosses it. Git then fails to stat the working copy
180
- // while resolving <commit>:<path>, so `git show` says the file is not there and the installer skips
181
- // it -- a skill missing seven of its reference files, and not a word said about it. Setting
182
- // core.longpaths on every call is what makes those paths reachable, and it costs nothing anywhere
183
- // else.
184
- const GIT = ["-c", "core.longpaths=true"];
185
- const at = (dir, args) => lib.run("git", [...GIT, "-C", dir, ...args]);
186
-
187
- // The upstream's version of a path at a commit, or null when the file did not exist there.
188
- // Read raw rather than through lib.run, which trims trailing whitespace: that is right for the
189
- // plumbing whose output is a hash or a status line, and wrong for a file. Trimmed, every installed
190
- // file lost its final newline, no copy was ever byte-identical to the upstream, and so every later
191
- // run re-merged files nobody had touched.
192
- // Text comes back as a string and anything holding a NUL byte as the Buffer it arrived in, which is
193
- // how Git itself tells the two apart. Decoded as UTF-8 and written back, every byte a PNG holds
194
- // outside ASCII becomes U+FFFD: the skill's logo installs as a broken image, and no later run ever
195
- // agrees with the upstream about it. Nothing merges a Buffer; it is written whole or kept whole.
196
- function blob(dir, commit, file) {
197
- const r = spawnSync("git", [...GIT, "-C", dir, "show", `${commit}:${file}`], { maxBuffer: 256 * 1024 * 1024 });
198
- if (r.status !== 0) return null;
199
- return r.stdout.includes(0) ? r.stdout : r.stdout.toString("utf8");
200
- }
178
+ // The commit graph, which is all the installer runs git for itself now that repo-view reads the
179
+ // files. core.longpaths for the same reason repo-view sets it: Windows stops at 260 characters, and
180
+ // the harness ships skill files nested deep enough that a project a few folders down the drive
181
+ // crosses it.
182
+ const git = (dir, args) => lib.run("git", ["-c", "core.longpaths=true", "-C", dir, ...args]);
201
183
 
202
184
  // ---------------------------------------------------------------- the adapters
203
185
  //
204
- // What plan() reads, and nothing more: the upstream at any commit, and the target as it stands. A
205
- // real run backs them with the upstream's git checkout and the target's directory; the suite backs
206
- // them with maps, so a whole install is a table row rather than a clone and a temp tree.
207
-
208
- // Every blob in one commit, read in two calls rather than one `git show` per path: an install reads
209
- // every file at the head, and a process per file made a first install take most of a minute on
210
- // Windows. Keyed by path, holding what blob() would return; a submodule entry is not a blob and is
211
- // left out, as `git show` would fail on it too.
212
- function treeBlobs(dir, commit) {
213
- const listing = spawnSync("git", [...GIT, "-C", dir, "ls-tree", "-r", "-z", commit], { maxBuffer: 64 * 1024 * 1024 });
214
- if (listing.status !== 0) return null;
215
- const entries = listing.stdout.toString("utf8").split("\0").filter(Boolean)
216
- .map(line => { const tab = line.indexOf("\t"); const [, type, oid] = line.slice(0, tab).split(" "); return { type, oid, file: line.slice(tab + 1) }; })
217
- .filter(e => e.type === "blob");
218
- const r = spawnSync("git", [...GIT, "-C", dir, "cat-file", "--batch"],
219
- { input: entries.map(e => e.oid).join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
220
- if (r.status !== 0) return null;
221
- const blobs = new Map();
222
- let at = 0;
223
- for (const { file } of entries) {
224
- const eol = r.stdout.indexOf(10, at);
225
- const size = Number(r.stdout.toString("utf8", at, eol).split(" ")[2]);
226
- const bytes = r.stdout.subarray(eol + 1, eol + 1 + size);
227
- blobs.set(file, bytes.includes(0) ? Buffer.from(bytes) : bytes.toString("utf8"));
228
- at = eol + 1 + size + 1;
229
- }
230
- return blobs;
186
+ // What plan() reads: the upstream at any commit, and the target as it stands. Both are repo-view
187
+ // adapters -- scripts/repo-view.js -- so a real run reads a checkout and a commit while the suite
188
+ // reads a map, through one implementation rather than two. That is the whole point of the seam: the
189
+ // stand-ins this file used to carry drifted, and the branch taken for a file with a NUL byte in it
190
+ // was unreachable from the suite for as long as a stand-in answered a bytes question with a string.
191
+ //
192
+ // What repo-view will not do is guess which of the two a caller wants, so the guess is made here.
193
+
194
+ // The upstream's version of a path, as the installer needs it: text as a string, and anything
195
+ // holding a NUL byte as the Buffer it arrived in, which is how Git itself tells the two apart.
196
+ // Decoded as UTF-8 and written back, every byte a PNG holds outside ASCII becomes U+FFFD: the
197
+ // skill's logo installs as a broken image, and no later run ever agrees with the upstream about it.
198
+ // Nothing merges a Buffer; it is written whole or kept whole.
199
+ // null when the commit has no such path, and equally when the checkout will not give it up: Git
200
+ // listed it a moment ago, so a read that fails is the checkout being unhappy rather than the file
201
+ // being absent, and plan() reports that as UNREADABLE rather than letting it end the run.
202
+ function contentOf(view, file) {
203
+ let bytes;
204
+ try { bytes = view.bytes(file); } catch { return null; }
205
+ if (bytes === null) return null;
206
+ return bytes.includes(0) ? bytes : bytes.toString("utf8");
231
207
  }
232
208
 
233
- function gitUpstream(dir, head) {
234
- let atHead;
209
+ // The upstream checkout: a view per commit, and the two questions only the commit graph can answer.
210
+ // Views are kept, because plan() asks the head for every path and a base for every file it merges;
211
+ // each one reads its tree once and its blobs in a single batch on first use.
212
+ function gitUpstream(dir) {
213
+ const views = new Map();
235
214
  return {
236
- // Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and
237
- // the harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill
238
- // under .claude/skills. Written as ordinary files they become text files holding a path,
239
- // which is how a harness ends up looking installed while the agent sees no skills at all.
240
- files() {
241
- const rows = repoView.indexModes(dir);
242
- if (!rows) fail(`could not list the upstream's files in ${dir}`);
243
- return rows.map(({ file, link, exec }) => ({ file, link, exec }));
244
- },
245
- // The head is read whole on first use; any other commit, which only a base or a base search
246
- // asks for, one path at a time.
247
- blob(commit, file) {
248
- if (commit !== head) return blob(dir, commit, file);
249
- if (atHead === undefined) atHead = treeBlobs(dir, head);
250
- if (atHead === null) return blob(dir, commit, file);
251
- return atHead.has(file) ? atHead.get(file) : null;
215
+ at(sha) {
216
+ if (!views.has(sha)) views.set(sha, repoView.commit(dir, sha));
217
+ return views.get(sha);
252
218
  },
253
219
  // A rewritten history no longer holds the recorded commit, which leaves the run without a base.
254
- hasCommit: commit => at(dir, ["cat-file", "-e", `${commit}^{commit}`]).status === 0,
220
+ has: sha => git(dir, ["cat-file", "-e", `${sha}^{commit}`]).status === 0,
255
221
  // The commits that touched a path, newest first.
256
222
  history(file) {
257
- const r = at(dir, ["log", "--format=%H", "--", file]);
223
+ const r = git(dir, ["log", "--format=%H", "--", file]);
258
224
  return r.status === 0 ? r.output.split(/\r?\n/).filter(Boolean) : [];
259
225
  },
260
226
  };
261
227
  }
262
228
 
263
- function fsTarget(root) {
264
- const full = file => path.join(root, file);
265
- return {
266
- exists: file => fs.existsSync(full(file)),
267
- // A Buffer when asked for bytes, text otherwise.
268
- read: (file, binary) => binary ? fs.readFileSync(full(file)) : fs.readFileSync(full(file), "utf8"),
269
- // null when nothing is there; otherwise whether it is a symlink, and where it points.
270
- lstat(file) {
271
- let s;
272
- try { s = fs.lstatSync(full(file)); } catch { return null; }
273
- return s.isSymbolicLink() ? { link: fs.readlinkSync(full(file)).split(path.sep).join("/") } : { link: null };
274
- },
275
- };
276
- }
277
-
278
229
  // The receipt is missing, so the base is found instead: the upstream version this copy is closest to
279
230
  // is where the project forked from, whatever a receipt would have said. An exact match is the clean
280
231
  // case, an older copy nobody touched; a project that has since edited its own file matches nothing
281
232
  // exactly, so the nearest version by shared lines stands in as the base. That turns a first install
282
233
  // into a real three-way merge for the files that need one, rather than one whole-file conflict.
283
- // Only reconcile-policy files pay for the search: one git log, then a blob read per commit that
284
- // touched the path.
234
+ // Only reconcile-policy files pay for the search: one git log, then a view per commit that touched
235
+ // the path.
285
236
  // Under half the lines in common is a different file, not an older one, and merging against it would
286
237
  // invent a diff the project never made.
287
238
  const NEAREST = 0.5;
@@ -293,7 +244,7 @@ function recoverBase(upstream, file, ours) {
293
244
  // same against a copy that has neither, and the older of them is the one whose merge puts that
294
245
  // line back. The newer would drop it silently, which is the failure this policy exists to stop.
295
246
  for (const commit of upstream.history(file).reverse()) {
296
- const text = upstream.blob(commit, file);
247
+ const text = contentOf(upstream.at(commit), file);
297
248
  if (typeof text !== "string") continue;
298
249
  if (text === ours) return text;
299
250
  const shared = overlap(want, lineCounts(text));
@@ -424,7 +375,6 @@ function settleDropped(text) {
424
375
  // mkdir create the path's folder and nothing else
425
376
  // silent counted in the summary, never printed as a line
426
377
  // and a { phase } entry heads each section of the output.
427
- const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
428
378
  const SKILLS = ".agents/skills/";
429
379
 
430
380
  // `previous` is the target's harness-lock.json, or null; `stamp` is what the receipt records about
@@ -438,7 +388,8 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
438
388
  // or an upstream whose history was rewritten -- an existing file is left alone instead of being
439
389
  // guessed at, and the run says so.
440
390
  let base = previous ? previous.commit : null;
441
- if (base && !upstream.hasCommit(base)) {
391
+ const atHead = upstream.at(head);
392
+ if (base && !upstream.has(base)) {
442
393
  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`);
443
394
  base = null;
444
395
  }
@@ -464,14 +415,16 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
464
415
  : `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.`);
465
416
  }
466
417
 
467
- const files = upstream.files();
418
+ // The mode Git recorded, rather than one worked out from the entry: a view answers for every path
419
+ // it holds without reading a blob, and 120000 against 100755 against 100644 is the whole of what
420
+ // the mode column says.
421
+ const files = atHead.modes();
468
422
  const skills = [];
469
423
  add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
470
424
  for (const entry of files) {
471
- const { file, link: isLink, exec } = entry;
425
+ const { file, link: isLink, exec, mode: m } = entry;
472
426
  const { policy, asked } = installPolicy.policyFor(rows, file, options.wants);
473
- const m = mode(entry);
474
- const theirs = upstream.blob(head, file);
427
+ const theirs = contentOf(atHead, file);
475
428
  // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
476
429
  // rather than the file being absent. Said out loud: skipped quietly, the run reports a clean
477
430
  // install of a harness missing whichever files the reader was never told about.
@@ -504,10 +457,11 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
504
457
  if (policy === "skills") { skills.push(entry); continue; }
505
458
  const { outcome, bucket, notice, ...act } = installPolicy.decide(policy,
506
459
  { 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,
460
+ held: () => Buffer.isBuffer(theirs) ? target.bytes(file) : target.read(file),
461
+ base: () => contentOf(upstream.at(base), file),
462
+ // A seed file the recorded commit already shipped was laid down then. isFile, not a
463
+ // read: whether a commit holds a path is a question its listing already answers.
464
+ shippedBefore: () => base !== null && upstream.at(base).isFile(file),
511
465
  recoverBase: ours => recoverBase(upstream, file, ours),
512
466
  merge: threeWay,
513
467
  });
@@ -528,7 +482,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
528
482
  }
529
483
 
530
484
  add({ phase: "skills, merged by name" });
531
- entries.push(...planSkills(upstream, target, head, skills));
485
+ entries.push(...planSkills(atHead, target, skills));
532
486
 
533
487
  // A list of strings on one line, as Prettier writes it: a project formatting its JSON with it
534
488
  // would otherwise reject the receipt at every push, and the next update would undo the fix.
@@ -541,10 +495,10 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
541
495
  // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
542
496
  // project vendored itself is never removed. skills-lock.json is the union, the project's entry
543
497
  // winning where both name the same skill, so a project that pinned a different source keeps it.
544
- function planSkills(upstream, target, head, files) {
498
+ function planSkills(atHead, target, files) {
545
499
  const LOCKFILE = "skills-lock.json";
546
- const theirLock = JSON.parse(upstream.blob(head, LOCKFILE) || '{"skills":{}}');
547
- const ourLock = target.exists(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
500
+ const theirLock = JSON.parse(atHead.read(LOCKFILE) || '{"skills":{}}');
501
+ const ourLock = target.isFile(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
548
502
  ourLock.skills = ourLock.skills || {};
549
503
  const mine = new Set(Object.keys(ourLock.skills));
550
504
 
@@ -563,11 +517,11 @@ function planSkills(upstream, target, head, files) {
563
517
  if (exec && exists) out.push({ file, silent: true, exec: true });
564
518
  // A skill the project installed under a name the upstream also uses stays the project's.
565
519
  if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
566
- const text = upstream.blob(head, file);
520
+ const text = contentOf(atHead, file);
567
521
  if (text === null) continue;
568
522
  // A vendored file the project has not touched still differs byte-for-byte on Windows, where
569
523
  // Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
570
- const held = exists ? target.read(file, true) : null;
524
+ const held = exists ? target.bytes(file) : null;
571
525
  let write;
572
526
  if (Buffer.isBuffer(text)) {
573
527
  if (lib.sameContent(held, text)) continue;
@@ -593,53 +547,32 @@ function planSkills(upstream, target, head, files) {
593
547
 
594
548
  // ---------------------------------------------------------------- applying it
595
549
 
596
- // Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
597
- // whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
598
- // that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
599
- // core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
600
- // hooks would run for whoever installed them and silently never run for anyone else. `git add
601
- // --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
602
- // install stages these few files rather than leaving them for the project's own `git add`.
603
- // Returns why it failed, or null.
604
- function carryMode(root, file) {
605
- try { fs.chmodSync(path.join(root, file), 0o755); } catch { /* the filesystem does not do modes */ }
606
- const r = lib.run("git", [...GIT, "-C", root, "add", "--chmod=+x", "--", file]);
607
- return r.status === 0 ? null : `could not mark ${file} executable: ${r.output}`;
608
- }
609
-
610
- // Carries out one entry. Returns null when it went as planned, or what happened instead: `why` to
611
- // print, and the `outcome` and `bucket` the run reports in place of the plan's.
612
- function perform(root, e) {
613
- const full = path.join(root, e.file);
614
- if (e.mkdir || e.link !== undefined || e.write !== undefined) fs.mkdirSync(path.dirname(full), { recursive: true });
615
- if (e.link !== undefined) {
616
- if (e.replace) fs.unlinkSync(full);
617
- // Windows needs Developer Mode and core.symlinks=true for this to work at all, so a refusal
618
- // is reported rather than thrown: the harness still functions with the link missing, it is
619
- // just invisible to the harnesses that read it, and README says how to turn them on.
620
- try { fs.symlinkSync(e.link.split("/").join(path.sep), full, "dir"); }
621
- catch (err) { return { outcome: "yours", bucket: "kept", why: `could not create the symlink ${e.file} -> ${e.link}: ${err.code || err.message}` }; }
622
- return null;
623
- }
624
- if (e.write !== undefined) fs.writeFileSync(full, e.write);
625
- if (e.exec) { const why = carryMode(root, e.file); if (why) return { why }; }
626
- return null;
627
- }
628
-
629
550
  // An install rewrites someone else's repository, so it says what it did to every path while it does
630
551
  // it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
631
552
  // lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
632
553
  // skills, and both look installed. A dry run prints the same lines straight from the plan.
554
+ //
555
+ // The writing is scripts/repo-edit.js, an entry at a time: the line for a path is printed as the
556
+ // path is written, and an edit handed the whole plan at once would leave a long install silent and
557
+ // then print all of it at the end. What order the work inside an entry goes in -- the parent folder
558
+ // before the file, the link's way cleared before the link, the mode after the content -- is the
559
+ // edit's, so this loop takes the plan's order as given and adds nothing to it.
633
560
  // Returns the entries as they turned out, which the summary is built from.
634
561
  function apply(entries, root, options) {
635
562
  const done = [];
563
+ const edit = repoEdit.worktreeEdit(root);
636
564
  for (const e of entries) {
637
565
  if (e.phase) { if (!options.quiet) say(`\n${e.phase}`); continue; }
638
566
  let shown = e;
639
567
  if (!options.dryRun) {
640
- const fix = perform(root, e);
641
- if (fix && fix.why) say(fix.why);
642
- if (fix && fix.outcome) shown = { ...e, outcome: fix.outcome, bucket: fix.bucket };
568
+ const [result] = edit.apply([e]);
569
+ // The edit reports; the policy decides. A symlink this platform will not make means the
570
+ // target keeps whatever it already had, which is an outcome word and therefore this
571
+ // run's to choose -- the edit says only that the link is not there and why.
572
+ if (result && !result.done) {
573
+ say(result.why);
574
+ if (result.kind === "link") shown = { ...e, outcome: "yours", bucket: "kept" };
575
+ }
643
576
  }
644
577
  if (!shown.silent && !options.quiet) say(` ${shown.policy.padEnd(9)}${shown.mode} ${shown.outcome.padEnd(12)}${shown.file}`);
645
578
  done.push(shown);
@@ -673,7 +606,7 @@ function finish(target, options) {
673
606
  // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
674
607
  // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
675
608
  // this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
676
- // harness invariants, and scripts/check-harness.js holds them as functions of a root.
609
+ // harness invariants, and scripts/check-harness.js holds them as functions of a repo-view.
677
610
  //
678
611
  // So they run from the upstream checkout against the target, and nothing is written into the target
679
612
  // to run them. The upstream's copy rather than the one just installed, so the check is the one that
@@ -684,6 +617,8 @@ function selfCheck(target, templateDir, options) {
684
617
  if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
685
618
  if (!options.quiet) say("\nself check: the harness invariants, run from the upstream against this repo");
686
619
  const harness = require(script);
620
+ // The root rather than a view of it: this is the upstream's copy of check-harness, at whichever
621
+ // ref the run is installing, and a ref old enough to predate the view still expects a path.
687
622
  const r = harness.check(target);
688
623
  return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
689
624
  }
@@ -750,10 +685,13 @@ function main(args) {
750
685
  const mistyped = mistypedArgs(args);
751
686
  if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
752
687
  const target = targetRoot(options);
753
- if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
688
+ // One view of the target, read from here on: whether it is a checkout at all, what its receipt
689
+ // says, and everything plan() asks of it. worktree() holds nothing between calls, so it still
690
+ // answers for the tree as the run leaves it rather than as the run found it.
691
+ const here = repoView.worktree(target);
692
+ if (!here.exists(".git")) fail(`${target} is not a git checkout`);
754
693
 
755
- const lockPath = path.join(target, LOCK);
756
- const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
694
+ const previous = here.isFile(LOCK) ? JSON.parse(here.read(LOCK)) : null;
757
695
  const ref = options.ref || (previous ? previous.ref : DEFAULT_REF);
758
696
  const { dir: templateDir, temporary } = templateCheckout(ref, options);
759
697
 
@@ -763,14 +701,14 @@ function main(args) {
763
701
  const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
764
702
  const unknown = unknownArgs(args, optional);
765
703
  if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
766
- const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
704
+ const head = git(templateDir, ["rev-parse", "HEAD"]).output.trim();
767
705
  if (upToDate(previous, head, options, optional)) {
768
706
  say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
769
707
  return 0;
770
708
  }
771
709
 
772
710
  const planned = plan({
773
- upstream: gitUpstream(templateDir, head), target: fsTarget(target), rows, head, ref, previous, options,
711
+ upstream: gitUpstream(templateDir), target: here, rows, head, ref, previous, options,
774
712
  stamp: { ...installer(), updated: new Date().toISOString().slice(0, 10) },
775
713
  });
776
714
  for (const notice of planned.notices) say(notice);