@salaros/ai-harness 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/scripts/README.md +16 -5
- package/scripts/lib.js +44 -15
- package/scripts/project-facts.js +113 -0
- package/scripts/repo-view.js +101 -0
- package/scripts/update-harness.js +412 -313
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Installs and updates the agent harness from its upstream repository: hooks, skills, agents and the documentation chain, merged into an existing repository without touching its own work.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai-harness": "scripts/update-harness.js"
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"scripts/update-harness.js",
|
|
10
10
|
"scripts/lib.js",
|
|
11
|
+
"scripts/project-facts.js",
|
|
12
|
+
"scripts/repo-view.js",
|
|
11
13
|
"README.md",
|
|
12
14
|
"LICENSE"
|
|
13
15
|
],
|
package/scripts/README.md
CHANGED
|
@@ -17,11 +17,13 @@ part of the product.
|
|
|
17
17
|
|
|
18
18
|
- One task per script, named after what it does (`githooks-init.js`,
|
|
19
19
|
`build.js`, `release.js`); Node, so they run the same on every OS.
|
|
20
|
-
- Scripts are safe to run from any working directory —
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
- Scripts are safe to run from any working directory — every script and hook
|
|
21
|
+
asks `lib.root()` which repo it is about, and gets one answer: `--root=<dir>`
|
|
22
|
+
when given, then the harness's project-dir variable
|
|
23
|
+
(`CLAUDE_PROJECT_DIR` and its Cursor and Gemini equivalents), then the
|
|
24
|
+
checkout the file sits in (`lib.args()` is the arguments without the flag).
|
|
25
|
+
The suite uses the flag to run a script against a temporary repo, and a
|
|
26
|
+
script that calls another passes its own root along.
|
|
25
27
|
- A script run as a command may `chdir` there (`lib.chdirRoot()`). A script
|
|
26
28
|
another script requires takes the root as its first argument and resolves
|
|
27
29
|
against it (`lib.root()` at the entry point, `path.resolve(root, …)`
|
|
@@ -36,6 +38,15 @@ part of the product.
|
|
|
36
38
|
and hand over to `githook.js <hook>`. Nothing else belongs in them: a
|
|
37
39
|
decision made in the shell is one no test can reach, and the suite asserts
|
|
38
40
|
the shape.
|
|
41
|
+
- A script is a thin shell around its decisions. Parsing, merging,
|
|
42
|
+
formatting and the summary it prints go in a pure module the suite tests;
|
|
43
|
+
the script only reads, writes and reports, for the same reason the hooks
|
|
44
|
+
above hold no logic.
|
|
45
|
+
- What two scripts share lives in one module here instead of a copy in each,
|
|
46
|
+
so the callers cannot drift: a threshold, a list, or a whole step, the way
|
|
47
|
+
`lib.js` holds root resolution and argument parsing for every script. When
|
|
48
|
+
several scripts do one job for different sources, a runner owns the common
|
|
49
|
+
steps and each script brings only its source and its rules.
|
|
39
50
|
- Scripts must be idempotent where possible — running them twice should not
|
|
40
51
|
break anything.
|
|
41
52
|
|
package/scripts/lib.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// which requires this file rather than the other way around, so scripts/ never reaches into the
|
|
5
5
|
// harness-specific folder.
|
|
6
6
|
// const lib = require("./lib");
|
|
7
|
-
// const root = lib.root(); // the repo root
|
|
7
|
+
// const root = lib.root(); // the repo root (precedence below)
|
|
8
8
|
// const root = lib.chdirRoot(); // the same, and cd there
|
|
9
9
|
// const [cmd, ...rest] = lib.args(); // the command line, without --root=<dir>
|
|
10
10
|
// lib.stdin() // everything on stdin, or "" if there is none
|
|
@@ -15,21 +15,50 @@ const fs = require("fs");
|
|
|
15
15
|
const path = require("path");
|
|
16
16
|
const { spawnSync } = require("child_process");
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
18
|
+
// Which repo an entry point is about, in one precedence every script and hook follows:
|
|
19
|
+
// 1. --root=<dir> on the command line, the explicit answer
|
|
20
|
+
// 2. CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR or GEMINI_PROJECT_DIR, the harness's answer
|
|
21
|
+
// 3. the checkout this file sits in
|
|
22
|
+
// The flag is how a check reaches a repo other than its own checkout: the suite points a case at a
|
|
23
|
+
// throwaway directory rather than planting files in the tree it runs in, and the installer checks a
|
|
24
|
+
// target with the upstream's copy of a script. One token, so an argument parser that skips "--"
|
|
25
|
+
// flags skips it too. The variables are how a harness says which project it is editing, which is
|
|
26
|
+
// not always the checkout a hook file sits in. A variable naming another checkout is honoured and
|
|
27
|
+
// reported on stderr; one naming nothing that exists is ignored and reported.
|
|
28
|
+
// A script run as a command chdirs to the root so its relative paths (SKILL.md files, README.md,
|
|
29
|
+
// stacks.tsv) work wherever it was invoked from. A script another script requires takes the root as
|
|
30
|
+
// an argument and resolves against it: chdir is a process-wide effect, so a library that moves the
|
|
31
|
+
// working directory moves it for its caller too.
|
|
28
32
|
const ROOT_FLAG = "--root=";
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
const ROOT_ENV_VARS = ["CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_DIR", "GEMINI_PROJECT_DIR"];
|
|
34
|
+
const CHECKOUT = path.resolve(__dirname, "..");
|
|
35
|
+
|
|
36
|
+
const win = process.platform === "win32";
|
|
37
|
+
const warn = msg => process.stderr.write(`harness: ${msg}\n`);
|
|
38
|
+
const same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
39
|
+
// Git Bash reports C:\x as /c/x; Windows APIs may prefix \\?\. Bring both to a form path can resolve.
|
|
40
|
+
const fix = p => {
|
|
41
|
+
let s = String(p).replace(/^\\\\\?\\/, "");
|
|
42
|
+
if (win) { const m = s.match(/^\/([a-zA-Z])(?:\/(.*))?$/); if (m) s = `${m[1].toUpperCase()}:/${m[2] || ""}`; }
|
|
43
|
+
return s;
|
|
32
44
|
};
|
|
45
|
+
const real = p => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
46
|
+
|
|
47
|
+
function root() {
|
|
48
|
+
const given = process.argv.slice(2).find(a => a.startsWith(ROOT_FLAG));
|
|
49
|
+
if (given) return path.resolve(fix(given.slice(ROOT_FLAG.length)));
|
|
50
|
+
const here = real(CHECKOUT);
|
|
51
|
+
for (const v of ROOT_ENV_VARS) {
|
|
52
|
+
const val = process.env[v];
|
|
53
|
+
if (!val) continue;
|
|
54
|
+
const dir = path.resolve(fix(val));
|
|
55
|
+
let st; try { st = fs.statSync(dir); } catch { }
|
|
56
|
+
if (!st || !st.isDirectory()) { warn(`${v} is not a directory; using the checkout this file lives in`); break; }
|
|
57
|
+
if (!same(real(dir), here)) warn(`${v} (${dir}) is not the checkout this file lives in (${here}); using ${v}`);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
return here;
|
|
61
|
+
}
|
|
33
62
|
const args = () => process.argv.slice(2).filter(a => !a.startsWith(ROOT_FLAG));
|
|
34
63
|
|
|
35
64
|
function chdirRoot() {
|
|
@@ -55,4 +84,4 @@ function readTsv(file) {
|
|
|
55
84
|
.map(l => l.split("\t"));
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
module.exports = { root, args, ROOT_FLAG, chdirRoot, stdin, run, node, shell, readTsv };
|
|
87
|
+
module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// scripts/project-facts.js
|
|
2
|
+
// The one reader of a project's facts: the labelled lines of MEMORY.md, and the name and purpose an
|
|
3
|
+
// INTENT.md gives in its "## Product" section. The initialisation gate, the commit-message check,
|
|
4
|
+
// docs-check, the docs portal and the installer's MEMORY.md skeleton all ask this module, so a line
|
|
5
|
+
// one of them counts as present is present to every one of them, and a placeholder one of them
|
|
6
|
+
// treats as unanswered is unanswered everywhere.
|
|
7
|
+
// Shipped in the npm package beside update-harness.js and lib.js, because the installer builds its
|
|
8
|
+
// skeleton from FACTS, so the installer must not require anything else from scripts/.
|
|
9
|
+
// Usage:
|
|
10
|
+
// const facts = require("./project-facts");
|
|
11
|
+
// const f = facts.readFactsAt(root); // { Name, Purpose, Requirements, ... }
|
|
12
|
+
// f["Issue tracker"] // null: no line; "": unanswered; else the value
|
|
13
|
+
// facts.unanswered(f) // the required labels with no answer
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const MEMORY = "MEMORY.md";
|
|
18
|
+
const INTENT = "INTENT.md";
|
|
19
|
+
|
|
20
|
+
// Every fact, in the order MEMORY.md lists them. `required` facts gate initialisation; the rest are
|
|
21
|
+
// read by the skills that need them and never block. `intent` marks the two an INTENT.md owns.
|
|
22
|
+
// `Issue tracker` is optional because a tracker is a choice rather than a property of the code,
|
|
23
|
+
// `Frontend` because a service or a library has no UI, and `Prose language` because a missing line
|
|
24
|
+
// already means English.
|
|
25
|
+
const FACTS = [
|
|
26
|
+
{ label: "Name", required: true, intent: true, placeholder: "<name>" },
|
|
27
|
+
{ label: "Purpose", required: true, intent: true, placeholder: "<purpose>" },
|
|
28
|
+
{ label: "Prose language", required: false, placeholder: "<prose language>" },
|
|
29
|
+
{ label: "Requirements", required: true, placeholder: "<requirements>" },
|
|
30
|
+
{ label: "Unit type", required: true, placeholder: "<unit type>" },
|
|
31
|
+
{ label: "Language", required: true, placeholder: "<language>" },
|
|
32
|
+
{ label: "Runtime / package manager", required: true, placeholder: "<runtime>" },
|
|
33
|
+
{ label: "Frontend", required: false, placeholder: "<framework>" },
|
|
34
|
+
{ label: "Issue tracker", required: false, placeholder: "<tracker>" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// INTENT.md (https://www.intentdocs.com/intent-md): the product's intent, one per product, at the
|
|
38
|
+
// root. Optional, so the harness neither ships nor requires one. Given its text, returns whether the
|
|
39
|
+
// "# INTENT.md" title is there, the product's bold name and the prose describing it from
|
|
40
|
+
// "## Product" (null when the section is missing), and whether "## MVP stories" is there; that heading
|
|
41
|
+
// may run on, as in "## MVP stories — build these first". docs-check holds the file to these sections.
|
|
42
|
+
function readIntent(text) {
|
|
43
|
+
const lines = text.split(/\r?\n/);
|
|
44
|
+
const h1 = lines.find(l => /^#\s/.test(l));
|
|
45
|
+
const section = name => {
|
|
46
|
+
const start = lines.findIndex(l => new RegExp(`^##\\s+${name}\\b`, "i").test(l));
|
|
47
|
+
if (start < 0) return null;
|
|
48
|
+
const end = lines.findIndex((l, i) => i > start && /^#{1,2}\s/.test(l));
|
|
49
|
+
return lines.slice(start + 1, end < 0 ? lines.length : end).join("\n").trim();
|
|
50
|
+
};
|
|
51
|
+
const body = section("Product");
|
|
52
|
+
let product = null;
|
|
53
|
+
if (body !== null) {
|
|
54
|
+
const bold = body.match(/\*\*([^*]+)\*\*/);
|
|
55
|
+
product = {
|
|
56
|
+
name: bold ? bold[1].trim().replace(/[:.]+$/, "").trim() : "",
|
|
57
|
+
purpose: body.replace(/\*\*[^*]+\*\*/, "").replace(/^[\s:.,—–-]+/, "").replace(/\s+/g, " ").trim(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { title: !!h1 && h1.trim() === "# INTENT.md", product, stories: section("MVP stories") !== null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A fact's line: a `-` or `*` bullet, then the label in bold with its colon inside or after the bold
|
|
64
|
+
// (`- **Name:** Acme`, `* **Name**: Acme`). An unbolded line is prose that happens to start with the
|
|
65
|
+
// word, so it is not a fact. The value is null when no line carries the label.
|
|
66
|
+
function lineValue(text, label) {
|
|
67
|
+
if (text === null) return null;
|
|
68
|
+
const escaped = label.replace(/[/.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
const m = text.match(new RegExp(`^\\s*[-*]\\s*\\*\\*${escaped}(?::\\*\\*|\\*\\*:)[ \\t]*(.*)$`, "mi"));
|
|
70
|
+
return m ? m[1].trim() : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A value is unanswered when nothing is left once every <placeholder> is removed: `<language>` and
|
|
74
|
+
// `<language> <version>` are the template, `C#` and `see <link> in docs/` are answers, kept whole.
|
|
75
|
+
const answer = value => value.replace(/<[^>]*>/g, "").trim() ? value : "";
|
|
76
|
+
|
|
77
|
+
// Every fact, from the texts of MEMORY.md and INTENT.md, each null when that file is absent. A label
|
|
78
|
+
// maps to null when nothing gives it, "" when it is unanswered, and the trimmed value otherwise.
|
|
79
|
+
// With an INTENT.md, Name and Purpose come from its "## Product" alone: a MEMORY.md line for either
|
|
80
|
+
// is ignored, so a stale copy there never stands in for the product's own words.
|
|
81
|
+
function readFacts({ memory = null, intent = null } = {}) {
|
|
82
|
+
const product = intent === null ? null : readIntent(intent).product;
|
|
83
|
+
const facts = {};
|
|
84
|
+
for (const { label, intent: owned } of FACTS) {
|
|
85
|
+
const raw = owned && intent !== null
|
|
86
|
+
? (product ? product[label.toLowerCase()] : null)
|
|
87
|
+
: lineValue(memory, label);
|
|
88
|
+
facts[label] = raw === null ? null : answer(raw);
|
|
89
|
+
}
|
|
90
|
+
return facts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// readFacts() for the files on disk under a root. A reader holding the texts some other way, such as
|
|
94
|
+
// docs-check through a repo view, calls readFacts() with them instead.
|
|
95
|
+
function readFactsAt(root) {
|
|
96
|
+
const read = file => {
|
|
97
|
+
const at = path.resolve(root, file);
|
|
98
|
+
return fs.existsSync(at) ? fs.readFileSync(at, "utf8") : null;
|
|
99
|
+
};
|
|
100
|
+
return readFacts({ memory: read(MEMORY), intent: read(INTENT) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The required labels readFacts() found no answer for, in FACTS order.
|
|
104
|
+
const unanswered = facts => FACTS.filter(f => f.required && !facts[f.label]).map(f => f.label);
|
|
105
|
+
|
|
106
|
+
// MEMORY.md's fact lines as the installer lays them down, every one a placeholder. Beside an
|
|
107
|
+
// INTENT.md, Name and Purpose give way to a line saying where they are.
|
|
108
|
+
function skeleton(hasIntent) {
|
|
109
|
+
const lines = FACTS.filter(f => !(hasIntent && f.intent)).map(f => `- **${f.label}:** ${f.placeholder}`);
|
|
110
|
+
return hasIntent ? [`The name and purpose are in \`${INTENT}\`, under \`## Product\`.`, "", ...lines] : lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { FACTS, MEMORY, INTENT, readFacts, readFactsAt, unanswered, readIntent, skeleton };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// scripts/repo-view.js
|
|
2
|
+
// A repo as a check reads it: four questions about repo-relative paths, answered from the working
|
|
3
|
+
// tree, from Git's index, or from a map a test builds. A check that takes a view reads what a
|
|
4
|
+
// commit will record as easily as what is on disk, and a test hands it the files it is about
|
|
5
|
+
// instead of building a tree or pointing it at a file that does not exist.
|
|
6
|
+
// exists(rel) a file, or a folder holding one
|
|
7
|
+
// isFile(rel) a file
|
|
8
|
+
// read(rel) its text, or null when there is none
|
|
9
|
+
// list(rel) the names directly inside a folder, sorted; [] when there is none
|
|
10
|
+
// Paths are repo-relative with forward slashes. Nothing here writes, prints or exits.
|
|
11
|
+
// indexModes() is the one reader of `git ls-files -s`: the mode Git records is how the harness
|
|
12
|
+
// tells a symlink (120000) and an executable (100755) from a plain file.
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { spawnSync } = require("child_process");
|
|
16
|
+
|
|
17
|
+
// stdout alone and untrimmed, since a blob's trailing newline is part of its text.
|
|
18
|
+
const git = (dir, args) => spawnSync("git", ["-c", "core.quotepath=off", ...args], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
19
|
+
|
|
20
|
+
const norm = rel => path.posix.normalize(String(rel).split(path.sep).join("/")).replace(/^\.\/?$|\/+$/g, "");
|
|
21
|
+
|
|
22
|
+
// Every path Git's index holds in `dir`, optionally under `paths` (pathspecs), as
|
|
23
|
+
// { file, mode, object, link, exec }. null when `dir` is not a git checkout.
|
|
24
|
+
function indexModes(dir, paths = []) {
|
|
25
|
+
const r = git(dir, ["ls-files", "-s", "-z", ...(paths.length ? ["--", ...paths] : [])]);
|
|
26
|
+
if (r.status !== 0) return null;
|
|
27
|
+
return r.stdout.split("\0").filter(Boolean).map(entry => {
|
|
28
|
+
const [meta, file] = entry.split("\t");
|
|
29
|
+
const [mode, object] = meta.split(" ");
|
|
30
|
+
return { file, mode, object, link: mode === "120000", exec: mode === "100755" };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A view over a set of files known up front, each read on first use. Folders are whatever the
|
|
35
|
+
// paths imply, which is all an index or a map has of them.
|
|
36
|
+
function filesView(loaders) {
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
const under = rel => rel ? rel + "/" : "";
|
|
39
|
+
return {
|
|
40
|
+
exists: rel => { rel = norm(rel); return loaders.has(rel) || [...loaders.keys()].some(f => f.startsWith(under(rel))); },
|
|
41
|
+
isFile: rel => loaders.has(norm(rel)),
|
|
42
|
+
read: rel => {
|
|
43
|
+
rel = norm(rel);
|
|
44
|
+
if (!loaders.has(rel)) return null;
|
|
45
|
+
if (!cache.has(rel)) cache.set(rel, loaders.get(rel)());
|
|
46
|
+
return cache.get(rel);
|
|
47
|
+
},
|
|
48
|
+
list: rel => {
|
|
49
|
+
const prefix = under(norm(rel));
|
|
50
|
+
const names = new Set([...loaders.keys()].filter(f => f.startsWith(prefix)).map(f => f.slice(prefix.length).split("/")[0]));
|
|
51
|
+
return [...names].sort();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The files on disk under `root`. A path outside it resolves as it stands, which is what a cited
|
|
57
|
+
// `../` path means.
|
|
58
|
+
function worktree(root) {
|
|
59
|
+
const at = rel => path.resolve(root, String(rel));
|
|
60
|
+
const stat = rel => { try { return fs.statSync(at(rel)); } catch { return null; } };
|
|
61
|
+
return {
|
|
62
|
+
exists: rel => !!stat(rel),
|
|
63
|
+
isFile: rel => !!(stat(rel) || { isFile: () => false }).isFile(),
|
|
64
|
+
read: rel => { const s = stat(rel); return s && s.isFile() ? fs.readFileSync(at(rel), "utf8") : null; },
|
|
65
|
+
list: rel => { const s = stat(rel); return s && s.isDirectory() ? fs.readdirSync(at(rel)).sort() : []; },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Git's index at `root`, limited to `paths` when given: what the next commit records, and nothing
|
|
70
|
+
// the working tree holds besides. Throws when `root` is not a git checkout, since an index nobody
|
|
71
|
+
// could read is not an empty one.
|
|
72
|
+
function index(root, paths = []) {
|
|
73
|
+
const rows = indexModes(root, paths);
|
|
74
|
+
if (!rows) throw new Error(`could not read the git index in ${root}`);
|
|
75
|
+
const blob = object => {
|
|
76
|
+
const r = git(root, ["cat-file", "blob", object]);
|
|
77
|
+
if (r.status !== 0) throw new Error(`could not read blob ${object}: ${r.stderr}`);
|
|
78
|
+
return r.stdout;
|
|
79
|
+
};
|
|
80
|
+
return filesView(new Map(rows.map(row => [row.file, () => blob(row.object)])));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What a commit will record for `paths`, and the working tree for everything else. Each of `paths`
|
|
84
|
+
// is read from the index when the index holds anything under it, and from the disk when it holds
|
|
85
|
+
// nothing: a repo that keeps one of them untracked still gets a meaningful answer.
|
|
86
|
+
function staged(root, paths) {
|
|
87
|
+
const idx = index(root, paths), disk = worktree(root);
|
|
88
|
+
const owner = rel => { rel = norm(rel); return paths.map(norm).find(p => rel === p || rel.startsWith(p + "/")); };
|
|
89
|
+
const pick = rel => { const p = owner(rel); return p !== undefined && idx.exists(p) ? idx : disk; };
|
|
90
|
+
return {
|
|
91
|
+
exists: rel => pick(rel).exists(rel),
|
|
92
|
+
isFile: rel => pick(rel).isFile(rel),
|
|
93
|
+
read: rel => pick(rel).read(rel),
|
|
94
|
+
list: rel => pick(rel).list(rel),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The files a test names, path -> text, and nothing else.
|
|
99
|
+
const fromMap = files => filesView(new Map(Object.entries(files).map(([f, text]) => [norm(f), () => text])));
|
|
100
|
+
|
|
101
|
+
module.exports = { worktree, index, staged, fromMap, indexModes };
|
|
@@ -32,6 +32,8 @@ const fs = require("fs");
|
|
|
32
32
|
const os = require("os");
|
|
33
33
|
const path = require("path");
|
|
34
34
|
const lib = require("./lib");
|
|
35
|
+
const projectFacts = require("./project-facts");
|
|
36
|
+
const repoView = require("./repo-view");
|
|
35
37
|
const { spawnSync } = require("child_process");
|
|
36
38
|
|
|
37
39
|
const TEMPLATE = "https://github.com/salaros/ai-harness.git";
|
|
@@ -39,9 +41,6 @@ const LOCK = "harness-lock.json";
|
|
|
39
41
|
const MANIFEST = "scripts/harness-files.tsv";
|
|
40
42
|
const DEFAULT_REF = "master";
|
|
41
43
|
|
|
42
|
-
const argv = process.argv.slice(2);
|
|
43
|
-
const flag = name => argv.includes(name);
|
|
44
|
-
const value = (name, fallback) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
|
|
45
44
|
// Every argument the installer knows. An optional part of the harness adds its own flag through the
|
|
46
45
|
// manifest (optional:<flag>), so those are checked once the upstream checkout is read. Anything else
|
|
47
46
|
// stops the run before a file is written: this script installs when it is run, so a mistyped
|
|
@@ -86,8 +85,31 @@ function usage() {
|
|
|
86
85
|
for (let i = start + 1; i < lines.length && lines[i].startsWith("// "); i++) out.push(lines[i].slice(3).replace("node scripts/update-harness.js", "npx @salaros/ai-harness"));
|
|
87
86
|
return ["Installs or updates the agent harness in the current git repository.", "", "Usage:", ...out].join("\n");
|
|
88
87
|
}
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
|
|
89
|
+
// The arguments, read once. Everything below takes this object rather than the process's argv, so a
|
|
90
|
+
// test can ask what an --adopt run would plan without being one. `wants` answers whether the run
|
|
91
|
+
// asked for an optional part of the harness by its flag.
|
|
92
|
+
function parseOptions(args) {
|
|
93
|
+
const flag = name => args.includes(name);
|
|
94
|
+
const value = name => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : null; };
|
|
95
|
+
return {
|
|
96
|
+
help: flag("--help") || flag("-h"),
|
|
97
|
+
dryRun: flag("--dry-run"),
|
|
98
|
+
adopt: flag("--adopt"),
|
|
99
|
+
quiet: flag("--quiet"),
|
|
100
|
+
check: !flag("--no-check"),
|
|
101
|
+
wants: name => flag(`--${name}`),
|
|
102
|
+
ref: value("--ref"),
|
|
103
|
+
target: value("--target"),
|
|
104
|
+
from: value("--from"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A reason to stop. Thrown rather than exiting, so main() is the one place the process ends, and a
|
|
109
|
+
// temporary clone is still removed on the way out.
|
|
110
|
+
class Stop extends Error {}
|
|
111
|
+
const fail = m => { throw new Stop(m); };
|
|
112
|
+
const say = m => console.log(m);
|
|
91
113
|
|
|
92
114
|
// ---------------------------------------------------------------- the target
|
|
93
115
|
|
|
@@ -95,9 +117,8 @@ const adopt = flag("--adopt");
|
|
|
95
117
|
// its scripts/ folder. Run through npx, the package is an extracted tarball with no .git of its own,
|
|
96
118
|
// so the answer is the directory the user is standing in. One rule covers both, and --target covers
|
|
97
119
|
// installing into a checkout from somewhere else entirely.
|
|
98
|
-
function targetRoot() {
|
|
99
|
-
|
|
100
|
-
if (given) return path.resolve(given);
|
|
120
|
+
function targetRoot(options) {
|
|
121
|
+
if (options.target) return path.resolve(options.target);
|
|
101
122
|
const beside = path.resolve(__dirname, "..");
|
|
102
123
|
return fs.existsSync(path.join(beside, ".git")) ? beside : process.cwd();
|
|
103
124
|
}
|
|
@@ -106,22 +127,21 @@ function targetRoot() {
|
|
|
106
127
|
|
|
107
128
|
// A clone deep enough to read the recorded commit: an update needs that commit's version of a file
|
|
108
129
|
// as the merge base, and --depth 1 would not have it. Removed again unless the caller supplied one.
|
|
109
|
-
function templateCheckout(ref) {
|
|
130
|
+
function templateCheckout(ref, options) {
|
|
110
131
|
// The manifest is what makes a checkout usable here, so both routes are held to it: a --from
|
|
111
132
|
// that points somewhere else, and a --ref naming a branch or tag from before the table existed,
|
|
112
133
|
// fail the same way. Without this the run reaches readTsv and dies in a stack trace naming a
|
|
113
134
|
// temporary directory the reader has never heard of.
|
|
114
135
|
const usable = dir => fs.existsSync(path.join(dir, MANIFEST));
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const dir = path.resolve(given);
|
|
136
|
+
if (options.from) {
|
|
137
|
+
const dir = path.resolve(options.from);
|
|
118
138
|
if (!usable(dir)) fail(`${dir} does not look like the upstream harness: no ${MANIFEST}`);
|
|
119
139
|
return { dir, temporary: false };
|
|
120
140
|
}
|
|
121
141
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
|
|
122
142
|
say(`cloning ${TEMPLATE} at ${ref}`);
|
|
123
143
|
const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
|
|
124
|
-
if (r.status !== 0) fail(`could not clone the upstream at ${ref}\n${r.output}`);
|
|
144
|
+
if (r.status !== 0) { fs.rmSync(dir, { recursive: true, force: true }); fail(`could not clone the upstream at ${ref}\n${r.output}`); }
|
|
125
145
|
if (!usable(dir)) {
|
|
126
146
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
127
147
|
fail(`${TEMPLATE} at ${ref} carries no ${MANIFEST}, so there is nothing to install from; try another --ref`);
|
|
@@ -151,8 +171,7 @@ function installer() {
|
|
|
151
171
|
const GIT = ["-c", "core.longpaths=true"];
|
|
152
172
|
const at = (dir, args) => lib.run("git", [...GIT, "-C", dir, ...args]);
|
|
153
173
|
|
|
154
|
-
// The upstream's version of a path at a commit, or null when the file did not exist there.
|
|
155
|
-
// a missing base is detected: a rewritten history no longer holds the recorded commit.
|
|
174
|
+
// The upstream's version of a path at a commit, or null when the file did not exist there.
|
|
156
175
|
// Read raw rather than through lib.run, which trims trailing whitespace: that is right for the
|
|
157
176
|
// plumbing whose output is a hash or a status line, and wrong for a file. Trimmed, every installed
|
|
158
177
|
// file lost its final newline, no copy was ever byte-identical to the upstream, and so every later
|
|
@@ -173,6 +192,82 @@ const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
|
|
|
173
192
|
? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
|
|
174
193
|
: a === b;
|
|
175
194
|
|
|
195
|
+
// ---------------------------------------------------------------- the adapters
|
|
196
|
+
//
|
|
197
|
+
// What plan() reads, and nothing more: the upstream at any commit, and the target as it stands. A
|
|
198
|
+
// real run backs them with the upstream's git checkout and the target's directory; the suite backs
|
|
199
|
+
// them with maps, so a whole install is a table row rather than a clone and a temp tree.
|
|
200
|
+
|
|
201
|
+
// Every blob in one commit, read in two calls rather than one `git show` per path: an install reads
|
|
202
|
+
// every file at the head, and a process per file made a first install take most of a minute on
|
|
203
|
+
// Windows. Keyed by path, holding what blob() would return; a submodule entry is not a blob and is
|
|
204
|
+
// left out, as `git show` would fail on it too.
|
|
205
|
+
function treeBlobs(dir, commit) {
|
|
206
|
+
const listing = spawnSync("git", [...GIT, "-C", dir, "ls-tree", "-r", "-z", commit], { maxBuffer: 64 * 1024 * 1024 });
|
|
207
|
+
if (listing.status !== 0) return null;
|
|
208
|
+
const entries = listing.stdout.toString("utf8").split("\0").filter(Boolean)
|
|
209
|
+
.map(line => { const tab = line.indexOf("\t"); const [, type, oid] = line.slice(0, tab).split(" "); return { type, oid, file: line.slice(tab + 1) }; })
|
|
210
|
+
.filter(e => e.type === "blob");
|
|
211
|
+
const r = spawnSync("git", [...GIT, "-C", dir, "cat-file", "--batch"],
|
|
212
|
+
{ input: entries.map(e => e.oid).join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
|
|
213
|
+
if (r.status !== 0) return null;
|
|
214
|
+
const blobs = new Map();
|
|
215
|
+
let at = 0;
|
|
216
|
+
for (const { file } of entries) {
|
|
217
|
+
const eol = r.stdout.indexOf(10, at);
|
|
218
|
+
const size = Number(r.stdout.toString("utf8", at, eol).split(" ")[2]);
|
|
219
|
+
const bytes = r.stdout.subarray(eol + 1, eol + 1 + size);
|
|
220
|
+
blobs.set(file, bytes.includes(0) ? Buffer.from(bytes) : bytes.toString("utf8"));
|
|
221
|
+
at = eol + 1 + size + 1;
|
|
222
|
+
}
|
|
223
|
+
return blobs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function gitUpstream(dir, head) {
|
|
227
|
+
let atHead;
|
|
228
|
+
return {
|
|
229
|
+
// Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and
|
|
230
|
+
// the harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill
|
|
231
|
+
// under .claude/skills. Written as ordinary files they become text files holding a path,
|
|
232
|
+
// which is how a harness ends up looking installed while the agent sees no skills at all.
|
|
233
|
+
files() {
|
|
234
|
+
const rows = repoView.indexModes(dir);
|
|
235
|
+
if (!rows) fail(`could not list the upstream's files in ${dir}`);
|
|
236
|
+
return rows.map(({ file, link, exec }) => ({ file, link, exec }));
|
|
237
|
+
},
|
|
238
|
+
// The head is read whole on first use; any other commit, which only a base or a base search
|
|
239
|
+
// asks for, one path at a time.
|
|
240
|
+
blob(commit, file) {
|
|
241
|
+
if (commit !== head) return blob(dir, commit, file);
|
|
242
|
+
if (atHead === undefined) atHead = treeBlobs(dir, head);
|
|
243
|
+
if (atHead === null) return blob(dir, commit, file);
|
|
244
|
+
return atHead.has(file) ? atHead.get(file) : null;
|
|
245
|
+
},
|
|
246
|
+
// A rewritten history no longer holds the recorded commit, which leaves the run without a base.
|
|
247
|
+
hasCommit: commit => at(dir, ["cat-file", "-e", `${commit}^{commit}`]).status === 0,
|
|
248
|
+
// The commits that touched a path, newest first.
|
|
249
|
+
history(file) {
|
|
250
|
+
const r = at(dir, ["log", "--format=%H", "--", file]);
|
|
251
|
+
return r.status === 0 ? r.output.split(/\r?\n/).filter(Boolean) : [];
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function fsTarget(root) {
|
|
257
|
+
const full = file => path.join(root, file);
|
|
258
|
+
return {
|
|
259
|
+
exists: file => fs.existsSync(full(file)),
|
|
260
|
+
// A Buffer when asked for bytes, text otherwise.
|
|
261
|
+
read: (file, binary) => binary ? fs.readFileSync(full(file)) : fs.readFileSync(full(file), "utf8"),
|
|
262
|
+
// null when nothing is there; otherwise whether it is a symlink, and where it points.
|
|
263
|
+
lstat(file) {
|
|
264
|
+
let s;
|
|
265
|
+
try { s = fs.lstatSync(full(file)); } catch { return null; }
|
|
266
|
+
return s.isSymbolicLink() ? { link: fs.readlinkSync(full(file)).split(path.sep).join("/") } : { link: null };
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
176
271
|
// The receipt is missing, so the base is found instead: the upstream version this copy is closest to
|
|
177
272
|
// is where the project forked from, whatever a receipt would have said. An exact match is the clean
|
|
178
273
|
// case, an older copy nobody touched; a project that has since edited its own file matches nothing
|
|
@@ -183,17 +278,15 @@ const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
|
|
|
183
278
|
// Under half the lines in common is a different file, not an older one, and merging against it would
|
|
184
279
|
// invent a diff the project never made.
|
|
185
280
|
const NEAREST = 0.5;
|
|
186
|
-
function recoverBase(
|
|
187
|
-
const r = at(dir, ["log", "--format=%H", "--", file]);
|
|
188
|
-
if (r.status !== 0) return null;
|
|
281
|
+
function recoverBase(upstream, file, ours) {
|
|
189
282
|
const want = lineCounts(ours);
|
|
190
283
|
let best = null;
|
|
191
284
|
let nearest = NEAREST;
|
|
192
285
|
// Oldest first, and a tie goes to the first seen: two upstream versions one line apart score the
|
|
193
286
|
// same against a copy that has neither, and the older of them is the one whose merge puts that
|
|
194
287
|
// line back. The newer would drop it silently, which is the failure this policy exists to stop.
|
|
195
|
-
for (const commit of
|
|
196
|
-
const text = blob(
|
|
288
|
+
for (const commit of upstream.history(file).reverse()) {
|
|
289
|
+
const text = upstream.blob(commit, file);
|
|
197
290
|
if (typeof text !== "string") continue;
|
|
198
291
|
if (text === ours) return text;
|
|
199
292
|
const shared = overlap(want, lineCounts(text));
|
|
@@ -242,59 +335,6 @@ function policyFor(rows, file, wants) {
|
|
|
242
335
|
return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
|
|
243
336
|
}
|
|
244
337
|
|
|
245
|
-
// Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and the
|
|
246
|
-
// harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill under
|
|
247
|
-
// .claude/skills. Written as ordinary files they become text files holding a path, which is how a
|
|
248
|
-
// harness ends up looking installed while the agent sees no skills and no agents at all.
|
|
249
|
-
function templateFiles(dir) {
|
|
250
|
-
const r = at(dir, ["ls-files", "-s"]);
|
|
251
|
-
if (r.status !== 0) fail(`could not list the upstream's files\n${r.output}`);
|
|
252
|
-
return r.output.split(/\r?\n/).filter(Boolean).map(line => {
|
|
253
|
-
const [meta, file] = line.split("\t");
|
|
254
|
-
return { file, link: meta.startsWith("120000"), exec: meta.startsWith("100755") };
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
|
|
259
|
-
// whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
|
|
260
|
-
// that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
|
|
261
|
-
// core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
|
|
262
|
-
// hooks would run for whoever installed them and silently never run for anyone else. `git add
|
|
263
|
-
// --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
|
|
264
|
-
// install stages these few files rather than leaving them for the project's own `git add`.
|
|
265
|
-
function carryMode(target, file) {
|
|
266
|
-
try { fs.chmodSync(path.join(target, file), 0o755); } catch { /* the filesystem does not do modes */ }
|
|
267
|
-
const r = lib.run("git", [...GIT, "-C", target, "add", "--chmod=+x", "--", file]);
|
|
268
|
-
if (r.status !== 0) say(`could not mark ${file} executable: ${r.output}`);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// A symlink recorded in Git is a blob holding its target. Windows needs Developer Mode and
|
|
272
|
-
// core.symlinks=true for this to work at all, so a refusal is reported rather than thrown: the
|
|
273
|
-
// harness still functions with the links missing, it is just invisible to the harnesses that read
|
|
274
|
-
// them, and README says how to turn them on.
|
|
275
|
-
function link(target, file, to) {
|
|
276
|
-
const full = path.join(target, file);
|
|
277
|
-
if (dryRun) return "written";
|
|
278
|
-
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
279
|
-
let existing = null;
|
|
280
|
-
try { existing = fs.lstatSync(full); } catch { /* absent */ }
|
|
281
|
-
if (existing) {
|
|
282
|
-
// Something of the project's is in the way -- or, in a repo whose harness predates the lock
|
|
283
|
-
// file, the link itself checked out as a text file holding a path, which is the failure that
|
|
284
|
-
// leaves an agent seeing no skills at all. --adopt is the only thing that replaces it.
|
|
285
|
-
if (!existing.isSymbolicLink()) { if (!adopt) return "kept"; fs.unlinkSync(full); }
|
|
286
|
-
else if (fs.readlinkSync(full).split(path.sep).join("/") === to) return null;
|
|
287
|
-
else fs.unlinkSync(full);
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
fs.symlinkSync(to.split("/").join(path.sep), full, "dir");
|
|
291
|
-
return existing ? "merged" : "written";
|
|
292
|
-
} catch (e) {
|
|
293
|
-
say(`could not create the symlink ${file} -> ${to}: ${e.code || e.message}`);
|
|
294
|
-
return "kept";
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
338
|
// ---------------------------------------------------------------- skeletons
|
|
299
339
|
|
|
300
340
|
// Three files the upstream does not ship, because there they would be lies: MEMORY.md
|
|
@@ -311,14 +351,6 @@ const SKELETONS = {
|
|
|
311
351
|
"`pre-commit` and `pre-push` hooks refuse to let work leave a clone while any value is still a",
|
|
312
352
|
"`<placeholder>`.",
|
|
313
353
|
"",
|
|
314
|
-
"- **Name:** <name>",
|
|
315
|
-
"- **Purpose:** <purpose>",
|
|
316
|
-
"- **Prose language:** <prose language>",
|
|
317
|
-
"- **Requirements:** <requirements>",
|
|
318
|
-
"- **Unit type:** <unit type>",
|
|
319
|
-
"- **Language:** <language>",
|
|
320
|
-
"- **Runtime / package manager:** <runtime>",
|
|
321
|
-
"",
|
|
322
354
|
],
|
|
323
355
|
"CONTEXT.md": [
|
|
324
356
|
"# Context",
|
|
@@ -338,21 +370,13 @@ const SKELETONS = {
|
|
|
338
370
|
],
|
|
339
371
|
};
|
|
340
372
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return [...
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function skeletons(target) {
|
|
351
|
-
for (const [file, lines] of Object.entries(SKELETONS)) {
|
|
352
|
-
if (fs.existsSync(path.join(target, file))) { step("seed", "100644", "yours", file); continue; }
|
|
353
|
-
write(target, file, skeletonLines(target, file, lines).join("\n"));
|
|
354
|
-
step("seed", "100644", "created", file, "seeded");
|
|
355
|
-
}
|
|
373
|
+
// MEMORY.md's fact lines come from scripts/project-facts.js, the table the gate checks them against,
|
|
374
|
+
// so a skeleton never asks for a fact the gate does not know or leaves out one it requires. A repo
|
|
375
|
+
// that already has an INTENT.md names the product and its purpose there, so the MEMORY.md laid down
|
|
376
|
+
// beside it leaves those two out rather than asking for them a second time.
|
|
377
|
+
function skeletonLines(file, lines, hasIntent) {
|
|
378
|
+
if (file !== projectFacts.MEMORY) return lines;
|
|
379
|
+
return [...lines, ...projectFacts.skeleton(hasIntent), ""];
|
|
356
380
|
}
|
|
357
381
|
|
|
358
382
|
// ---------------------------------------------------------------- merging
|
|
@@ -372,25 +396,6 @@ function threeWay(base, ours, theirs) {
|
|
|
372
396
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
373
397
|
}
|
|
374
398
|
|
|
375
|
-
// ---------------------------------------------------------------- reporting
|
|
376
|
-
|
|
377
|
-
const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [], check: null };
|
|
378
|
-
const say = m => console.log(m);
|
|
379
|
-
function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
|
|
380
|
-
|
|
381
|
-
// An install rewrites someone else's repository, so it says what it did to every path while it does
|
|
382
|
-
// it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
|
|
383
|
-
// lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
|
|
384
|
-
// skills, and both look installed. One call records the outcome and prints the line, so the running
|
|
385
|
-
// commentary and the summary below cannot drift apart.
|
|
386
|
-
const quiet = flag("--quiet");
|
|
387
|
-
const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
|
|
388
|
-
function step(policy, m, outcome, file, bucket) {
|
|
389
|
-
if (bucket) notes[bucket].push(file);
|
|
390
|
-
if (!quiet) say(` ${policy.padEnd(9)}${m} ${outcome.padEnd(12)}${file}`);
|
|
391
|
-
}
|
|
392
|
-
const phase = m => { if (!quiet) say(`\n${m}`); };
|
|
393
|
-
|
|
394
399
|
// Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
|
|
395
400
|
// the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
|
|
396
401
|
// touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
|
|
@@ -409,14 +414,11 @@ const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
|
|
|
409
414
|
//
|
|
410
415
|
// What happens to one file the target already has, decided apart from doing it. Everything these two
|
|
411
416
|
// read is an argument, including the three things they cannot compute -- the base's text, the search
|
|
412
|
-
// for a base when the receipt has none, and the three-way merge itself -- so
|
|
413
|
-
// in and the loop below is left reading, writing and reporting.
|
|
417
|
+
// for a base when the receipt has none, and the three-way merge itself -- so plan() passes them in.
|
|
414
418
|
//
|
|
415
419
|
// Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
|
|
416
420
|
// belongs in (null for a file nothing happened to), and `text` what to write, or null to write
|
|
417
|
-
// nothing.
|
|
418
|
-
// someone else's repository, and every branch below used to need a git checkout and a temp tree to
|
|
419
|
-
// reach even once.
|
|
421
|
+
// nothing.
|
|
420
422
|
|
|
421
423
|
// A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
|
|
422
424
|
// decides which. A logo the project replaced stays replaced.
|
|
@@ -468,246 +470,290 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
|
|
|
468
470
|
return { outcome: "merged", bucket: "merged", text: result };
|
|
469
471
|
}
|
|
470
472
|
|
|
471
|
-
|
|
472
|
-
const full = path.join(target, file);
|
|
473
|
-
if (dryRun) return;
|
|
474
|
-
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
475
|
-
fs.writeFileSync(full, text);
|
|
476
|
-
if (exec) carryMode(target, file);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// ---------------------------------------------------------------- the self check
|
|
480
|
-
|
|
481
|
-
// Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
|
|
482
|
-
// still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
|
|
483
|
-
// this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
|
|
484
|
-
// harness invariants, and scripts/check-harness.js holds them as functions of a root.
|
|
473
|
+
// ---------------------------------------------------------------- the plan
|
|
485
474
|
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
475
|
+
// Everything a run will do to the target, decided before anything is written: an install rewrites
|
|
476
|
+
// someone else's repository, and deciding and writing in the same loop left every branch but the
|
|
477
|
+
// per-file decision reachable only through a git checkout and a temp tree. A dry run prints the plan;
|
|
478
|
+
// a real run applies it. Each entry is one line of the run's output and at most one thing done to
|
|
479
|
+
// one path:
|
|
480
|
+
// file, policy, mode, outcome, bucket what the run prints, and the summary list the path joins
|
|
481
|
+
// write the path's new content, text or a Buffer
|
|
482
|
+
// link, replace a symlink to `link`, replacing what is there when `replace`
|
|
483
|
+
// exec mark the path executable, whether or not it is written
|
|
484
|
+
// mkdir create the path's folder and nothing else
|
|
485
|
+
// silent counted in the summary, never printed as a line
|
|
486
|
+
// and a { phase } entry heads each section of the output.
|
|
487
|
+
const mode = f => f.link ? "120000" : f.exec ? "100755" : "100644";
|
|
488
|
+
const SKILLS = ".agents/skills/";
|
|
489
|
+
|
|
490
|
+
// `previous` is the target's harness-lock.json, or null; `stamp` is what the receipt records about
|
|
491
|
+
// this run besides the upstream commit, passed in so a plan is the same whenever it is made.
|
|
492
|
+
function plan({ upstream, target, rows, head, ref, previous, options, stamp = {} }) {
|
|
493
|
+
const entries = [];
|
|
494
|
+
const notices = [];
|
|
495
|
+
const add = e => entries.push(e);
|
|
496
|
+
|
|
497
|
+
// A base is what makes this an update rather than an overwrite. Without one -- a first install,
|
|
498
|
+
// or an upstream whose history was rewritten -- an existing file is left alone instead of being
|
|
499
|
+
// guessed at, and the run says so.
|
|
500
|
+
let base = previous ? previous.commit : null;
|
|
501
|
+
if (base && !upstream.hasCommit(base)) {
|
|
502
|
+
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`);
|
|
503
|
+
base = null;
|
|
504
|
+
}
|
|
505
|
+
// --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest way to
|
|
506
|
+
// reach that state is an install that wrote the receipt and kept a stale harness. So the run stays
|
|
507
|
+
// open at the recorded commit, and main() answers "nothing to update" only without --adopt.
|
|
508
|
+
if (previous && base === head) notices.push(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
|
|
509
|
+
|
|
510
|
+
// A repo carrying a harness from before harness-lock.json existed. Without a base the rule below
|
|
511
|
+
// keeps every file that is already there, which protects the project's work and also preserves
|
|
512
|
+
// the old harness: its checks then run against the new skills and agents and fail, naming rules
|
|
513
|
+
// this version dropped. Worth saying out loud, because the run otherwise looks like a success.
|
|
514
|
+
const MARKERS = [".agents/hooks/lib.js", "scripts/lib.js", ".githooks/pre-commit"];
|
|
515
|
+
const stale = previous ? [] : MARKERS.filter(f => target.exists(f));
|
|
516
|
+
if (stale.length) {
|
|
517
|
+
notices.push(options.adopt
|
|
518
|
+
? `this repo has a harness but no ${LOCK}, and --adopt was given: harness files are replaced with ${ref}'s, and edits to them are lost`
|
|
519
|
+
: `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.`);
|
|
520
|
+
}
|
|
512
521
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if
|
|
528
|
-
|
|
529
|
-
|
|
522
|
+
const files = upstream.files();
|
|
523
|
+
const skills = [];
|
|
524
|
+
add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
|
|
525
|
+
for (const entry of files) {
|
|
526
|
+
const { file, link: isLink, exec } = entry;
|
|
527
|
+
const policy = policyFor(rows, file, options.wants);
|
|
528
|
+
const m = mode(entry);
|
|
529
|
+
const theirs = upstream.blob(head, file);
|
|
530
|
+
// Git listed the path a moment ago, so failing to read it is the checkout being unhappy
|
|
531
|
+
// rather than the file being absent. Said out loud: skipped quietly, the run reports a clean
|
|
532
|
+
// install of a harness missing whichever files the reader was never told about.
|
|
533
|
+
if (theirs === null) { add({ file, policy, mode: m, outcome: "UNREADABLE", bucket: "unreadable" }); continue; }
|
|
534
|
+
const exists = target.exists(file);
|
|
535
|
+
// The executable bit is not the project's content, so a file kept for its content still has
|
|
536
|
+
// its mode corrected. Git runs a hook only if it is executable and says nothing when it is
|
|
537
|
+
// not, so a hook kept at 100644 by an install that had no merge base looks installed and
|
|
538
|
+
// gates nothing at all -- the failure the mode column exists to catch.
|
|
539
|
+
const line = (outcome, bucket, act = {}) =>
|
|
540
|
+
add({ file, policy, mode: m, outcome, bucket, ...act, exec: exec && (exists || act.write !== undefined) });
|
|
541
|
+
|
|
542
|
+
// Not installed anywhere, and named in one line of the summary instead: sixty-five lines
|
|
543
|
+
// saying nothing happened bury the thirty-eight saying something did.
|
|
544
|
+
if (policy === "template") { line("template", "template", { silent: true }); continue; }
|
|
545
|
+
if (isLink) {
|
|
546
|
+
// A skill link is relink's to make, once the directory it lives in exists: it knows which
|
|
547
|
+
// skills this project actually has, where the upstream only knows its own.
|
|
548
|
+
if (policy === "skills") { add({ file, mkdir: true, silent: true }); continue; }
|
|
549
|
+
const to = theirs.trim();
|
|
550
|
+
const found = target.lstat(file);
|
|
551
|
+
// Something of the project's in the way -- or, in a repo whose harness predates the lock
|
|
552
|
+
// file, the link itself checked out as a text file holding a path, which is the failure
|
|
553
|
+
// that leaves an agent seeing no skills at all. --adopt is the only thing that replaces it.
|
|
554
|
+
if (!found) line("written", "written", { link: to });
|
|
555
|
+
else if (!found.link) line(options.adopt ? "merged" : "yours", options.adopt ? "merged" : "kept", options.adopt ? { link: to, replace: true } : {});
|
|
556
|
+
else if (found.link === to) line("unchanged", null);
|
|
557
|
+
else line("merged", "merged", { link: to, replace: true });
|
|
558
|
+
continue;
|
|
530
559
|
}
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
if (
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
// naming rules this version dropped. Worth saying out loud, because the run otherwise looks
|
|
542
|
-
// like a success.
|
|
543
|
-
const MARKERS = [".agents/hooks/lib.js", "scripts/lib.js", ".githooks/pre-commit"];
|
|
544
|
-
const stale = !previous && MARKERS.filter(f => fs.existsSync(path.join(target, f)));
|
|
545
|
-
if (stale && stale.length) {
|
|
546
|
-
if (adopt) say(`this repo has a harness but no ${LOCK}, and --adopt was given: harness files are replaced with ${ref}'s, and edits to them are lost`);
|
|
547
|
-
else say(`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.`);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const wants = name => flag(`--${name}`);
|
|
551
|
-
const files = templateFiles(templateDir);
|
|
552
|
-
const skills = [];
|
|
553
|
-
|
|
554
|
-
phase(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`);
|
|
555
|
-
for (const entry of files) {
|
|
556
|
-
const { file, link: isLink, exec } = entry;
|
|
557
|
-
const policy = policyFor(rows, file, wants);
|
|
558
|
-
const m = mode(entry);
|
|
559
|
-
const theirs = blob(templateDir, head, file);
|
|
560
|
-
// Git listed the path a moment ago, so failing to read it is the checkout being unhappy
|
|
561
|
-
// rather than the file being absent. Said out loud: skipped quietly, the run reports a
|
|
562
|
-
// clean install of a harness missing whichever files the reader was never told about.
|
|
563
|
-
if (theirs === null) { step(policy, m, "UNREADABLE", file, "unreadable"); continue; }
|
|
564
|
-
const full = path.join(target, file);
|
|
565
|
-
const exists = fs.existsSync(full);
|
|
566
|
-
|
|
567
|
-
// The executable bit is not the project's content, so a file kept for its content still
|
|
568
|
-
// has its mode corrected. Git runs a hook only if it is executable and says nothing when
|
|
569
|
-
// it is not, so a hook kept at 100644 by an install that had no merge base looks
|
|
570
|
-
// installed and gates nothing at all -- the failure this whole column exists to catch.
|
|
571
|
-
if (exec && exists && !dryRun) carryMode(target, file);
|
|
572
|
-
|
|
573
|
-
// Not installed anywhere, and named in one line of the summary instead: sixty-five
|
|
574
|
-
// lines saying nothing happened bury the thirty-eight saying something did.
|
|
575
|
-
if (policy === "template") { notes.template.push(file); continue; }
|
|
576
|
-
if (isLink) {
|
|
577
|
-
// A skill link is relink's to make, once the directory it lives in exists: it knows
|
|
578
|
-
// which skills this project actually has, where the upstream only knows its own.
|
|
579
|
-
if (policy === "skills") {
|
|
580
|
-
if (!dryRun) fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
const how = link(target, file, theirs.trim());
|
|
584
|
-
step(policy, m, how === "kept" ? "yours" : how || "unchanged", file, how);
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
587
|
-
// Reported one line per skill by mergeSkills below, not one per reference file: a skill
|
|
588
|
-
// is the unit a project installs, and its files run to several hundred.
|
|
589
|
-
if (policy === "skills") { skills.push(file); continue; }
|
|
590
|
-
// Reported only when the target actually has it: "left alone, yours" about a file the
|
|
591
|
-
// repo does not have names something that was never there.
|
|
592
|
-
if (policy === "skip") { step(policy, m, exists ? "yours" : "absent", file, exists && "skipped"); continue; }
|
|
593
|
-
|
|
594
|
-
if (policy === "seed") {
|
|
595
|
-
if (exists) { step(policy, m, "yours", file, "kept"); continue; }
|
|
596
|
-
write(target, file, theirs, exec);
|
|
597
|
-
step(policy, m, "created", file, "seeded");
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
// merge
|
|
601
|
-
if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
|
|
602
|
-
|
|
603
|
-
// Everything the decision needs, read here; what to do with its answer, done here. The
|
|
604
|
-
// decision itself is decideBinary/decideText, which touch neither git nor the disk.
|
|
605
|
-
const hasBase = base !== null;
|
|
606
|
-
const held = Buffer.isBuffer(theirs) ? fs.readFileSync(full) : fs.readFileSync(full, "utf8");
|
|
607
|
-
const { outcome, bucket, text } = Buffer.isBuffer(theirs)
|
|
608
|
-
? decideBinary({ held, theirs, hasBase, adopt }, { baseBytes: () => blob(templateDir, base, file) })
|
|
609
|
-
: decideText({ policy, raw: held, theirs, hasBase, adopt }, {
|
|
610
|
-
baseText: () => blob(templateDir, base, file),
|
|
611
|
-
recoverBase: ours => recoverBase(templateDir, file, ours),
|
|
612
|
-
merge: threeWay,
|
|
613
|
-
});
|
|
614
|
-
if (text !== null) write(target, file, text, exec);
|
|
615
|
-
step(policy, m, outcome, file, bucket);
|
|
560
|
+
// Reported one line per skill by planSkills below, not one per reference file: a skill is the
|
|
561
|
+
// unit a project installs, and its files run to several hundred.
|
|
562
|
+
if (policy === "skills") { skills.push(entry); continue; }
|
|
563
|
+
// Reported only when the target actually has it: "left alone, yours" about a file the repo
|
|
564
|
+
// does not have names something that was never there.
|
|
565
|
+
if (policy === "skip") { line(exists ? "yours" : "absent", exists ? "skipped" : null); continue; }
|
|
566
|
+
if (policy === "seed") {
|
|
567
|
+
if (exists) line("yours", "kept");
|
|
568
|
+
else line("created", "seeded", { write: theirs });
|
|
569
|
+
continue;
|
|
616
570
|
}
|
|
571
|
+
// merge and reconcile
|
|
572
|
+
if (!exists) { line("written", "written", { write: theirs }); continue; }
|
|
573
|
+
const hasBase = base !== null;
|
|
574
|
+
const held = target.read(file, Buffer.isBuffer(theirs));
|
|
575
|
+
const { outcome, bucket, text } = Buffer.isBuffer(theirs)
|
|
576
|
+
? decideBinary({ held, theirs, hasBase, adopt: options.adopt }, { baseBytes: () => upstream.blob(base, file) })
|
|
577
|
+
: decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
|
|
578
|
+
baseText: () => upstream.blob(base, file),
|
|
579
|
+
recoverBase: ours => recoverBase(upstream, file, ours),
|
|
580
|
+
merge: threeWay,
|
|
581
|
+
});
|
|
582
|
+
line(outcome, bucket, text === null ? {} : { write: text });
|
|
583
|
+
}
|
|
617
584
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (!dryRun) {
|
|
624
|
-
fs.writeFileSync(lockPath, JSON.stringify({
|
|
625
|
-
template: TEMPLATE, ref, commit: head, ...installer(),
|
|
626
|
-
updated: new Date().toISOString().slice(0, 10),
|
|
627
|
-
}, null, 2) + "\n");
|
|
628
|
-
finish(target);
|
|
629
|
-
// After finish(), because the invariants check the links relink has just written.
|
|
630
|
-
if (!flag("--no-check")) notes.check = selfCheck(target, templateDir);
|
|
631
|
-
}
|
|
632
|
-
report(target, head, ref, base);
|
|
633
|
-
} finally {
|
|
634
|
-
if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
|
|
585
|
+
add({ phase: "skeletons a project starts with" });
|
|
586
|
+
const hasIntent = target.exists(projectFacts.INTENT);
|
|
587
|
+
for (const [file, lines] of Object.entries(SKELETONS)) {
|
|
588
|
+
if (target.exists(file)) add({ file, policy: "seed", mode: "100644", outcome: "yours", bucket: null });
|
|
589
|
+
else add({ file, policy: "seed", mode: "100644", outcome: "created", bucket: "seeded", write: skeletonLines(file, lines, hasIntent).join("\n") });
|
|
635
590
|
}
|
|
591
|
+
|
|
592
|
+
add({ phase: "skills, merged by name" });
|
|
593
|
+
entries.push(...planSkills(upstream, target, head, skills));
|
|
594
|
+
|
|
595
|
+
add({ file: LOCK, silent: true, write: JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp }, null, 2) + "\n" });
|
|
596
|
+
return { entries, notices, base };
|
|
636
597
|
}
|
|
637
598
|
|
|
638
599
|
// Skills merge by name, not by content: the upstream's are added and updated, and a skill the
|
|
639
600
|
// project vendored itself is never removed. skills-lock.json is the union, the project's entry
|
|
640
601
|
// winning where both name the same skill, so a project that pinned a different source keeps it.
|
|
641
|
-
function
|
|
642
|
-
const
|
|
643
|
-
const
|
|
644
|
-
const
|
|
645
|
-
const ourLock = fs.existsSync(ours) ? JSON.parse(fs.readFileSync(ours, "utf8")) : { skills: {} };
|
|
602
|
+
function planSkills(upstream, target, head, files) {
|
|
603
|
+
const LOCKFILE = "skills-lock.json";
|
|
604
|
+
const theirLock = JSON.parse(upstream.blob(head, LOCKFILE) || '{"skills":{}}');
|
|
605
|
+
const ourLock = target.exists(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
|
|
646
606
|
ourLock.skills = ourLock.skills || {};
|
|
647
|
-
|
|
648
607
|
const mine = new Set(Object.keys(ourLock.skills));
|
|
649
|
-
|
|
650
|
-
|
|
608
|
+
|
|
609
|
+
const out = [];
|
|
610
|
+
// One line per skill, not per file. Outcome is decided across the whole folder: a skill counts as
|
|
611
|
+
// changed the moment any file in it did, and only an untouched folder reads "unchanged".
|
|
651
612
|
const outcomes = new Map();
|
|
652
613
|
const seen = name => outcomes.get(name) || outcomes.set(name, { added: 0, updated: 0, files: 0 }).get(name);
|
|
653
|
-
for (const file of files) {
|
|
614
|
+
for (const { file, exec } of files) {
|
|
654
615
|
if (!file.startsWith(SKILLS)) continue; // .claude/skills links are rebuilt, not copied
|
|
655
616
|
const name = file.slice(SKILLS.length).split("/")[0];
|
|
656
617
|
const tally = seen(name);
|
|
657
618
|
tally.files++;
|
|
619
|
+
const exists = target.exists(file);
|
|
620
|
+
// A script the skill runs keeps its executable bit whoever owns the content, as a hook does.
|
|
621
|
+
if (exec && exists) out.push({ file, silent: true, exec: true });
|
|
658
622
|
// A skill the project installed under a name the upstream also uses stays the project's.
|
|
659
623
|
if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
|
|
660
|
-
const text = blob(
|
|
624
|
+
const text = upstream.blob(head, file);
|
|
661
625
|
if (text === null) continue;
|
|
662
|
-
const full = path.join(target, file);
|
|
663
|
-
const exists = fs.existsSync(full);
|
|
664
626
|
// A vendored file the project has not touched still differs byte-for-byte on Windows, where
|
|
665
627
|
// Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
|
|
666
|
-
const held = exists ?
|
|
628
|
+
const held = exists ? target.read(file, true) : null;
|
|
629
|
+
let write;
|
|
667
630
|
if (Buffer.isBuffer(text)) {
|
|
668
631
|
if (same(held, text)) continue;
|
|
669
|
-
write
|
|
632
|
+
write = text;
|
|
670
633
|
} else {
|
|
671
634
|
const ourText = held === null ? null : held.toString("utf8");
|
|
672
635
|
if (ourText !== null && toLf(ourText) === text) continue;
|
|
673
|
-
write
|
|
636
|
+
write = asFound(text, ourText !== null && isCrlf(ourText));
|
|
674
637
|
}
|
|
675
|
-
if (exists)
|
|
676
|
-
|
|
638
|
+
if (exists) tally.updated++; else tally.added++;
|
|
639
|
+
out.push({ file, silent: true, write, bucket: exists ? "merged" : "written", exec: exec && !exists });
|
|
677
640
|
}
|
|
678
641
|
for (const [name, t] of [...outcomes].sort()) {
|
|
679
642
|
const what = t.yours ? "yours" : t.added ? "added" : t.updated ? "updated" : "unchanged";
|
|
680
|
-
|
|
643
|
+
out.push({ file: `${SKILLS}${name} (${t.files} file(s))`, policy: "skills", mode: "100644", outcome: what, bucket: null });
|
|
681
644
|
}
|
|
682
645
|
for (const [name, entry] of Object.entries(theirLock.skills)) {
|
|
683
646
|
if (!ourLock.skills[name]) ourLock.skills[name] = entry;
|
|
684
647
|
}
|
|
685
|
-
|
|
648
|
+
out.push({ file: LOCKFILE, silent: true, write: JSON.stringify(ourLock, null, 2) + "\n" });
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------- applying it
|
|
653
|
+
|
|
654
|
+
// Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
|
|
655
|
+
// whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
|
|
656
|
+
// that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
|
|
657
|
+
// core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
|
|
658
|
+
// hooks would run for whoever installed them and silently never run for anyone else. `git add
|
|
659
|
+
// --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
|
|
660
|
+
// install stages these few files rather than leaving them for the project's own `git add`.
|
|
661
|
+
// Returns why it failed, or null.
|
|
662
|
+
function carryMode(root, file) {
|
|
663
|
+
try { fs.chmodSync(path.join(root, file), 0o755); } catch { /* the filesystem does not do modes */ }
|
|
664
|
+
const r = lib.run("git", [...GIT, "-C", root, "add", "--chmod=+x", "--", file]);
|
|
665
|
+
return r.status === 0 ? null : `could not mark ${file} executable: ${r.output}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Carries out one entry. Returns null when it went as planned, or what happened instead: `why` to
|
|
669
|
+
// print, and the `outcome` and `bucket` the run reports in place of the plan's.
|
|
670
|
+
function perform(root, e) {
|
|
671
|
+
const full = path.join(root, e.file);
|
|
672
|
+
if (e.mkdir || e.link !== undefined || e.write !== undefined) fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
673
|
+
if (e.link !== undefined) {
|
|
674
|
+
if (e.replace) fs.unlinkSync(full);
|
|
675
|
+
// Windows needs Developer Mode and core.symlinks=true for this to work at all, so a refusal
|
|
676
|
+
// is reported rather than thrown: the harness still functions with the link missing, it is
|
|
677
|
+
// just invisible to the harnesses that read it, and README says how to turn them on.
|
|
678
|
+
try { fs.symlinkSync(e.link.split("/").join(path.sep), full, "dir"); }
|
|
679
|
+
catch (err) { return { outcome: "yours", bucket: "kept", why: `could not create the symlink ${e.file} -> ${e.link}: ${err.code || err.message}` }; }
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
if (e.write !== undefined) fs.writeFileSync(full, e.write);
|
|
683
|
+
if (e.exec) { const why = carryMode(root, e.file); if (why) return { why }; }
|
|
684
|
+
return null;
|
|
686
685
|
}
|
|
687
686
|
|
|
687
|
+
// An install rewrites someone else's repository, so it says what it did to every path while it does
|
|
688
|
+
// it, and --quiet asks for the summary alone. The mode is worth a column of its own: a hook that
|
|
689
|
+
// lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
|
|
690
|
+
// skills, and both look installed. A dry run prints the same lines straight from the plan.
|
|
691
|
+
// Returns the entries as they turned out, which the summary is built from.
|
|
692
|
+
function apply(entries, root, options) {
|
|
693
|
+
const done = [];
|
|
694
|
+
for (const e of entries) {
|
|
695
|
+
if (e.phase) { if (!options.quiet) say(`\n${e.phase}`); continue; }
|
|
696
|
+
let shown = e;
|
|
697
|
+
if (!options.dryRun) {
|
|
698
|
+
const fix = perform(root, e);
|
|
699
|
+
if (fix && fix.why) say(fix.why);
|
|
700
|
+
if (fix && fix.outcome) shown = { ...e, outcome: fix.outcome, bucket: fix.bucket };
|
|
701
|
+
}
|
|
702
|
+
if (!shown.silent && !options.quiet) say(` ${shown.policy.padEnd(9)}${shown.mode} ${shown.outcome.padEnd(12)}${shown.file}`);
|
|
703
|
+
done.push(shown);
|
|
704
|
+
}
|
|
705
|
+
return done;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---------------------------------------------------------------- after it
|
|
709
|
+
|
|
688
710
|
// Two files nothing copied: the per-harness skill links, which depend on which skills this project
|
|
689
711
|
// has rather than which the upstream ships, and the third-party notice, which must describe this
|
|
690
712
|
// project's lock file. Both are generated, so the install leaves a harness that works rather than a
|
|
691
|
-
// list of commands to remember.
|
|
692
|
-
|
|
693
|
-
|
|
713
|
+
// list of commands to remember. Each run names the target with --root: the shared resolver prefers a
|
|
714
|
+
// harness's project-dir variable to the checkout a script sits in, and an install started from a
|
|
715
|
+
// session open on another repo would otherwise link and describe that repo instead.
|
|
716
|
+
function finish(target, options) {
|
|
717
|
+
if (!options.quiet) say("\nlinks and notices");
|
|
694
718
|
for (const [label, args] of [["links", ["relink"]], ["notices", ["notices"]]]) {
|
|
695
|
-
const r = lib.node([path.join(target, "scripts/skills.js"), ...args], { cwd: target });
|
|
719
|
+
const r = lib.node([path.join(target, "scripts/skills.js"), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
|
|
696
720
|
say(r.status === 0 ? r.output : `${label}: ${r.output}`);
|
|
697
721
|
}
|
|
698
722
|
}
|
|
699
723
|
|
|
700
|
-
|
|
724
|
+
// Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
|
|
725
|
+
// still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
|
|
726
|
+
// this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
|
|
727
|
+
// harness invariants, and scripts/check-harness.js holds them as functions of a root.
|
|
728
|
+
//
|
|
729
|
+
// So they run from the upstream checkout against the target, and nothing is written into the target
|
|
730
|
+
// to run them. The upstream's copy rather than the one just installed, so the check is the one that
|
|
731
|
+
// matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
|
|
732
|
+
// scripts, which the upstream's own CI has already done.
|
|
733
|
+
function selfCheck(target, templateDir, options) {
|
|
734
|
+
const script = path.join(templateDir, "scripts", "check-harness.js");
|
|
735
|
+
if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
|
|
736
|
+
if (!options.quiet) say("\nself check: the harness invariants, run from the upstream against this repo");
|
|
737
|
+
const harness = require(script);
|
|
738
|
+
const r = harness.check(target);
|
|
739
|
+
return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The summary, from the entries as they turned out. Returns the exit code: 1 while anything is left
|
|
743
|
+
// for the reader to act on.
|
|
744
|
+
function report({ entries, base, head, ref, target, check, options }) {
|
|
745
|
+
const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [] };
|
|
746
|
+
for (const e of entries) if (e.bucket) notes[e.bucket].push(e.file);
|
|
701
747
|
// Every path was named as it happened, so repeating the lists here doubles the output; a quiet
|
|
702
748
|
// run never saw them and gets them in full. Conflicts are listed either way: they are what the
|
|
703
749
|
// reader has to act on, and they belong beside the instructions for acting on them.
|
|
704
750
|
const list = (label, arr, always) => {
|
|
705
751
|
if (!arr.length) return;
|
|
706
|
-
if (quiet || always) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`);
|
|
752
|
+
if (options.quiet || always) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`);
|
|
707
753
|
else say(`\n${label}: ${arr.length}`);
|
|
708
754
|
};
|
|
709
755
|
say("");
|
|
710
|
-
say(dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
|
|
756
|
+
say(options.dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
|
|
711
757
|
if (!base) say("no merge base: this was an install, so nothing that already existed was changed");
|
|
712
758
|
list("added", notes.written);
|
|
713
759
|
list("merged", notes.merged);
|
|
@@ -729,21 +775,74 @@ function report(target, head, ref, base) {
|
|
|
729
775
|
list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
|
|
730
776
|
say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then check the harness:\n node scripts/check-harness.js`);
|
|
731
777
|
}
|
|
732
|
-
const check = notes.check;
|
|
733
778
|
if (check && check.skipped) say(`\nself check skipped: ${check.skipped}`);
|
|
734
779
|
else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
|
|
735
780
|
else if (check) say(`\nself check: ${check.summary}`);
|
|
736
781
|
|
|
737
|
-
if (!dryRun) {
|
|
782
|
+
if (!options.dryRun) {
|
|
738
783
|
say(`\nIn ${target}, point Git at the hooks once per clone, then check the harness:`);
|
|
739
784
|
say(` node scripts/githooks-init.js && node scripts/check-harness.js`);
|
|
740
785
|
}
|
|
741
|
-
|
|
786
|
+
return notes.conflicted.length || notes.unreadable.length || (check && check.failed) ? 1 : 0;
|
|
742
787
|
}
|
|
743
788
|
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
789
|
+
// ---------------------------------------------------------------- the run
|
|
790
|
+
|
|
791
|
+
// Returns the exit code, and throws Stop for a run that could not start.
|
|
792
|
+
function main(args) {
|
|
793
|
+
const options = parseOptions(args);
|
|
794
|
+
if (options.help) { console.log(usage()); return 0; }
|
|
795
|
+
const mistyped = mistypedArgs(args);
|
|
796
|
+
if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
|
|
797
|
+
const target = targetRoot(options);
|
|
798
|
+
if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
|
|
799
|
+
|
|
800
|
+
const lockPath = path.join(target, LOCK);
|
|
801
|
+
const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
|
|
802
|
+
const ref = options.ref || (previous ? previous.ref : DEFAULT_REF);
|
|
803
|
+
const { dir: templateDir, temporary } = templateCheckout(ref, options);
|
|
748
804
|
|
|
749
|
-
|
|
805
|
+
try {
|
|
806
|
+
// Checked before anything is said about the target, so a bad argument is the only message.
|
|
807
|
+
const rows = policies(templateDir);
|
|
808
|
+
const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
|
|
809
|
+
const unknown = unknownArgs(args, optional);
|
|
810
|
+
if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
|
|
811
|
+
const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
|
|
812
|
+
if (previous && previous.commit === head && !options.adopt) {
|
|
813
|
+
say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
|
|
814
|
+
return 0;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const planned = plan({
|
|
818
|
+
upstream: gitUpstream(templateDir, head), target: fsTarget(target), rows, head, ref, previous, options,
|
|
819
|
+
stamp: { ...installer(), updated: new Date().toISOString().slice(0, 10) },
|
|
820
|
+
});
|
|
821
|
+
for (const notice of planned.notices) say(notice);
|
|
822
|
+
const entries = apply(planned.entries, target, options);
|
|
823
|
+
let check = null;
|
|
824
|
+
if (!options.dryRun) {
|
|
825
|
+
// After the plan is applied, because relink needs the skills in place and the invariants
|
|
826
|
+
// check the links relink has just written.
|
|
827
|
+
finish(target, options);
|
|
828
|
+
if (options.check) check = selfCheck(target, templateDir, options);
|
|
829
|
+
}
|
|
830
|
+
return report({ entries, base: planned.base, head, ref, target, check, options });
|
|
831
|
+
} finally {
|
|
832
|
+
if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// The plan and the decisions under it, so the suite can put a case in and read the answer out rather
|
|
837
|
+
// than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
|
|
838
|
+
// writes nothing; main() writes to somebody's repository and is reached through the command line.
|
|
839
|
+
module.exports = { unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
|
|
840
|
+
|
|
841
|
+
if (require.main === module) {
|
|
842
|
+
try { process.exitCode = main(process.argv.slice(2)); }
|
|
843
|
+
catch (e) {
|
|
844
|
+
if (!(e instanceof Stop)) throw e;
|
|
845
|
+
console.error(`update-harness: ${e.message}`);
|
|
846
|
+
process.exitCode = 1;
|
|
847
|
+
}
|
|
848
|
+
}
|