@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 +1 -1
- package/package.json +2 -1
- package/scripts/install-policy.js +91 -1
- package/scripts/plan-entry.js +80 -0
- package/scripts/repo-edit.js +168 -13
- package/scripts/update-harness.js +191 -134
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.
|
|
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
|
-
|
|
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 };
|
package/scripts/repo-edit.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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",
|
|
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
|
-
//
|
|
372
|
-
//
|
|
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(
|
|
424
|
-
for (const
|
|
425
|
-
const { file, link: isLink, exec, mode: m } =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
481
|
-
|
|
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(
|
|
485
|
-
|
|
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(
|
|
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
|
|
556
|
-
// path is written, and an edit
|
|
557
|
-
// then
|
|
558
|
-
// before the file, the link's way cleared before the link, the mode after the content -- is
|
|
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,
|
|
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)
|
|
566
|
-
let
|
|
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
|
-
|
|
574
|
-
if (result.kind === "link")
|
|
536
|
+
out(result.why);
|
|
537
|
+
if (result.kind === "link") actual = entry.turnedOut(e, "yours", "kept");
|
|
575
538
|
}
|
|
576
539
|
}
|
|
577
|
-
|
|
578
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
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
|
|
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
|
|
711
|
-
upstream: gitUpstream(templateDir),
|
|
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
|
-
|
|
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
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
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)); }
|