@salaros/ai-harness 0.4.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.4.1",
3
+ "version": "0.5.2",
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
  ],
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,11 +121,16 @@ 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
128
128
 
129
+ // The command line that clone runs on, which is how every install begins that did not bring its own
130
+ // checkout. Separate from the running of it because nothing in the suite clones: a check can read
131
+ // arguments, and cannot watch a network call it must not make.
132
+ const cloneArgs = (ref, dir) => ["-c", "core.longpaths=true", "clone", "--quiet", "--branch", ref, TEMPLATE, dir];
133
+
129
134
  // A clone deep enough to read the recorded commit: an update needs that commit's version of a file
130
135
  // as the merge base, and --depth 1 would not have it. Removed again unless the caller supplied one.
131
136
  function templateCheckout(ref, options) {
@@ -141,7 +146,7 @@ function templateCheckout(ref, options) {
141
146
  }
142
147
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
143
148
  say(`cloning ${TEMPLATE} at ${ref}`);
144
- const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
149
+ const r = lib.run("git", cloneArgs(ref, dir));
145
150
  if (r.status !== 0) { fs.rmSync(dir, { recursive: true, force: true }); fail(`could not clone the upstream at ${ref}\n${r.output}`); }
146
151
  if (!usable(dir)) {
147
152
  fs.rmSync(dir, { recursive: true, force: true });
@@ -171,117 +176,68 @@ function installer() {
171
176
  const home = path.resolve(__dirname, "..");
172
177
  let pkg = {};
173
178
  try { pkg = JSON.parse(fs.readFileSync(path.join(home, "package.json"), "utf8")); } catch { return {}; }
174
- const described = at(home, ["describe", "--tags", "--exact-match", "HEAD"]);
179
+ const described = git(home, ["describe", "--tags", "--exact-match", "HEAD"]);
175
180
  return installerStamp({ name: pkg.name, version: pkg.version, tag: described.status === 0 ? described.output.trim() : null });
176
181
  }
177
182
 
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
- }
183
+ // The commit graph, which is all the installer runs git for itself now that repo-view reads the
184
+ // files. core.longpaths for the same reason repo-view sets it: Windows stops at 260 characters, and
185
+ // the harness ships skill files nested deep enough that a project a few folders down the drive
186
+ // crosses it.
187
+ const git = (dir, args) => lib.run("git", ["-c", "core.longpaths=true", "-C", dir, ...args]);
201
188
 
202
189
  // ---------------------------------------------------------------- the adapters
203
190
  //
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;
191
+ // What plan() reads: the upstream at any commit, and the target as it stands. Both are repo-view
192
+ // adapters -- scripts/repo-view.js -- so a real run reads a checkout and a commit while the suite
193
+ // reads a map, through one implementation rather than two. That is the whole point of the seam: the
194
+ // stand-ins this file used to carry drifted, and the branch taken for a file with a NUL byte in it
195
+ // was unreachable from the suite for as long as a stand-in answered a bytes question with a string.
196
+ //
197
+ // What repo-view will not do is guess which of the two a caller wants, so the guess is made here.
198
+
199
+ // The upstream's version of a path, as the installer needs it: text as a string, and anything
200
+ // holding a NUL byte as the Buffer it arrived in, which is how Git itself tells the two apart.
201
+ // Decoded as UTF-8 and written back, every byte a PNG holds outside ASCII becomes U+FFFD: the
202
+ // skill's logo installs as a broken image, and no later run ever agrees with the upstream about it.
203
+ // Nothing merges a Buffer; it is written whole or kept whole.
204
+ // null when the commit has no such path, and equally when the checkout will not give it up: Git
205
+ // listed it a moment ago, so a read that fails is the checkout being unhappy rather than the file
206
+ // being absent, and plan() reports that as UNREADABLE rather than letting it end the run.
207
+ function contentOf(view, file) {
208
+ let bytes;
209
+ try { bytes = view.bytes(file); } catch { return null; }
210
+ if (bytes === null) return null;
211
+ return bytes.includes(0) ? bytes : bytes.toString("utf8");
231
212
  }
232
213
 
233
- function gitUpstream(dir, head) {
234
- let atHead;
214
+ // The upstream checkout: a view per commit, and the two questions only the commit graph can answer.
215
+ // Views are kept, because plan() asks the head for every path and a base for every file it merges;
216
+ // each one reads its tree once and its blobs in a single batch on first use.
217
+ function gitUpstream(dir) {
218
+ const views = new Map();
235
219
  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;
220
+ at(sha) {
221
+ if (!views.has(sha)) views.set(sha, repoView.commit(dir, sha));
222
+ return views.get(sha);
252
223
  },
253
224
  // 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,
225
+ has: sha => git(dir, ["cat-file", "-e", `${sha}^{commit}`]).status === 0,
255
226
  // The commits that touched a path, newest first.
256
227
  history(file) {
257
- const r = at(dir, ["log", "--format=%H", "--", file]);
228
+ const r = git(dir, ["log", "--format=%H", "--", file]);
258
229
  return r.status === 0 ? r.output.split(/\r?\n/).filter(Boolean) : [];
259
230
  },
260
231
  };
261
232
  }
262
233
 
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
234
  // The receipt is missing, so the base is found instead: the upstream version this copy is closest to
279
235
  // is where the project forked from, whatever a receipt would have said. An exact match is the clean
280
236
  // case, an older copy nobody touched; a project that has since edited its own file matches nothing
281
237
  // exactly, so the nearest version by shared lines stands in as the base. That turns a first install
282
238
  // 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.
239
+ // Only reconcile-policy files pay for the search: one git log, then a view per commit that touched
240
+ // the path.
285
241
  // Under half the lines in common is a different file, not an older one, and merging against it would
286
242
  // invent a diff the project never made.
287
243
  const NEAREST = 0.5;
@@ -293,7 +249,7 @@ function recoverBase(upstream, file, ours) {
293
249
  // same against a copy that has neither, and the older of them is the one whose merge puts that
294
250
  // line back. The newer would drop it silently, which is the failure this policy exists to stop.
295
251
  for (const commit of upstream.history(file).reverse()) {
296
- const text = upstream.blob(commit, file);
252
+ const text = contentOf(upstream.at(commit), file);
297
253
  if (typeof text !== "string") continue;
298
254
  if (text === ours) return text;
299
255
  const shared = overlap(want, lineCounts(text));
@@ -424,7 +380,6 @@ function settleDropped(text) {
424
380
  // mkdir create the path's folder and nothing else
425
381
  // silent counted in the summary, never printed as a line
426
382
  // and a { phase } entry heads each section of the output.
427
- const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
428
383
  const SKILLS = ".agents/skills/";
429
384
 
430
385
  // `previous` is the target's harness-lock.json, or null; `stamp` is what the receipt records about
@@ -438,7 +393,8 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
438
393
  // or an upstream whose history was rewritten -- an existing file is left alone instead of being
439
394
  // guessed at, and the run says so.
440
395
  let base = previous ? previous.commit : null;
441
- if (base && !upstream.hasCommit(base)) {
396
+ const atHead = upstream.at(head);
397
+ if (base && !upstream.has(base)) {
442
398
  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
399
  base = null;
444
400
  }
@@ -464,14 +420,16 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
464
420
  : `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
421
  }
466
422
 
467
- const files = upstream.files();
423
+ // The mode Git recorded, rather than one worked out from the entry: a view answers for every path
424
+ // it holds without reading a blob, and 120000 against 100755 against 100644 is the whole of what
425
+ // the mode column says.
426
+ const files = atHead.modes();
468
427
  const skills = [];
469
428
  add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
470
429
  for (const entry of files) {
471
- const { file, link: isLink, exec } = entry;
430
+ const { file, link: isLink, exec, mode: m } = entry;
472
431
  const { policy, asked } = installPolicy.policyFor(rows, file, options.wants);
473
- const m = mode(entry);
474
- const theirs = upstream.blob(head, file);
432
+ const theirs = contentOf(atHead, file);
475
433
  // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
476
434
  // rather than the file being absent. Said out loud: skipped quietly, the run reports a clean
477
435
  // install of a harness missing whichever files the reader was never told about.
@@ -504,10 +462,11 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
504
462
  if (policy === "skills") { skills.push(entry); continue; }
505
463
  const { outcome, bucket, notice, ...act } = installPolicy.decide(policy,
506
464
  { 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,
465
+ held: () => Buffer.isBuffer(theirs) ? target.bytes(file) : target.read(file),
466
+ base: () => contentOf(upstream.at(base), file),
467
+ // A seed file the recorded commit already shipped was laid down then. isFile, not a
468
+ // read: whether a commit holds a path is a question its listing already answers.
469
+ shippedBefore: () => base !== null && upstream.at(base).isFile(file),
511
470
  recoverBase: ours => recoverBase(upstream, file, ours),
512
471
  merge: threeWay,
513
472
  });
@@ -528,7 +487,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
528
487
  }
529
488
 
530
489
  add({ phase: "skills, merged by name" });
531
- entries.push(...planSkills(upstream, target, head, skills));
490
+ entries.push(...planSkills(atHead, target, skills));
532
491
 
533
492
  // A list of strings on one line, as Prettier writes it: a project formatting its JSON with it
534
493
  // would otherwise reject the receipt at every push, and the next update would undo the fix.
@@ -541,10 +500,10 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
541
500
  // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
542
501
  // project vendored itself is never removed. skills-lock.json is the union, the project's entry
543
502
  // winning where both name the same skill, so a project that pinned a different source keeps it.
544
- function planSkills(upstream, target, head, files) {
503
+ function planSkills(atHead, target, files) {
545
504
  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: {} };
505
+ const theirLock = JSON.parse(atHead.read(LOCKFILE) || '{"skills":{}}');
506
+ const ourLock = target.isFile(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
548
507
  ourLock.skills = ourLock.skills || {};
549
508
  const mine = new Set(Object.keys(ourLock.skills));
550
509
 
@@ -563,11 +522,11 @@ function planSkills(upstream, target, head, files) {
563
522
  if (exec && exists) out.push({ file, silent: true, exec: true });
564
523
  // A skill the project installed under a name the upstream also uses stays the project's.
565
524
  if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
566
- const text = upstream.blob(head, file);
525
+ const text = contentOf(atHead, file);
567
526
  if (text === null) continue;
568
527
  // A vendored file the project has not touched still differs byte-for-byte on Windows, where
569
528
  // 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;
529
+ const held = exists ? target.bytes(file) : null;
571
530
  let write;
572
531
  if (Buffer.isBuffer(text)) {
573
532
  if (lib.sameContent(held, text)) continue;
@@ -593,53 +552,32 @@ function planSkills(upstream, target, head, files) {
593
552
 
594
553
  // ---------------------------------------------------------------- applying it
595
554
 
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
555
  // An install rewrites someone else's repository, so it says what it did to every path while it does
630
556
  // it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
631
557
  // lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
632
558
  // skills, and both look installed. A dry run prints the same lines straight from the plan.
559
+ //
560
+ // The writing is scripts/repo-edit.js, an entry at a time: the line for a path is printed as the
561
+ // path is written, and an edit handed the whole plan at once would leave a long install silent and
562
+ // then print all of it at the end. What order the work inside an entry goes in -- the parent folder
563
+ // before the file, the link's way cleared before the link, the mode after the content -- is the
564
+ // edit's, so this loop takes the plan's order as given and adds nothing to it.
633
565
  // Returns the entries as they turned out, which the summary is built from.
634
566
  function apply(entries, root, options) {
635
567
  const done = [];
568
+ const edit = repoEdit.worktreeEdit(root);
636
569
  for (const e of entries) {
637
570
  if (e.phase) { if (!options.quiet) say(`\n${e.phase}`); continue; }
638
571
  let shown = e;
639
572
  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 };
573
+ const [result] = edit.apply([e]);
574
+ // The edit reports; the policy decides. A symlink this platform will not make means the
575
+ // target keeps whatever it already had, which is an outcome word and therefore this
576
+ // run's to choose -- the edit says only that the link is not there and why.
577
+ if (result && !result.done) {
578
+ say(result.why);
579
+ if (result.kind === "link") shown = { ...e, outcome: "yours", bucket: "kept" };
580
+ }
643
581
  }
644
582
  if (!shown.silent && !options.quiet) say(` ${shown.policy.padEnd(9)}${shown.mode} ${shown.outcome.padEnd(12)}${shown.file}`);
645
583
  done.push(shown);
@@ -673,7 +611,7 @@ function finish(target, options) {
673
611
  // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
674
612
  // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
675
613
  // 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.
614
+ // harness invariants, and scripts/check-harness.js holds them as functions of a repo-view.
677
615
  //
678
616
  // So they run from the upstream checkout against the target, and nothing is written into the target
679
617
  // to run them. The upstream's copy rather than the one just installed, so the check is the one that
@@ -684,6 +622,8 @@ function selfCheck(target, templateDir, options) {
684
622
  if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
685
623
  if (!options.quiet) say("\nself check: the harness invariants, run from the upstream against this repo");
686
624
  const harness = require(script);
625
+ // The root rather than a view of it: this is the upstream's copy of check-harness, at whichever
626
+ // ref the run is installing, and a ref old enough to predate the view still expects a path.
687
627
  const r = harness.check(target);
688
628
  return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
689
629
  }
@@ -750,10 +690,13 @@ function main(args) {
750
690
  const mistyped = mistypedArgs(args);
751
691
  if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
752
692
  const target = targetRoot(options);
753
- if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
693
+ // One view of the target, read from here on: whether it is a checkout at all, what its receipt
694
+ // says, and everything plan() asks of it. worktree() holds nothing between calls, so it still
695
+ // answers for the tree as the run leaves it rather than as the run found it.
696
+ const here = repoView.worktree(target);
697
+ if (!here.exists(".git")) fail(`${target} is not a git checkout`);
754
698
 
755
- const lockPath = path.join(target, LOCK);
756
- const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
699
+ const previous = here.isFile(LOCK) ? JSON.parse(here.read(LOCK)) : null;
757
700
  const ref = options.ref || (previous ? previous.ref : DEFAULT_REF);
758
701
  const { dir: templateDir, temporary } = templateCheckout(ref, options);
759
702
 
@@ -763,14 +706,14 @@ function main(args) {
763
706
  const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
764
707
  const unknown = unknownArgs(args, optional);
765
708
  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();
709
+ const head = git(templateDir, ["rev-parse", "HEAD"]).output.trim();
767
710
  if (upToDate(previous, head, options, optional)) {
768
711
  say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
769
712
  return 0;
770
713
  }
771
714
 
772
715
  const planned = plan({
773
- upstream: gitUpstream(templateDir, head), target: fsTarget(target), rows, head, ref, previous, options,
716
+ upstream: gitUpstream(templateDir), target: here, rows, head, ref, previous, options,
774
717
  stamp: { ...installer(), updated: new Date().toISOString().slice(0, 10) },
775
718
  });
776
719
  for (const notice of planned.notices) say(notice);
@@ -792,7 +735,7 @@ function main(args) {
792
735
  // The plan and the decisions under it, so the suite can put a case in and read the answer out rather
793
736
  // than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
794
737
  // writes nothing; main() writes to somebody's repository and is reached through the command line.
795
- module.exports = { installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, lineCounts, overlap, NEAREST, skeletonLines };
738
+ module.exports = { cloneArgs, installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, lineCounts, overlap, NEAREST, skeletonLines };
796
739
 
797
740
  if (require.main === module) {
798
741
  try { process.exitCode = main(process.argv.slice(2)); }