@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.
- package/.dz-manifest.json +107 -47
- package/README.md +108 -0
- package/dist/agentdb-index.d.ts +17 -6
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +61 -17
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +68 -5
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +466 -37
- package/dist/apply-leg.js.map +1 -1
- package/dist/embed-socket-path.d.ts +65 -0
- package/dist/embed-socket-path.d.ts.map +1 -0
- package/dist/embed-socket-path.js +100 -0
- package/dist/embed-socket-path.js.map +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +105 -3
- package/dist/operations.js.map +1 -1
- package/dist/packed-install-smoke.d.ts +108 -0
- package/dist/packed-install-smoke.d.ts.map +1 -0
- package/dist/packed-install-smoke.js +172 -0
- package/dist/packed-install-smoke.js.map +1 -0
- package/dist/publish-sibling-drift.d.ts +139 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +408 -0
- package/dist/publish-sibling-drift.js.map +1 -0
- package/dist/publish.d.ts +44 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +243 -24
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +16 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +1 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release.d.ts +91 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +317 -21
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts +36 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +96 -2
- package/dist/setup.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 +112 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +196 -46
- package/src/agentdb-index.ts +65 -17
- package/src/apply-leg.ts +461 -37
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +44 -3
- package/src/operations.ts +104 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +498 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +366 -19
- package/src/setup.ts +108 -2
- 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
|
-
|
|
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}${
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
''
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
671
|
-
return { title, body
|
|
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
|
-
|
|
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
|
}
|