@salaros/ai-harness 0.3.3 → 0.3.4

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.4",
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"
@@ -468,9 +468,12 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
468
468
  let from = hasBase ? baseText() : null;
469
469
  if (from === null && policy === "reconcile") from = recoverBase(ours);
470
470
  if (from === null && policy === "reconcile") from = "";
471
+ // A union table merges by row whether or not there is a base, and even an untouched copy goes
472
+ // through that merge: it may hold a row the upstream dropped and the project still needs.
473
+ if (from === null && policy === "union") from = "";
471
474
  if (from === null) return keep;
472
475
 
473
- if (ours === from) return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
476
+ if (ours === from && policy !== "union") return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
474
477
  const merged = merge(from, ours, theirs);
475
478
  if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
476
479
  const result = asFound(merged.text, crlf);
@@ -482,6 +485,31 @@ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recover
482
485
  return { outcome: "merged", bucket: "merged", text: result };
483
486
  }
484
487
 
488
+ // A union table, merged row by row rather than line by line: a row is keyed by its first
489
+ // tab-separated column, and the table is a set of them, so there is nothing to conflict over.
490
+ // The upstream's comments and order come first. Each of its rows is the project's where only the
491
+ // project changed it, or where both did, and the upstream's otherwise; a row the project deleted
492
+ // stays deleted. Every row of the project's the upstream lacks follows, whether the project added it
493
+ // or the upstream dropped it: the licence of a skill the upstream stopped shipping is still needed
494
+ // here, because the skills merge keeps the skill. All three texts are LF; `base` is null without a
495
+ // receipt, and then the project's copy of a row wins.
496
+ function mergeRows(base, ours, theirs) {
497
+ const rows = text => new Map((text || "").split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => [l.split("\t")[0], l]));
498
+ const was = rows(base), mine = rows(ours), up = rows(theirs);
499
+ const out = [];
500
+ for (const line of theirs.split("\n")) {
501
+ const key = line.split("\t")[0];
502
+ if (!line.trim() || line.startsWith("#") || !up.has(key)) { out.push(line); continue; }
503
+ const o = mine.get(key), b = was.get(key);
504
+ if (o === undefined) { if (b === undefined) out.push(line); continue; }
505
+ out.push(o === b ? line : o);
506
+ }
507
+ const extra = [...mine].filter(([key]) => !up.has(key)).map(([, line]) => line);
508
+ if (!extra.length) return out.join("\n");
509
+ while (out.length && out[out.length - 1] === "") out.pop();
510
+ return [...out, ...extra, ""].join("\n");
511
+ }
512
+
485
513
  // ---------------------------------------------------------------- the plan
486
514
  //
487
515
  // Everything a run will do to the target, decided before anything is written: an install rewrites
@@ -516,8 +544,13 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
516
544
  }
517
545
  // --adopt is how a repo whose harness files are wrong gets them replaced, and the commonest way to
518
546
  // 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`);
547
+ // open at the recorded commit, and main() answers "nothing to update" only without --adopt. An
548
+ // optional part asked for by its flag keeps it open too, and then only that part has anything to do.
549
+ if (previous && base === head) {
550
+ notices.push(options.adopt
551
+ ? `harness is already at ${head.slice(0, 8)} (${ref}); --adopt takes every harness file again anyway`
552
+ : `harness is already at ${head.slice(0, 8)} (${ref}); installing only the optional part(s) asked for`);
553
+ }
521
554
 
522
555
  // A repo carrying a harness from before harness-lock.json existed. Without a base the rule below
523
556
  // keeps every file that is already there, which protects the project's work and also preserves
@@ -589,7 +622,10 @@ function plan({ upstream, target, rows, head, ref, previous, options, stamp = {}
589
622
  : decideText({ policy, raw: held, theirs, hasBase, adopt: options.adopt }, {
590
623
  baseText: () => upstream.blob(base, file),
591
624
  recoverBase: ours => recoverBase(upstream, file, ours),
592
- merge: threeWay,
625
+ // A union table has no lines to conflict over, and no base means the project's rows win.
626
+ merge: policy === "union"
627
+ ? (from, ours, up) => ({ text: mergeRows(from, ours, up), conflicts: false, failed: false })
628
+ : threeWay,
593
629
  });
594
630
  line(outcome, bucket, text === null ? {} : { write: text });
595
631
  }
@@ -800,6 +836,13 @@ function report({ entries, base, head, ref, target, check, options }) {
800
836
 
801
837
  // ---------------------------------------------------------------- the run
802
838
 
839
+ // Whether the run has nothing to do: the receipt already names the upstream's head, and neither
840
+ // --adopt nor the flag of an optional part asks for more. The first --astro-docs usually comes after
841
+ // the harness is current, so an optional part is something to install even at the recorded commit.
842
+ function upToDate(previous, head, options, optional) {
843
+ return !!previous && previous.commit === head && !options.adopt && !optional.some(name => options.wants(name));
844
+ }
845
+
803
846
  // Returns the exit code, and throws Stop for a run that could not start.
804
847
  function main(args) {
805
848
  const options = parseOptions(args);
@@ -821,7 +864,7 @@ function main(args) {
821
864
  const unknown = unknownArgs(args, optional);
822
865
  if (unknown.length) fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
823
866
  const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
824
- if (previous && previous.commit === head && !options.adopt) {
867
+ if (upToDate(previous, head, options, optional)) {
825
868
  say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`);
826
869
  return 0;
827
870
  }
@@ -848,7 +891,7 @@ function main(args) {
848
891
  // The plan and the decisions under it, so the suite can put a case in and read the answer out rather
849
892
  // than building a git checkout to reach one branch. apply() is here for its dry run, which prints and
850
893
  // 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 };
894
+ module.exports = { installerStamp, upToDate, mergeRows, unknownArgs, mistypedArgs, usage, parseOptions, policyFor, plan, apply, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
852
895
 
853
896
  if (require.main === module) {
854
897
  try { process.exitCode = main(process.argv.slice(2)); }