@codyswann/lisa 4.36.1 → 4.37.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.
Files changed (82) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +2 -2
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +626 -0
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +26 -7
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/strategies/package-lisa.js +10 -0
  11. package/dist/strategies/package-lisa.js.map +1 -1
  12. package/package.json +5 -4
  13. package/plugins/lisa/.claude-plugin/plugin.json +5 -1
  14. package/plugins/lisa/.codex-plugin/hooks.json +4 -0
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/hooks/block-blind-automerge.agy.sh +54 -0
  17. package/plugins/lisa/hooks/block-blind-automerge.sh +623 -0
  18. package/plugins/lisa-agy/hooks/block-blind-automerge.agy.sh +54 -0
  19. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +623 -0
  20. package/plugins/lisa-agy/hooks.json +13 -0
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
  28. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +623 -0
  29. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +623 -0
  31. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  32. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo-agy/plugin.json +1 -1
  35. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  55. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails-agy/plugin.json +1 -1
  60. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  65. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  70. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/materialized-artifacts.json +1 -0
  73. package/plugins/src/base/.claude-plugin/plugin.json +4 -0
  74. package/plugins/src/base/hooks/block-blind-automerge.agy.sh +54 -0
  75. package/plugins/src/base/hooks/block-blind-automerge.sh +623 -0
  76. package/scripts/build-plugins.sh +1 -1
  77. package/scripts/check-self-dependency-pin.mjs +401 -0
  78. package/scripts/generate-agy-plugin-artifacts.mjs +11 -0
  79. package/scripts/lib/per-agent-hook-filter.mjs +10 -0
  80. package/scripts/lisa-enforcement-fallback.sh +2 -2
  81. package/scripts/merge-generated-artifact.mjs +182 -20
  82. package/scripts/two-channel-couplings.json +1 -1
@@ -31,10 +31,26 @@
31
31
  * For a pointwise function, **a pointwise 3-way merge of the OUTPUT equals the
32
32
  * function applied to the merged INPUT**. If a key changed on our side only,
33
33
  * the merged tree has our version of the file it describes, so our entry is the
34
- * right one; symmetrically for theirs; if both sides changed the same key
35
- * differently, the merged tree has a file that neither side described, and this
36
- * driver conflicts rather than inventing an answer. So the result is not one
37
- * side's blob — it is a reconstruction of what regeneration would produce.
34
+ * right one; symmetrically for theirs. So the result is not one side's blob —
35
+ * it is a reconstruction of what regeneration would produce.
36
+ *
37
+ * ## The one case where reconstruction is impossible
38
+ *
39
+ * If both sides changed the same key differently, the merged tree has a file
40
+ * that neither side described, so there is no right answer to reconstruct.
41
+ * CodySwannGT/lisa#3822 measured what that costs: it is not an exotic case but
42
+ * **the guaranteed consequence of two agents editing one source file**, because
43
+ * the manifest collapses a whole file to a single line. On the real repository,
44
+ * two branches editing different regions of one hook script auto-merge with
45
+ * both edits present and the manifest is the ONLY conflicting path — a derived
46
+ * file turning a clean merge into a failed one.
47
+ *
48
+ * So the two artifacts diverge here, and only here.
49
+ * {@link DERIVED_ENTRY_RESOLUTION_PATHS} names the manifest: a contested entry
50
+ * takes our side, the merge finishes, and the file is announced as stale until
51
+ * the generator runs against the whole tree. The ledger keeps conflicting,
52
+ * because it has never had this problem to solve — both decisions are argued at
53
+ * that constant.
38
54
  *
39
55
  * ## Why the driver cannot simply run the generators
40
56
  *
@@ -87,7 +103,9 @@
87
103
  * node scripts/merge-generated-artifact.mjs --base %O --ours %A --theirs %B --path %P
88
104
  *
89
105
  * Exit codes:
90
- * 0 — merged cleanly; the result was written over the `--ours` file.
106
+ * 0 — merged; the result was written over the `--ours` file. For the manifest
107
+ * this can mean "merged and deliberately stale" — the driver says so on
108
+ * stderr and names the regeneration command.
91
109
  * 1 — conflict. `--ours` is left untouched (a parseable artifact plus a
92
110
  * precise stderr reason beats a corrupted one; both sides remain in the
93
111
  * index at stages 2 and 3), and git records the path as unmerged.
@@ -117,6 +135,63 @@ const ENTRY_LINE = /^(\s+)("(?:[^"\\]|\\.)*"|[A-Za-z_$][\w$]*)\s*:/u;
117
135
  /** Header of an append-only array entry, e.g. `"dest": Object.freeze([`. */
118
136
  const ARRAY_OPEN = /Object\.freeze\(\[\s*$/u;
119
137
 
138
+ /**
139
+ * Artifacts whose same-key collisions are resolved to one side instead of
140
+ * conflicting (CodySwannGT/lisa#3822).
141
+ *
142
+ * ## Why only this file
143
+ *
144
+ * The manifest collapses each whole file to ONE line — a path and a content
145
+ * hash — so **two concurrent edits to the same source file are a guaranteed
146
+ * textual collision here**, whether or not the edits themselves conflict.
147
+ * Measured on the real repository: two branches editing different regions of
148
+ * `all/copy-overwrite/scripts/lib/kill-marks.mjs` auto-merge with both edits
149
+ * present, and the manifest is the ONLY conflicting path. The artifact converts
150
+ * a clean merge into a failed one.
151
+ *
152
+ * Neither side is right in that case and neither is wrong: the merged tree has
153
+ * a file that neither side described, so the correct hash is a RECOMPUTATION,
154
+ * not a choice. The driver cannot recompute it — measured in #3816, a driver
155
+ * that shells out to the generator reads a half-merged working tree and is
156
+ * guaranteed to be wrong about exactly the paths that caused the conflict. So
157
+ * the driver resolves to one side and the artifact is stale BY CONSTRUCTION
158
+ * until regeneration, which is announced on stderr and enforced elsewhere:
159
+ * `tests/unit/scripts/upstream-evidence-manifest.test.ts` asserts the
160
+ * generator's own `--check`, and `test-correctness` is a required gate at push
161
+ * and on the pull request. A stale manifest cannot reach `main`.
162
+ *
163
+ * ## Why NOT the hash ledger
164
+ *
165
+ * `src/core/lisa-owned-hash-ledger.ts` appears in **0 of 7** historical
166
+ * conflicts despite a 43% touch rate, and merged cleanly on both sides of two
167
+ * live PRs the day #3822 was filed. Its entries are append-only arrays that
168
+ * `unionArrayEntry` already merges exactly. It needs nothing, and a same-key
169
+ * collision there means something this driver does not understand — so it keeps
170
+ * conflicting, which is the safe answer for an artifact with no measured
171
+ * problem. Measuring TOUCH RATE would have ranked it a major offender;
172
+ * measuring CONFLICTS shows it is not an offender at all.
173
+ * @type {ReadonlySet<string>}
174
+ */
175
+ export const DERIVED_ENTRY_RESOLUTION_PATHS = Object.freeze(
176
+ new Set(["src/core/upstream-evidence-manifest.ts"])
177
+ );
178
+
179
+ /**
180
+ * Whether same-key collisions in this artifact resolve instead of conflicting.
181
+ *
182
+ * An unknown path (git supplied no `%P`, or a fixture under another name) is
183
+ * strict. Defaulting the other way would extend a deliberately narrow exception
184
+ * to every file the driver is ever mapped to, silently.
185
+ * @param {string | undefined} artifactPath - Repo-relative path git is merging
186
+ * @returns {boolean} True when one side may be taken for a contested entry
187
+ */
188
+ export function resolvesDerivedEntries(artifactPath) {
189
+ return (
190
+ artifactPath !== undefined &&
191
+ DERIVED_ENTRY_RESOLUTION_PATHS.has(artifactPath)
192
+ );
193
+ }
194
+
120
195
  /**
121
196
  * Compare two keys the way both generators sort theirs.
122
197
  *
@@ -334,38 +409,76 @@ function mergeSide(hasBase, base, ours, theirs, key) {
334
409
  /** Identity of an entry body (or its absence) for three-way comparison. */
335
410
  const entryKey = lines => (lines === undefined ? ABSENT : lines.join("\n"));
336
411
 
412
+ /**
413
+ * Decide what one entry both sides changed becomes.
414
+ *
415
+ * Three outcomes, in the order they are tried: an append-only union when both
416
+ * sides grew the same array; a resolution to OUR side when the artifact is one
417
+ * whose entries are recomputed rather than authored (see
418
+ * {@link DERIVED_ENTRY_RESOLUTION_PATHS}); a conflict otherwise.
419
+ *
420
+ * Resolution requires BOTH sides to have the entry. A key one side deleted and
421
+ * the other changed says the source file was removed on one branch and edited
422
+ * on the other — a real disagreement about the tree, which git itself surfaces
423
+ * on the source path. Taking a side there would answer a question nobody asked
424
+ * this driver.
425
+ * @param {string[] | undefined} base - Merge-base entry lines
426
+ * @param {string[] | undefined} ours - Our entry lines
427
+ * @param {string[] | undefined} theirs - Their entry lines
428
+ * @param {boolean} resolveDerived - Whether one side may be taken
429
+ * @returns {{kind: "unioned" | "derived", lines: string[]} | {kind: "conflict"}} Outcome
430
+ */
431
+ function resolveContestedEntry(base, ours, theirs, resolveDerived) {
432
+ if (ours === undefined || theirs === undefined) return { kind: "conflict" };
433
+ const unioned = unionArrayEntry(base, ours, theirs);
434
+ if (unioned !== undefined) return { kind: "unioned", lines: unioned };
435
+ if (resolveDerived) return { kind: "derived", lines: ours };
436
+ return { kind: "conflict" };
437
+ }
438
+
337
439
  /**
338
440
  * Merge one entries block.
339
441
  * @param {Map<string, string[]> | undefined} base - Merge-base entries
340
442
  * @param {Map<string, string[]>} ours - Our entries
341
443
  * @param {Map<string, string[]>} theirs - Their entries
342
- * @returns {{ok: true, entries: Map<string, string[]>} | {ok: false, reason: string}} Merged block
444
+ * @param {boolean} resolveDerived - Whether a contested entry may take our side
445
+ * @returns {{ok: true, entries: Map<string, string[]>, resolved: string[]} | {ok: false, reason: string}} Merged block
343
446
  */
344
- function mergeEntries(base, ours, theirs) {
447
+ function mergeEntries(base, ours, theirs, resolveDerived) {
345
448
  const merged = new Map();
449
+ const resolved = [];
346
450
  const keys = [
347
451
  ...new Set([...(base?.keys() ?? []), ...ours.keys(), ...theirs.keys()]),
348
452
  ];
349
453
  for (const name of keys.sort(compareKeys)) {
350
454
  const b = base?.get(name);
351
- const o = ours.get(name);
352
- const t = theirs.get(name);
353
- const straight = mergeSide(base !== undefined, b, o, t, entryKey);
455
+ const straight = mergeSide(
456
+ base !== undefined,
457
+ b,
458
+ ours.get(name),
459
+ theirs.get(name),
460
+ entryKey
461
+ );
354
462
  if (straight.ok) {
355
463
  if (straight.value !== undefined) merged.set(name, straight.value);
356
464
  continue;
357
465
  }
358
- const unioned =
359
- o !== undefined && t !== undefined ? unionArrayEntry(b, o, t) : undefined;
360
- if (unioned === undefined) {
466
+ const outcome = resolveContestedEntry(
467
+ b,
468
+ ours.get(name),
469
+ theirs.get(name),
470
+ resolveDerived
471
+ );
472
+ if (outcome.kind === "conflict") {
361
473
  return {
362
474
  ok: false,
363
475
  reason: `both sides changed the entry for ${name} in incompatible ways`,
364
476
  };
365
477
  }
366
- merged.set(name, unioned);
478
+ if (outcome.kind === "derived") resolved.push(name);
479
+ merged.set(name, outcome.lines);
367
480
  }
368
- return { ok: true, entries: merged };
481
+ return { ok: true, entries: merged, resolved };
369
482
  }
370
483
 
371
484
  /**
@@ -377,9 +490,13 @@ function mergeEntries(base, ours, theirs) {
377
490
  * @param {string} base - `%O` contents ("" when the file is new on both sides)
378
491
  * @param {string} ours - `%A` contents
379
492
  * @param {string} theirs - `%B` contents
380
- * @returns {{ok: true, text: string} | {ok: false, reason: string}} Merge result
493
+ * @param {string} [artifactPath] - `%P`, the path being merged. Omitted means
494
+ * unknown, which is strict: only a path in
495
+ * {@link DERIVED_ENTRY_RESOLUTION_PATHS} resolves a contested entry.
496
+ * @returns {{ok: true, text: string, resolved: string[]} | {ok: false, reason: string}} Merge result
381
497
  */
382
- export function mergeGeneratedArtifact(base, ours, theirs) {
498
+ export function mergeGeneratedArtifact(base, ours, theirs, artifactPath) {
499
+ const resolveDerived = resolvesDerivedEntries(artifactPath);
383
500
  const parsedOurs = parseArtifact(ours);
384
501
  const parsedTheirs = parseArtifact(theirs);
385
502
  if (!parsedOurs.ok)
@@ -406,6 +523,7 @@ export function mergeGeneratedArtifact(base, ours, theirs) {
406
523
  parsedBase = undefined;
407
524
  }
408
525
  const merged = [];
526
+ const resolved = [];
409
527
  for (const [index, ourChunk] of parsedOurs.chunks.entries()) {
410
528
  const theirChunk = parsedTheirs.chunks[index];
411
529
  const baseChunk = parsedBase?.[index];
@@ -429,16 +547,18 @@ export function mergeGeneratedArtifact(base, ours, theirs) {
429
547
  const result = mergeEntries(
430
548
  baseChunk?.entries,
431
549
  ourChunk.entries,
432
- theirChunk.entries
550
+ theirChunk.entries,
551
+ resolveDerived
433
552
  );
434
553
  if (!result.ok) return result;
554
+ resolved.push(...result.resolved);
435
555
  merged.push({
436
556
  kind: "entries",
437
557
  indent: ourChunk.indent,
438
558
  entries: result.entries,
439
559
  });
440
560
  }
441
- return { ok: true, text: renderArtifact(merged) };
561
+ return { ok: true, text: renderArtifact(merged), resolved };
442
562
  }
443
563
 
444
564
  /**
@@ -474,6 +594,41 @@ export function parseArgs(argv) {
474
594
  return options;
475
595
  }
476
596
 
597
+ /** Most contested paths to name before the notice starts summarising. */
598
+ const NAMED_RESOLVED = 3;
599
+
600
+ /**
601
+ * The notice a resolved-to-one-side merge prints (CodySwannGT/lisa#3822).
602
+ *
603
+ * Says the merge SUCCEEDED and the file is nonetheless wrong, which is not the
604
+ * usual shape of a merge message and is the whole point: the merged tree
605
+ * contains bytes neither side hashed, so the recorded hash is stale by
606
+ * construction until the generator runs against the whole tree. Regeneration is
607
+ * the operator's next action and the command is named here rather than left to
608
+ * be looked up.
609
+ * @param {string} label - Path git is merging
610
+ * @param {readonly string[]} resolved - Keys taken from our side
611
+ * @returns {string} Operator-facing notice, newline-terminated
612
+ */
613
+ export function describeResolved(label, resolved) {
614
+ const shown = resolved.slice(0, NAMED_RESOLVED).join(", ");
615
+ const rest =
616
+ resolved.length > NAMED_RESOLVED
617
+ ? ` and ${resolved.length - NAMED_RESOLVED} more`
618
+ : "";
619
+ return (
620
+ `merge-generated-artifact: ${label} merged, and is now STALE on purpose.\n` +
621
+ ` ${resolved.length} entr${resolved.length === 1 ? "y" : "ies"} changed on both sides (${shown}${rest}).\n` +
622
+ ` Each records a whole file as one hash, so any two edits to that file collide here\n` +
623
+ ` even when the edits themselves merge cleanly — as they just did. The merged tree\n` +
624
+ ` describes bytes neither side hashed, so no side is the right answer and this\n` +
625
+ ` driver took ours to let the merge finish. Recompute before you commit:\n` +
626
+ ` bun run build:upstream-evidence-manifest && git add ${label}\n` +
627
+ ` Forgetting is caught, not silent: the unit suite asserts the generator's --check,\n` +
628
+ ` and test-correctness is required at push and on the pull request.\n`
629
+ );
630
+ }
631
+
477
632
  /**
478
633
  * Run the driver against git-supplied side files.
479
634
  * @param {readonly string[]} argv - Arguments after the script name
@@ -501,7 +656,12 @@ export function runMergeGeneratedArtifact(
501
656
  return 1;
502
657
  }
503
658
  const [baseText, oursText, theirsText] = sides.map(side => side.content);
504
- const result = mergeGeneratedArtifact(baseText, oursText, theirsText);
659
+ const result = mergeGeneratedArtifact(
660
+ baseText,
661
+ oursText,
662
+ theirsText,
663
+ options.path
664
+ );
505
665
  if (!result.ok) {
506
666
  error(
507
667
  `merge-generated-artifact: ${label} could not be merged mechanically — ${result.reason}.\n` +
@@ -511,6 +671,8 @@ export function runMergeGeneratedArtifact(
511
671
  return 1;
512
672
  }
513
673
  writeFileSync(ours, result.text);
674
+ if (result.resolved.length > 0)
675
+ error(describeResolved(label, result.resolved));
514
676
  return 0;
515
677
  }
516
678
 
@@ -7,7 +7,7 @@
7
7
  "workflows": 22,
8
8
  "steps": 716,
9
9
  "couplings": 34,
10
- "inventory": 72
10
+ "inventory": 73
11
11
  },
12
12
  "counts": {
13
13
  "package-backed": 10,