@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.
- package/.dz-manifest.json +52 -52
- package/README.md +228 -5
- package/dist/agentdb-index.d.ts +39 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +217 -23
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +197 -6
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +858 -46
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +16 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +115 -13
- package/dist/operations.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +72 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +150 -4
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/release.d.ts +72 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +236 -19
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -4
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +51 -51
- package/src/agentdb-index.ts +223 -24
- package/src/apply-leg.ts +875 -46
- package/src/index.ts +18 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +117 -14
- package/src/publish-sibling-drift.ts +209 -4
- package/src/release.ts +263 -17
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- 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}${
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
''
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
772
|
-
return { title, body
|
|
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
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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-
|
|
816
|
-
//
|
|
817
|
-
// OR our own) — see the WHY above for why that
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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',
|