@salaros/ai-harness 0.2.1 → 0.2.3
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 +5 -1
- package/package.json +1 -1
- package/scripts/update-harness.js +173 -24
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A starting point that does not assume a language or framework: `.gitignore`, `.gitattributes`, Git hooks, a folder layout with a README in every folder, and an AI-agnostic **agent harness** (skills, hook scripts, four agents) that works the same in Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, and any other tool that reads `AGENTS.md` and `.agents/skills`.
|
|
4
4
|
|
|
5
|
-
Requires **Node 22 or newer** (the only runtime the harness needs) and Git; nothing else. The [harness CI workflow](.github/workflows/harness.yml) runs the suite on Ubuntu and Windows. A second workflow, [skills-update](.github/workflows/skills-update.yml), runs `npx skills update` weekly, relinks, regenerates the third-party notice, and commits the result to `development` only if the suite and the chain check still pass; it then opens a pull request promoting `development` to `master`, because a skill is a prompt an agent runs with full permissions and its diff wants a reader.
|
|
5
|
+
Requires **Node 22 or newer** (the only runtime the harness needs) and Git; nothing else. The [harness CI workflow](.github/workflows/harness.yml) runs the suite on Ubuntu and Windows. A second workflow, [skills-update](.github/workflows/skills-update.yml), runs `npx skills update` weekly, relinks, regenerates the third-party notice, and commits the result to `development` only if the suite and the chain check still pass; it then opens a pull request promoting `development` to `master`, because a skill is a prompt an agent runs with full permissions and its diff wants a reader. A third, [release](.github/workflows/release.yml), publishes this package to npm when a `v*` tag is pushed, over npm trusted publishing rather than a stored token: it proves the tag and `package.json` agree, runs the suite and the chain check, and refuses to publish a tarball missing the installer.
|
|
6
6
|
|
|
7
7
|
## Adding the harness to a repository you already have
|
|
8
8
|
|
|
@@ -24,10 +24,14 @@ Every run proves itself before it finishes. Merging is not checking: the install
|
|
|
24
24
|
|
|
25
25
|
Running it again updates. The first run writes `harness-lock.json` naming the upstream commit it took, which gives a later run a merge base: a harness file nobody edited takes the new version, an edited one keeps its edits and gains the changes around them, and only a real collision is written with conflict markers, reported, and exits non-zero. That is how `AGENTS.md` gains a new section while keeping your own rules, and how `docs/agents/issue-tracker.md` keeps your project key.
|
|
26
26
|
|
|
27
|
+
Two files are merged even on a first install, where there is no receipt to take a base from: `AGENTS.md`, the map every agent reads and the table `docs-check` parses, and `docs/README.md`, which says what the chain puts where. Keeping a stale copy of either leaves a repo that looks installed and behaves like the version it came from, so the installer finds the base instead — the upstream version yours is closest to is where you forked from, whatever a receipt would have said. A copy nobody edited merges cleanly; an edited one keeps its edits and gains the rest. A file written from scratch matches nothing in the upstream's history and is written with conflict markers, so both versions are there to read.
|
|
28
|
+
|
|
27
29
|
Skills are added and updated, never removed: one you vendored yourself survives every update, and `skills-lock.json` is merged as a union.
|
|
28
30
|
|
|
29
31
|
A repo whose harness predates `harness-lock.json` is the one case where "never touch what is already there" works against you: with no receipt there is no merge base, so the old harness stays and its checks then run against the new skills and fail, naming rules this version dropped. The run says so and names `--adopt`, which replaces every harness file with the upstream's — hooks, agents, scripts, `AGENTS.md`, and a `.claude/agents` that checked out as a text file rather than a symlink. It stays opt-in because it discards your edits to those files. `--dry-run --quiet` lists them first.
|
|
30
32
|
|
|
33
|
+
That first install writes a receipt even though it kept the old files, so `--adopt` has to keep working afterwards — it is normally the self check that tells you the harness is stale, and by then the receipt exists. A run at the recorded commit says so and takes every harness file again, rather than answering the one command that fixes it with "nothing to update".
|
|
34
|
+
|
|
31
35
|
The run reports each path as it works on it: the policy, the mode Git records, what happened, and the path, with the skills tree collapsed to one line per skill. `--quiet` gives the summary alone.
|
|
32
36
|
|
|
33
37
|
## Getting started
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// node scripts/update-harness.js --astro-docs add tools/docs-site, the Astro renderer for the chain
|
|
24
24
|
// node scripts/update-harness.js --no-check install without proving it afterwards
|
|
25
25
|
// node scripts/update-harness.js --quiet the summary alone, no line per path
|
|
26
|
-
// node scripts/update-harness.js --adopt
|
|
26
|
+
// node scripts/update-harness.js --adopt take every harness file from the upstream, losing local edits
|
|
27
27
|
// Installing into a repo that has no harness yet, from anywhere:
|
|
28
28
|
// git clone https://github.com/salaros/ai-harness .harness && \
|
|
29
29
|
// node .harness/scripts/update-harness.js --from .harness --target . && rm -rf .harness
|
|
@@ -31,6 +31,7 @@ const fs = require("fs");
|
|
|
31
31
|
const os = require("os");
|
|
32
32
|
const path = require("path");
|
|
33
33
|
const lib = require("./lib");
|
|
34
|
+
const { spawnSync } = require("child_process");
|
|
34
35
|
|
|
35
36
|
const TEMPLATE = "https://github.com/salaros/ai-harness.git";
|
|
36
37
|
const LOCK = "harness-lock.json";
|
|
@@ -74,7 +75,7 @@ function templateCheckout(ref) {
|
|
|
74
75
|
}
|
|
75
76
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
|
|
76
77
|
say(`cloning ${TEMPLATE} at ${ref}`);
|
|
77
|
-
const r = lib.run("git", ["clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
|
|
78
|
+
const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
|
|
78
79
|
if (r.status !== 0) fail(`could not clone the upstream at ${ref}\n${r.output}`);
|
|
79
80
|
if (!usable(dir)) {
|
|
80
81
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -96,13 +97,86 @@ function installer() {
|
|
|
96
97
|
} catch { return {}; }
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
// Windows stops at 260 characters for a path, and the harness ships skill files nested deep enough
|
|
101
|
+
// that a project a few folders down the drive crosses it. Git then fails to stat the working copy
|
|
102
|
+
// while resolving <commit>:<path>, so `git show` says the file is not there and the installer skips
|
|
103
|
+
// it -- a skill missing seven of its reference files, and not a word said about it. Setting
|
|
104
|
+
// core.longpaths on every call is what makes those paths reachable, and it costs nothing anywhere
|
|
105
|
+
// else.
|
|
106
|
+
const GIT = ["-c", "core.longpaths=true"];
|
|
107
|
+
const at = (dir, args) => lib.run("git", [...GIT, "-C", dir, ...args]);
|
|
100
108
|
|
|
101
109
|
// The upstream's version of a path at a commit, or null when the file did not exist there. Also how
|
|
102
110
|
// a missing base is detected: a rewritten history no longer holds the recorded commit.
|
|
111
|
+
// Read raw rather than through lib.run, which trims trailing whitespace: that is right for the
|
|
112
|
+
// plumbing whose output is a hash or a status line, and wrong for a file. Trimmed, every installed
|
|
113
|
+
// file lost its final newline, no copy was ever byte-identical to the upstream, and so every later
|
|
114
|
+
// run re-merged files nobody had touched.
|
|
115
|
+
// Text comes back as a string and anything holding a NUL byte as the Buffer it arrived in, which is
|
|
116
|
+
// how Git itself tells the two apart. Decoded as UTF-8 and written back, every byte a PNG holds
|
|
117
|
+
// outside ASCII becomes U+FFFD: the skill's logo installs as a broken image, and no later run ever
|
|
118
|
+
// agrees with the upstream about it. Nothing merges a Buffer; it is written whole or kept whole.
|
|
103
119
|
function blob(dir, commit, file) {
|
|
104
|
-
const r =
|
|
105
|
-
|
|
120
|
+
const r = spawnSync("git", [...GIT, "-C", dir, "show", `${commit}:${file}`], { maxBuffer: 256 * 1024 * 1024 });
|
|
121
|
+
if (r.status !== 0) return null;
|
|
122
|
+
return r.stdout.includes(0) ? r.stdout : r.stdout.toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One test for both, so a caller comparing what blob returned against what is on disk does not have
|
|
126
|
+
// to know which it got.
|
|
127
|
+
const same = (a, b) => Buffer.isBuffer(a) || Buffer.isBuffer(b)
|
|
128
|
+
? Buffer.isBuffer(a) && Buffer.isBuffer(b) && a.equals(b)
|
|
129
|
+
: a === b;
|
|
130
|
+
|
|
131
|
+
// The receipt is missing, so the base is found instead: the upstream version this copy is closest to
|
|
132
|
+
// is where the project forked from, whatever a receipt would have said. An exact match is the clean
|
|
133
|
+
// case, an older copy nobody touched; a project that has since edited its own file matches nothing
|
|
134
|
+
// exactly, so the nearest version by shared lines stands in as the base. That turns a first install
|
|
135
|
+
// into a real three-way merge for the files that need one, rather than one whole-file conflict.
|
|
136
|
+
// Only reconcile-policy files pay for the search: one git log, then a blob read per commit that
|
|
137
|
+
// touched the path.
|
|
138
|
+
// Under half the lines in common is a different file, not an older one, and merging against it would
|
|
139
|
+
// invent a diff the project never made.
|
|
140
|
+
const NEAREST = 0.5;
|
|
141
|
+
function recoverBase(dir, file, ours) {
|
|
142
|
+
const r = at(dir, ["log", "--format=%H", "--", file]);
|
|
143
|
+
if (r.status !== 0) return null;
|
|
144
|
+
const want = lineCounts(ours);
|
|
145
|
+
let best = null;
|
|
146
|
+
let nearest = NEAREST;
|
|
147
|
+
// Oldest first, and a tie goes to the first seen: two upstream versions one line apart score the
|
|
148
|
+
// same against a copy that has neither, and the older of them is the one whose merge puts that
|
|
149
|
+
// line back. The newer would drop it silently, which is the failure this policy exists to stop.
|
|
150
|
+
for (const commit of r.output.split(/\r?\n/).filter(Boolean).reverse()) {
|
|
151
|
+
const text = blob(dir, commit, file);
|
|
152
|
+
if (typeof text !== "string") continue;
|
|
153
|
+
if (text === ours) return text;
|
|
154
|
+
const shared = overlap(want, lineCounts(text));
|
|
155
|
+
if (shared > nearest) { best = text; nearest = shared; }
|
|
156
|
+
}
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Lines to counts, blank ones left out: they carry no content and every version of a markdown file
|
|
161
|
+
// has plenty, so counting them would score two unrelated documents as half the same.
|
|
162
|
+
function lineCounts(text) {
|
|
163
|
+
const counts = new Map();
|
|
164
|
+
for (const line of text.split(/\r?\n/)) {
|
|
165
|
+
if (!line.trim()) continue;
|
|
166
|
+
counts.set(line, (counts.get(line) || 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
return counts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The share of the longer file the two have in common, so neither a version that added a section nor
|
|
172
|
+
// one that cut it scores as a perfect match.
|
|
173
|
+
function overlap(ours, theirs) {
|
|
174
|
+
let shared = 0;
|
|
175
|
+
let mine = 0;
|
|
176
|
+
for (const [line, n] of ours) { mine += n; shared += Math.min(n, theirs.get(line) || 0); }
|
|
177
|
+
let other = 0;
|
|
178
|
+
for (const n of theirs.values()) other += n;
|
|
179
|
+
return shared / Math.max(mine, other, 1);
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
// ---------------------------------------------------------------- the manifest
|
|
@@ -143,7 +217,7 @@ function templateFiles(dir) {
|
|
|
143
217
|
// install stages these few files rather than leaving them for the project's own `git add`.
|
|
144
218
|
function carryMode(target, file) {
|
|
145
219
|
try { fs.chmodSync(path.join(target, file), 0o755); } catch { /* the filesystem does not do modes */ }
|
|
146
|
-
const r = lib.run("git", ["-C", target, "add", "--chmod=+x", "--", file]);
|
|
220
|
+
const r = lib.run("git", [...GIT, "-C", target, "add", "--chmod=+x", "--", file]);
|
|
147
221
|
if (r.status !== 0) say(`could not mark ${file} executable: ${r.output}`);
|
|
148
222
|
}
|
|
149
223
|
|
|
@@ -244,7 +318,7 @@ function threeWay(base, ours, theirs) {
|
|
|
244
318
|
|
|
245
319
|
// ---------------------------------------------------------------- reporting
|
|
246
320
|
|
|
247
|
-
const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], check: null };
|
|
321
|
+
const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [], check: null };
|
|
248
322
|
const say = m => console.log(m);
|
|
249
323
|
function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
|
|
250
324
|
|
|
@@ -261,6 +335,20 @@ function step(policy, m, outcome, file, bucket) {
|
|
|
261
335
|
}
|
|
262
336
|
const phase = m => { if (!quiet) say(`\n${m}`); };
|
|
263
337
|
|
|
338
|
+
// Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
|
|
339
|
+
// the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
|
|
340
|
+
// touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
|
|
341
|
+
// the comparison and the merge happen in LF, and the result is written back in the endings the file
|
|
342
|
+
// already had.
|
|
343
|
+
const CRLF = /\r\n/g;
|
|
344
|
+
const LF = /\n/g;
|
|
345
|
+
// This script's own conflict label, on a line of its own, so prose about conflict markers is not
|
|
346
|
+
// mistaken for one.
|
|
347
|
+
const MARKED = /^<{7} yours\r?$/m;
|
|
348
|
+
const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) || []).length;
|
|
349
|
+
const toLf = text => text.replace(CRLF, "\n");
|
|
350
|
+
const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
|
|
351
|
+
|
|
264
352
|
function write(target, file, text, exec) {
|
|
265
353
|
const full = path.join(target, file);
|
|
266
354
|
if (dryRun) return;
|
|
@@ -332,7 +420,12 @@ function main() {
|
|
|
332
420
|
say(`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`);
|
|
333
421
|
base = null;
|
|
334
422
|
}
|
|
335
|
-
|
|
423
|
+
// --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest
|
|
424
|
+
// way to reach that state is an install that wrote the receipt and kept a stale harness. So
|
|
425
|
+
// the run has to stay open at the recorded commit: short-circuiting here would answer the
|
|
426
|
+
// one command that fixes it with "nothing to update".
|
|
427
|
+
if (previous && base === head && !adopt) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
|
|
428
|
+
if (previous && base === head) say(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
|
|
336
429
|
|
|
337
430
|
// A repo carrying a harness from before harness-lock.json existed. Without a base the rule
|
|
338
431
|
// below keeps every file that is already there, which protects the project's work and also
|
|
@@ -356,10 +449,19 @@ function main() {
|
|
|
356
449
|
const policy = policyFor(rows, file);
|
|
357
450
|
const m = mode(entry);
|
|
358
451
|
const theirs = blob(templateDir, head, file);
|
|
359
|
-
|
|
452
|
+
// Git listed the path a moment ago, so failing to read it is the checkout being unhappy
|
|
453
|
+
// rather than the file being absent. Said out loud: skipped quietly, the run reports a
|
|
454
|
+
// clean install of a harness missing whichever files the reader was never told about.
|
|
455
|
+
if (theirs === null) { step(policy, m, "UNREADABLE", file, "unreadable"); continue; }
|
|
360
456
|
const full = path.join(target, file);
|
|
361
457
|
const exists = fs.existsSync(full);
|
|
362
458
|
|
|
459
|
+
// The executable bit is not the project's content, so a file kept for its content still
|
|
460
|
+
// has its mode corrected. Git runs a hook only if it is executable and says nothing when
|
|
461
|
+
// it is not, so a hook kept at 100644 by an install that had no merge base looks
|
|
462
|
+
// installed and gates nothing at all -- the failure this whole column exists to catch.
|
|
463
|
+
if (exec && exists && !dryRun) carryMode(target, file);
|
|
464
|
+
|
|
363
465
|
// Not installed anywhere, and named in one line of the summary instead: sixty-five
|
|
364
466
|
// lines saying nothing happened bury the thirty-eight saying something did.
|
|
365
467
|
if (policy === "template") { notes.template.push(file); continue; }
|
|
@@ -389,22 +491,54 @@ function main() {
|
|
|
389
491
|
}
|
|
390
492
|
// merge
|
|
391
493
|
if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
494
|
+
// Binary: there are no lines to merge, so it is the upstream's copy or it is the
|
|
495
|
+
// project's, and the base decides which. A logo the project replaced stays replaced.
|
|
496
|
+
if (Buffer.isBuffer(theirs)) {
|
|
497
|
+
const held = fs.readFileSync(full);
|
|
498
|
+
if (same(held, theirs)) { step(policy, m, "unchanged", file); continue; }
|
|
499
|
+
const was = base === null ? null : blob(templateDir, base, file);
|
|
500
|
+
if (adopt || same(held, was)) {
|
|
501
|
+
write(target, file, theirs, exec);
|
|
502
|
+
step(policy, m, adopt ? "adopted" : "written", file, "written");
|
|
503
|
+
} else step(policy, m, base === null ? "yours, no base" : "yours, binary", file, "kept");
|
|
398
504
|
continue;
|
|
399
505
|
}
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
506
|
+
const raw = fs.readFileSync(full, "utf8");
|
|
507
|
+
const crlf = isCrlf(raw);
|
|
508
|
+
const ours = toLf(raw);
|
|
509
|
+
if (ours === theirs) { step(policy, m, "unchanged", file); continue; }
|
|
510
|
+
// Before the base logic, not inside it: a repo that needs adopting usually has a
|
|
511
|
+
// receipt already, written by the install that kept the stale files in the first place.
|
|
512
|
+
if (adopt) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "adopted", file, "written"); continue; }
|
|
513
|
+
// Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file
|
|
514
|
+
// is now its own nearest base, so the merge takes it whole, the run says "unchanged" and
|
|
515
|
+
// a half-merged harness passes as settled. Named instead, and the run exits 1 until
|
|
516
|
+
// someone resolves it or --adopt above throws it away.
|
|
517
|
+
if (MARKED.test(ours)) { step(policy, m, "STILL OPEN", file, "conflicted"); continue; }
|
|
518
|
+
// A reconcile file is one the harness cannot work around: AGENTS.md is the map every
|
|
519
|
+
// agent reads and holds the table docs-check parses, and docs/README.md says what the
|
|
520
|
+
// chain puts where. Keeping a stale one leaves a repo that looks installed and behaves
|
|
521
|
+
// like the version it came from, so these are merged even when the receipt is missing.
|
|
522
|
+
let from = base === null ? null : blob(templateDir, base, file);
|
|
523
|
+
if (from === null && policy === "reconcile") from = recoverBase(templateDir, file, ours);
|
|
524
|
+
if (from === null && policy === "reconcile") {
|
|
525
|
+
// Nothing in the upstream's history matches, so this copy was written by hand. An
|
|
526
|
+
// empty base makes the whole file one conflict, which is the honest answer: both
|
|
527
|
+
// versions are there to read, and the run exits 1 rather than pretending.
|
|
528
|
+
from = "";
|
|
529
|
+
}
|
|
530
|
+
if (from === null) { step(policy, m, base === null ? "yours, no base" : "yours, new here", file, "kept"); continue; }
|
|
531
|
+
if (ours === from) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "written", file, "written"); continue; }
|
|
403
532
|
const merged = threeWay(from, ours, theirs);
|
|
404
533
|
if (merged.failed) { step(policy, m, "yours, merge failed", file, "kept"); continue; }
|
|
405
|
-
|
|
406
|
-
if (merged.conflicts) step(policy, m, "CONFLICT", file, "conflicted");
|
|
407
|
-
|
|
534
|
+
const result = asFound(merged.text, crlf);
|
|
535
|
+
if (merged.conflicts) { write(target, file, result, exec); step(policy, m, "CONFLICT", file, "conflicted"); continue; }
|
|
536
|
+
// A file that keeps a local edit merges cleanly on every later run and comes out the same
|
|
537
|
+
// every time. Reported as merged each run it reads as churn, and the reader goes looking
|
|
538
|
+
// for a change nobody made, so what the run did is decided by the result, not the route.
|
|
539
|
+
if (result === raw) { step(policy, m, "unchanged", file); continue; }
|
|
540
|
+
write(target, file, result, exec);
|
|
541
|
+
step(policy, m, "merged", file, "merged");
|
|
408
542
|
}
|
|
409
543
|
|
|
410
544
|
phase("skeletons a project starts with");
|
|
@@ -453,8 +587,17 @@ function mergeSkills(target, templateDir, head, files) {
|
|
|
453
587
|
if (text === null) continue;
|
|
454
588
|
const full = path.join(target, file);
|
|
455
589
|
const exists = fs.existsSync(full);
|
|
456
|
-
|
|
457
|
-
|
|
590
|
+
// A vendored file the project has not touched still differs byte-for-byte on Windows, where
|
|
591
|
+
// Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
|
|
592
|
+
const held = exists ? fs.readFileSync(full) : null;
|
|
593
|
+
if (Buffer.isBuffer(text)) {
|
|
594
|
+
if (same(held, text)) continue;
|
|
595
|
+
write(target, file, text);
|
|
596
|
+
} else {
|
|
597
|
+
const ourText = held === null ? null : held.toString("utf8");
|
|
598
|
+
if (ourText !== null && toLf(ourText) === text) continue;
|
|
599
|
+
write(target, file, asFound(text, ourText !== null && isCrlf(ourText)));
|
|
600
|
+
}
|
|
458
601
|
if (exists) { tally.updated++; notes.merged.push(file); }
|
|
459
602
|
else { tally.added++; notes.written.push(file); }
|
|
460
603
|
}
|
|
@@ -502,6 +645,12 @@ function report(target, head, ref, base) {
|
|
|
502
645
|
const named = notes.template.filter(f => !f.startsWith(".agents/hooks/tests/"));
|
|
503
646
|
say(`\nnot installed, the upstream's own (${notes.template.length}): ${named.join(", ")}, and the suite's fixtures`);
|
|
504
647
|
}
|
|
648
|
+
// Listed whatever the verbosity, like a conflict: a file the upstream ships and this run could
|
|
649
|
+
// not read is missing from the install, and the reader is the only one who can say why.
|
|
650
|
+
if (notes.unreadable.length) {
|
|
651
|
+
list("UNREADABLE in the upstream checkout, so not installed", notes.unreadable, true);
|
|
652
|
+
say(`\nGit could not read these out of the upstream checkout. On Windows a path over 260 characters is\nthe usual cause: install into a shorter path, or set core.longpaths=true globally.`);
|
|
653
|
+
}
|
|
505
654
|
if (notes.conflicted.length) {
|
|
506
655
|
list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
|
|
507
656
|
say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then run the suite:\n node .agents/hooks/test.js`);
|
|
@@ -516,7 +665,7 @@ function report(target, head, ref, base) {
|
|
|
516
665
|
say(`\nIn ${target}, point Git at the hooks once per clone:`);
|
|
517
666
|
say(` node scripts/githooks-init.js${suite ? " && node .agents/hooks/test.js" : " && node scripts/docs-check.js"}`);
|
|
518
667
|
}
|
|
519
|
-
if (notes.conflicted.length || (check && check.failed)) process.exit(1);
|
|
668
|
+
if (notes.conflicted.length || notes.unreadable.length || (check && check.failed)) process.exit(1);
|
|
520
669
|
}
|
|
521
670
|
|
|
522
671
|
main();
|