@salaros/ai-harness 0.5.1 → 0.6.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 CHANGED
@@ -10,7 +10,7 @@ It assumes no language or framework. What you put in `src/` decides the stack.
10
10
  - **Skills**: 60 vendored [agent skills](https://skills.sh) for requirements, design, testing, code review and more.
11
11
  - **Agents**: `engineer`, `business-analyst`, `devops` and `assistant`, each routing work to the right skills.
12
12
  - **Hooks**: agent hooks block dangerous shell commands and check every edit. Git hooks check commit messages, the documentation chain and formatting.
13
- - **A documentation chain**: BRD → PRD → EARS → BDD → ADR → SPEC → TDD → plan → code, with a checker that keeps every document traceable to the one before it.
13
+ - **A documentation chain**: PDD → BRD → PRD → TRD → EARS → BDD → RFC → ADR → SPEC → TDD → plan → code, with a checker that keeps every document traceable to the one before it.
14
14
 
15
15
  ## Requirements
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.5.1",
3
+ "version": "0.6.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"
@@ -12,6 +12,7 @@
12
12
  "scripts/project-facts.js",
13
13
  "scripts/repo-view.js",
14
14
  "scripts/repo-edit.js",
15
+ "scripts/plan-entry.js",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
@@ -5,6 +5,7 @@
5
5
  // const policy = require("./install-policy");
6
6
  // const { policy: name, asked } = policy.policyFor(rows, file, wants);
7
7
  // const { outcome, bucket, write, silent } = policy.decide(name, facts, reads);
8
+ // const entries = policy.decideRoster(name, roster, reads);
8
9
  // `facts` is what the caller already knows about the path; `reads` holds the things a decision may
9
10
  // need and costs something to find out, called only when the decision gets that far:
10
11
  // facts exists the target has something at the path
@@ -21,7 +22,18 @@
21
22
  // for a path nothing happened to), `write` the content to write when there is any, `silent`
22
23
  // when the path is counted in the summary but never printed as a line, and `notice` a sentence the
23
24
  // run prints before its summary, for a result someone has to finish by hand.
25
+ //
26
+ // One policy is decided for a folder rather than for a path: skills, where the unit a project
27
+ // installs is the skill and one line of output stands for its several hundred files. decideRoster
28
+ // takes every path the manifest gave that policy, each as the upstream's listing holds it
29
+ // ({ file, link, exec }), and answers in plan entries, because a folder's answer is several paths
30
+ // whose reporting differs between them. Its `reads` are the same kinds of thing by another shape,
31
+ // one path at a time:
32
+ // reads exists(file) the target has something at the path
33
+ // theirs(file) the upstream's content at the head: LF text, a Buffer, or null
34
+ // held(file) the target's copy as bytes, or null when it has none
24
35
  const lib = require("./lib");
36
+ const entry = require("./plan-entry");
25
37
 
26
38
  // ---------------------------------------------------------------- the manifest
27
39
 
@@ -297,6 +309,13 @@ function layDown(existing) {
297
309
 
298
310
  // ---------------------------------------------------------------- the policies
299
311
 
312
+ // Every summary list a path can join, named where the policies that answer with them are written.
313
+ // The summary used to keep its own copy, so a policy answering with a name that copy did not have
314
+ // threw at the push rather than being reported. UNFINISHED is the ones that leave work for the
315
+ // reader, which is what makes a run exit 1.
316
+ const BUCKETS = ["written", "merged", "conflicted", "seeded", "kept", "skipped", "template", "unreadable"];
317
+ const UNFINISHED = ["conflicted", "unreadable"];
318
+
300
319
  const POLICIES = {
301
320
  merge: merging("merge"),
302
321
  reconcile: merging("reconcile"),
@@ -315,10 +334,81 @@ const POLICIES = {
315
334
  template: () => ({ outcome: "template", bucket: "template", silent: true }),
316
335
  };
317
336
 
337
+ // ---------------------------------------------------------------- deciding a whole folder
338
+
339
+ const SKILLS = ".agents/skills/";
340
+ const SKILLS_LOCK = "skills-lock.json";
341
+
342
+ // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
343
+ // project vendored itself is never removed. skills-lock.json is the union, the project's entry
344
+ // winning where both name the same skill, so a project that pinned a different source keeps it.
345
+ function skills(roster, reads) {
346
+ const theirLock = JSON.parse(reads.theirs(SKILLS_LOCK) || '{"skills":{}}');
347
+ const ourBytes = reads.held(SKILLS_LOCK);
348
+ const ourLock = ourBytes === null ? { skills: {} } : JSON.parse(ourBytes.toString("utf8"));
349
+ ourLock.skills = ourLock.skills || {};
350
+ const mine = new Set(Object.keys(ourLock.skills));
351
+
352
+ const out = [];
353
+ // One line per skill, not per file. Outcome is decided across the whole folder: a skill counts as
354
+ // changed the moment any file in it did, and only an untouched folder reads "unchanged".
355
+ const outcomes = new Map();
356
+ const seen = name => outcomes.get(name) || outcomes.set(name, { added: 0, updated: 0, files: 0 }).get(name);
357
+ for (const { file, link, exec } of roster) {
358
+ // A skill link is relink's to make, once the directory it lives in exists: relink knows which
359
+ // skills this project actually has, where the upstream only knows its own.
360
+ if (link) { out.push(entry.folder(file, entry.quiet())); continue; }
361
+ if (!file.startsWith(SKILLS)) continue;
362
+ const name = file.slice(SKILLS.length).split("/")[0];
363
+ const tally = seen(name);
364
+ tally.files++;
365
+ const exists = reads.exists(file);
366
+ // A script the skill runs keeps its executable bit whoever owns the content, as a hook does.
367
+ if (exec && exists) out.push(entry.marked(file, entry.quiet()));
368
+ // A skill the project installed under a name the upstream also uses stays the project's.
369
+ if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
370
+ const theirs = reads.theirs(file);
371
+ if (theirs === null) continue;
372
+ // A vendored file the project has not touched still differs byte-for-byte on Windows, where
373
+ // Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
374
+ const held = exists ? reads.held(file) : null;
375
+ let write;
376
+ if (Buffer.isBuffer(theirs)) {
377
+ if (lib.sameContent(held, theirs)) continue;
378
+ write = theirs;
379
+ } else {
380
+ const ours = held === null ? null : held.toString("utf8");
381
+ if (ours !== null && lib.toLf(ours) === theirs) continue;
382
+ write = lib.asFound(theirs, ours !== null && lib.isCrlf(ours));
383
+ }
384
+ if (exists) tally.updated++; else tally.added++;
385
+ out.push(entry.written(file, write, entry.quiet(exists ? "merged" : "written"), { exec: exec && !exists }));
386
+ }
387
+ for (const [name, t] of [...outcomes].sort()) {
388
+ const what = t.yours ? "yours" : t.added ? "added" : t.updated ? "updated" : "unchanged";
389
+ out.push(entry.noted(`${SKILLS}${name} (${t.files} file(s))`, entry.shown("skills", "100644", what)));
390
+ }
391
+ for (const [name, pinned] of Object.entries(theirLock.skills)) {
392
+ if (!ourLock.skills[name]) ourLock.skills[name] = pinned;
393
+ }
394
+ out.push(entry.written(SKILLS_LOCK, JSON.stringify(ourLock, null, 2) + "\n", entry.quiet()));
395
+ return out;
396
+ }
397
+
398
+ const ROSTER_POLICIES = { skills };
399
+
318
400
  function decide(policy, facts, reads = {}) {
401
+ if (ROSTER_POLICIES[policy]) throw new Error(`install-policy: "${policy}" is decided for a whole folder, so ask decideRoster for its roster`);
319
402
  const rule = POLICIES[policy];
320
403
  if (!rule) throw new Error(`install-policy: no policy named "${policy}"`);
321
404
  return rule(facts, reads);
322
405
  }
323
406
 
324
- module.exports = { policyFor, decide };
407
+ // `roster` is every path the manifest gave this policy, as the upstream's listing holds it.
408
+ function decideRoster(policy, roster, reads = {}) {
409
+ const rule = ROSTER_POLICIES[policy];
410
+ if (!rule) throw new Error(`install-policy: no policy named "${policy}" is decided for a whole folder`);
411
+ return rule(roster, reads);
412
+ }
413
+
414
+ module.exports = { policyFor, decide, decideRoster, BUCKETS, UNFINISHED };
@@ -0,0 +1,80 @@
1
+ // scripts/plan-entry.js
2
+ // One line of an install plan: at most one thing done to one path, and how the run reports it.
3
+ //
4
+ // An install rewrites somebody else's repository, and it decides everything before it writes
5
+ // anything, so the plan is the whole of what a run is. Its entries used to be a record with eleven
6
+ // optional keys, built in three places and read in three others, with the rules for both written out
7
+ // as a comment above the loop. A comment is not checked: apply() padded `policy` on every entry not
8
+ // marked silent, so a producer that left the column out would have thrown halfway through somebody's
9
+ // repo, at print time, with no way to tell which producer built the entry.
10
+ //
11
+ // The kinds below are the whole vocabulary an install plan needs. Each says what is done to the path
12
+ // -- four of the five words repo-edit.js reads, write, link, mkdir and mark, plus doing nothing at
13
+ // all -- and carries
14
+ // how it is reported, which is either `shown(...)`, four columns demanded where the entry is made,
15
+ // or `quiet(...)`, counted in the summary and never printed. Nothing here touches a repo, prints, or
16
+ // decides an outcome word: repo-edit does the first, the run does the second, install-policy the
17
+ // third.
18
+
19
+ // How an entry is reported: the policy the manifest gave the path, the mode Git recorded, the
20
+ // outcome word, and the summary list the path joins. The bucket is the one that may be left out -- a
21
+ // line can be printed and belong to no list, which is what "unchanged" is. The other three are the
22
+ // line, so leaving one out is refused here, by name, rather than at the padEnd that would have hit it.
23
+ function shown(policy, mode, outcome, bucket = null) {
24
+ for (const [name, value] of [["policy", policy], ["mode", mode], ["outcome", outcome]]) {
25
+ if (typeof value !== "string" || !value) throw new Error(`a plan entry that is printed needs a ${name}`);
26
+ }
27
+ return { policy, mode, outcome, bucket };
28
+ }
29
+
30
+ // Counted in the summary and never printed: a skill's own files, where the skill is the line and its
31
+ // four hundred files are not; the two receipts; the folder made for a link.
32
+ const quiet = (bucket = null) => ({ silent: true, bucket });
33
+
34
+ // A section of the output. Heads the lines that follow it and does nothing to any path.
35
+ const heading = phase => ({ phase });
36
+
37
+ const at = (file, as, act) => {
38
+ if (typeof file !== "string" || !file) throw new Error("a plan entry needs the path it is about");
39
+ if (!as || (!as.silent && !as.outcome)) throw new Error(`${file}: a plan entry needs either shown(...) or quiet(...)`);
40
+ return { file, ...act, ...as };
41
+ };
42
+
43
+ // The path is left exactly as it was found, and the line says so: unchanged, yours, UNREADABLE, or a
44
+ // skill whose folder nothing in this run touched.
45
+ const noted = (file, as) => at(file, as, {});
46
+
47
+ // `text` is what the path will hold, a string or a Buffer. `exec` marks it executable as well, which
48
+ // is part of the same entry rather than a second one: a hook written 100644 gates nothing and looks
49
+ // installed either way.
50
+ const written = (file, text, as, { exec = false } = {}) => at(file, as, { write: text, exec });
51
+
52
+ // A symlink to `to`. `replace` clears whatever is in its way first, which is how a link checked out
53
+ // as a regular file -- the failure that leaves an agent seeing no skills at all -- gets undone.
54
+ const linked = (file, to, as, { replace = false } = {}) => at(file, as, { link: to, replace });
55
+
56
+ // The executable bit alone, for a path whose content is somebody else's to keep.
57
+ const marked = (file, as) => at(file, as, { exec: true });
58
+
59
+ // The folder and nothing in it, for a link whose parent has to exist before it can be made.
60
+ const folder = (file, as) => at(file, as, { mkdir: true });
61
+
62
+ // The run's line for an entry, or null for one that prints nothing. One function rather than a
63
+ // format string at each consumer, so the widths and the rule for what is printed live with the
64
+ // record they are about.
65
+ // A column is its word, a space, and then padding out to the width: padding alone lines the columns
66
+ // up only while every word is shorter than its column, and "reconcile" filled a nine-wide policy
67
+ // column exactly while "yours appended" overran a twelve-wide outcome, so both ran into what came
68
+ // after them. A word too long now pushes its column out of line, which is a worse-looking line and
69
+ // a readable one. Nothing noticed either way for as long as a run's lines could be read only as the
70
+ // stdout of a real install.
71
+ const column = (word, width) => `${word} `.padEnd(width);
72
+ const describe = e => (e.phase || e.silent ? null : ` ${column(e.policy, 9)}${e.mode} ${column(e.outcome, 12)}${e.file}`);
73
+
74
+ // The entry as it turned out, when that is not what it planned. The only case is a symlink the
75
+ // platform refused: the edit reports the link is not there, and which outcome word that deserves is
76
+ // the run's to say. A copy, and only the reporting changes -- what was asked of the path is what was
77
+ // asked, whatever became of it.
78
+ const turnedOut = (e, outcome, bucket = null) => ({ ...e, outcome, bucket });
79
+
80
+ module.exports = { shown, quiet, heading, noted, written, linked, marked, folder, describe, turnedOut };
@@ -5,7 +5,7 @@
5
5
  // apply(entries) carry out a finished plan, and return what became of it
6
6
  // Each entry that asks for work gets one result:
7
7
  // { file, kind, done, why }
8
- // `kind` is write, link, mkdir or mark; `done` is whether the tree now holds what the entry asked
8
+ // `kind` is write, link, mkdir, mark or move; `done` is whether the tree now holds what the entry asked
9
9
  // for; `why` is the mechanical reason it does not, or null. No outcome word and no summary bucket
10
10
  // appears here: those are the install policy's, and the run translates these results into them.
11
11
  // Nothing here prints or exits, so a refusal is a value the caller reads rather than a message a
@@ -20,9 +20,29 @@ const git = (root, args) => spawnSync("git", ["-c", "core.longpaths=true", "-C",
20
20
 
21
21
  const result = (file, kind, why) => ({ file, kind, done: !why, why: why || null });
22
22
 
23
+ // A move's two refusals, worded once. Both adapters answer the same question and have to answer it
24
+ // the same way, which is easier to keep true when there is one sentence rather than two copies.
25
+ const noSource = e => `nothing at ${e.move} to move to ${e.file}`;
26
+ const taken = e => `${e.file} is already there, so ${e.move} was left where it is`;
27
+ // The third, for the step where the rename is done and Git will not record it: worded once for the
28
+ // same reason, and carrying the command's own words, since what Git refused is the whole of what
29
+ // there is to say about it.
30
+ const cannotStage = (e, r) => `could not stage the move of ${e.move} to ${e.file}: ${(r.stderr || "").trim()}`;
31
+ // A path a move in the same plan is moving to, claimed by some other entry as well. A plan that
32
+ // says two things about one path is a plan that contradicts itself, and the second of them is
33
+ // refused rather than performed: see `apply`, where the reason this can arise at all is set out.
34
+ const landed = (e, from) => `${e.file} is where ${from} was moved, so nothing else in the plan writes there`;
35
+ // A symlink that was not made, worded as the filesystem words it, because that is what the disk
36
+ // adapter is reporting: EEXIST for a path something is already at, EPERM for a platform that will
37
+ // not make one at all.
38
+ const noLink = (e, code) => `could not create the symlink ${e.file} -> ${e.link}: ${code}`;
39
+
23
40
  // What an entry asks to have done, or null when it asks for nothing: a phase heading, or a path the
24
41
  // plan decided to leave exactly as it found it.
25
42
  function work(e) {
43
+ // Before the rest, because a move is about a path that does not exist yet and the entry naming
44
+ // it may well go on to link something at where it came from.
45
+ if (e.move !== undefined) return "move";
26
46
  if (e.link !== undefined) return "link";
27
47
  if (e.write !== undefined) return "write";
28
48
  if (e.mkdir) return "mkdir";
@@ -39,10 +59,27 @@ function editing(act) {
39
59
  return {
40
60
  apply(entries) {
41
61
  const out = [];
42
- for (const e of entries) {
62
+ // W-2. A move empties a path, and an entry that links something at where it came from
63
+ // reads naturally after it -- so a caller writes it that way and the edit, not the
64
+ // caller, is what makes the order safe. Left in the caller's order, the link ran first,
65
+ // `clear` took the project's own folder out of the way, and the move then carried the
66
+ // link off to the destination: work destroyed, with both entries reporting success.
67
+ // Moves go first instead, which is the order every caller already writes. The one thing
68
+ // it costs: a move's source has to be in the tree already, not written by the same plan.
69
+ const moves = entries.filter(e => work(e) === "move");
70
+ // And what the reordering itself costs, paid here rather than by the caller. A move now
71
+ // runs before an entry written above it, so the path it lands on is a path that entry
72
+ // was about to land on -- and if that entry is a link, `clear` takes the moved work out
73
+ // of its way: the same destruction, one end of the move further along. A move owns both
74
+ // ends of its path, so the rest of the plan is refused at the destination. A mark is not
75
+ // refused: it changes the mode of whatever is at the path rather than putting something
76
+ // else there, which is how a hook is moved into place and then made executable.
77
+ const to = new Map(moves.map(e => [e.file, e.move]));
78
+ for (const e of [...moves, ...entries.filter(e => work(e) !== "move")]) {
43
79
  const kind = work(e);
44
80
  if (!kind) continue;
45
- let why = kind === "mark" ? null : act[kind](e);
81
+ const onAMove = kind !== "move" && kind !== "mark" && to.has(e.file);
82
+ let why = onAMove ? landed(e, to.get(e.file)) : kind === "mark" ? null : act[kind](e);
46
83
  if (!why && e.exec) why = act.mark(e);
47
84
  out.push(result(e.file, kind, why));
48
85
  }
@@ -67,17 +104,55 @@ function mapEdit(files = {}, { links = true } = {}) {
67
104
  const held = { ...files };
68
105
  const marked = [];
69
106
  const view = () => repoView.fromMap(held);
107
+ // Every key a path stands for: the file itself, and everything under it when it is a folder.
108
+ // A map has no directories, so this is what "something is at this path" means here.
109
+ const anyUnder = rel => [rel, ...Object.keys(held).filter(k => k.startsWith(`${rel}/`))].filter(k => k in held);
110
+ // The ancestor a path cannot be reached through, because the map holds a file there. A map has
111
+ // no folders to be blocked by, so this is the only shape in which "a file where a folder has to
112
+ // go" exists here at all -- and on disk it is what makes mkdir and the write after it refuse.
113
+ const ancestorFile = rel => rel.split("/").slice(0, -1)
114
+ .map((_, i, parts) => parts.slice(0, i + 1).join("/"))
115
+ .find(p => p in held) || null;
70
116
  return {
71
117
  ...editing({
72
- write: e => { held[e.file] = e.write; return null; },
118
+ // A folder at the path is a refusal, which in a map is keys under it: the disk answers
119
+ // EISDIR, and an adapter that quietly wrote instead would be the one place the suite
120
+ // could not see the failure an install really gets.
121
+ write: e => {
122
+ if (anyUnder(e.file).some(k => k !== e.file)) return `could not write ${e.file}: EISDIR`;
123
+ if (ancestorFile(e.file)) return `could not write ${e.file}: ENOTDIR`;
124
+ held[e.file] = e.write;
125
+ return null;
126
+ },
73
127
  link: e => {
74
- if (!links) return `could not create the symlink ${e.file} -> ${e.link}: EPERM`;
128
+ if (!links) return noLink(e, "EPERM");
129
+ if (ancestorFile(e.file)) return noLink(e, "ENOTDIR");
130
+ // A file or a link at the path is replaced and a folder holding anything is not,
131
+ // because that is what the disk does: `clear` unlinks, and its rmdir fallback fails
132
+ // on a folder with something in it, so the symlink then refuses with EEXIST.
133
+ if (anyUnder(e.file).some(k => k !== e.file)) return noLink(e, "EEXIST");
75
134
  held[e.file] = { link: e.link };
76
135
  return null;
77
136
  },
78
137
  // A map holds files, and a folder in it is whatever a path implies, so there is nothing
79
- // to make: the entry is satisfied the moment anything is written under it.
80
- mkdir: () => null,
138
+ // to make: the entry is satisfied the moment anything is written under it. A file at the
139
+ // exact path is the one case with an answer to give, and it is the disk's.
140
+ mkdir: e => {
141
+ if (e.file in held) return `could not create the folder ${e.file}: EEXIST`;
142
+ return ancestorFile(e.file) ? `could not create the folder ${e.file}: ENOTDIR` : null;
143
+ },
144
+ // A folder in a map is a prefix, so moving one is re-keying every path under it, and a
145
+ // file is the exact key. Whatever each one held travels with it, mode and all.
146
+ move: e => {
147
+ const from = anyUnder(e.move);
148
+ if (!from.length) return noSource(e);
149
+ if (anyUnder(e.file).length) return taken(e);
150
+ for (const key of from) {
151
+ held[`${e.file}${key.slice(e.move.length)}`] = held[key];
152
+ delete held[key];
153
+ }
154
+ return null;
155
+ },
81
156
  mark: e => {
82
157
  const row = view().modes().find(r => r.file === e.file);
83
158
  if (!row) return `nothing at ${e.file} to mark executable`;
@@ -98,7 +173,49 @@ function mapEdit(files = {}, { links = true } = {}) {
98
173
  function worktreeEdit(root) {
99
174
  const at = rel => path.resolve(root, String(rel));
100
175
  const parent = rel => fs.mkdirSync(path.dirname(at(rel)), { recursive: true });
101
- const clear = rel => { try { fs.unlinkSync(at(rel)); } catch { /* nothing was in the way */ } };
176
+ // rmdir after unlink, because a directory symlink on Windows -- and a junction, which is what
177
+ // `npx skills` leaves behind -- is a directory to unlink and a link to rmdir, and only one of the
178
+ // two calls works on either. Without the second, replacing a junction with a relative symlink
179
+ // fails at the symlink for want of clearing its way.
180
+ const clear = rel => {
181
+ try { fs.unlinkSync(at(rel)); } catch { try { fs.rmdirSync(at(rel)); } catch { /* nothing was in the way */ } }
182
+ };
183
+ // Whether anything is at the path, the link itself counting rather than what it points at.
184
+ const there = rel => { try { fs.lstatSync(at(rel)); return true; } catch { return false; } };
185
+ // The rename is the filesystem's, but its record is Git's: moving a committed folder leaves the
186
+ // index recording the old path, and no amount of writing to disk corrects that. The same reason
187
+ // `mark` below goes through Git rather than chmod -- the disk cannot say it. Staged only when
188
+ // the source was committed, which is the only case where the index now contradicts the disk: a
189
+ // path nothing tracked leaves nothing to correct, so a repository mid-edit keeps its index, and
190
+ // a root with no index at all has nothing to keep in step.
191
+ const stage = e => {
192
+ const tracked = repoView.indexModes(root, [e.move]);
193
+ if (!tracked || !tracked.length) return null;
194
+ // The destination on its own and first. Given both paths at once, `git add` stages the
195
+ // source's deletion and then fails on the destination -- a target whose .gitignore covers
196
+ // where the harness keeps its skills is enough -- which leaves the index recording the skill
197
+ // at neither path while the disk holds it at the new one. So the destination is offered
198
+ // alone, and if Git will not have it the rename goes back and the index is untouched: the
199
+ // refusal a real target actually produces costs the repository nothing.
200
+ const added = git(root, ["add", "-A", "--", e.file]);
201
+ if (added.status !== 0) {
202
+ try { fs.renameSync(at(e.file), at(e.move)); }
203
+ catch { /* the way back is gone too; the message below is all there is to give */ }
204
+ return cannotStage(e, added);
205
+ }
206
+ // Dropping the source afterwards is judged by the index, not by what `git add` answers. A
207
+ // source now covered by the target's .gitignore is refused and dropped in the same breath:
208
+ // the command exits non-zero over the ignore rule and takes the entry out of the index all
209
+ // the same, which is the staging that was asked for. So the index is read back, and only a
210
+ // read that returns and finds nothing says the move is staged: a read that fails says
211
+ // nothing, and leaves the refusal standing, which is the safe way round. Nothing rolls back
212
+ // here and nothing should, the destination being staged by then -- putting the file back on
213
+ // disk would leave the two contradicting each other rather than as they started.
214
+ const dropped = git(root, ["add", "-A", "--", e.move]);
215
+ if (dropped.status === 0) return null;
216
+ const stillTracked = repoView.indexModes(root, [e.move]);
217
+ return stillTracked && !stillTracked.length ? null : cannotStage(e, dropped);
218
+ };
102
219
  const marked = [];
103
220
  // Git runs a hook only if it is executable and says nothing when it is not, so an installed
104
221
  // harness whose hooks are 644 looks installed and gates nothing. The upstream records them
@@ -111,16 +228,50 @@ function worktreeEdit(root) {
111
228
  const alreadyExec = file => (repoView.indexModes(root, [file]) || []).some(r => r.file === file && r.exec);
112
229
  return {
113
230
  ...editing({
114
- write: e => { parent(e.file); fs.writeFileSync(at(e.file), e.write); return null; },
115
- mkdir: e => { fs.mkdirSync(at(e.file), { recursive: true }); return null; },
231
+ // Both inside a try, for the same reason the move below is: a project with a folder
232
+ // where a file goes, or a file where a folder goes, would otherwise take the whole
233
+ // install down mid-way through with a throw out of `apply`. A refusal is this module's
234
+ // contract, and it has to hold for the paths the harness writes as well.
235
+ write: e => {
236
+ try { parent(e.file); fs.writeFileSync(at(e.file), e.write); }
237
+ catch (err) { return `could not write ${e.file}: ${err.code || err.message}`; }
238
+ return null;
239
+ },
240
+ mkdir: e => {
241
+ try { fs.mkdirSync(at(e.file), { recursive: true }); }
242
+ catch (err) { return `could not create the folder ${e.file}: ${err.code || err.message}`; }
243
+ return null;
244
+ },
245
+ // Git is not asked to do the rename: a skill being adopted is usually untracked, and
246
+ // `git mv` refuses that. Nothing is overwritten, so a name already taken at the
247
+ // destination is a refusal rather than a project's work quietly replaced. Asked with
248
+ // lstat rather than existsSync, because a dangling symlink is something in the way and
249
+ // exists() follows the link and reports the path free -- and because the map adapter,
250
+ // holding a link as an entry like any other, answers that question the same way.
251
+ move: e => {
252
+ if (!there(e.move)) return noSource(e);
253
+ if (there(e.file)) return taken(e);
254
+ // Inside the try with the rename, unlike the writes above: those go to the harness's
255
+ // own paths, while a move's destination is made under whatever the project already
256
+ // has there. A repository with a file where .agents/skills should be would take the
257
+ // whole install down mid-way through, and a refusal is this module's contract.
258
+ try {
259
+ parent(e.file);
260
+ fs.renameSync(at(e.move), at(e.file));
261
+ }
262
+ catch (err) { return `could not move ${e.move} to ${e.file}: ${err.code || err.message}`; }
263
+ return stage(e);
264
+ },
116
265
  link: e => {
117
- parent(e.file);
266
+ // Before `clear`, so a parent that cannot be made refuses with the path still as it
267
+ // was rather than with its way already cleared for a link that never arrives.
268
+ try { parent(e.file); } catch (err) { return noLink(e, err.code || err.message); }
118
269
  clear(e.file);
119
270
  // Windows needs Developer Mode and core.symlinks=true for this to work at all, so a
120
271
  // refusal is a result rather than a throw: the harness still functions with the link
121
272
  // missing, it is just invisible to the agent harnesses that read it.
122
273
  try { fs.symlinkSync(e.link.split("/").join(path.sep), at(e.file), "dir"); }
123
- catch (err) { return `could not create the symlink ${e.file} -> ${e.link}: ${err.code || err.message}`; }
274
+ catch (err) { return noLink(e, err.code || err.message); }
124
275
  return null;
125
276
  },
126
277
  mark: e => {
@@ -137,4 +288,8 @@ function worktreeEdit(root) {
137
288
  };
138
289
  }
139
290
 
140
- module.exports = { worktreeEdit, mapEdit };
291
+ // `kindOf` is exported for the plan's entries, which are built to be read by it: one name per kind
292
+ // across the two, checked rather than kept in step by hand. It covers the four an install plan
293
+ // builds; `move` is relink's alone, and relink assembles its entries directly rather than
294
+ // through `scripts/plan-entry.js`.
295
+ module.exports = { worktreeEdit, mapEdit, kindOf: work };
@@ -36,6 +36,7 @@ const projectFacts = require("./project-facts");
36
36
  const repoView = require("./repo-view");
37
37
  const repoEdit = require("./repo-edit");
38
38
  const installPolicy = require("./install-policy");
39
+ const entry = require("./plan-entry");
39
40
 
40
41
  const TEMPLATE = "https://github.com/salaros/ai-harness.git";
41
42
  const LOCK = "harness-lock.json";
@@ -126,6 +127,11 @@ function targetRoot(options) {
126
127
 
127
128
  // ---------------------------------------------------------------- the upstream
128
129
 
130
+ // The command line that clone runs on, which is how every install begins that did not bring its own
131
+ // checkout. Separate from the running of it because nothing in the suite clones: a check can read
132
+ // arguments, and cannot watch a network call it must not make.
133
+ const cloneArgs = (ref, dir) => ["-c", "core.longpaths=true", "clone", "--quiet", "--branch", ref, TEMPLATE, dir];
134
+
129
135
  // A clone deep enough to read the recorded commit: an update needs that commit's version of a file
130
136
  // as the merge base, and --depth 1 would not have it. Removed again unless the caller supplied one.
131
137
  function templateCheckout(ref, options) {
@@ -141,7 +147,7 @@ function templateCheckout(ref, options) {
141
147
  }
142
148
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
143
149
  say(`cloning ${TEMPLATE} at ${ref}`);
144
- const r = lib.run("git", [...GIT, "clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
150
+ const r = lib.run("git", cloneArgs(ref, dir));
145
151
  if (r.status !== 0) { fs.rmSync(dir, { recursive: true, force: true }); fail(`could not clone the upstream at ${ref}\n${r.output}`); }
146
152
  if (!usable(dir)) {
147
153
  fs.rmSync(dir, { recursive: true, force: true });
@@ -329,6 +335,10 @@ function skeletonLines(file, lines, hasIntent) {
329
335
 
330
336
  // git merge-file writes the merged result and reports the number of conflicts, or a negative status
331
337
  // for trouble. Used rather than a hand-rolled diff3 because the target already needs Git.
338
+ // This is the one thing a policy decision does that reaches outside the process: it makes a temp
339
+ // directory and spawns Git, which is why the otherwise in-memory decision table touches real disk.
340
+ // That is deliberate, and ADR-0002 is why: a seam here would run the table against a stand-in for
341
+ // diff3, and what diff3 really does to a conflict is the only thing worth checking.
332
342
  function threeWay(base, ours, theirs) {
333
343
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "merge-"));
334
344
  const f = n => path.join(dir, n);
@@ -367,16 +377,9 @@ function settleDropped(text) {
367
377
  // someone else's repository, and deciding and writing in the same loop left every branch but the
368
378
  // per-file decision reachable only through a git checkout and a temp tree. A dry run prints the plan;
369
379
  // a real run applies it. Each entry is one line of the run's output and at most one thing done to
370
- // one path:
371
- // file, policy, mode, outcome, bucket what the run prints, and the summary list the path joins
372
- // write the path's new content, text or a Buffer
373
- // link, replace a symlink to `link`, replacing what is there when `replace`
374
- // exec mark the path executable, whether or not it is written
375
- // mkdir create the path's folder and nothing else
376
- // silent counted in the summary, never printed as a line
377
- // and a { phase } entry heads each section of the output.
378
- const SKILLS = ".agents/skills/";
379
-
380
+ // one path, and what an entry may be is scripts/plan-entry.js: written, linked, marked, folder,
381
+ // noted or heading, each reported by shown(...) or quiet(...). Nothing here builds an entry by hand,
382
+ // so the rules are the ones that module enforces rather than the ones this comment used to list.
380
383
  // `previous` is the target's harness-lock.json, or null; `stamp` is what the receipt records about
381
384
  // this run besides the upstream commit, passed in so a plan is the same whenever it is made.
382
385
  function plan({ upstream, target, rows, head, ref, previous, options, stamp = {} }) {
@@ -420,27 +423,35 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
420
423
  // the mode column says.
421
424
  const files = atHead.modes();
422
425
  const skills = [];
423
- add({ phase: `${files.length} path(s) in ${ref} at ${head.slice(0, 8)}` });
424
- for (const entry of files) {
425
- const { file, link: isLink, exec, mode: m } = entry;
426
+ add(entry.heading(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`));
427
+ for (const row of files) {
428
+ const { file, link: isLink, exec, mode: m } = row;
426
429
  const { policy, asked } = installPolicy.policyFor(rows, file, options.wants);
427
430
  const theirs = contentOf(atHead, file);
428
431
  // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
429
432
  // rather than the file being absent. Said out loud: skipped quietly, the run reports a clean
430
433
  // install of a harness missing whichever files the reader was never told about.
431
- if (theirs === null) { add({ file, policy, mode: m, outcome: "UNREADABLE", bucket: "unreadable" }); continue; }
434
+ if (theirs === null) { add(entry.noted(file, entry.shown(policy, m, "UNREADABLE", "unreadable"))); continue; }
432
435
  const exists = target.exists(file);
433
436
  // The executable bit is not the project's content, so a file kept for its content still has
434
437
  // its mode corrected. Git runs a hook only if it is executable and says nothing when it is
435
438
  // not, so a hook kept at 100644 by an install that had no merge base looks installed and
436
439
  // gates nothing at all -- the failure the mode column exists to catch.
437
- const line = (outcome, bucket, act = {}) =>
438
- add({ file, policy, mode: m, outcome, bucket, ...act, exec: exec && (exists || act.write !== undefined) });
439
-
440
+ const line = (outcome, bucket, act = {}, silent = false) => {
441
+ // The upstream's own files are counted and never printed: sixty of them are the suite's
442
+ // fixtures, and the rule is worth a sentence in the summary rather than sixty lines.
443
+ const as = silent ? entry.quiet(bucket) : entry.shown(policy, m, outcome, bucket);
444
+ if (act.link !== undefined) return add(entry.linked(file, act.link, as, { replace: !!act.replace }));
445
+ const bit = exec && (exists || act.write !== undefined);
446
+ if (act.write !== undefined) return add(entry.written(file, act.write, as, { exec: bit }));
447
+ return add(bit ? entry.marked(file, as) : entry.noted(file, as));
448
+ };
449
+
450
+ // Skills are decided for the folder rather than for the path: a skill merges by name, and
451
+ // one line of output stands for its several hundred files. Collected here and handed to the
452
+ // seam below, links and all, so this loop leaks around its own policy seam nowhere.
453
+ if (policy === "skills") { skills.push(row); continue; }
440
454
  if (isLink && policy !== "template") {
441
- // A skill link is relink's to make, once the directory it lives in exists: it knows which
442
- // skills this project actually has, where the upstream only knows its own.
443
- if (policy === "skills") { add({ file, mkdir: true, silent: true }); continue; }
444
455
  const to = theirs.trim();
445
456
  const found = target.lstat(file);
446
457
  // Something of the project's in the way -- or, in a repo whose harness predates the lock
@@ -452,10 +463,7 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
452
463
  else line("merged", "merged", { link: to, replace: true });
453
464
  continue;
454
465
  }
455
- // Reported one line per skill by planSkills below, not one per reference file: a skill is the
456
- // unit a project installs, and its files run to several hundred.
457
- if (policy === "skills") { skills.push(entry); continue; }
458
- const { outcome, bucket, notice, ...act } = installPolicy.decide(policy,
466
+ const { outcome, bucket, notice, silent, ...act } = installPolicy.decide(policy,
459
467
  { exists, theirs, hasBase: base !== null, adopt: options.adopt, asked }, {
460
468
  held: () => Buffer.isBuffer(theirs) ? target.bytes(file) : target.read(file),
461
469
  base: () => contentOf(upstream.at(base), file),
@@ -466,10 +474,10 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
466
474
  merge: threeWay,
467
475
  });
468
476
  if (notice) notices.push(`${file}: ${notice}`);
469
- line(outcome, bucket, act);
477
+ line(outcome, bucket, act, silent);
470
478
  }
471
479
 
472
- add({ phase: "skeletons a project starts with" });
480
+ add(entry.heading("skeletons a project starts with"));
473
481
  const hasIntent = target.exists(projectFacts.INTENT);
474
482
  // The receipt lists the skeletons its run knew, so one missing on an update is one the project
475
483
  // deleted, and it stays deleted; a skeleton added since still arrives. A receipt from before the
@@ -477,74 +485,29 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
477
485
  const known = new Set(previous && base !== null ? previous.skeletons || Object.keys(SKELETONS) : []);
478
486
  for (const [file, lines] of Object.entries(SKELETONS)) {
479
487
  const theirs = skeletonLines(file, lines, hasIntent).join("\n");
480
- const decision = installPolicy.decide("skeleton", { exists: target.exists(file), theirs, asked: false }, { shippedBefore: () => known.has(file) });
481
- add({ file, policy: "seed", mode: "100644", ...decision });
488
+ const { outcome, bucket, write } = installPolicy.decide("skeleton",
489
+ { exists: target.exists(file), theirs, asked: false }, { shippedBefore: () => known.has(file) });
490
+ const as = entry.shown("seed", "100644", outcome, bucket);
491
+ add(write === undefined ? entry.noted(file, as) : entry.written(file, write, as));
482
492
  }
483
493
 
484
- add({ phase: "skills, merged by name" });
485
- entries.push(...planSkills(atHead, target, skills));
494
+ add(entry.heading("skills, merged by name"));
495
+ // Everything the roster is decided from, read through the two views the run already holds: the
496
+ // policy reads nothing itself, as it reads nothing for one path.
497
+ entries.push(...installPolicy.decideRoster("skills", skills, {
498
+ exists: file => target.exists(file),
499
+ theirs: file => contentOf(atHead, file),
500
+ held: file => (target.isFile(file) ? target.bytes(file) : null),
501
+ }));
486
502
 
487
503
  // A list of strings on one line, as Prettier writes it: a project formatting its JSON with it
488
504
  // would otherwise reject the receipt at every push, and the next update would undo the fix.
489
505
  const receipt = JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp, skeletons: Object.keys(SKELETONS) }, null, 2)
490
506
  .replace(/\[\n\s+("[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s*\]/g, (all, items) => `[${items.split(/,\n\s+/).join(", ")}]`);
491
- add({ file: LOCK, silent: true, write: receipt + "\n" });
507
+ add(entry.written(LOCK, receipt + "\n", entry.quiet()));
492
508
  return { entries, notices, base };
493
509
  }
494
510
 
495
- // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
496
- // project vendored itself is never removed. skills-lock.json is the union, the project's entry
497
- // winning where both name the same skill, so a project that pinned a different source keeps it.
498
- function planSkills(atHead, target, files) {
499
- const LOCKFILE = "skills-lock.json";
500
- const theirLock = JSON.parse(atHead.read(LOCKFILE) || '{"skills":{}}');
501
- const ourLock = target.isFile(LOCKFILE) ? JSON.parse(target.read(LOCKFILE)) : { skills: {} };
502
- ourLock.skills = ourLock.skills || {};
503
- const mine = new Set(Object.keys(ourLock.skills));
504
-
505
- const out = [];
506
- // One line per skill, not per file. Outcome is decided across the whole folder: a skill counts as
507
- // changed the moment any file in it did, and only an untouched folder reads "unchanged".
508
- const outcomes = new Map();
509
- const seen = name => outcomes.get(name) || outcomes.set(name, { added: 0, updated: 0, files: 0 }).get(name);
510
- for (const { file, exec } of files) {
511
- if (!file.startsWith(SKILLS)) continue; // .claude/skills links are rebuilt, not copied
512
- const name = file.slice(SKILLS.length).split("/")[0];
513
- const tally = seen(name);
514
- tally.files++;
515
- const exists = target.exists(file);
516
- // A script the skill runs keeps its executable bit whoever owns the content, as a hook does.
517
- if (exec && exists) out.push({ file, silent: true, exec: true });
518
- // A skill the project installed under a name the upstream also uses stays the project's.
519
- if (mine.has(name) && !theirLock.skills[name]) { tally.yours = true; continue; }
520
- const text = contentOf(atHead, file);
521
- if (text === null) continue;
522
- // A vendored file the project has not touched still differs byte-for-byte on Windows, where
523
- // Git checked it out with CRLF. Compared raw, every skill would report as updated every run.
524
- const held = exists ? target.bytes(file) : null;
525
- let write;
526
- if (Buffer.isBuffer(text)) {
527
- if (lib.sameContent(held, text)) continue;
528
- write = text;
529
- } else {
530
- const ourText = held === null ? null : held.toString("utf8");
531
- if (ourText !== null && lib.toLf(ourText) === text) continue;
532
- write = lib.asFound(text, ourText !== null && lib.isCrlf(ourText));
533
- }
534
- if (exists) tally.updated++; else tally.added++;
535
- out.push({ file, silent: true, write, bucket: exists ? "merged" : "written", exec: exec && !exists });
536
- }
537
- for (const [name, t] of [...outcomes].sort()) {
538
- const what = t.yours ? "yours" : t.added ? "added" : t.updated ? "updated" : "unchanged";
539
- out.push({ file: `${SKILLS}${name} (${t.files} file(s))`, policy: "skills", mode: "100644", outcome: what, bucket: null });
540
- }
541
- for (const [name, entry] of Object.entries(theirLock.skills)) {
542
- if (!ourLock.skills[name]) ourLock.skills[name] = entry;
543
- }
544
- out.push({ file: LOCKFILE, silent: true, write: JSON.stringify(ourLock, null, 2) + "\n" });
545
- return out;
546
- }
547
-
548
511
  // ---------------------------------------------------------------- applying it
549
512
 
550
513
  // An install rewrites someone else's repository, so it says what it did to every path while it does
@@ -552,30 +515,31 @@ function planSkills(atHead, target, files) {
552
515
  // lands 100644 gates nothing and a skill link written as a regular file leaves the agent with no
553
516
  // skills, and both look installed. A dry run prints the same lines straight from the plan.
554
517
  //
555
- // The writing is scripts/repo-edit.js, an entry at a time: the line for a path is printed as the
556
- // path is written, and an edit handed the whole plan at once would leave a long install silent and
557
- // then print all of it at the end. What order the work inside an entry goes in -- the parent folder
558
- // before the file, the link's way cleared before the link, the mode after the content -- is the
559
- // edit's, so this loop takes the plan's order as given and adds nothing to it.
518
+ // The writing is the repo-edit the run was handed, an entry at a time: the line for a path is said
519
+ // as the path is written, and an edit given the whole plan at once would leave a long install silent
520
+ // and then say all of it at the end. What order the work inside an entry goes in -- the parent
521
+ // folder before the file, the link's way cleared before the link, the mode after the content -- is
522
+ // the edit's, so this loop takes the plan's order as given and adds nothing to it. `out` is where a
523
+ // line goes as it happens; the run collects them, and only the command line prints.
560
524
  // Returns the entries as they turned out, which the summary is built from.
561
- function apply(entries, root, options) {
525
+ function apply(entries, edit, options, out = say) {
562
526
  const done = [];
563
- const edit = repoEdit.worktreeEdit(root);
564
527
  for (const e of entries) {
565
- if (e.phase) { if (!options.quiet) say(`\n${e.phase}`); continue; }
566
- let shown = e;
528
+ if (e.phase) { if (!options.quiet) out(`\n${e.phase}`); continue; }
529
+ let actual = e;
567
530
  if (!options.dryRun) {
568
531
  const [result] = edit.apply([e]);
569
532
  // The edit reports; the policy decides. A symlink this platform will not make means the
570
533
  // target keeps whatever it already had, which is an outcome word and therefore this
571
534
  // run's to choose -- the edit says only that the link is not there and why.
572
535
  if (result && !result.done) {
573
- say(result.why);
574
- if (result.kind === "link") shown = { ...e, outcome: "yours", bucket: "kept" };
536
+ out(result.why);
537
+ if (result.kind === "link") actual = entry.turnedOut(e, "yours", "kept");
575
538
  }
576
539
  }
577
- if (!shown.silent && !options.quiet) say(` ${shown.policy.padEnd(9)}${shown.mode} ${shown.outcome.padEnd(12)}${shown.file}`);
578
- done.push(shown);
540
+ const line = entry.describe(actual);
541
+ if (line && !options.quiet) out(line);
542
+ done.push(actual);
579
543
  }
580
544
  return done;
581
545
  }
@@ -590,17 +554,19 @@ function apply(entries, root, options) {
590
554
  // install leaves a harness that works rather than a list of commands to remember. Each run names the
591
555
  // target with --root: the shared resolver prefers a harness's project-dir variable to the checkout a
592
556
  // script sits in, and an install started from a session open on another repo would otherwise wire,
593
- // link and describe that repo instead. Returns whether the hooks were wired.
557
+ // link and describe that repo instead.
558
+ // { hooks, lines }: whether the hooks were wired, and what the three steps had to say
594
559
  function finish(target, options) {
595
- if (!options.quiet) say("\nGit hooks, links and notices");
560
+ const lines = [];
561
+ if (!options.quiet) lines.push("\nGit hooks, links and notices");
596
562
  const steps = [["git hooks", "scripts/githooks-init.js", []], ["links", "scripts/skills.js", ["relink"]], ["notices", "scripts/skills.js", ["notices"]]];
597
563
  let hooks = true;
598
564
  for (const [label, script, args] of steps) {
599
565
  const r = lib.node([path.join(target, script), ...args, `${lib.ROOT_FLAG}${target}`], { cwd: target });
600
- say(r.status === 0 ? r.output : `${label}: ${r.output}`);
566
+ lines.push(r.status === 0 ? r.output : `${label}: ${r.output}`);
601
567
  if (label === "git hooks" && r.status !== 0) hooks = false;
602
568
  }
603
- return hooks;
569
+ return { hooks, lines };
604
570
  }
605
571
 
606
572
  // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
@@ -612,22 +578,61 @@ function finish(target, options) {
612
578
  // to run them. The upstream's copy rather than the one just installed, so the check is the one that
613
579
  // matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
614
580
  // scripts, which the upstream's own CI has already done.
581
+ // { check, lines }: what the invariants made of the target, and what running them had to say
615
582
  function selfCheck(target, templateDir, options) {
616
583
  const script = path.join(templateDir, "scripts", "check-harness.js");
617
- if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
618
- if (!options.quiet) say("\nself check: the harness invariants, run from the upstream against this repo");
584
+ if (!fs.existsSync(script)) return { check: { skipped: "this upstream ref has no scripts/check-harness.js" }, lines: [] };
585
+ const lines = options.quiet ? [] : ["\nself check: the harness invariants, run from the upstream against this repo"];
619
586
  const harness = require(script);
620
587
  // The root rather than a view of it: this is the upstream's copy of check-harness, at whichever
621
588
  // ref the run is installing, and a ref old enough to predate the view still expects a path.
622
589
  const r = harness.check(target);
623
- return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
590
+ return { check: { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) }, lines };
591
+ }
592
+
593
+ // The two steps above, which are the only ones that reach outside this process: three scripts
594
+ // spawned in the target, and the upstream's own invariants required out of its checkout and run
595
+ // against it. Behind one adapter, because a run that wants to know what it would do has no target to
596
+ // spawn in and no checkout to require from -- and because every ordering fix this installer has
597
+ // needed landed in exactly these two steps, where nothing short of a real install could reach them.
598
+ // wire(root, options) -> { hooks, lines }
599
+ // invariants(root, upstreamDir, options) -> { check, lines }
600
+ const onDisk = { wire: finish, invariants: selfCheck };
601
+
602
+ // Which paths joined which summary list. The lists are install-policy's to name: it is where a
603
+ // bucket is decided, and a second copy of the names here is a second place to forget one.
604
+ const bucketed = entries => {
605
+ const notes = Object.fromEntries(installPolicy.BUCKETS.map(b => [b, []]));
606
+ for (const e of entries) if (e.bucket) (notes[e.bucket] || []).push(e.file);
607
+ return notes;
608
+ };
609
+
610
+ // Whether the run left anything for the reader to act on, and what. The one place that knows what
611
+ // makes an install fail: it used to be a single expression at the foot of the printer below, so the
612
+ // only way to ask was to run the install and read its stdout. Every reason is named, not the first
613
+ // one found -- a run with conflicts and unwired hooks has two things wrong with it.
614
+ function verdict({ entries, check, hooks = true }) {
615
+ const notes = bucketed(entries);
616
+ const why = [];
617
+ for (const bucket of installPolicy.UNFINISHED) {
618
+ const n = notes[bucket].length;
619
+ if (n) why.push(bucket === "conflicted"
620
+ ? `${n} path(s) hold conflict markers to resolve by hand`
621
+ : `${n} path(s) could not be read out of the upstream checkout, so they are not installed`);
622
+ }
623
+ if (check && check.failed) why.push("the harness's own checks do not pass in the target");
624
+ if (!hooks) why.push("the target's Git hooks are not wired, so nothing gates a commit there");
625
+ return { failed: why.length > 0, why };
624
626
  }
625
627
 
626
- // The summary, from the entries as they turned out. Returns the exit code: 1 while anything is left
627
- // for the reader to act on.
628
- function report({ entries, base, head, ref, target, check, hooks = true, options }) {
629
- const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], unreadable: [] };
630
- for (const e of entries) if (e.bucket) notes[e.bucket].push(e.file);
628
+ // The summary in words, as lines: what happened, then whatever the verdict says is outstanding.
629
+ // Returned rather than printed, so what a run reports can be read by a check the way a reader reads
630
+ // it. The verdict is read here and never worked out again -- two answers to "did this run fail?"
631
+ // drift, and the one CI reads is the one nobody is looking at.
632
+ function summarise({ entries, base, head, ref, target, check, hooks = true, options }) {
633
+ const notes = bucketed(entries);
634
+ const out = [];
635
+ const say = line => out.push(line);
631
636
  // Every path was named as it happened, so repeating the lists here doubles the output; a quiet
632
637
  // run never saw them and gets them in full. Conflicts are listed either way: they are what the
633
638
  // reader has to act on, and they belong beside the instructions for acting on them.
@@ -666,7 +671,7 @@ function report({ entries, base, head, ref, target, check, hooks = true, options
666
671
  // Git hooks are wired per clone: this one was wired above, and every other clone runs the script once.
667
672
  if (!options.dryRun && !hooks) say(`\nGIT HOOKS NOT WIRED: in ${target}, run node scripts/githooks-init.js`);
668
673
  if (!options.dryRun) say(`\nEvery other clone of ${target} wires its Git hooks once with: node scripts/githooks-init.js`);
669
- return notes.conflicted.length || notes.unreadable.length || (check && check.failed) || !hooks ? 1 : 0;
674
+ return out;
670
675
  }
671
676
 
672
677
  // ---------------------------------------------------------------- the run
@@ -678,6 +683,68 @@ function upToDate(previous, head, options, optional) {
678
683
  return !!previous && previous.commit === head && !options.adopt && !optional.some(name => options.wants(name));
679
684
  }
680
685
 
686
+ // The parts of the harness a project has to ask for by name, as the manifest names them.
687
+ const optionalParts = rows => rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
688
+
689
+ // One install, end to end, as a value. It decides whether there is anything to do, plans it, applies
690
+ // the plan through the target's repo-edit, takes the two steps that reach outside this process, and
691
+ // works out what it all came to -- and it returns every one of those rather than printing any of
692
+ // them. Which is the whole point: two-thirds of this file used to be reachable only by spawning the
693
+ // installer at somebody's repository, so an ordering question could be asked only of stdout after an
694
+ // eighteen-second install, and was therefore asked only after it had already gone wrong.
695
+ //
696
+ // `say` is where a line goes the moment it is produced, and defaults to nowhere. An install rewrites
697
+ // someone else's repository and has to narrate itself while it does it, so the lines cannot simply
698
+ // be handed over at the end; collecting them and passing them on at once is the same list either
699
+ // way, which is why a check can assert on what a run said without watching a terminal.
700
+ // upstream { view, rows, head, ref, dir } the upstream side, already checked out and read
701
+ // target { view, edit, root, previous } the target side: what to read, what to write through
702
+ // options, stamp the parsed command line, and what the receipt records
703
+ // after the steps outside this process; see onDisk above
704
+ // -> { entries, notices, lines, base, head, ref, target, check, hooks, options, verdict }
705
+ function run({ upstream, target, options, stamp = {}, after = onDisk, say: out = () => {} }) {
706
+ const lines = [];
707
+ const said = line => { lines.push(line); out(line); };
708
+
709
+ if (upToDate(target.previous, upstream.head, options, optionalParts(upstream.rows))) {
710
+ said(`harness is already at ${upstream.head.slice(0, 8)} (${upstream.ref}); nothing to update`);
711
+ return { entries: [], notices: [], lines, base: null, head: upstream.head, ref: upstream.ref,
712
+ target: target.root, check: null, hooks: true, options, verdict: { failed: false, why: [] } };
713
+ }
714
+
715
+ const planned = plan({
716
+ upstream: upstream.view, target: target.view, rows: upstream.rows,
717
+ head: upstream.head, ref: upstream.ref, previous: target.previous, options, stamp,
718
+ });
719
+ for (const notice of planned.notices) said(notice);
720
+ const entries = apply(planned.entries, target.edit, options, said);
721
+
722
+ let check = null;
723
+ let hooks = true;
724
+ // After the plan is applied, because relink needs the skills in place and the invariants check
725
+ // the links relink has just written. A dry run wrote nothing, so there is nothing to wire or
726
+ // check and both steps are the target's own business until it is installed for real.
727
+ if (!options.dryRun) {
728
+ const wired = after.wire(target.root, options);
729
+ hooks = wired.hooks;
730
+ for (const line of wired.lines) said(line);
731
+ if (options.check) {
732
+ const ran = after.invariants(target.root, upstream.dir, options);
733
+ check = ran.check;
734
+ for (const line of ran.lines) said(line);
735
+ }
736
+ }
737
+
738
+ const result = { entries, notices: planned.notices, base: planned.base, head: upstream.head,
739
+ ref: upstream.ref, target: target.root, check, hooks, options };
740
+ for (const line of summarise(result)) said(line);
741
+ return { ...result, lines, verdict: verdict(result) };
742
+ }
743
+
744
+ // The command line: the one adapter over the run above. It works out which upstream and which target
745
+ // the arguments mean, clones if it has to, prints every line the run says as the run says it, and
746
+ // turns the verdict into the exit code npx and CI read. Nothing here decides anything about an
747
+ // install; everything it decides is about the process it is running in.
681
748
  // Returns the exit code, and throws Stop for a run that could not start.
682
749
  function main(args) {
683
750
  const options = parseOptions(args);
@@ -698,39 +765,29 @@ function main(args) {
698
765
  try {
699
766
  // Checked before anything is said about the target, so a bad argument is the only message.
700
767
  const rows = policies(templateDir);
701
- const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
702
- const unknown = unknownArgs(args, optional);
768
+ const unknown = unknownArgs(args, optionalParts(rows));
703
769
  if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
704
770
  const head = git(templateDir, ["rev-parse", "HEAD"]).output.trim();
705
- if (upToDate(previous, head, options, optional)) {
706
- say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
707
- return 0;
708
- }
709
771
 
710
- const planned = plan({
711
- upstream: gitUpstream(templateDir), target: here, rows, head, ref, previous, options,
772
+ const result = run({
773
+ upstream: { view: gitUpstream(templateDir), rows, head, ref, dir: templateDir },
774
+ target: { view: here, edit: repoEdit.worktreeEdit(target), root: target, previous },
775
+ options,
712
776
  stamp: { ...installer(), updated: new Date().toISOString().slice(0, 10) },
777
+ say,
713
778
  });
714
- for (const notice of planned.notices) say(notice);
715
- const entries = apply(planned.entries, target, options);
716
- let check = null;
717
- let hooks = true;
718
- if (!options.dryRun) {
719
- // After the plan is applied, because relink needs the skills in place and the invariants
720
- // check the links relink has just written.
721
- hooks = finish(target, options);
722
- if (options.check) check = selfCheck(target, templateDir, options);
723
- }
724
- return report({ entries, base: planned.base, head, ref, target, check, hooks, options });
779
+ return result.verdict.failed ? 1 : 0;
725
780
  } finally {
726
781
  if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
727
782
  }
728
783
  }
729
784
 
730
- // The plan and the decisions under it, so the suite can put a case in and read the answer out rather
731
- // than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
732
- // writes nothing; main() writes to somebody's repository and is reached through the command line.
733
- module.exports = { installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, lineCounts, overlap, NEAREST, skeletonLines };
785
+ // run() is the interface: a whole install as a value, against whatever upstream, target and steps
786
+ // the caller hands it. The rest are the decisions under it, exported so a case can go in and an
787
+ // answer come out without a git checkout to reach one branch of one of them. main() is not among
788
+ // them: it is the command line, it writes to somebody's repository, and it is reached by running
789
+ // this file.
790
+ module.exports = { run, cloneArgs, installerStamp, upToDate, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, plan, apply, verdict, summarise, lineCounts, overlap, NEAREST, skeletonLines };
734
791
 
735
792
  if (require.main === module) {
736
793
  try { process.exitCode = main(process.argv.slice(2)); }