@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 +2 -1
- 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 +94 -151
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
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,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
|
|
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",
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
|
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;
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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 =
|
|
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
|
|
284
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
508
|
-
base: () => upstream.
|
|
509
|
-
// A seed file the recorded commit already shipped was laid down then.
|
|
510
|
-
|
|
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(
|
|
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(
|
|
503
|
+
function planSkills(atHead, target, files) {
|
|
545
504
|
const LOCKFILE = "skills-lock.json";
|
|
546
|
-
const theirLock = JSON.parse(
|
|
547
|
-
const ourLock = target.
|
|
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 =
|
|
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.
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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)); }
|