@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 +1 -1
- package/package.json +2 -1
- package/scripts/install-policy.js +13 -7
- package/scripts/lib.js +9 -6
- package/scripts/repo-edit.js +140 -0
- package/scripts/repo-view.js +150 -20
- package/scripts/update-harness.js +87 -149
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.
|
|
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
|
|
183
|
-
//
|
|
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
|
|
186
|
-
if (
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 };
|
package/scripts/repo-view.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
// scripts/repo-view.js
|
|
2
|
-
// A repo as a check reads it:
|
|
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", [
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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([...
|
|
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 =
|
|
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, () =>
|
|
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,
|
|
99
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
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
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
284
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
508
|
-
base: () => upstream.
|
|
509
|
-
// A seed file the recorded commit already shipped was laid down then.
|
|
510
|
-
|
|
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(
|
|
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(
|
|
498
|
+
function planSkills(atHead, target, files) {
|
|
545
499
|
const LOCKFILE = "skills-lock.json";
|
|
546
|
-
const theirLock = JSON.parse(
|
|
547
|
-
const ourLock = target.
|
|
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 =
|
|
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.
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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);
|