@dzhechkov/harness-core 0.8.33 → 0.8.35

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 (53) hide show
  1. package/.dz-manifest.json +52 -52
  2. package/README.md +228 -5
  3. package/dist/agentdb-index.d.ts +39 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +217 -23
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +197 -6
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +858 -46
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/index.d.ts +7 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +9 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/mutation-gate.d.ts +19 -0
  16. package/dist/mutation-gate.d.ts.map +1 -1
  17. package/dist/mutation-gate.js +37 -1
  18. package/dist/mutation-gate.js.map +1 -1
  19. package/dist/operations.d.ts +16 -1
  20. package/dist/operations.d.ts.map +1 -1
  21. package/dist/operations.js +115 -13
  22. package/dist/operations.js.map +1 -1
  23. package/dist/publish-sibling-drift.d.ts +72 -0
  24. package/dist/publish-sibling-drift.d.ts.map +1 -1
  25. package/dist/publish-sibling-drift.js +150 -4
  26. package/dist/publish-sibling-drift.js.map +1 -1
  27. package/dist/release.d.ts +72 -0
  28. package/dist/release.d.ts.map +1 -1
  29. package/dist/release.js +236 -19
  30. package/dist/release.js.map +1 -1
  31. package/dist/setup.d.ts.map +1 -1
  32. package/dist/setup.js +90 -14
  33. package/dist/setup.js.map +1 -1
  34. package/dist/skills.d.ts +87 -3
  35. package/dist/skills.d.ts.map +1 -1
  36. package/dist/skills.js +266 -15
  37. package/dist/skills.js.map +1 -1
  38. package/dist/vector-tier.d.ts +27 -2
  39. package/dist/vector-tier.d.ts.map +1 -1
  40. package/dist/vector-tier.js +117 -4
  41. package/dist/vector-tier.js.map +1 -1
  42. package/package.json +2 -2
  43. package/sbom.json +51 -51
  44. package/src/agentdb-index.ts +223 -24
  45. package/src/apply-leg.ts +875 -46
  46. package/src/index.ts +18 -2
  47. package/src/mutation-gate.ts +58 -2
  48. package/src/operations.ts +117 -14
  49. package/src/publish-sibling-drift.ts +209 -4
  50. package/src/release.ts +263 -17
  51. package/src/setup.ts +81 -16
  52. package/src/skills.ts +303 -14
  53. package/src/vector-tier.ts +157 -5
package/src/release.ts CHANGED
@@ -142,6 +142,19 @@ export interface GateFailure {
142
142
  readonly pkg?: string | undefined;
143
143
  readonly reason: string;
144
144
  readonly class: ReleaseFailureClass;
145
+ /**
146
+ * Feature release-gate-output-tail (AM-4): the last non-empty lines of the step's stdout and
147
+ * stderr, KEPT SEPARATE — each stream through {@link outputTail} on its own, never merged —
148
+ * so a reader can tell which stream a line came from. Set for `tests`/`syntax`/`smoke`
149
+ * EXIT_NONZERO/TIMEOUT failures; absent for `audit` (its own detail line already summarizes)
150
+ * and for failures with no execution record (e.g. UNEXECUTED_STEP).
151
+ *
152
+ * Scope honesty (AM-4): the two streams are captured independently, so a printed/issued
153
+ * `stdout:`/`stderr:` pair does NOT reconstruct the chronological interleaving of the two
154
+ * streams as the process actually emitted them — only each stream's own tail order is
155
+ * preserved. Documented in the CLI README (AM-8), not silently implied.
156
+ */
157
+ readonly tails?: { readonly stdout: string; readonly stderr: string };
145
158
  }
146
159
 
147
160
  /** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
@@ -600,6 +613,138 @@ function firstLine(...chunks: readonly unknown[]): string {
600
613
  return '';
601
614
  }
602
615
 
616
+ /**
617
+ * Strip ANSI/VT100 escape sequences (colour codes, cursor moves, OSC hyperlinks) so pattern
618
+ * matching sees the plain text a human reads on a non-colour terminal. AM-2/AM-1 precondition:
619
+ * `testsFailureDetail` and the issue-body redaction both run this FIRST, before any regex tries
620
+ * to recognise a runner's summary/FAIL lines or a secret value — a coloured `FAIL` token (e.g.
621
+ * `\x1b[31mFAIL\x1b[0m`) must still match `/^FAIL\b/` once stripped.
622
+ */
623
+ // eslint-disable-next-line no-control-regex -- deliberately matching raw ESC control bytes
624
+ function stripAnsi(s: string): string {
625
+ return s
626
+ .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC … BEL | OSC … ST
627
+ .replace(/\x1B[[()#;?]*[0-9]*(?:;[0-9]*)*[a-zA-Z@]/g, ''); // CSI/other short escapes
628
+ }
629
+
630
+ /**
631
+ * Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
632
+ * `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
633
+ * just the first output line, which for `pnpm test`/vitest is routinely an unrelated
634
+ * vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
635
+ *
636
+ * AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
637
+ * plain one). Recognised shapes, collected in this priority order and joined:
638
+ * 1. vitest summary lines (`Tests …`, `Test Files …`);
639
+ * 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
640
+ * 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
641
+ *
642
+ * If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
643
+ * to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
644
+ * knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
645
+ * output at all), in which case the mark would manufacture a synthetic line where none existed
646
+ * and is withheld. Capped at 600 chars — a detail line, not a dump.
647
+ */
648
+ export function testsFailureDetail(stdout: unknown, stderr: unknown): string {
649
+ const all = stripAnsi(`${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`);
650
+ const lines = all
651
+ .split('\n')
652
+ .map((l) => l.trim())
653
+ .filter((l) => l.length > 0);
654
+ const summaryLines = lines.filter((l) => /^(Tests|Test Files)\b/.test(l));
655
+ const failLines = lines.filter((l) => /^(FAIL\b|×|❯)/.test(l)).slice(0, 5);
656
+ const tapNotOkLines = lines.filter((l) => /^not ok \d+/.test(l)).slice(0, 5);
657
+ const tapFailCountLines = lines.filter((l) => /^# fail \d+/i.test(l));
658
+ const parts = [...summaryLines, ...failLines, ...tapNotOkLines, ...tapFailCountLines];
659
+ if (parts.length === 0) {
660
+ // lead r2: the fallback is derived from the ANSI-STRIPPED text, never the raw stream
661
+ const fl = lines[0] ?? '';
662
+ return fl.length === 0 ? '' : `${fl.slice(0, 200)} (no test-runner summary recognised)`;
663
+ }
664
+ return parts.join(' — ').slice(0, 600);
665
+ }
666
+
667
+ /** Truncate `s` to at most `maxBytes` UTF-8 bytes, never splitting a multi-byte character. */
668
+ function truncateToBytes(s: string, maxBytes: number): string {
669
+ if (maxBytes <= 0) return '';
670
+ const buf = Buffer.from(s, 'utf-8');
671
+ if (buf.length <= maxBytes) return s;
672
+ let end = maxBytes;
673
+ // back off while the next byte is a UTF-8 continuation byte (10xxxxxx)
674
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end -= 1;
675
+ return buf.subarray(0, end).toString('utf-8');
676
+ }
677
+
678
+ /**
679
+ * Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
680
+ * stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
681
+ * byte size) so a runaway suite cannot blow up a report or an issue body.
682
+ *
683
+ * AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
684
+ * (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
685
+ * - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
686
+ * - Whole-line selection: lines are pulled from the END while the running BYTE total (each
687
+ * line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
688
+ * - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
689
+ * boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
690
+ *
691
+ * Empty/whitespace-only output → `''` (never a synthetic line).
692
+ */
693
+ export function outputTail(stdout: unknown, stderr: unknown, maxLines = 40, maxBytes = 8192): string {
694
+ if (maxLines <= 0 || maxBytes <= 0) return '';
695
+ const all = `${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`;
696
+ const nonEmpty = all
697
+ .split('\n')
698
+ .map((l) => l.replace(/\r$/, ''))
699
+ .filter((l) => l.trim().length > 0);
700
+ const tailLines = nonEmpty.slice(-maxLines);
701
+ if (tailLines.length === 0) return '';
702
+
703
+ const lastLine = tailLines[tailLines.length - 1]!;
704
+ if (Buffer.byteLength(lastLine, 'utf-8') > maxBytes) {
705
+ // lead r2: the marker lives INSIDE the byte budget, so the returned text never exceeds maxBytes
706
+ const marker = '… (line truncated)';
707
+ const room = Math.max(0, maxBytes - Buffer.byteLength(marker, 'utf-8'));
708
+ return `${truncateToBytes(lastLine, room)}${marker}`;
709
+ }
710
+
711
+ const selected: string[] = [];
712
+ let bytes = 0;
713
+ for (let i = tailLines.length - 1; i >= 0; i--) {
714
+ const line = tailLines[i]!;
715
+ const lineBytes = Buffer.byteLength(line, 'utf-8');
716
+ const joinerBytes = selected.length > 0 ? 1 : 0; // the '\n' this line adds once prepended
717
+ if (bytes + lineBytes + joinerBytes > maxBytes) break;
718
+ selected.unshift(line);
719
+ bytes += lineBytes + joinerBytes;
720
+ }
721
+ return selected.join('\n');
722
+ }
723
+
724
+ /**
725
+ * Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
726
+ * reaches a GitHub issue body. Patterns, each independently redacted:
727
+ * - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
728
+ * KEY survives, only the value is replaced;
729
+ * - `Bearer <token>` HTTP auth headers;
730
+ * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
731
+ * - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
732
+ * without a recognisable prefix.
733
+ * Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
734
+ * a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
735
+ */
736
+ export function redactSecrets(text: string): string {
737
+ let out = text;
738
+ out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
739
+ out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
740
+ out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
741
+ out = out.replace(/\bsk-[A-Za-z0-9]+/g, '[redacted]');
742
+ out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
743
+ out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
744
+ out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
745
+ return out;
746
+ }
747
+
603
748
  /**
604
749
  * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
605
750
  * point (ADR load-bearing property):
@@ -670,6 +815,9 @@ export function classifyGateExecutions(
670
815
  pkg: step.pkg,
671
816
  reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
672
817
  class: gate === 'smoke' ? 'SMOKE_TIMEOUT' : 'TIMEOUT',
818
+ // FR-3 / AM-4: a killed-by-timeout step still has whatever it printed before the
819
+ // kill — captured per-stream, never merged (see GateFailure.tails doc comment).
820
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
673
821
  });
674
822
  continue;
675
823
  }
@@ -679,10 +827,15 @@ export function classifyGateExecutions(
679
827
  const detail = auditDetailLine(exec.stdout, exec.stderr);
680
828
  failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
681
829
  } else {
830
+ // FR-1: for tests/syntax/smoke, name the ACTUAL failure (summary + failing tests),
831
+ // not just the first output line — see testsFailureDetail's doc comment for why.
832
+ const detail = testsFailureDetail(exec.stderr, exec.stdout);
682
833
  failures.push({
683
834
  pkg: step.pkg,
684
- reason: `exit ${String(exec.exitCode)}: ${step.cmd}${firstLine(exec.stderr, exec.stdout) ? ` — ${firstLine(exec.stderr, exec.stdout)}` : ''}`,
835
+ reason: `exit ${String(exec.exitCode)}: ${step.cmd}${detail ? ` — ${detail}` : ''}`,
685
836
  class: 'EXIT_NONZERO',
837
+ // AM-4: per-stream tails, never merged — see GateFailure.tails doc comment.
838
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
686
839
  });
687
840
  }
688
841
  continue;
@@ -744,32 +897,125 @@ export interface FailureIssueContext {
744
897
  readonly repo?: string | undefined;
745
898
  }
746
899
 
900
+ /** AM-1: total issue-body cap — a courier never balloons into an unpostable payload. */
901
+ const MAX_ISSUE_BODY_BYTES = 60 * 1024;
902
+
903
+ /**
904
+ * AM-5: fence `text` so the payload can never prematurely close the code block — the fence is
905
+ * N+1 backticks, where N is the LONGEST run of consecutive backticks already present in `text`.
906
+ * Every content line (and the fence itself) carries `indent` so a multi-line block renders as a
907
+ * continuation of the enclosing markdown list item, not as a sibling paragraph.
908
+ */
909
+ function fencedBlock(text: string, indent = ' '): string[] {
910
+ const runs = text.match(/`+/g) ?? [];
911
+ const longestRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
912
+ // GFM needs >= 3 backticks for a FENCED (block) code fence — fewer reads as inline code.
913
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
914
+ const contentLines = text.split('\n').map((l) => `${indent}${l}`);
915
+ return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
916
+ }
917
+
747
918
  /**
748
919
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
749
920
  * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
921
+ *
922
+ * AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
923
+ * {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
924
+ * per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
925
+ * blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
926
+ * payload cannot break out of its code block; (d) the WHOLE body is capped at
927
+ * {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
928
+ * (byte-proportional), not by dropping some tails whole while keeping others untouched.
750
929
  */
751
930
  export function buildFailureIssue(verdict: ReleaseVerdict, ctx: FailureIssueContext = {}): { title: string; body: string } {
752
931
  const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
753
932
  const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
754
- const lines: string[] = [
755
- `Verified release blocked at ${verdict.timestamp}.`,
756
- '',
757
- ...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
758
- ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
759
- '## Gate verdict',
760
- '',
761
- ];
933
+
934
+ interface TailRef {
935
+ readonly stream: 'stdout' | 'stderr';
936
+ text: string;
937
+ readonly rawBytes: number;
938
+ }
939
+ const refsByFailure = new Map<GateFailure, TailRef[]>();
762
940
  for (const g of verdict.gates) {
763
- const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
764
- lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
765
- for (const f of g.failures) lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${f.reason}`);
941
+ for (const f of g.failures) {
942
+ if (f.tails === undefined) continue;
943
+ const refs: TailRef[] = [];
944
+ for (const stream of ['stdout', 'stderr'] as const) {
945
+ const raw = f.tails[stream];
946
+ if (raw.length === 0) continue;
947
+ const clean = redactSecrets(stripAnsi(raw));
948
+ refs.push({ stream, text: clean, rawBytes: Buffer.byteLength(clean, 'utf-8') });
949
+ }
950
+ if (refs.length > 0) refsByFailure.set(f, refs);
951
+ }
766
952
  }
767
- if (verdict.skipped.length > 0) {
768
- lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
769
- for (const s of verdict.skipped) lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
953
+
954
+ const render = (): string => {
955
+ const lines: string[] = [
956
+ `Verified release blocked at ${verdict.timestamp}.`,
957
+ '',
958
+ ...(ctx.invocation ? [`Invocation: \`${redactSecrets(stripAnsi(ctx.invocation)).replace(/`/g, "'")}\``, ''] : []),
959
+ ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
960
+ '## Gate verdict',
961
+ '',
962
+ ];
963
+ for (const g of verdict.gates) {
964
+ const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
965
+ lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
966
+ for (const f of g.failures) {
967
+ // lead r2 (HIGH): the reason is output-derived free text — strip ANSI and redact it like a tail
968
+ lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${redactSecrets(stripAnsi(f.reason))}`);
969
+ // FR-2 / AM-4 / AM-5: a labelled, fenced block per non-empty stream — the issue is the
970
+ // echo of the verdict, so a reader can see the actual failing output without re-running.
971
+ for (const ref of refsByFailure.get(f) ?? []) {
972
+ lines.push(` ${ref.stream}:`, ...fencedBlock(ref.text));
973
+ }
974
+ }
975
+ }
976
+ if (verdict.skipped.length > 0) {
977
+ lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
978
+ for (const s of verdict.skipped) lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
979
+ }
980
+ lines.push(
981
+ '',
982
+ `Blocked by: ${verdict.blockedBy.join('; ')}`,
983
+ '',
984
+ '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._',
985
+ );
986
+ return lines.join('\n');
987
+ };
988
+
989
+ let body = render();
990
+ let bodyBytes = Buffer.byteLength(body, 'utf-8');
991
+
992
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES && refsByFailure.size > 0) {
993
+ const allRefs = [...refsByFailure.values()].flat();
994
+ let overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
995
+ // Bounded iteration: each pass's cut is based on the LATEST measured overage (markup like
996
+ // "… (truncated)" adds a few bytes back per ref, so one pass rarely lands exactly) — a few
997
+ // passes converge; the safety net below closes any pathological remainder.
998
+ for (let pass = 0; pass < 3 && overage > 0; pass++) {
999
+ const perRefCut = Math.ceil(overage / allRefs.length);
1000
+ for (const ref of allRefs) {
1001
+ const targetBytes = Math.max(0, ref.rawBytes - perRefCut);
1002
+ if (Buffer.byteLength(ref.text, 'utf-8') > targetBytes) {
1003
+ ref.text = `${truncateToBytes(ref.text, targetBytes)}… (truncated)`;
1004
+ }
1005
+ }
1006
+ body = render();
1007
+ bodyBytes = Buffer.byteLength(body, 'utf-8');
1008
+ overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
1009
+ }
1010
+ // Safety net: a pathological shape (a huge non-tail skeleton, tiny/no tails) can still exceed
1011
+ // the cap after every tail is wiped — hard-truncate the whole body as the last resort so the
1012
+ // cap is an INVARIANT, never a best-effort.
1013
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES) {
1014
+ body = `${truncateToBytes(body, MAX_ISSUE_BODY_BYTES - 20)}\n… (truncated)`;
1015
+ }
770
1016
  }
771
- lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
772
- return { title, body: lines.join('\n') };
1017
+
1018
+ return { title, body };
773
1019
  }
774
1020
 
775
1021
  /** Short, bounded release notes from injected `git log --oneline`-style lines. */
package/src/setup.ts CHANGED
@@ -752,12 +752,17 @@ function installDriverDocs(projectRoot: string, force: boolean): string {
752
752
  * forth forever and neither step ever reports `skipped`, breaking the pre-existing
753
753
  * `setup.test.ts` "PreCompact merge is idempotent" contract (FR-6) this feature must not touch.
754
754
  *
755
- * ADDITIVE-ONLY, deliberately NOT `mergeManagedHookEntries`: this step never needs to REPLACE a
756
- * stale command text (the two commands `applyLegHookEntries()` emits do not change without an
757
- * `APPLY_LEG_VERSION` bump, and a version bump is about the FILE content, not the hook command) —
758
- * it only needs "is our command already referenced under this event, anywhere, in any position?".
759
- * That question is order-independent, so it can never itself be a source of reordering, and it is
760
- * exactly what keeps "Configure hooks" stable once the first run has established the layout above.
755
+ * ADD-OR-REPLACE-IN-PLACE, deliberately NOT `mergeManagedHookEntries`: this step never REORDERS —
756
+ * a match keeps its POSITION, only its command text is swapped — so it stays the same "is our
757
+ * command already referenced under this event, anywhere, in any position?" question
758
+ * `mergeManagedHookEntries`'s drop-and-reappend-at-tail algorithm answers differently (by moving
759
+ * the entry), which is exactly what "Configure hooks" must never do to a foreign SessionStart entry
760
+ * on the very first run (see above). Before feature `apply-leg-install-root` the two commands never
761
+ * changed without an `APPLY_LEG_VERSION` bump (a version bump is about the FILE content, not the
762
+ * hook command), so ADDITIVE-ONLY (skip on any match) and ADD-OR-REPLACE (rewrite text on a
763
+ * stale-form match) were behaviourally identical; an install-root migration now changes the command
764
+ * text on its own, independent of the file version, so a stale `CLAUDE_PROJECT_DIR`-relative entry
765
+ * from a pre-feature install must be rewritten in place on the next `dz setup`, not left stale.
761
766
  */
762
767
  function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupStep {
763
768
  if (opts.noHooks) return { name: 'Install apply-leg', status: 'skipped', detail: '--no-hooks' };
@@ -812,9 +817,10 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
812
817
  wroteHelpers = true;
813
818
  }
814
819
 
815
- // ADD-IF-MISSING, per event: FR-2's literal contract — "ours is added only if no command of
816
- // the event already contains OUR entry". Never removes or reorders an existing entry (foreign
817
- // OR our own) — see the WHY above for why that matters here.
820
+ // ADD-OR-REPLACE, per event: "ours is added when no command of the event references OUR marker
821
+ // yet, and REWRITTEN IN PLACE (same position) when one does but its text is stale". Never
822
+ // removes or reorders an existing entry (foreign OR our own) — see the WHY above for why that
823
+ // matters here.
818
824
  //
819
825
  // MEDIUM finding "совпадение подстроки в чужой команде" (fix round 1): the substring probe used
820
826
  // to be the bare filename (`recall-hook.cjs`), so a foreign command that merely MENTIONS the
@@ -823,20 +829,79 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
823
829
  // of the command we would emit, or the command containing our full relative PATH
824
830
  // (`.claude/helpers/<file>`, the same marker `applyLegStatus` structurally looks for) — a bare
825
831
  // filename mention under any other wrapper text no longer counts.
826
- const entries = applyLegHookEntries();
832
+ // FR-2 (ADR-001 D2, apply-leg-install-root): bake THIS install's own absolute root into the
833
+ // two commands — the deployed helper already bakes an absolute CORE_DIST_DIR, so a relative
834
+ // command only masked that non-portability (issue #2, `Cannot find module` when project ===
835
+ // $HOME and a foreign session's CLAUDE_PROJECT_DIR pointed elsewhere, swallowed by
836
+ // `2>/dev/null || true`).
837
+ const entries = applyLegHookEntries(opts.projectRoot);
827
838
  const existingSettings = existsSync(settingsPath)
828
839
  ? (JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>)
829
840
  : {};
830
841
  const hooks = { ...((existingSettings['hooks'] ?? {}) as Record<string, unknown[]>) };
831
842
  let hooksAdded = false;
843
+ // ADD-OR-REPLACE, per event (FR-2/AC-3, apply-leg-install-root): a command that already
844
+ // invokes our marker path is OURS, whatever exact form it takes — a pre-feature
845
+ // `CLAUDE_PROJECT_DIR`-relative entry (or, in principle, a relocated install's stale absolute
846
+ // one) is REPLACED by the current command in place, never left stale AND never duplicated. An
847
+ // EXACT match of the command we would emit is a true no-op (idempotent re-setup — this is what
848
+ // keeps a routine re-run from ever thrashing the file, same guarantee the prior ADDITIVE-ONLY
849
+ // design gave when the command text truly never changed without a version bump; it can now
850
+ // change on install-root migration too, so replace must be part of the contract).
851
+ //
852
+ // AM-2 (fix round 1, HIGH): the prior version replaced the WHOLE matching GROUP
853
+ // (`hooks[event][i]`) with our bare `entry` — a group is Claude Code's matcher-plus-commands
854
+ // shape (`{matcher, hooks:[...]}`), so that discarded the group's `matcher` and any FOREIGN
855
+ // sibling command sharing the same `hooks[]` array whenever ours needed an upgrade. Fixed: only
856
+ // the ONE command object inside the group's own `hooks[]` array that matches OUR marker is
857
+ // replaced — the matcher and every other command in that array survive untouched. A single pass
858
+ // also now upgrades EVERY matching group, not just the first `findIndex` hit, so two stale
859
+ // managed entries left in two different groups (a prior bug's residue, or a hand-edited file)
860
+ // are both fixed in place rather than the second one being silently ignored.
832
861
  const addIfMissing = (event: string, ownCommand: string, markerPath: string, entry: unknown): void => {
833
862
  const current = Array.isArray(hooks[event]) ? hooks[event] : [];
834
- const alreadyPresent = current.some((e) =>
835
- commandsOf(e).some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath)),
836
- );
837
- if (alreadyPresent) return;
838
- hooks[event] = [...current, entry];
839
- hooksAdded = true;
863
+ let anyMatch = false;
864
+ let anyChanged = false;
865
+ const updated = current.map((e) => {
866
+ const cmds = commandsOf(e);
867
+ const matchesHere = cmds.some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath));
868
+ if (!matchesHere) return e;
869
+ anyMatch = true;
870
+ const group = e as { hooks?: { command?: unknown }[] };
871
+ if (!Array.isArray(group.hooks)) {
872
+ if (cmds.some((cmd) => cmd === ownCommand)) return e; // legacy flat, already exact
873
+ anyChanged = true;
874
+ return entry; // legacy flat {command:...} — nothing else to preserve
875
+ }
876
+ // Codex round-2 (AM-2 residual): a group that holds BOTH the exact own command and a stale
877
+ // copy (or two stale copies) used to be skipped as "already exact" — the stale twin stayed
878
+ // forever. Walk the group once: the first own/stale command becomes the exact form, every
879
+ // later own/stale copy is dropped, every foreign sibling and the group's `matcher` survive.
880
+ let seenOwn = false;
881
+ let groupChanged = false;
882
+ const newGroupHooks: { command?: unknown }[] = [];
883
+ for (const h of group.hooks) {
884
+ const cmd = String((h as { command?: unknown })?.command ?? '');
885
+ const isOurs = cmd === ownCommand || hookCommandInvokes(cmd, markerPath);
886
+ if (!isOurs) { newGroupHooks.push(h); continue; }
887
+ if (seenOwn) { groupChanged = true; continue; } // duplicate of ours — dropped
888
+ seenOwn = true;
889
+ if (cmd !== ownCommand) groupChanged = true;
890
+ newGroupHooks.push(cmd === ownCommand ? h : { ...h, command: ownCommand });
891
+ }
892
+ if (!groupChanged) return e;
893
+ anyChanged = true;
894
+ return { ...(e as Record<string, unknown>), hooks: newGroupHooks };
895
+ });
896
+ if (!anyMatch) {
897
+ hooks[event] = [...current, entry];
898
+ hooksAdded = true;
899
+ return;
900
+ }
901
+ if (anyChanged) {
902
+ hooks[event] = updated;
903
+ hooksAdded = true;
904
+ }
840
905
  };
841
906
  addIfMissing(
842
907
  'UserPromptSubmit',