@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.3.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.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
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: fs.readFileSync(f("ours"), "utf8"), conflicts: r.status > 0, failed: r.status < 0 };
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
- if (previous && base === head) notices.push(`harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`);
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
- merge: threeWay,
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 && previous.commit === head && !options.adopt) {
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)); }