@salaros/ai-harness 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/update-harness.js +83 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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"
|
|
@@ -340,8 +340,9 @@ function policies(templateDir) {
|
|
|
340
340
|
// the docs site is the case, useful to some projects and dead weight in the rest.
|
|
341
341
|
// `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
|
|
342
342
|
// on the process's own argv and a test can ask what a repo would get either way.
|
|
343
|
+
const rowFor = (rows, file) => rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
|
|
343
344
|
function policyFor(rows, file, wants) {
|
|
344
|
-
const row = rows
|
|
345
|
+
const row = rowFor(rows, file);
|
|
345
346
|
if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
|
|
346
347
|
if (!row.policy.startsWith("optional:")) return row.policy;
|
|
347
348
|
return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
|
|
@@ -402,12 +403,31 @@ function threeWay(base, ours, theirs) {
|
|
|
402
403
|
fs.writeFileSync(f("base"), base);
|
|
403
404
|
fs.writeFileSync(f("ours"), ours);
|
|
404
405
|
fs.writeFileSync(f("theirs"), theirs);
|
|
405
|
-
const r = lib.run("git", ["merge-file", "-L", "yours", "-L", "upstream (base)", "-L", "upstream (new)",
|
|
406
|
+
const r = lib.run("git", ["merge-file", "--diff3", "-L", "yours", "-L", "upstream (base)", "-L", "upstream (new)",
|
|
406
407
|
f("ours"), f("base"), f("theirs")]);
|
|
407
|
-
return { text:
|
|
408
|
+
if (r.status < 0) return { text: "", conflicts: false, failed: true };
|
|
409
|
+
return settleDropped(fs.readFileSync(f("ours"), "utf8"));
|
|
408
410
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
409
411
|
}
|
|
410
412
|
|
|
413
|
+
// The conflicts of a --diff3 merge, each settled or written the way this script always has: the
|
|
414
|
+
// project's side and the upstream's, no base. A conflict whose project side shares no line with the
|
|
415
|
+
// base is a section the project dropped or replaced with its own, as a project does with the harness's
|
|
416
|
+
// part of .gitignore, and it stays dropped: the upstream's edit is to text the project no longer has.
|
|
417
|
+
// A conflict with an empty base is both sides adding at one place, and stays a conflict.
|
|
418
|
+
function settleDropped(text) {
|
|
419
|
+
const HUNK = /^<{7} yours\n([\s\S]*?)^\|{7} upstream \(base\)\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} upstream \(new\)\n/gm;
|
|
420
|
+
const lines = s => s.split("\n").map(l => l.trim()).filter(Boolean);
|
|
421
|
+
let conflicts = 0;
|
|
422
|
+
const out = text.replace(HUNK, (all, ours, base, theirs) => {
|
|
423
|
+
const was = new Set(lines(base));
|
|
424
|
+
if (was.size && !lines(ours).some(l => was.has(l))) return ours;
|
|
425
|
+
conflicts++;
|
|
426
|
+
return `<<<<<<< yours\n${ours}=======\n${theirs}>>>>>>> upstream (new)\n`;
|
|
427
|
+
});
|
|
428
|
+
return { text: out, conflicts: conflicts > 0, failed: false };
|
|
429
|
+
}
|
|
430
|
+
|
|
411
431
|
// Git checks a repo out with the platform's line endings, so a Windows working copy holds CRLF where
|
|
412
432
|
// the upstream stores LF. Compared raw, every line of every file reads as changed: a copy nobody
|
|
413
433
|
// touched reports as edited, and a real edit is buried in a whole-file conflict nobody can read. So
|
|
@@ -468,9 +488,12 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
|
|
|
468
488
|
let from = hasBase ? baseText() : null;
|
|
469
489
|
if (from === null && policy === "reconcile") from = recoverBase(ours);
|
|
470
490
|
if (from === null && policy === "reconcile") from = "";
|
|
491
|
+
// A union table merges by row whether or not there is a base, and even an untouched copy goes
|
|
492
|
+
// through that merge: it may hold a row the upstream dropped and the project still needs.
|
|
493
|
+
if (from === null && policy === "union") from = "";
|
|
471
494
|
if (from === null) return keep;
|
|
472
495
|
|
|
473
|
-
if (ours === from) return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
|
|
496
|
+
if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
|
|
474
497
|
const merged = merge(from, ours, theirs);
|
|
475
498
|
if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
|
|
476
499
|
const result = asFound(merged.text, crlf);
|
|
@@ -482,6 +505,31 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
|
|
|
482
505
|
return { outcome: "merged", bucket: "merged", text: result };
|
|
483
506
|
}
|
|
484
507
|
|
|
508
|
+
// A union table, merged row by row rather than line by line: a row is keyed by its first
|
|
509
|
+
// tab-separated column, and the table is a set of them, so there is nothing to conflict over.
|
|
510
|
+
// The upstream's comments and order come first. Each of its rows is the project's where only the
|
|
511
|
+
// project changed it, or where both did, and the upstream's otherwise; a row the project deleted
|
|
512
|
+
// stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
|
|
513
|
+
// or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
|
|
514
|
+
// here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
|
|
515
|
+
// receipt, and then the project's copy of a row wins.
|
|
516
|
+
function mergeRows(base, ours, theirs) {
|
|
517
|
+
const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
|
|
518
|
+
const was = rows(base), mine = rows(ours), up = rows(theirs);
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const line of theirs.split("\n")) {
|
|
521
|
+
const key = line.split("\t")[0];
|
|
522
|
+
if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
|
|
523
|
+
const o = mine.get(key), b = was.get(key);
|
|
524
|
+
if (o === undefined) { if (b === undefined) out.push(line); continue; }
|
|
525
|
+
out.push(o === b ? line : o);
|
|
526
|
+
}
|
|
527
|
+
const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
|
|
528
|
+
if (!extra.length) return out.join("\n");
|
|
529
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
530
|
+
return [...out, ...extra, ""].join("\n");
|
|
531
|
+
}
|
|
532
|
+
|
|
485
533
|
// ---------------------------------------------------------------- the plan
|
|
486
534
|
//
|
|
487
535
|
// Everything a run will do to the target, decided before anything is written: an install rewrites
|
|
@@ -516,8 +564,13 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
516
564
|
}
|
|
517
565
|
// --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest way to
|
|
518
566
|
// reach that state is an install that wrote the receipt and kept a stale harness. So the run stays
|
|
519
|
-
// open at the recorded commit, and main() answers "nothing to update" only without --adopt.
|
|
520
|
-
|
|
567
|
+
// open at the recorded commit, and main() answers "nothing to update" only without --adopt. An
|
|
568
|
+
// optional part asked for by its flag keeps it open too, and then only that part has anything to do.
|
|
569
|
+
if (previous && base === head) {
|
|
570
|
+
notices.push(options.adopt
|
|
571
|
+
? `harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`
|
|
572
|
+
: `harness is already at ${head.slice(0, 8)} (${ref}); installing only the optional part(s) asked for`);
|
|
573
|
+
}
|
|
521
574
|
|
|
522
575
|
// A repo carrying a harness from before harness-lock.json existed. Without a base the rule below
|
|
523
576
|
// keeps every file that is already there, which protects the project's work and also preserves
|
|
@@ -576,7 +629,12 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
576
629
|
// does not have names something that was never there.
|
|
577
630
|
if (policy === "skip") { line(exists ? "yours" : "absent", exists ? "skipped" : null); continue; }
|
|
578
631
|
if (policy === "seed") {
|
|
632
|
+
// A seed file the recorded commit already shipped was laid down then, so its absence is
|
|
633
|
+
// the project deleting it, and it stays deleted. An optional part is the exception: its
|
|
634
|
+
// flag is the project asking for it now, whatever an earlier run left out.
|
|
635
|
+
const asked = (rowFor(rows, file) || { policy: "" }).policy.startsWith("optional:");
|
|
579
636
|
if (exists) line("yours", "kept");
|
|
637
|
+
else if (!asked && base !== null && upstream.blob(base, file) !== null) line("deleted here", null);
|
|
580
638
|
else line("created", "seeded", { write: theirs });
|
|
581
639
|
continue;
|
|
582
640
|
}
|
|
@@ -589,22 +647,30 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
|
|
|
589
647
|
: decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
|
|
590
648
|
baseText: () => upstream.blob(base, file),
|
|
591
649
|
recoverBase: ours => recoverBase(upstream, file, ours),
|
|
592
|
-
|
|
650
|
+
// A union table has no lines to conflict over, and no base means the project's rows win.
|
|
651
|
+
merge: policy === "union"
|
|
652
|
+
? (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false })
|
|
653
|
+
: threeWay,
|
|
593
654
|
});
|
|
594
655
|
line(outcome, bucket, text === null ? {} : { write: text });
|
|
595
656
|
}
|
|
596
657
|
|
|
597
658
|
add({ phase: "skeletons a project starts with" });
|
|
598
659
|
const hasIntent = target.exists(projectFacts.INTENT);
|
|
660
|
+
// The receipt lists the skeletons its run knew, so one missing on an update is one the project
|
|
661
|
+
// deleted, and it stays deleted; a skeleton added since still arrives. A receipt from before the
|
|
662
|
+
// list is taken to know them all, which every install since the skeletons began has laid down.
|
|
663
|
+
const known = new Set(previous && base !== null ? previous.skeletons || Object.keys(SKELETONS) : []);
|
|
599
664
|
for (const [file, lines] of Object.entries(SKELETONS)) {
|
|
600
665
|
if (target.exists(file)) add({ file, policy: "seed", mode: "100644", outcome: "yours", bucket: null });
|
|
666
|
+
else if (known.has(file)) add({ file, policy: "seed", mode: "100644", outcome: "deleted here", bucket: null });
|
|
601
667
|
else add({ file, policy: "seed", mode: "100644", outcome: "created", bucket: "seeded", write: skeletonLines(file, lines, hasIntent).join("\n") });
|
|
602
668
|
}
|
|
603
669
|
|
|
604
670
|
add({ phase: "skills, merged by name" });
|
|
605
671
|
entries.push(...planSkills(upstream, target, head, skills));
|
|
606
672
|
|
|
607
|
-
add({ file: LOCK, silent: true, write: JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp }, null, 2) + "\n" });
|
|
673
|
+
add({ file: LOCK, silent: true, write: JSON.stringify({ template: TEMPLATE, ref, commit: head, ...stamp, skeletons: Object.keys(SKELETONS) }, null, 2) + "\n" });
|
|
608
674
|
return { entries, notices, base };
|
|
609
675
|
}
|
|
610
676
|
|
|
@@ -800,6 +866,13 @@ function report({ entries, base, head, ref, target, check, options }) {
|
|
|
800
866
|
|
|
801
867
|
// ---------------------------------------------------------------- the run
|
|
802
868
|
|
|
869
|
+
// Whether the run has nothing to do: the receipt already names the upstream's head, and neither
|
|
870
|
+
// --adopt nor the flag of an optional part asks for more. The first --astro-docs usually comes after
|
|
871
|
+
// the harness is current, so an optional part is something to install even at the recorded commit.
|
|
872
|
+
function upToDate(previous, head, options, optional) {
|
|
873
|
+
return !!previous && previous.commit === head && !options.adopt && !optional.some(name => options.wants(name));
|
|
874
|
+
}
|
|
875
|
+
|
|
803
876
|
// Returns the exit code, and throws Stop for a run that could not start.
|
|
804
877
|
function main(args) {
|
|
805
878
|
const options = parseOptions(args);
|
|
@@ -821,7 +894,7 @@ function main(args) {
|
|
|
821
894
|
const unknown = unknownArgs(args, optional);
|
|
822
895
|
if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
|
|
823
896
|
const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
|
|
824
|
-
if (previous
|
|
897
|
+
if (upToDate(previous, head, options, optional)) {
|
|
825
898
|
say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
|
|
826
899
|
return 0;
|
|
827
900
|
}
|
|
@@ -848,7 +921,7 @@ function main(args) {
|
|
|
848
921
|
// The plan and the decisions under it, so the suite can put a case in and read the answer out rather
|
|
849
922
|
// than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
|
|
850
923
|
// writes nothing; main() writes to somebody's repository and is reached through the command line.
|
|
851
|
-
module.exports = { installerStamp, unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
|
|
924
|
+
module.exports = { installerStamp, upToDate, mergeRows, settleDropped, unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
|
|
852
925
|
|
|
853
926
|
if (require.main === module) {
|
|
854
927
|
try { process.exitCode = main(process.argv.slice(2)); }
|