@dzhechkov/harness-core 0.8.32 → 0.8.34

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 (63) hide show
  1. package/.dz-manifest.json +107 -47
  2. package/README.md +108 -0
  3. package/dist/agentdb-index.d.ts +17 -6
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +61 -17
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +68 -5
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +466 -37
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/embed-socket-path.d.ts +65 -0
  12. package/dist/embed-socket-path.d.ts.map +1 -0
  13. package/dist/embed-socket-path.js +100 -0
  14. package/dist/embed-socket-path.js.map +1 -0
  15. package/dist/index.d.ts +11 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +105 -3
  21. package/dist/operations.js.map +1 -1
  22. package/dist/packed-install-smoke.d.ts +108 -0
  23. package/dist/packed-install-smoke.d.ts.map +1 -0
  24. package/dist/packed-install-smoke.js +172 -0
  25. package/dist/packed-install-smoke.js.map +1 -0
  26. package/dist/publish-sibling-drift.d.ts +139 -0
  27. package/dist/publish-sibling-drift.d.ts.map +1 -0
  28. package/dist/publish-sibling-drift.js +408 -0
  29. package/dist/publish-sibling-drift.js.map +1 -0
  30. package/dist/publish.d.ts +44 -0
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +243 -24
  33. package/dist/publish.js.map +1 -1
  34. package/dist/qe-bridge.d.ts +16 -0
  35. package/dist/qe-bridge.d.ts.map +1 -1
  36. package/dist/qe-bridge.js +1 -0
  37. package/dist/qe-bridge.js.map +1 -1
  38. package/dist/release.d.ts +91 -0
  39. package/dist/release.d.ts.map +1 -1
  40. package/dist/release.js +317 -21
  41. package/dist/release.js.map +1 -1
  42. package/dist/setup.d.ts +36 -0
  43. package/dist/setup.d.ts.map +1 -1
  44. package/dist/setup.js +96 -2
  45. package/dist/setup.js.map +1 -1
  46. package/dist/vector-tier.d.ts +27 -2
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +112 -3
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +23 -23
  51. package/sbom.json +196 -46
  52. package/src/agentdb-index.ts +65 -17
  53. package/src/apply-leg.ts +461 -37
  54. package/src/embed-socket-path.ts +113 -0
  55. package/src/index.ts +44 -3
  56. package/src/operations.ts +104 -3
  57. package/src/packed-install-smoke.ts +273 -0
  58. package/src/publish-sibling-drift.ts +498 -0
  59. package/src/publish.ts +280 -25
  60. package/src/qe-bridge.ts +14 -0
  61. package/src/release.ts +366 -19
  62. package/src/setup.ts +108 -2
  63. package/src/vector-tier.ts +147 -5
package/src/release.ts CHANGED
@@ -23,9 +23,11 @@
23
23
 
24
24
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
25
  import type { Dirent } from 'node:fs';
26
- import { join } from 'node:path';
26
+ import { join, relative } from 'node:path';
27
27
 
28
28
  import { discoverPackages, orderByDependencies } from './publish.js';
29
+ import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
30
+ import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
29
31
 
30
32
  /** The four HARD verify gates, in execution order. */
31
33
  export type ReleaseGateId = 'tests' | 'audit' | 'syntax' | 'smoke';
@@ -114,6 +116,15 @@ export interface GatePlan {
114
116
  readonly skips: readonly GateSkip[];
115
117
  /** Package names in the release set (dependency order). */
116
118
  readonly packages: readonly string[];
119
+ /**
120
+ * AM-7 (feature publish-sibling-drift-gate): the packed-install sub-plan, carried through so
121
+ * {@link classifyGateExecutions} can re-judge its `bin-version`/`bin-exists` steps through the
122
+ * SAME judge `dz publish` uses (`judgePackedInstallSmoke`: exit 0 AND non-empty stdout, AND the
123
+ * declared bin must exist post-install) instead of the generic exit-code-only check every other
124
+ * step gets. Without this, a silently no-op bin could pass `dz release` while `dz publish`
125
+ * refuses it — the two doors would not be equal, contradicting FR-6's own claim.
126
+ */
127
+ readonly packedInstallPlan?: PackedInstallPlan | undefined;
117
128
  }
118
129
 
119
130
  /** The CLI's record of running one exec step. */
@@ -131,6 +142,19 @@ export interface GateFailure {
131
142
  readonly pkg?: string | undefined;
132
143
  readonly reason: string;
133
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 };
134
158
  }
135
159
 
136
160
  /** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
@@ -303,6 +327,12 @@ export interface PlanReleaseGatesOptions {
303
327
  readonly auditTimeoutMs?: number | undefined;
304
328
  readonly syntaxTimeoutMs?: number | undefined;
305
329
  readonly smokeTimeoutMs?: number | undefined;
330
+ /**
331
+ * FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke,
332
+ * supplied by the CLI (planning stays pure — it never calls mkdtemp itself). Omitted ⇒ no
333
+ * packed-install steps are planned, byte-identical to the pre-feature behavior.
334
+ */
335
+ readonly packedInstall?: { readonly packDir: string; readonly installDir: string } | undefined;
306
336
  }
307
337
 
308
338
  /**
@@ -481,7 +511,50 @@ export function planReleaseGates(facts: readonly ReleasePackageFacts[], opts: Pl
481
511
  }
482
512
  }
483
513
 
484
- return { steps, skips, packages: facts.map((f) => f.name) };
514
+ // FR-6 (feature publish-sibling-drift-gate): the packed-install smoke joins the SAME smoke
515
+ // gate `dz publish` runs (planPackedInstallSmoke, ADR-001 Decision 2) — so both doors apply
516
+ // the identical rule. Opt-in via `opts.packedInstall` (real tmp dirs, supplied by the CLI):
517
+ // omitted, this is byte-identical to the pre-feature plan, which every existing planner test
518
+ // relies on. Skipped entirely when nothing in the batch has a bin — packing siblings nobody
519
+ // will boot proves nothing a fresh `npm install` doesn't already cover elsewhere.
520
+ let packedInstallPlan: PackedInstallPlan | undefined;
521
+ if (opts.packedInstall !== undefined) {
522
+ const bins = facts.flatMap((f) =>
523
+ f.bins.map((b) => ({ pkg: f.name, binName: b.name, relPath: relative(f.dir, b.path) })),
524
+ );
525
+ if (bins.length > 0) {
526
+ const packages = facts.map((f) => ({ name: f.name, dir: f.dir, version: f.version }));
527
+ const smokePlan = planPackedInstallSmoke({
528
+ packages,
529
+ bins,
530
+ packDir: opts.packedInstall.packDir,
531
+ installDir: opts.packedInstall.installDir,
532
+ });
533
+ packedInstallPlan = smokePlan; // AM-7: kept for classifyGateExecutions's re-judge pass
534
+ for (const s of smokePlan.steps) {
535
+ const reason =
536
+ s.kind === 'pack'
537
+ ? `pack ${s.pkg} for the packed-install smoke — the tarball a consumer would actually receive`
538
+ : s.kind === 'install'
539
+ ? 'install every packed tarball together in a clean dir — out-of-batch siblings resolve from the registry, exactly like a fresh user'
540
+ : s.kind === 'bin-exists'
541
+ ? `bin "${s.binName}" must EXIST after the packed install (AM-8)`
542
+ : `bin "${s.binName}" must boot from the PACKED install (--version, exit 0, non-empty stdout)`;
543
+ steps.push({
544
+ id: `smoke:packed-install:${s.id}`,
545
+ gate: 'smoke',
546
+ pkg: s.pkg,
547
+ cmd: s.cmd,
548
+ cwd: s.cwd,
549
+ timeoutMs: s.timeoutMs,
550
+ reason,
551
+ kind: 'exec',
552
+ });
553
+ }
554
+ }
555
+ }
556
+
557
+ return { steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
485
558
  }
486
559
 
487
560
  /* ------------------------------------------------------------------ */
@@ -540,6 +613,138 @@ function firstLine(...chunks: readonly unknown[]): string {
540
613
  return '';
541
614
  }
542
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
+
543
748
  /**
544
749
  * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
545
750
  * point (ADR load-bearing property):
@@ -559,6 +764,30 @@ export function classifyGateExecutions(
559
764
  if (e != null && typeof e.stepId === 'string') byId.set(e.stepId, e);
560
765
  }
561
766
 
767
+ // AM-7: `bin-exists`/`bin-version` packed-install steps are judged through
768
+ // `judgePackedInstallSmoke` — the SAME rule `dz publish` applies (exit 0 AND non-empty stdout,
769
+ // AND the declared bin must exist post-install) — instead of the generic exit-code-only check
770
+ // every other step gets. The generic loop below SKIPS these step ids; the judged verdict is
771
+ // folded into the 'smoke' gate's failures/passed count after the loop.
772
+ const packedInstallPlan = plan?.packedInstallPlan;
773
+ const packedInstallBinStepIds = new Set(
774
+ (packedInstallPlan?.steps ?? []).filter((s) => s.kind === 'bin-exists' || s.kind === 'bin-version').map((s) => `smoke:packed-install:${s.id}`),
775
+ );
776
+ let packedInstallVerdict: ReturnType<typeof judgePackedInstallSmoke> | undefined;
777
+ if (packedInstallPlan !== undefined) {
778
+ const prefix = 'smoke:packed-install:';
779
+ const translated: PackedInstallExecution[] = (executions ?? [])
780
+ .filter((e): e is GateExecution => e != null && typeof e.stepId === 'string' && e.stepId.startsWith(prefix))
781
+ .map((e) => ({
782
+ stepId: e.stepId.slice(prefix.length),
783
+ exitCode: e.exitCode,
784
+ stdout: e.stdout ?? '',
785
+ stderr: e.stderr ?? '',
786
+ ...(e.timedOut !== undefined ? { timedOut: e.timedOut } : {}),
787
+ }));
788
+ packedInstallVerdict = judgePackedInstallSmoke(packedInstallPlan, translated);
789
+ }
790
+
562
791
  const gates: GateResult[] = RELEASE_GATE_ORDER.map((gate) => {
563
792
  const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
564
793
  const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
@@ -567,6 +796,7 @@ export function classifyGateExecutions(
567
796
 
568
797
  for (const step of gateSteps) {
569
798
  try {
799
+ if (packedInstallBinStepIds.has(step.id)) continue; // judged separately below (AM-7)
570
800
  if (step.kind === 'synthetic-fail') {
571
801
  failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
572
802
  continue;
@@ -585,6 +815,9 @@ export function classifyGateExecutions(
585
815
  pkg: step.pkg,
586
816
  reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
587
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) },
588
821
  });
589
822
  continue;
590
823
  }
@@ -594,10 +827,15 @@ export function classifyGateExecutions(
594
827
  const detail = auditDetailLine(exec.stdout, exec.stderr);
595
828
  failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
596
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);
597
833
  failures.push({
598
834
  pkg: step.pkg,
599
- 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}` : ''}`,
600
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) },
601
839
  });
602
840
  }
603
841
  continue;
@@ -609,6 +847,22 @@ export function classifyGateExecutions(
609
847
  }
610
848
  }
611
849
 
850
+ // AM-7: fold the packed-install bin verdicts (judged via judgePackedInstallSmoke, above) into
851
+ // the 'smoke' gate — the ONLY gate that ever plans packed-install steps.
852
+ if (gate === 'smoke' && packedInstallVerdict !== undefined) {
853
+ for (const bin of packedInstallVerdict.bins) {
854
+ if (bin.ok) {
855
+ passed += 1;
856
+ } else {
857
+ failures.push({
858
+ pkg: bin.pkg,
859
+ reason: `packed-install bin "${bin.binName}" ${bin.detail ?? 'failed'}`,
860
+ class: 'EXIT_NONZERO',
861
+ });
862
+ }
863
+ }
864
+ }
865
+
612
866
  const status: GateResult['status'] = failures.length > 0 ? 'fail' : passed > 0 ? 'pass' : 'skip';
613
867
  return { gate, status, passed, failures, skips: gateSkips };
614
868
  });
@@ -643,32 +897,125 @@ export interface FailureIssueContext {
643
897
  readonly repo?: string | undefined;
644
898
  }
645
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
+
646
918
  /**
647
919
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
648
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.
649
929
  */
650
930
  export function buildFailureIssue(verdict: ReleaseVerdict, ctx: FailureIssueContext = {}): { title: string; body: string } {
651
931
  const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
652
932
  const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
653
- const lines: string[] = [
654
- `Verified release blocked at ${verdict.timestamp}.`,
655
- '',
656
- ...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
657
- ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
658
- '## Gate verdict',
659
- '',
660
- ];
933
+
934
+ interface TailRef {
935
+ readonly stream: 'stdout' | 'stderr';
936
+ text: string;
937
+ readonly rawBytes: number;
938
+ }
939
+ const refsByFailure = new Map<GateFailure, TailRef[]>();
661
940
  for (const g of verdict.gates) {
662
- const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
663
- lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
664
- 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
+ }
665
952
  }
666
- if (verdict.skipped.length > 0) {
667
- lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
668
- 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
+ }
669
1016
  }
670
- lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
671
- return { title, body: lines.join('\n') };
1017
+
1018
+ return { title, body };
672
1019
  }
673
1020
 
674
1021
  /** Short, bounded release notes from injected `git log --oneline`-style lines. */
package/src/setup.ts CHANGED
@@ -44,6 +44,77 @@ import {
44
44
  /** Memory backend type. */
45
45
  export type MemoryBackend = 'jsonl' | 'agentdb';
46
46
 
47
+ /** Where a resolved setup memory backend came from (feature `setup-backend-from-config`). */
48
+ export type MemoryBackendSource = 'flag' | 'config' | 'default' | 'default-unreadable' | 'disabled';
49
+
50
+ /** Result of {@link resolveSetupMemoryBackend}. */
51
+ export interface ResolvedSetupMemoryBackend {
52
+ readonly backend: MemoryBackend;
53
+ readonly source: MemoryBackendSource;
54
+ /** `true` only when an explicit `--memory jsonl` pulled an agentdb-configured project down to jsonl (FR-2). */
55
+ readonly downgraded: boolean;
56
+ }
57
+
58
+ /**
59
+ * FR-1/FR-2/FR-3 (feature `setup-backend-from-config`). Before this function, `runSetup` decided
60
+ * the backend as `opts.memory ?? 'jsonl'` — a repeat `dz setup --target claude-code` (no `--memory`)
61
+ * on an agentdb project silently reset it to jsonl and dropped `.dz/agentdb-writer.mjs` from
62
+ * `SessionStart` (AC-1, red-first). This is the ONE place that decides the backend for a run, so
63
+ * the config write, the printed source line, and the doctor cross-check can never disagree.
64
+ *
65
+ * - An explicit `--memory <x>` always wins (`source: 'flag'`) — including the one case that
66
+ * DOWNGRADES an agentdb-configured project to jsonl (FR-2): `downgraded` is set so the caller can
67
+ * warn and force the config write back in sync even without `--force`.
68
+ * - No flag, and `.dz/config.json` has a recognised `memory.backend` → that value, `source: 'config'`.
69
+ * - No flag, and no config (absent, unreadable, or an unrecognised backend value) → `jsonl`,
70
+ * `source: 'default'` — the literal ticket command on an empty project (no change, named in
71
+ * 01_requirements.md "Что НЕ чинится").
72
+ */
73
+ export function resolveSetupMemoryBackend(
74
+ projectRoot: string,
75
+ memoryOpt: MemoryBackend | undefined,
76
+ noMemory: boolean = false,
77
+ ): ResolvedSetupMemoryBackend {
78
+ // Lead edit after Codex review (findings 5/6): a config that EXISTS but cannot be read, or names an
79
+ // unknown backend, is not "no config" — its source is labeled so, and a later step never overwrites
80
+ // it silently. `--no-memory` disables memory entirely: no downgrade, no config rewrite.
81
+ let configuredBackend: MemoryBackend | undefined;
82
+ let configUnreadable = false;
83
+ const configPath = join(projectRoot, '.dz', 'config.json');
84
+ if (existsSync(configPath)) {
85
+ try {
86
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as { memory?: { backend?: string } };
87
+ if (cfg.memory?.backend === 'agentdb') configuredBackend = 'agentdb';
88
+ else if (cfg.memory?.backend === 'jsonl') configuredBackend = 'jsonl';
89
+ else configUnreadable = true;
90
+ } catch {
91
+ configUnreadable = true;
92
+ }
93
+ }
94
+ if (noMemory) {
95
+ return { backend: memoryOpt ?? configuredBackend ?? 'jsonl', source: 'disabled', downgraded: false };
96
+ }
97
+ if (memoryOpt !== undefined) {
98
+ return { backend: memoryOpt, source: 'flag', downgraded: memoryOpt === 'jsonl' && configuredBackend === 'agentdb' };
99
+ }
100
+ if (configuredBackend !== undefined) {
101
+ return { backend: configuredBackend, source: 'config', downgraded: false };
102
+ }
103
+ return { backend: 'jsonl', source: configUnreadable ? 'default-unreadable' : 'default', downgraded: false };
104
+ }
105
+
106
+ /**
107
+ * FR-3: the human-readable "source" suffix, shared between `runSetup`'s own warning step and the
108
+ * CLI's printed `memory backend: …` line so the two texts can never drift apart.
109
+ */
110
+ export function memoryBackendSourceLabel(source: MemoryBackendSource): string {
111
+ if (source === 'flag') return 'from --memory';
112
+ if (source === 'config') return 'from .dz/config.json';
113
+ if (source === 'default-unreadable') return 'default — .dz/config.json unreadable or names no known backend';
114
+ if (source === 'disabled') return 'memory disabled (--no-memory)';
115
+ return 'default — no .dz/config.json';
116
+ }
117
+
47
118
  /** Setup options. */
48
119
  export interface SetupOptions {
49
120
  readonly projectRoot: string;
@@ -72,6 +143,12 @@ export interface SetupResult {
72
143
  readonly totalSteps: number;
73
144
  readonly completed: number;
74
145
  readonly skipped: number;
146
+ /** The memory backend this run actually used (feature `setup-backend-from-config`, FR-1). */
147
+ readonly memoryBackend: MemoryBackend;
148
+ /** Where {@link memoryBackend} came from — FR-3, also the `--json` field name. */
149
+ readonly memoryBackendSource: MemoryBackendSource;
150
+ /** `true` when an explicit `--memory jsonl` pulled an agentdb-configured project down (FR-2). */
151
+ readonly memoryBackendDowngraded: boolean;
75
152
  }
76
153
 
77
154
  /** A single setup step. */
@@ -809,7 +886,10 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
809
886
  export function runSetup(opts: SetupOptions): SetupResult {
810
887
  const steps: SetupStep[] = [];
811
888
  const dzDir = join(opts.projectRoot, '.dz');
812
- const backend: MemoryBackend = opts.memory ?? 'jsonl';
889
+ // FR-1/FR-2/FR-3 (feature `setup-backend-from-config`): read BEFORE this run writes anything, so
890
+ // the comparison is against the PRIOR config, never the one this same call is about to produce.
891
+ const resolvedMemory = resolveSetupMemoryBackend(opts.projectRoot, opts.memory, opts.noMemory === true);
892
+ const backend: MemoryBackend = resolvedMemory.backend;
813
893
 
814
894
  // Step 0: Install agentdb + better-sqlite3 locally so the session-hook writer can import them
815
895
  // and share a native store with the MCP server. Best-effort — the writer self-degrades to a
@@ -835,14 +915,37 @@ export function runSetup(opts: SetupOptions): SetupResult {
835
915
  steps.push({ name: 'Create .dz directory', status: 'skipped', detail: 'already exists' });
836
916
  }
837
917
 
838
- // Step 2: Write .dz/config.json
918
+ // Step 2: Write .dz/config.json. FR-2: a DOWNGRADE (explicit --memory jsonl over an
919
+ // agentdb-configured project) forces the write even without --force — "two truths after any
920
+ // setup coincide" means the config may not keep claiming agentdb once the caller has explicitly
921
+ // asked for jsonl.
839
922
  const configPath = join(dzDir, 'config.json');
840
923
  if (!existsSync(configPath) || opts.force) {
841
924
  writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
842
925
  steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `${backend} backend` });
926
+ } else if (resolvedMemory.downgraded) {
927
+ // Lead edit after Codex review (finding 3): a downgrade changes ONLY memory.backend — every other
928
+ // field the owner keeps in .dz/config.json survives; an unparsable file falls back to regeneration.
929
+ let rewritten = false;
930
+ try {
931
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
932
+ const memory = (cfg['memory'] !== null && typeof cfg['memory'] === 'object') ? (cfg['memory'] as Record<string, unknown>) : {};
933
+ cfg['memory'] = { ...memory, backend };
934
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
935
+ rewritten = true;
936
+ } catch { /* fall through to regeneration */ }
937
+ if (!rewritten) writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
938
+ steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `memory.backend → ${backend} (other fields kept)` });
843
939
  } else {
844
940
  steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already exists (use --force)' });
845
941
  }
942
+ if (resolvedMemory.downgraded) {
943
+ steps.push({
944
+ name: 'Memory backend downgrade',
945
+ status: 'done',
946
+ detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl',
947
+ });
948
+ }
846
949
 
847
950
  // Step 3: Initialize session log
848
951
  const sessionsPath = join(dzDir, 'sessions.jsonl');
@@ -1219,5 +1322,8 @@ export function runSetup(opts: SetupOptions): SetupResult {
1219
1322
  totalSteps: steps.length,
1220
1323
  completed: steps.filter((s) => s.status === 'done').length,
1221
1324
  skipped: steps.filter((s) => s.status === 'skipped').length,
1325
+ memoryBackend: resolvedMemory.backend,
1326
+ memoryBackendSource: resolvedMemory.source,
1327
+ memoryBackendDowngraded: resolvedMemory.downgraded,
1222
1328
  };
1223
1329
  }