@salaros/ai-harness 0.3.5 → 0.4.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/README.md +11 -1
- package/package.json +2 -1
- package/scripts/README.md +5 -0
- package/scripts/install-policy.js +318 -0
- package/scripts/lib.js +20 -1
- package/scripts/update-harness.js +49 -178
package/README.md
CHANGED
|
@@ -38,7 +38,17 @@ From the repository's root:
|
|
|
38
38
|
npx @salaros/ai-harness
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
The installer adds the harness files and leaves your own work alone: it never writes `README.md` or `LICENSE`, and it adds a folder README only where one is missing. It records the upstream commit it installed in `harness-lock.json
|
|
41
|
+
The installer adds the harness files and leaves your own work alone: it never writes `README.md` or `LICENSE`, and it adds a folder README only where one is missing. It records the upstream commit it installed in `harness-lock.json`, then points Git at the harness's hooks in this clone. Other clones run `node scripts/githooks-init.js` once.
|
|
42
|
+
|
|
43
|
+
If the repository already has agent files of its own, the first install keeps them and adds what the harness needs:
|
|
44
|
+
|
|
45
|
+
| File | What the first install does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `AGENTS.md`, `docs/README.md` | Writes the harness's version and appends yours under `## This project`, for you to fold in |
|
|
48
|
+
| `CLAUDE.md` | Adds `@AGENTS.md` at the top if it's missing |
|
|
49
|
+
| `.claude/settings.json` | Merges by key: replaces the harness's hooks, keeps your permissions and hooks (on every update, too) |
|
|
50
|
+
| `.mcp.json` | Adds the harness's MCP servers; yours win where both define one |
|
|
51
|
+
| `.gitignore` | Appends the harness's patterns you don't have, under a comment |
|
|
42
52
|
|
|
43
53
|
Run the same command again to update. Files you haven't edited take the new version, files you have edited keep your changes and gain the new ones, and a real conflict is written with conflict markers and reported.
|
|
44
54
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"scripts/update-harness.js",
|
|
10
|
+
"scripts/install-policy.js",
|
|
10
11
|
"scripts/lib.js",
|
|
11
12
|
"scripts/project-facts.js",
|
|
12
13
|
"scripts/repo-view.js",
|
package/scripts/README.md
CHANGED
|
@@ -17,6 +17,11 @@ 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
|
+
- The scripts are CommonJS. `package.json` here, and its twin in
|
|
21
|
+
`.agents/hooks/`, says so, so a project whose root `package.json` declares
|
|
22
|
+
`"type": "module"` does not turn them into ES modules that fail on their
|
|
23
|
+
first `require`. A script of the project's own that uses `import` is
|
|
24
|
+
named `.mjs`.
|
|
20
25
|
- Scripts are safe to run from any working directory — every script and hook
|
|
21
26
|
asks `lib.root()` which repo it is about, and gets one answer: `--root=<dir>`
|
|
22
27
|
when given, then the harness's project-dir variable
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// scripts/install-policy.js
|
|
2
|
+
// What an install does with one path the upstream ships: the policies scripts/harness-files.tsv
|
|
3
|
+
// names, each decided here and nowhere else. scripts/update-harness.js walks the paths, reads what a
|
|
4
|
+
// decision needs and applies what it answers; this module reads nothing and writes nothing.
|
|
5
|
+
// const policy = require("./install-policy");
|
|
6
|
+
// const { policy: name, asked } = policy.policyFor(rows, file, wants);
|
|
7
|
+
// const { outcome, bucket, write, silent } = policy.decide(name, facts, reads);
|
|
8
|
+
// `facts` is what the caller already knows about the path; `reads` holds the things a decision may
|
|
9
|
+
// need and costs something to find out, called only when the decision gets that far:
|
|
10
|
+
// facts exists the target has something at the path
|
|
11
|
+
// theirs the upstream's content at the head: LF text, or a Buffer
|
|
12
|
+
// hasBase the receipt names a commit the upstream still has
|
|
13
|
+
// adopt --adopt was given
|
|
14
|
+
// asked the path is an optional part, and the run asked for it
|
|
15
|
+
// reads held() the target's copy, read the way `theirs` is (text or bytes)
|
|
16
|
+
// base() the upstream's content at the receipt's commit, or null
|
|
17
|
+
// shippedBefore() an earlier run laid this path down, so its absence is a deletion
|
|
18
|
+
// recoverBase(ours) the upstream version nearest the target's copy, or null
|
|
19
|
+
// merge(base, ours, theirs) a three-way merge: { text, conflicts, failed }
|
|
20
|
+
// The answer: `outcome` is the word the run prints, `bucket` the summary list the path joins (null
|
|
21
|
+
// for a path nothing happened to), `write` the content to write when there is any, `silent`
|
|
22
|
+
// when the path is counted in the summary but never printed as a line, and `notice` a sentence the
|
|
23
|
+
// run prints before its summary, for a result someone has to finish by hand.
|
|
24
|
+
const lib = require("./lib");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- the manifest
|
|
27
|
+
|
|
28
|
+
// First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
|
|
29
|
+
// `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
|
|
30
|
+
// the docs site is the case, useful to some projects and dead weight in the rest.
|
|
31
|
+
// `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
|
|
32
|
+
// on the process's own argv and a test can ask what a repo would get either way.
|
|
33
|
+
const rowFor = (rows, file) => rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
|
|
34
|
+
function policyFor(rows, file, wants) {
|
|
35
|
+
const row = rowFor(rows, file);
|
|
36
|
+
if (!row) return { policy: "merge", asked: false }; // anything the upstream ships and nobody classified is harness
|
|
37
|
+
if (!row.policy.startsWith("optional:")) return { policy: row.policy, asked: false };
|
|
38
|
+
const asked = wants(row.policy.slice("optional:".length));
|
|
39
|
+
return { policy: asked ? "seed" : "template", asked };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- merging a file the target has
|
|
43
|
+
|
|
44
|
+
// This script's own conflict label, on a line of its own, so prose about conflict markers is not
|
|
45
|
+
// mistaken for one.
|
|
46
|
+
const MARKED = /^<{7} yours\r?$/m;
|
|
47
|
+
|
|
48
|
+
// A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
|
|
49
|
+
// decides which. A logo the project replaced stays replaced.
|
|
50
|
+
function decideBinary({ held, theirs, hasBase, adopt }, { base }) {
|
|
51
|
+
if (lib.sameContent(held, theirs)) return { outcome: "unchanged", bucket: null };
|
|
52
|
+
const was = hasBase ? base() : null;
|
|
53
|
+
if (adopt || lib.sameContent(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", write: theirs };
|
|
54
|
+
return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
|
|
58
|
+
// always LF. The comparison and the merge happen in LF and the result is written back in the endings
|
|
59
|
+
// the file already had, so a Windows checkout does not read as edited from top to bottom.
|
|
60
|
+
function decideText({ policy, raw, theirs, hasBase, adopt }, { base, recoverBase, merge }) {
|
|
61
|
+
const crlf = lib.isCrlf(raw);
|
|
62
|
+
const ours = lib.toLf(raw);
|
|
63
|
+
const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept" };
|
|
64
|
+
|
|
65
|
+
if (ours === theirs) return { outcome: "unchanged", bucket: null };
|
|
66
|
+
// Before the base logic, not inside it: a repo that needs adopting usually has a receipt
|
|
67
|
+
// already, written by the install that kept the stale files in the first place.
|
|
68
|
+
if (adopt) return { outcome: "adopted", bucket: "written", write: lib.asFound(theirs, crlf) };
|
|
69
|
+
// Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
|
|
70
|
+
// its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
|
|
71
|
+
// harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
|
|
72
|
+
// --adopt above throws it away.
|
|
73
|
+
if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted" };
|
|
74
|
+
|
|
75
|
+
// Claude Code's settings are merged by key on every run, base or none: see mergeSettings.
|
|
76
|
+
if (policy === "settings") return decideSettings(ours, theirs, crlf);
|
|
77
|
+
|
|
78
|
+
// A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
|
|
79
|
+
// and holds the table docs-check parses, and docs/README.md says what the chain puts where.
|
|
80
|
+
// Keeping a stale one leaves a repo that looks installed and behaves like the version it came
|
|
81
|
+
// from, so these are merged even when the receipt is missing. Nothing in the upstream's history
|
|
82
|
+
// matching means this copy was written by hand, for a project that had agent instructions before
|
|
83
|
+
// it had the harness: see appendProject.
|
|
84
|
+
let from = hasBase ? base() : null;
|
|
85
|
+
if (from === null && policy === "reconcile") from = recoverBase(ours);
|
|
86
|
+
// A union table merges by row whether or not there is a base, and even an untouched copy goes
|
|
87
|
+
// through that merge: it may hold a row the upstream dropped and the project still needs.
|
|
88
|
+
if (from === null && policy === "union") from = "";
|
|
89
|
+
if (from === null) {
|
|
90
|
+
const own = WITHOUT_BASE[policy];
|
|
91
|
+
const done = own ? own(ours, theirs) : null;
|
|
92
|
+
if (!done) return keep;
|
|
93
|
+
return { ...done, write: lib.asFound(done.write, crlf) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", write: lib.asFound(theirs, crlf) };
|
|
97
|
+
const merged = merge(from, ours, theirs);
|
|
98
|
+
if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept" };
|
|
99
|
+
const result = lib.asFound(merged.text, crlf);
|
|
100
|
+
if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", write: result };
|
|
101
|
+
// A file that keeps a local edit merges cleanly on every later run and comes out the same every
|
|
102
|
+
// time. Reported as merged each run it reads as churn, and the reader goes looking for a change
|
|
103
|
+
// nobody made, so what the run did is decided by the result, not the route.
|
|
104
|
+
if (result === raw) return { outcome: "unchanged", bucket: null };
|
|
105
|
+
return { outcome: "merged", bucket: "merged", write: result };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A union table, merged row by row rather than line by line: a row is keyed by its first
|
|
109
|
+
// tab-separated column, and the table is a set of them, so there is nothing to conflict over.
|
|
110
|
+
// The upstream's comments and order come first. Each of its rows is the project's where only the
|
|
111
|
+
// project changed it, or where both did, and the upstream's otherwise; a row the project deleted
|
|
112
|
+
// stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
|
|
113
|
+
// or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
|
|
114
|
+
// here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
|
|
115
|
+
// receipt, and then the project's copy of a row wins.
|
|
116
|
+
function mergeRows(base, ours, theirs) {
|
|
117
|
+
const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
|
|
118
|
+
const was = rows(base), mine = rows(ours), up = rows(theirs);
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const line of theirs.split("\n")) {
|
|
121
|
+
const key = line.split("\t")[0];
|
|
122
|
+
if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
|
|
123
|
+
const o = mine.get(key), b = was.get(key);
|
|
124
|
+
if (o === undefined) { if (b === undefined) out.push(line); continue; }
|
|
125
|
+
out.push(o === b ? line : o);
|
|
126
|
+
}
|
|
127
|
+
const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
|
|
128
|
+
if (!extra.length) return out.join("\n");
|
|
129
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
130
|
+
return [...out, ...extra, ""].join("\n");
|
|
131
|
+
}
|
|
132
|
+
const unionMerge = (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false });
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------- a file that has no base
|
|
135
|
+
|
|
136
|
+
// What a policy does with a text file the target already has and no upstream version to merge it
|
|
137
|
+
// against: a first install into a project that set up its own agent files, ignore list or MCP
|
|
138
|
+
// servers before it took the harness. Plain merge keeps the file whole, the project's work being
|
|
139
|
+
// the one thing an install must not lose; these policies also bring in the part of the upstream's
|
|
140
|
+
// copy the harness cannot work without. Each answers { outcome, bucket, write, notice? } in LF, or
|
|
141
|
+
// null to keep the file as it is. A later run has the receipt, so each runs once per file.
|
|
142
|
+
const WITHOUT_BASE = {
|
|
143
|
+
reconcile: appendProject,
|
|
144
|
+
import: addImport,
|
|
145
|
+
ignore: appendPatterns,
|
|
146
|
+
keyed: (ours, theirs) => {
|
|
147
|
+
const merged = mergeJson(ours, theirs, (o, t) => mergeKeys(o, t, 2));
|
|
148
|
+
return merged && { outcome: "merged by key", bucket: "merged", write: merged };
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// The upstream's copy, then the project's under a heading of its own. A whole-file conflict was the
|
|
153
|
+
// earlier answer, and it left the one file every agent reads full of markers, with the run exiting 1
|
|
154
|
+
// on a first install. The harness's text is what the rest of the harness assumes; the project's is
|
|
155
|
+
// what nobody else knows. Both stay, and the notice asks someone to fold the second into the first.
|
|
156
|
+
// The project's headings move down under the new one, so its title does not compete with the file's.
|
|
157
|
+
const PROJECT_HEADING = "## This project";
|
|
158
|
+
function appendProject(ours, theirs) {
|
|
159
|
+
const note = "<!-- ai-harness: this project's own copy of this file, kept from before the harness was installed. Fold what still applies into the sections above, then delete this section. -->";
|
|
160
|
+
return {
|
|
161
|
+
outcome: "yours appended", bucket: "merged",
|
|
162
|
+
write: [theirs.trimEnd(), "", note, PROJECT_HEADING, "", demoteHeadings(ours).trim(), ""].join("\n"),
|
|
163
|
+
notice: `the harness's copy was written with the project's own appended under "${PROJECT_HEADING}": fold what still applies into it`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Moves every Markdown heading outside a code fence down, so the shallowest lands one level below
|
|
168
|
+
// PROJECT_HEADING. Six stays six: Markdown has nothing deeper.
|
|
169
|
+
function demoteHeadings(text) {
|
|
170
|
+
const lines = text.split("\n");
|
|
171
|
+
let fence = false;
|
|
172
|
+
const level = lines.map(line => {
|
|
173
|
+
if (/^\s*(```|~~~)/.test(line)) { fence = !fence; return 0; }
|
|
174
|
+
const m = !fence && /^(#{1,6})\s/.exec(line);
|
|
175
|
+
return m ? m[1].length : 0;
|
|
176
|
+
});
|
|
177
|
+
const top = Math.min(...level.filter(Boolean));
|
|
178
|
+
if (!Number.isFinite(top) || top >= 3) return text;
|
|
179
|
+
return lines.map((line, i) => level[i] ? "#".repeat(Math.min(6, level[i] + 3 - top)) + line.slice(level[i]) : line).join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// CLAUDE.md is how Claude Code reaches AGENTS.md. A project's own CLAUDE.md, kept whole, leaves Claude
|
|
183
|
+
// reading instructions that never mention the harness, so the import goes on top and the rest stays.
|
|
184
|
+
const IMPORT = "@AGENTS.md";
|
|
185
|
+
function addImport(ours) {
|
|
186
|
+
if (ours.split("\n").some(l => l.trim() === IMPORT)) return null;
|
|
187
|
+
return { outcome: "import added", bucket: "merged", write: `${IMPORT}\n\n${ours}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A .gitignore is a set of patterns, so the upstream's that this one lacks go at the end, under a
|
|
191
|
+
// comment saying where they came from; nothing of the project's moves. A pattern the project
|
|
192
|
+
// negates is its decision, and stays unlisted.
|
|
193
|
+
function appendPatterns(ours, theirs) {
|
|
194
|
+
const have = new Set(ours.split("\n").map(l => l.trim()));
|
|
195
|
+
const missing = [...new Set(theirs.split("\n").map(l => l.trim()))]
|
|
196
|
+
.filter(l => l && !l.startsWith("#") && !have.has(l) && !have.has("!" + l));
|
|
197
|
+
if (!missing.length) return null;
|
|
198
|
+
const note = "# Added by the ai-harness install: the harness's patterns this file did not have.";
|
|
199
|
+
return { outcome: "patterns appended", bucket: "merged", write: [ours.trimEnd(), "", note, ...missing, ""].join("\n") };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- JSON merged by key
|
|
203
|
+
|
|
204
|
+
const isObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
205
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
206
|
+
|
|
207
|
+
// The project's value wins at every key both have; a key only the upstream has is added. Objects are
|
|
208
|
+
// merged down to `depth` levels, below which the project's value is taken whole: an MCP server the
|
|
209
|
+
// project configured is its own, not a blend of two. Arrays are a union, the project's order first.
|
|
210
|
+
function mergeKeys(ours, theirs, depth = Infinity) {
|
|
211
|
+
if (depth > 0 && isObject(ours) && isObject(theirs)) {
|
|
212
|
+
const out = { ...ours };
|
|
213
|
+
for (const [k, v] of Object.entries(theirs)) out[k] = k in ours ? mergeKeys(ours[k], v, depth - 1) : v;
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(ours) && Array.isArray(theirs)) return [...ours, ...theirs.filter(t => !ours.some(o => same(o, t)))];
|
|
217
|
+
return ours;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Both texts parsed and merged; null when either is not JSON or the merge adds nothing. The result is
|
|
221
|
+
// the upstream's own text when it comes out equal to it, so the upstream's layout survives.
|
|
222
|
+
function mergeJson(ours, theirs, merge) {
|
|
223
|
+
let o, t;
|
|
224
|
+
try { o = JSON.parse(ours); t = JSON.parse(theirs); } catch { return null; }
|
|
225
|
+
const merged = merge(o, t);
|
|
226
|
+
if (same(merged, o)) return null;
|
|
227
|
+
return same(merged, t) ? theirs : JSON.stringify(merged, null, 2) + "\n";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// .claude/settings.json holds the harness's hook launchers beside whatever the project set up: its
|
|
231
|
+
// permissions, its own hooks, its environment. A line merge of that JSON either keeps a stale
|
|
232
|
+
// launcher or conflicts over a brace, so it is merged by key on every run, receipt or none. Every
|
|
233
|
+
// hook whose command runs a script in .agents/hooks/ is the harness's, and is replaced by the
|
|
234
|
+
// upstream's current set; every other key and hook is the project's and stays.
|
|
235
|
+
const HARNESS_HOOK = /\.agents\/hooks\//;
|
|
236
|
+
function mergeSettings(ours, theirs) {
|
|
237
|
+
const merged = mergeKeys(ours, theirs);
|
|
238
|
+
const hooks = {};
|
|
239
|
+
for (const [event, groups] of Object.entries(isObject(ours.hooks) ? ours.hooks : {})) {
|
|
240
|
+
if (!Array.isArray(groups)) { hooks[event] = groups; continue; }
|
|
241
|
+
hooks[event] = groups.map(g => isObject(g) && Array.isArray(g.hooks)
|
|
242
|
+
? { ...g, hooks: g.hooks.filter(h => !(isObject(h) && typeof h.command === "string" && HARNESS_HOOK.test(h.command))) }
|
|
243
|
+
: g).filter(g => !isObject(g) || !Array.isArray(g.hooks) || g.hooks.length);
|
|
244
|
+
}
|
|
245
|
+
for (const [event, groups] of Object.entries(isObject(theirs.hooks) ? theirs.hooks : {}))
|
|
246
|
+
hooks[event] = [...(Array.isArray(hooks[event]) ? hooks[event] : []), ...groups];
|
|
247
|
+
for (const event of Object.keys(hooks)) if (Array.isArray(hooks[event]) && !hooks[event].length) delete hooks[event];
|
|
248
|
+
if (Object.keys(hooks).length || "hooks" in ours) merged.hooks = hooks;
|
|
249
|
+
return merged;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A project's settings that are not JSON are left for someone to read; the upstream's always are.
|
|
253
|
+
function decideSettings(ours, theirs, crlf) {
|
|
254
|
+
try { JSON.parse(ours); } catch { return { outcome: "yours, not JSON", bucket: "kept" }; }
|
|
255
|
+
const merged = mergeJson(ours, theirs, mergeSettings);
|
|
256
|
+
if (merged === null) return { outcome: "unchanged", bucket: null };
|
|
257
|
+
return { outcome: "merged by key", bucket: "merged", write: lib.asFound(merged, crlf) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------- the merging policies
|
|
261
|
+
|
|
262
|
+
// merge, reconcile, union, import, ignore, keyed and settings: a file the target lacks is written,
|
|
263
|
+
// and one it has is merged.
|
|
264
|
+
function merging(policy) {
|
|
265
|
+
return (facts, reads) => {
|
|
266
|
+
if (!facts.exists) return { outcome: "written", bucket: "written", write: facts.theirs };
|
|
267
|
+
const held = reads.held();
|
|
268
|
+
if (Buffer.isBuffer(facts.theirs)) return decideBinary({ ...facts, held }, reads);
|
|
269
|
+
// A union table has no lines to conflict over, and no base means the project's rows win.
|
|
270
|
+
return decideText({ ...facts, policy, raw: held }, policy === "union" ? { ...reads, merge: unionMerge } : reads);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------- laying a file down once
|
|
275
|
+
|
|
276
|
+
// What the project deleted stays deleted. Seed files and skeletons both go down once and are never
|
|
277
|
+
// touched again, so a missing one is either new to this run or one the project removed, and
|
|
278
|
+
// shippedBefore() tells the two apart: for a seed file, the receipt's commit shipped it; for a
|
|
279
|
+
// skeleton, the receipt lists it.
|
|
280
|
+
// TODO: an existing seed file joins the "kept" summary list and an existing skeleton joins none;
|
|
281
|
+
// the two differ only because they grew apart, and unifying them changes what a run reports.
|
|
282
|
+
function layDown(existing) {
|
|
283
|
+
return ({ exists, theirs, asked }, { shippedBefore }) => {
|
|
284
|
+
if (exists) return existing;
|
|
285
|
+
// An optional part is the exception: its flag is the project asking for it now, whatever an
|
|
286
|
+
// earlier run left out.
|
|
287
|
+
if (!asked && shippedBefore()) return { outcome: "deleted here", bucket: null };
|
|
288
|
+
return { outcome: "created", bucket: "seeded", write: theirs };
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------- the policies
|
|
293
|
+
|
|
294
|
+
const POLICIES = {
|
|
295
|
+
merge: merging("merge"),
|
|
296
|
+
reconcile: merging("reconcile"),
|
|
297
|
+
union: merging("union"),
|
|
298
|
+
import: merging("import"),
|
|
299
|
+
ignore: merging("ignore"),
|
|
300
|
+
keyed: merging("keyed"),
|
|
301
|
+
settings: merging("settings"),
|
|
302
|
+
seed: layDown({ outcome: "yours", bucket: "kept" }),
|
|
303
|
+
skeleton: layDown({ outcome: "yours", bucket: null }),
|
|
304
|
+
// Reported only when the target actually has it: "left alone, yours" about a file the repo does
|
|
305
|
+
// not have names something that was never there.
|
|
306
|
+
skip: ({ exists }) => exists ? { outcome: "yours", bucket: "skipped" } : { outcome: "absent", bucket: null },
|
|
307
|
+
// Not installed anywhere, and named in one line of the summary instead: sixty-five lines saying
|
|
308
|
+
// nothing happened bury the thirty-eight saying something did.
|
|
309
|
+
template: () => ({ outcome: "template", bucket: "template", silent: true }),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function decide(policy, facts, reads = {}) {
|
|
313
|
+
const rule = POLICIES[policy];
|
|
314
|
+
if (!rule) throw new Error(`install-policy: no policy named "${policy}"`);
|
|
315
|
+
return rule(facts, reads);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { policyFor, decide };
|
package/scripts/lib.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
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.toLf(text), lib.asFound(text, crlf) // compare in LF, write back in the endings a file had
|
|
15
|
+
// lib.sameContent(a, b) // equal text, or equal bytes; never text against bytes
|
|
14
16
|
const fs = require("fs");
|
|
15
17
|
const path = require("path");
|
|
16
18
|
const { spawnSync } = require("child_process");
|
|
@@ -84,4 +86,21 @@ function readTsv(file) {
|
|
|
84
86
|
.map(l => l.split("\t"));
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
// Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
|
|
90
|
+
// the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
|
|
91
|
+
// touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
|
|
92
|
+
// a comparison or a merge happens in LF, and the result is written back in the endings the file
|
|
93
|
+
// already had.
|
|
94
|
+
const CRLF = /\r\n/g;
|
|
95
|
+
const LF = /\n/g;
|
|
96
|
+
const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
|
|
97
|
+
const toLf = text => text.replace(CRLF, "\n");
|
|
98
|
+
const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
|
|
99
|
+
|
|
100
|
+
// One test for text and bytes, so a caller comparing a blob against what is on disk does not have
|
|
101
|
+
// to know which it got.
|
|
102
|
+
const sameContent = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
|
|
103
|
+
? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
|
|
104
|
+
: a === b;
|
|
105
|
+
|
|
106
|
+
module.exports = { root, args, ROOT_FLAG, ROOT_ENV_VARS, CHECKOUT, chdirRoot, fix, warn, stdin, run, node, shell, readTsv, isCrlf, toLf, asFound, sameContent };
|
|
@@ -34,6 +34,7 @@ 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 installPolicy = require("./install-policy");
|
|
37
38
|
const { spawnSync } = require("child_process");
|
|
38
39
|
|
|
39
40
|
const TEMPLATE = "https://github.com/salaros/ai-harness.git";
|
|
@@ -198,12 +199,6 @@ function blob(dir, commit, file) {
|
|
|
198
199
|
return r.stdout.includes(0) ? r.stdout : r.stdout.toString("utf8");
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
// One test for both, so a caller comparing what blob returned against what is on disk does not have
|
|
202
|
-
// to know which it got.
|
|
203
|
-
const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
|
|
204
|
-
? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
|
|
205
|
-
: a === b;
|
|
206
|
-
|
|
207
202
|
// ---------------------------------------------------------------- the adapters
|
|
208
203
|
//
|
|
209
204
|
// What plan() reads, and nothing more: the upstream at any commit, and the target as it stands. A
|
|
@@ -335,19 +330,6 @@ function policies(templateDir) {
|
|
|
335
330
|
return lib.readTsv(path.join(templateDir, MANIFEST)).map(([p, policy]) => ({ path: p, policy }));
|
|
336
331
|
}
|
|
337
332
|
|
|
338
|
-
// First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
|
|
339
|
-
// `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
|
|
340
|
-
// the docs site is the case, useful to some projects and dead weight in the rest.
|
|
341
|
-
// `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
|
|
342
|
-
// on the process's own argv and a test can ask what a repo would get either way.
|
|
343
|
-
const rowFor = (rows, file) => rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
|
|
344
|
-
function policyFor(rows, file, wants) {
|
|
345
|
-
const row = rowFor(rows, file);
|
|
346
|
-
if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
|
|
347
|
-
if (!row.policy.startsWith("optional:")) return row.policy;
|
|
348
|
-
return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
|
|
349
|
-
}
|
|
350
|
-
|
|
351
333
|
// ---------------------------------------------------------------- skeletons
|
|
352
334
|
|
|
353
335
|
// Three files the upstream does not ship, because there they would be lies: MEMORY.md
|
|
@@ -428,108 +410,6 @@ function settleDropped(text) {
|
|
|
428
410
|
return { text: out, conflicts: conflicts > 0, failed: false };
|
|
429
411
|
}
|
|
430
412
|
|
|
431
|
-
// Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
|
|
432
|
-
// the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
|
|
433
|
-
// touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
|
|
434
|
-
// the comparison and the merge happen in LF, and the result is written back in the endings the file
|
|
435
|
-
// already had.
|
|
436
|
-
const CRLF = /\r\n/g;
|
|
437
|
-
const LF = /\n/g;
|
|
438
|
-
// This script's own conflict label, on a line of its own, so prose about conflict markers is not
|
|
439
|
-
// mistaken for one.
|
|
440
|
-
const MARKED = /^<{7} yours\r?$/m;
|
|
441
|
-
const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
|
|
442
|
-
const toLf = text => text.replace(CRLF, "\n");
|
|
443
|
-
const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
|
|
444
|
-
|
|
445
|
-
// ---------------------------------------------------------------- the decision
|
|
446
|
-
//
|
|
447
|
-
// What happens to one file the target already has, decided apart from doing it. Everything these two
|
|
448
|
-
// read is an argument, including the three things they cannot compute -- the base's text, the search
|
|
449
|
-
// for a base when the receipt has none, and the three-way merge itself -- so plan() passes them in.
|
|
450
|
-
//
|
|
451
|
-
// Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
|
|
452
|
-
// belongs in (null for a file nothing happened to), and `text` what to write, or null to write
|
|
453
|
-
// nothing.
|
|
454
|
-
|
|
455
|
-
// A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
|
|
456
|
-
// decides which. A logo the project replaced stays replaced.
|
|
457
|
-
function decideBinary({ held, theirs, hasBase, adopt }, { baseBytes }) {
|
|
458
|
-
if (same(held, theirs)) return { outcome: "unchanged", bucket: null, text: null };
|
|
459
|
-
const was = hasBase ? baseBytes() : null;
|
|
460
|
-
if (adopt || same(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", text: theirs };
|
|
461
|
-
return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept", text: null };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
|
|
465
|
-
// always LF. The comparison and the merge happen in LF and the result is written back in the endings
|
|
466
|
-
// the file already had, so a Windows checkout does not read as edited from top to bottom.
|
|
467
|
-
function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recoverBase, merge }) {
|
|
468
|
-
const crlf = isCrlf(raw);
|
|
469
|
-
const ours = toLf(raw);
|
|
470
|
-
const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept", text: null };
|
|
471
|
-
|
|
472
|
-
if (ours === theirs) return { outcome: "unchanged", bucket: null, text: null };
|
|
473
|
-
// Before the base logic, not inside it: a repo that needs adopting usually has a receipt
|
|
474
|
-
// already, written by the install that kept the stale files in the first place.
|
|
475
|
-
if (adopt) return { outcome: "adopted", bucket: "written", text: asFound(theirs, crlf) };
|
|
476
|
-
// Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
|
|
477
|
-
// its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
|
|
478
|
-
// harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
|
|
479
|
-
// --adopt above throws it away.
|
|
480
|
-
if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted", text: null };
|
|
481
|
-
|
|
482
|
-
// A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
|
|
483
|
-
// and holds the table docs-check parses, and docs/README.md says what the chain puts where.
|
|
484
|
-
// Keeping a stale one leaves a repo that looks installed and behaves like the version it came
|
|
485
|
-
// from, so these are merged even when the receipt is missing. Nothing in the upstream's history
|
|
486
|
-
// matching means this copy was written by hand, and an empty base makes the whole file one
|
|
487
|
-
// conflict -- the honest answer: both versions are there to read, and the run exits 1.
|
|
488
|
-
let from = hasBase ? baseText() : null;
|
|
489
|
-
if (from === null && policy === "reconcile") from = recoverBase(ours);
|
|
490
|
-
if (from === null && policy === "reconcile") from = "";
|
|
491
|
-
// A union table merges by row whether or not there is a base, and even an untouched copy goes
|
|
492
|
-
// through that merge: it may hold a row the upstream dropped and the project still needs.
|
|
493
|
-
if (from === null && policy === "union") from = "";
|
|
494
|
-
if (from === null) return keep;
|
|
495
|
-
|
|
496
|
-
if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
|
|
497
|
-
const merged = merge(from, ours, theirs);
|
|
498
|
-
if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
|
|
499
|
-
const result = asFound(merged.text, crlf);
|
|
500
|
-
if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", text: result };
|
|
501
|
-
// A file that keeps a local edit merges cleanly on every later run and comes out the same every
|
|
502
|
-
// time. Reported as merged each run it reads as churn, and the reader goes looking for a change
|
|
503
|
-
// nobody made, so what the run did is decided by the result, not the route.
|
|
504
|
-
if (result === raw) return { outcome: "unchanged", bucket: null, text: null };
|
|
505
|
-
return { outcome: "merged", bucket: "merged", text: result };
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// A union table, merged row by row rather than line by line: a row is keyed by its first
|
|
509
|
-
// tab-separated column, and the table is a set of them, so there is nothing to conflict over.
|
|
510
|
-
// The upstream's comments and order come first. Each of its rows is the project's where only the
|
|
511
|
-
// project changed it, or where both did, and the upstream's otherwise; a row the project deleted
|
|
512
|
-
// stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
|
|
513
|
-
// or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
|
|
514
|
-
// here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
|
|
515
|
-
// receipt, and then the project's copy of a row wins.
|
|
516
|
-
function mergeRows(base, ours, theirs) {
|
|
517
|
-
const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
|
|
518
|
-
const was = rows(base), mine = rows(ours), up = rows(theirs);
|
|
519
|
-
const out = [];
|
|
520
|
-
for (const line of theirs.split("\n")) {
|
|
521
|
-
const key = line.split("\t")[0];
|
|
522
|
-
if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
|
|
523
|
-
const o = mine.get(key), b = was.get(key);
|
|
524
|
-
if (o === undefined) { if (b === undefined) out.push(line); continue; }
|
|
525
|
-
out.push(o === b ? line : o);
|
|
526
|
-
}
|
|
527
|
-
const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
|
|
528
|
-
if (!extra.length) return out.join("\n");
|
|
529
|
-
while (out.length && out[out.length - 1] === "") out.pop();
|
|
530
|
-
return [...out, ...extra, ""].join("\n");
|
|
531
|
-
}
|
|
532
|
-
|
|
533
413
|
// ---------------------------------------------------------------- the plan
|
|
534
414
|
//
|
|
535
415
|
// Everything a run will do to the target, decided before anything is written: an install rewrites
|
|
@@ -589,7 +469,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
589
469
|
add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
|
|
590
470
|
for (const entry of files) {
|
|
591
471
|
const { file, link: isLink, exec } = entry;
|
|
592
|
-
const policy = policyFor(rows, file, options.wants);
|
|
472
|
+
const { policy, asked } = installPolicy.policyFor(rows, file, options.wants);
|
|
593
473
|
const m = mode(entry);
|
|
594
474
|
const theirs = upstream.blob(head, file);
|
|
595
475
|
// Git listed the path a moment ago, so failing to read it is the checkout being unhappy
|
|
@@ -604,10 +484,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
604
484
|
const line = (outcome, bucket, act = {}) =>
|
|
605
485
|
add({ file, policy, mode: m, outcome, bucket, ...act, exec: exec && (exists || act.write !== undefined) });
|
|
606
486
|
|
|
607
|
-
|
|
608
|
-
// saying nothing happened bury the thirty-eight saying something did.
|
|
609
|
-
if (policy === "template") { line("template", "template", { silent: true }); continue; }
|
|
610
|
-
if (isLink) {
|
|
487
|
+
if (isLink && policy !== "template") {
|
|
611
488
|
// A skill link is relink's to make, once the directory it lives in exists: it knows which
|
|
612
489
|
// skills this project actually has, where the upstream only knows its own.
|
|
613
490
|
if (policy === "skills") { add({ file, mkdir: true, silent: true }); continue; }
|
|
@@ -625,34 +502,17 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
625
502
|
// Reported one line per skill by planSkills below, not one per reference file: a skill is the
|
|
626
503
|
// unit a project installs, and its files run to several hundred.
|
|
627
504
|
if (policy === "skills") { skills.push(entry); continue; }
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
// flag is the project asking for it now, whatever an earlier run left out.
|
|
635
|
-
const asked = (rowFor(rows, file) || { policy: "" }).policy.startsWith("optional:");
|
|
636
|
-
if (exists) line("yours", "kept");
|
|
637
|
-
else if (!asked && base !== null && upstream.blob(base, file) !== null) line("deleted here", null);
|
|
638
|
-
else line("created", "seeded", { write: theirs });
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
// merge and reconcile
|
|
642
|
-
if (!exists) { line("written", "written", { write: theirs }); continue; }
|
|
643
|
-
const hasBase = base !== null;
|
|
644
|
-
const held = target.read(file, Buffer.isBuffer(theirs));
|
|
645
|
-
const { outcome, bucket, text } = Buffer.isBuffer(theirs)
|
|
646
|
-
? decideBinary({ held, theirs, hasBase, adopt: options.adopt }, { baseBytes: () => upstream.blob(base, file) })
|
|
647
|
-
: decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
|
|
648
|
-
baseText: () => upstream.blob(base, file),
|
|
505
|
+
const { outcome, bucket, notice, ...act } = installPolicy.decide(policy,
|
|
506
|
+
{ exists, theirs, hasBase: base !== null, adopt: options.adopt, asked }, {
|
|
507
|
+
held: () => target.read(file, Buffer.isBuffer(theirs)),
|
|
508
|
+
base: () => upstream.blob(base, file),
|
|
509
|
+
// A seed file the recorded commit already shipped was laid down then.
|
|
510
|
+
shippedBefore: () => base !== null && upstream.blob(base, file) !== null,
|
|
649
511
|
recoverBase: ours => recoverBase(upstream, file, ours),
|
|
650
|
-
|
|
651
|
-
merge: policy === "union"
|
|
652
|
-
? (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false })
|
|
653
|
-
: threeWay,
|
|
512
|
+
merge: threeWay,
|
|
654
513
|
});
|
|
655
|
-
|
|
514
|
+
if (notice) notices.push(`${file}: ${notice}`);
|
|
515
|
+
line(outcome, bucket, act);
|
|
656
516
|
}
|
|
657
517
|
|
|
658
518
|
add({ phase: "skeletons a project starts with" });
|
|
@@ -662,15 +522,19 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
662
522
|
// list is taken to know them all, which every install since the skeletons began has laid down.
|
|
663
523
|
const known = new Set(previous && base !== null ? previous.skeletons || Object.keys(SKELETONS) : []);
|
|
664
524
|
for (const [file, lines] of Object.entries(SKELETONS)) {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
525
|
+
const theirs = skeletonLines(file, lines, hasIntent).join("\n");
|
|
526
|
+
const decision = installPolicy.decide("skeleton", { exists: target.exists(file), theirs, asked: false }, { shippedBefore: () => known.has(file) });
|
|
527
|
+
add({ file, policy: "seed", mode: "100644", ...decision });
|
|
668
528
|
}
|
|
669
529
|
|
|
670
530
|
add({ phase: "skills, merged by name" });
|
|
671
531
|
entries.push(...planSkills(upstream, target, head, skills));
|
|
672
532
|
|
|
673
|
-
|
|
533
|
+
// A list of strings on one line, as Prettier writes it: a project formatting its JSON with it
|
|
534
|
+
// would otherwise reject the receipt at every push, and the next update would undo the fix.
|
|
535
|
+
const receipt = JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp, skeletons: Object.keys(SKELETONS) }, null, 2)
|
|
536
|
+
.replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g, (all, items) => `[${items.split(/,\n\s+/).join(", ")}]`);
|
|
537
|
+
add({ file: LOCK, silent: true, write: receipt + "\n" });
|
|
674
538
|
return { entries, notices, base };
|
|
675
539
|
}
|
|
676
540
|
|
|
@@ -706,12 +570,12 @@ function planSkills(upstream, target, head, files) {
|
|
|
706
570
|
const held = exists ? target.read(file, true) : null;
|
|
707
571
|
let write;
|
|
708
572
|
if (Buffer.isBuffer(text)) {
|
|
709
|
-
if (
|
|
573
|
+
if (lib.sameContent(held, text)) continue;
|
|
710
574
|
write = text;
|
|
711
575
|
} else {
|
|
712
576
|
const ourText = held === null ? null : held.toString("utf8");
|
|
713
|
-
if (ourText !== null && toLf(ourText) === text) continue;
|
|
714
|
-
write = asFound(text, ourText !== null && isCrlf(ourText));
|
|
577
|
+
if (ourText !== null && lib.toLf(ourText) === text) continue;
|
|
578
|
+
write = lib.asFound(text, ourText !== null && lib.isCrlf(ourText));
|
|
715
579
|
}
|
|
716
580
|
if (exists) tally.updated++; else tally.added++;
|
|
717
581
|
out.push({ file, silent: true, write, bucket: exists ? "merged" : "written", exec: exec && !exists });
|
|
@@ -785,18 +649,25 @@ function apply(entries, root, options) {
|
|
|
785
649
|
|
|
786
650
|
// ---------------------------------------------------------------- after it
|
|
787
651
|
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
652
|
+
// First the Git hooks, pointed at .githooks/ before anything else: an install that stopped at a
|
|
653
|
+
// printed reminder left clones whose hooks never ran, and nothing says so -- Git skips a hooks
|
|
654
|
+
// folder it was never told about in silence. Then two files nothing copied: the per-harness skill
|
|
655
|
+
// links, which depend on which skills this project has rather than which the upstream ships, and the
|
|
656
|
+
// third-party notice, which must describe this project's lock file. All three are generated, so the
|
|
657
|
+
// install leaves a harness that works rather than a list of commands to remember. Each run names the
|
|
658
|
+
// target with --root: the shared resolver prefers a harness's project-dir variable to the checkout a
|
|
659
|
+
// script sits in, and an install started from a session open on another repo would otherwise wire,
|
|
660
|
+
// link and describe that repo instead. Returns whether the hooks were wired.
|
|
794
661
|
function finish(target, options) {
|
|
795
|
-
if (!options.quiet) say("\
|
|
796
|
-
|
|
797
|
-
|
|
662
|
+
if (!options.quiet) say("\nGit hooks, links and notices");
|
|
663
|
+
const steps = [["git hooks", "scripts/githooks-init.js", []], ["links", "scripts/skills.js", ["relink"]], ["notices", "scripts/skills.js", ["notices"]]];
|
|
664
|
+
let hooks = true;
|
|
665
|
+
for (const [label, script, args] of steps) {
|
|
666
|
+
const r = lib.node([path.join(target, script), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
|
|
798
667
|
say(r.status === 0 ? r.output : `${label}: ${r.output}`);
|
|
668
|
+
if (label === "git hooks" && r.status !== 0) hooks = false;
|
|
799
669
|
}
|
|
670
|
+
return hooks;
|
|
800
671
|
}
|
|
801
672
|
|
|
802
673
|
// Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
|
|
@@ -819,7 +690,7 @@ function selfCheck(target, templateDir, options) {
|
|
|
819
690
|
|
|
820
691
|
// The summary, from the entries as they turned out. Returns the exit code: 1 while anything is left
|
|
821
692
|
// for the reader to act on.
|
|
822
|
-
function report({ entries, base, head, ref, target, check, options }) {
|
|
693
|
+
function report({ entries, base, head, ref, target, check, hooks = true, options }) {
|
|
823
694
|
const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [] };
|
|
824
695
|
for (const e of entries) if (e.bucket) notes[e.bucket].push(e.file);
|
|
825
696
|
// Every path was named as it happened, so repeating the lists here doubles the output; a quiet
|
|
@@ -832,7 +703,7 @@ function report({ entries, base, head, ref, target, check, options }) {
|
|
|
832
703
|
};
|
|
833
704
|
say("");
|
|
834
705
|
say(options.dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
|
|
835
|
-
if (!base) say("no merge base: this was an install, so
|
|
706
|
+
if (!base) say("no merge base: this was an install, so a file already here was kept, or given only the part the harness needs");
|
|
836
707
|
list("written", notes.written);
|
|
837
708
|
list("merged", notes.merged);
|
|
838
709
|
list("created for the first time", notes.seeded);
|
|
@@ -857,11 +728,10 @@ function report({ entries, base, head, ref, target, check, options }) {
|
|
|
857
728
|
else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
|
|
858
729
|
else if (check) say(`\nself check: ${check.summary}`);
|
|
859
730
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
return notes.conflicted.length || notes.unreadable.length || (check && check.failed) ? 1 : 0;
|
|
731
|
+
// Git hooks are wired per clone: this one was wired above, and every other clone runs the script once.
|
|
732
|
+
if (!options.dryRun && !hooks) say(`\nGIT HOOKS NOT WIRED: in ${target}, run node scripts/githooks-init.js`);
|
|
733
|
+
if (!options.dryRun) say(`\nEvery other clone of ${target} wires its Git hooks once with: node scripts/githooks-init.js`);
|
|
734
|
+
return notes.conflicted.length || notes.unreadable.length || (check && check.failed) || !hooks ? 1 : 0;
|
|
865
735
|
}
|
|
866
736
|
|
|
867
737
|
// ---------------------------------------------------------------- the run
|
|
@@ -906,13 +776,14 @@ function main(args) {
|
|
|
906
776
|
for (const notice of planned.notices) say(notice);
|
|
907
777
|
const entries = apply(planned.entries, target, options);
|
|
908
778
|
let check = null;
|
|
779
|
+
let hooks = true;
|
|
909
780
|
if (!options.dryRun) {
|
|
910
781
|
// After the plan is applied, because relink needs the skills in place and the invariants
|
|
911
782
|
// check the links relink has just written.
|
|
912
|
-
finish(target, options);
|
|
783
|
+
hooks = finish(target, options);
|
|
913
784
|
if (options.check) check = selfCheck(target, templateDir, options);
|
|
914
785
|
}
|
|
915
|
-
return report({ entries, base: planned.base, head, ref, target, check, options });
|
|
786
|
+
return report({ entries, base: planned.base, head, ref, target, check, hooks, options });
|
|
916
787
|
} finally {
|
|
917
788
|
if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
|
|
918
789
|
}
|
|
@@ -921,7 +792,7 @@ function main(args) {
|
|
|
921
792
|
// The plan and the decisions under it, so the suite can put a case in and read the answer out rather
|
|
922
793
|
// than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
|
|
923
794
|
// writes nothing; main() writes to somebody's repository and is reached through the command line.
|
|
924
|
-
module.exports = { installerStamp, upToDate,
|
|
795
|
+
module.exports = { installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, lineCounts, overlap, NEAREST, skeletonLines };
|
|
925
796
|
|
|
926
797
|
if (require.main === module) {
|
|
927
798
|
try { process.exitCode = main(process.argv.slice(2)); }
|