@dzhechkov/harness-cli 0.8.22 → 0.8.23
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 +16 -16
- package/README.md +478 -135
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +2 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +68 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1515 -318
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +20 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +7 -7
- package/sbom.json +15 -15
- package/src/boolean-flags.ts +2 -0
- package/src/cli.ts +1618 -345
- package/src/known-flags.ts +20 -0
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,37 @@ import { createRequire } from 'node:module';
|
|
|
19
19
|
import { isDeepStrictEqual } from 'node:util';
|
|
20
20
|
import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed, type JournalKind, type JournalIo } from '@dzhechkov/harness-core';
|
|
21
21
|
import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply, type WorktreeFact, type RunEvent } from '@dzhechkov/harness-core';
|
|
22
|
+
import {
|
|
23
|
+
openRound,
|
|
24
|
+
closeRound,
|
|
25
|
+
listRounds,
|
|
26
|
+
parseCodexTokens,
|
|
27
|
+
classifyRoundExecOutcome,
|
|
28
|
+
buildRoundExecRow,
|
|
29
|
+
type RoundLedgerRow,
|
|
30
|
+
type RoundExecLedgerRow,
|
|
31
|
+
type RoundState,
|
|
32
|
+
} from '@dzhechkov/harness-core';
|
|
33
|
+
|
|
34
|
+
// round-state-lock fix-round AM-1: augment (not fork) harness-core's `RoundState` with an opaque
|
|
35
|
+
// per-open identity token. Module augmentation keeps this CLI-only (harness-core/src/round.ts is
|
|
36
|
+
// out of this fix's scope — its own pure decisions never need to know the token exists) while still
|
|
37
|
+
// letting every `RoundState`-typed value in this file carry `stateId` with full type-checking. The
|
|
38
|
+
// field is OPTIONAL: a state written before this fix (or by a test's raw `writeFileSync`) parses
|
|
39
|
+
// fine without it, and identity checks below treat a missing `stateId` as `undefined === undefined`
|
|
40
|
+
// (matches only itself), never as a wildcard.
|
|
41
|
+
declare module '@dzhechkov/harness-core' {
|
|
42
|
+
interface RoundState {
|
|
43
|
+
/** 16 random hex chars, minted once by `open`. The identity comparison `exec`/`close` use
|
|
44
|
+
* instead of pid: `process.ppid` coincides for two `dz` launched from the same shell, and every
|
|
45
|
+
* run-owned state carries pid 0 (teach:0ea46034 — pid is not identity). */
|
|
46
|
+
readonly stateId?: string;
|
|
47
|
+
/** Lead edit after Codex re-review: identity of ONE exec claim (two execs of the same round
|
|
48
|
+
* instance are different claims) and when it was taken — the stale-exec warning counts from it. */
|
|
49
|
+
readonly execClaimId?: string;
|
|
50
|
+
readonly execClaimedAt?: string;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
22
53
|
|
|
23
54
|
import {
|
|
24
55
|
createSkill,
|
|
@@ -98,10 +129,15 @@ import {
|
|
|
98
129
|
hasPolicyFence,
|
|
99
130
|
TARGET_NAMES,
|
|
100
131
|
buildParityMatrix,
|
|
132
|
+
computeParity,
|
|
133
|
+
PARITY_FEATURES,
|
|
101
134
|
downgradeForStaleEvidence,
|
|
102
135
|
findStaleTranscriptEvidence,
|
|
103
136
|
TARGET_CAPABILITIES,
|
|
104
137
|
TARGET_SHORT_LABELS,
|
|
138
|
+
applyLegStatus,
|
|
139
|
+
applyLegReasonMessage,
|
|
140
|
+
resolveAgentdbPath,
|
|
105
141
|
WORKFLOW_TEMPLATES_RETIRED_MESSAGE,
|
|
106
142
|
parsePlan,
|
|
107
143
|
isParseErrors,
|
|
@@ -174,7 +210,7 @@ import {
|
|
|
174
210
|
type StoreCountSnapshot,
|
|
175
211
|
type RunSegment,
|
|
176
212
|
type StageSample,
|
|
177
|
-
|
|
213
|
+
computeSpendReport,
|
|
178
214
|
deriveCostLedger,
|
|
179
215
|
planLedgerBackfill,
|
|
180
216
|
listCostLedgerRuns,
|
|
@@ -186,10 +222,7 @@ import {
|
|
|
186
222
|
verifyCostLedgerReport,
|
|
187
223
|
writeCostLedgerJsonl,
|
|
188
224
|
COST_LEDGER_SCOPE,
|
|
189
|
-
|
|
190
|
-
normalizeClaudeUsageModelKey,
|
|
191
|
-
readUsageLimits,
|
|
192
|
-
parseWeeklyResetAnchor,
|
|
225
|
+
spendReport,
|
|
193
226
|
claimCheck,
|
|
194
227
|
summarize,
|
|
195
228
|
BUNDLED_SLOP_REGISTRY_URL,
|
|
@@ -210,6 +243,10 @@ import {
|
|
|
210
243
|
recordToPattern,
|
|
211
244
|
bundleSkills,
|
|
212
245
|
brainHome,
|
|
246
|
+
brainAgentdbPath,
|
|
247
|
+
listPreReindexSnapshots,
|
|
248
|
+
rotatePreReindexSnapshots,
|
|
249
|
+
scanSnapshotDir,
|
|
213
250
|
listBrain,
|
|
214
251
|
bookKbPath,
|
|
215
252
|
promoteProjectToBrain,
|
|
@@ -418,6 +455,7 @@ import {
|
|
|
418
455
|
countRecallEventsForRun,
|
|
419
456
|
unknownFlagNotice,
|
|
420
457
|
mirrorWriterExplanation,
|
|
458
|
+
mirrorWriterReason,
|
|
421
459
|
appendRecallUsage,
|
|
422
460
|
closenessLine,
|
|
423
461
|
anyAboveFloor,
|
|
@@ -510,6 +548,8 @@ import {
|
|
|
510
548
|
renderReqeList,
|
|
511
549
|
REQE_SCOPE,
|
|
512
550
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
551
|
+
REGISTRY_SELFCHECK_TESTS,
|
|
552
|
+
buildMutationTestCommand,
|
|
513
553
|
parseMutationRegistry,
|
|
514
554
|
applyMutationToText,
|
|
515
555
|
attributeBaselineRedness,
|
|
@@ -593,7 +633,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
|
|
|
593
633
|
import type { LogTail } from '@dzhechkov/harness-core';
|
|
594
634
|
import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
|
|
595
635
|
import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
|
|
596
|
-
import type { ProvenanceMode, PackVerdict,
|
|
636
|
+
import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
|
|
597
637
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
598
638
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
599
639
|
|
|
@@ -637,7 +677,7 @@ export const DZ_COMMANDS: readonly string[] = [
|
|
|
637
677
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
638
678
|
'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
|
|
639
679
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
640
|
-
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
680
|
+
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain', 'round',
|
|
641
681
|
];
|
|
642
682
|
|
|
643
683
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -699,7 +739,8 @@ Usage:
|
|
|
699
739
|
dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N / AM-CP-N row must resolve to a test found INSIDE the file the row names (the challenge-panel prefix is part of the id: AM-CP-1 is never AM-1); the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass, UNLESS the plan explicitly declares \"None\"/\"нет\", which is an answer and reports skip. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
|
|
700
740
|
dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
|
|
701
741
|
dz journal add --kind decision|verdict|run|error|block "<text>" [--ref <trace>] [--at <ISO>] [--quote <file>] [--commit-quote]; dz journal show [--day|--week] [--at <date>] [--kind <kind>] [--json] (UTC day files, witnessed append; quotes stay local unless explicitly staged)
|
|
702
|
-
dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the
|
|
742
|
+
dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--run-id <id>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; for a ledger row, 'ts' is ALWAYS the actual write instant (ledger-stage-minutes FR-1) — a payload-supplied 'ts' is never trusted for the delta below, and is preserved as 'payloadTs' rather than discarded; --run-id fills the payload's runId ONLY when it is a gap — absent, null, '', or non-string, the same 'missing when absent or blank' rule runnerId uses — and stamps runIdSource:'cli-flag' when it does; for an auto:true ledger row that carries a runId — from the payload, from --run-id, or resolved at write time — the append also carries minutesSincePrev/minutesSource:'ledger-ts-delta' measured against the LAST row of the same run found by a best-effort reverse scan that reports 'unavailable' (never a guess) on a missing prior row OR a corrupt/non-object ledger line anywhere between it and the file's end (ledger-corrupt-line); minutes itself stays untouched. New fields (ts, minutesSincePrev, minutesSource) are always appended after every existing key, never reordering one. The append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
|
|
743
|
+
dz round open --slug <s> --round <n|auto> --topic <text> [--project <brain>] [--run <id>] [--owner-pid <n>|--owner-run <runId>] [--force] [--json]; dz round exec --slug <s> --round <n> --brief <file> [--log <file>] [--model gpt-5.6-sol] [--effort high] [--timeout-min 30] [--json]; dz round close --slug <s> --round <n> --outcome shipped|refuted|blocked|abandoned [--reason <text>] [--lesson teach:<id>...]|[--no-new-knowledge <reason>] [--tokens N] [--agents N] [--coder <spec>] [--reviewer <spec>] [--note <text>] [--no-cost] [--json]; dz round status [--older-than <minutes>] [--json] (focused rounds outside feature-adr: open tracks the parent process by default, an explicit pid, or a registered run; live/stalled run owners stay live and missing registry evidence stays unknown; open --force refuses a live or unknown owner and archives a known-dead owner's state; recall precedes work, then the witnessed ledger is trusted only after reading it back)
|
|
703
744
|
dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
|
|
704
745
|
dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
|
|
705
746
|
dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
|
|
@@ -732,7 +773,8 @@ Usage:
|
|
|
732
773
|
dz brain query "<q>" [--source <slug>] [--limit <N>] [--any] [--rerank] [--json] (cross-source recall; --any = OR match; --rerank reorders top-K)
|
|
733
774
|
dz brain add [--source <slug>] [--project <dir>] [--from-slice <f>|--from-pack <p>|--from-kus <f> --slug <s>] [--kind <k>] [--license <spdx>] [--json] (grow the brain: promote this project, or import a slice/pack/KU-array)
|
|
734
775
|
dz brain update <slug> [--project <dir>] [--json] (non-destructive refresh: re-mirror a re-ingested source into the brain)
|
|
735
|
-
dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model)
|
|
776
|
+
dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model; also rotates old pre-reindex snapshots)
|
|
777
|
+
dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>] (list — or, with --prune, rotate — pre-reindex snapshot families of the home brain, or of <dir>/.dz/agentdb.db; default keep 3)
|
|
736
778
|
dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
|
|
737
779
|
dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
|
|
738
780
|
dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full] (UserPromptSubmit hook; --budget inlines top-K KUs within ~N tokens; --full = ~8000)
|
|
@@ -741,7 +783,7 @@ Usage:
|
|
|
741
783
|
dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
|
|
742
784
|
dz store-guard [--status|--reset] [--yes] [--project <dir>] (show the monotonic external high-water mark; --reset is the only lowering path and requires confirmation or --yes)
|
|
743
785
|
dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--run-id <id>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; the monotone guard absorbs a backwards plain "Step <n>" only within the same non-empty run id, while an absent/empty id retains legacy fresh-slot behavior — prefix the label with ⛔ or ⏸ to record a legitimate regression)
|
|
744
|
-
dz usage [--json] [--project <dir>]
|
|
786
|
+
dz usage [--json] [--project <dir>] (7-day UTC spend from local Claude Code + subagent transcripts; provider-limit routing disabled by design)
|
|
745
787
|
dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
|
|
746
788
|
dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
|
|
747
789
|
dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
|
|
@@ -801,7 +843,12 @@ export interface MutationGateRunnerObservation {
|
|
|
801
843
|
|
|
802
844
|
export type MutationGateRunner = (
|
|
803
845
|
command: string,
|
|
804
|
-
options: {
|
|
846
|
+
options: {
|
|
847
|
+
readonly cwd: string;
|
|
848
|
+
readonly timeoutMs: number;
|
|
849
|
+
readonly phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline';
|
|
850
|
+
readonly entryId?: string;
|
|
851
|
+
},
|
|
805
852
|
) => MutationGateRunnerObservation;
|
|
806
853
|
|
|
807
854
|
/** Output sink + working directory — injectable so the CLI is testable. */
|
|
@@ -830,10 +877,45 @@ export interface CliIo {
|
|
|
830
877
|
readonly interactive?: boolean;
|
|
831
878
|
/** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
|
|
832
879
|
readonly classMatcher?: RecallPatternsOptions['classMatcher'];
|
|
880
|
+
/** Focused-round seams: production still uses the real store, writer, ledger tail and pid probe. */
|
|
881
|
+
readonly roundNow?: () => number;
|
|
882
|
+
readonly roundPid?: number;
|
|
883
|
+
readonly roundRecall?: (projectRoot: string, topic: string, options: {
|
|
884
|
+
readonly limit: number;
|
|
885
|
+
readonly runId?: string;
|
|
886
|
+
}) => Promise<readonly {
|
|
887
|
+
readonly id: string;
|
|
888
|
+
readonly reward: number;
|
|
889
|
+
readonly domain: string;
|
|
890
|
+
readonly text: string;
|
|
891
|
+
}[]>;
|
|
892
|
+
readonly roundLessonExists?: (projectRoot: string, id: string) => boolean;
|
|
893
|
+
readonly roundLedgerWriter?: (projectRoot: string, row: RoundLedgerRow | RoundExecLedgerRow) => unknown;
|
|
894
|
+
readonly roundLedgerReader?: (projectRoot: string) => string;
|
|
895
|
+
readonly roundPidProbe?: (pid: number) => boolean | null;
|
|
896
|
+
readonly roundRunRegistryReader?: (projectRoot: string) => string;
|
|
897
|
+
readonly roundKillGraceMs?: number;
|
|
898
|
+
/** round-state-lock NFR-2: overrides `withNamedLockSync`'s acquisition deadline for `dz round`
|
|
899
|
+
* mutations so a test can force `lock busy` deterministically. Omitted in production. */
|
|
900
|
+
readonly roundLockTimeoutMs?: number;
|
|
901
|
+
readonly roundSpawn?: (request: {
|
|
902
|
+
readonly command: 'codex';
|
|
903
|
+
readonly args: readonly string[];
|
|
904
|
+
readonly cwd: string;
|
|
905
|
+
readonly logPath: string;
|
|
906
|
+
readonly timeoutMs: number;
|
|
907
|
+
readonly killGraceMs?: number;
|
|
908
|
+
}) => Promise<{
|
|
909
|
+
readonly exitCode: number | null;
|
|
910
|
+
readonly timedOut: boolean;
|
|
911
|
+
readonly signal: NodeJS.Signals | null;
|
|
912
|
+
readonly errorCode?: string;
|
|
913
|
+
readonly error?: string;
|
|
914
|
+
}>;
|
|
833
915
|
/** Guard decision seam; production always uses the real vector-backed teach guard. */
|
|
834
916
|
readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
|
|
835
917
|
/** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
|
|
836
|
-
readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward
|
|
918
|
+
readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward?: number) => Promise<{ readonly flushed: number; readonly dzId?: string }>;
|
|
837
919
|
/**
|
|
838
920
|
* Test seam for `dz release`: overrides subprocess execution for gate steps and the
|
|
839
921
|
* gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
|
|
@@ -2994,10 +3076,13 @@ function cmdStatusline(
|
|
|
2994
3076
|
: `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
|
|
2995
3077
|
? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
|
|
2996
3078
|
: ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
|
|
2997
|
-
//
|
|
2998
|
-
//
|
|
2999
|
-
|
|
3000
|
-
|
|
3079
|
+
// Зеркало — самостоятельный источник панели. Отсутствие печатается явно; нечитаемый файл
|
|
3080
|
+
// сохраняет прежнее отдельное состояние, чтобы отказ инструмента не выглядел как настройка off.
|
|
3081
|
+
line += data.patternMirror?.state === 'unavailable'
|
|
3082
|
+
? ' · mirror: unreadable ⚠'
|
|
3083
|
+
: data.mirror.available
|
|
3084
|
+
? ` · mirror: ${data.mirror.lessons} lessons (pending ${data.mirror.pending})`
|
|
3085
|
+
: ' · mirror: absent';
|
|
3001
3086
|
if (data.storeHealth?.verdict === 'collapsed') {
|
|
3002
3087
|
line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
|
|
3003
3088
|
} else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
|
|
@@ -3041,172 +3126,6 @@ function cmdStatusline(
|
|
|
3041
3126
|
}
|
|
3042
3127
|
}
|
|
3043
3128
|
|
|
3044
|
-
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
3045
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3046
|
-
}
|
|
3047
|
-
|
|
3048
|
-
function usageConfigPath(projectRoot: string): string {
|
|
3049
|
-
return join(projectRoot, '.dz', 'config.json');
|
|
3050
|
-
}
|
|
3051
|
-
|
|
3052
|
-
function readProjectConfigForUsage(projectRoot: string): { config: Record<string, unknown>; warning?: string } {
|
|
3053
|
-
const path = usageConfigPath(projectRoot);
|
|
3054
|
-
try {
|
|
3055
|
-
if (!existsSync(path)) return { config: {} };
|
|
3056
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
3057
|
-
if (isJsonRecord(parsed)) return { config: parsed };
|
|
3058
|
-
return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
|
|
3059
|
-
} catch {
|
|
3060
|
-
return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: UsageCalibrationPlan): Record<string, unknown> {
|
|
3065
|
-
const next: Record<string, unknown> = { ...config };
|
|
3066
|
-
const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
|
|
3067
|
-
const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
|
|
3068
|
-
|
|
3069
|
-
for (const change of plan.changes) {
|
|
3070
|
-
if (change.key === 'session') {
|
|
3071
|
-
usage['sessionTokenLimit'] = change.after;
|
|
3072
|
-
} else if (change.key === 'weekly') {
|
|
3073
|
-
usage['weeklyTokenLimit'] = change.after;
|
|
3074
|
-
} else {
|
|
3075
|
-
const model = normalizeClaudeUsageModelKey(change.key);
|
|
3076
|
-
if (model) {
|
|
3077
|
-
const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
|
|
3078
|
-
existingByModel[model] = change.after;
|
|
3079
|
-
usage['weeklyTokenLimitByModel'] = existingByModel;
|
|
3080
|
-
}
|
|
3081
|
-
}
|
|
3082
|
-
}
|
|
3083
|
-
|
|
3084
|
-
if (plan.changes.length > 0) {
|
|
3085
|
-
usage['calibratedAt'] = plan.after.calibratedAt;
|
|
3086
|
-
usage['source'] = plan.after.source;
|
|
3087
|
-
// A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
|
|
3088
|
-
// the calibration is the very act the disable-note demanded.
|
|
3089
|
-
usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
|
-
memory['usage'] = usage;
|
|
3093
|
-
next['memory'] = memory;
|
|
3094
|
-
return next;
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
function parseUsageModelArgs(modelArgs: readonly string[]): { modelPct: Record<string, unknown>; skipped: string[] } {
|
|
3098
|
-
const modelPct: Record<string, unknown> = {};
|
|
3099
|
-
const skipped: string[] = [];
|
|
3100
|
-
for (const raw of modelArgs) {
|
|
3101
|
-
const eq = raw.indexOf('=');
|
|
3102
|
-
if (eq <= 0 || eq === raw.length - 1) {
|
|
3103
|
-
skipped.push(`model ${raw}: skipped malformed model=pct argument`);
|
|
3104
|
-
continue;
|
|
3105
|
-
}
|
|
3106
|
-
const modelName = raw.slice(0, eq).trim();
|
|
3107
|
-
const model = normalizeClaudeUsageModelKey(modelName);
|
|
3108
|
-
if (!model) {
|
|
3109
|
-
skipped.push(`model ${modelName}: skipped unknown model`);
|
|
3110
|
-
continue;
|
|
3111
|
-
}
|
|
3112
|
-
modelPct[model] = raw.slice(eq + 1).trim();
|
|
3113
|
-
}
|
|
3114
|
-
return { modelPct, skipped };
|
|
3115
|
-
}
|
|
3116
|
-
|
|
3117
|
-
function writeUsageCalibrationSummary(opts: {
|
|
3118
|
-
projectRoot: string;
|
|
3119
|
-
plan: UsageCalibrationPlan;
|
|
3120
|
-
preSkipped: readonly string[];
|
|
3121
|
-
configWarning?: string | undefined;
|
|
3122
|
-
wrote: boolean;
|
|
3123
|
-
write: Write;
|
|
3124
|
-
}): void {
|
|
3125
|
-
opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
|
|
3126
|
-
opts.write(`usage calibrate: project ${opts.projectRoot}`);
|
|
3127
|
-
if (opts.configWarning) opts.write(`usage calibrate: ${opts.configWarning}`);
|
|
3128
|
-
for (const change of opts.plan.changes) {
|
|
3129
|
-
opts.write(
|
|
3130
|
-
`usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`,
|
|
3131
|
-
);
|
|
3132
|
-
}
|
|
3133
|
-
const skipped = [...opts.preSkipped, ...opts.plan.skipped];
|
|
3134
|
-
for (const item of skipped) opts.write(`usage calibrate: skipped ${item}`);
|
|
3135
|
-
if (opts.wrote) {
|
|
3136
|
-
opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
|
|
3137
|
-
} else {
|
|
3138
|
-
opts.write('usage calibrate: no config changes written');
|
|
3139
|
-
}
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
function cmdUsageCalibrate(
|
|
3143
|
-
options: Map<string, string>,
|
|
3144
|
-
optionLists: Map<string, string[]>,
|
|
3145
|
-
cwd: string,
|
|
3146
|
-
write: Write,
|
|
3147
|
-
): number {
|
|
3148
|
-
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3149
|
-
const suppliedModels = optionLists.get('model') ?? [];
|
|
3150
|
-
const parsedModels = parseUsageModelArgs(suppliedModels);
|
|
3151
|
-
const modelPct = parsedModels.modelPct;
|
|
3152
|
-
const hasModelPct = Object.keys(modelPct).length > 0;
|
|
3153
|
-
const input = {
|
|
3154
|
-
...(options.has('session') ? { sessionPct: options.get('session') } : {}),
|
|
3155
|
-
...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
|
|
3156
|
-
...(hasModelPct ? { modelPct } : {}),
|
|
3157
|
-
calibratedAt: new Date().toISOString(),
|
|
3158
|
-
source: 'claude.ai/settings/usage' as const,
|
|
3159
|
-
};
|
|
3160
|
-
const missingInputs: string[] = [];
|
|
3161
|
-
if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
|
|
3162
|
-
missingInputs.push('no calibration percentages supplied');
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
try {
|
|
3166
|
-
const current = computeUsage(projectRoot);
|
|
3167
|
-
const before = readUsageLimits(projectRoot);
|
|
3168
|
-
const plan = deriveUsageCalibration(current, before, input);
|
|
3169
|
-
if (plan.changes.length === 0) {
|
|
3170
|
-
writeUsageCalibrationSummary({
|
|
3171
|
-
projectRoot,
|
|
3172
|
-
plan,
|
|
3173
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs],
|
|
3174
|
-
wrote: false,
|
|
3175
|
-
write,
|
|
3176
|
-
});
|
|
3177
|
-
return 0;
|
|
3178
|
-
}
|
|
3179
|
-
|
|
3180
|
-
const existing = readProjectConfigForUsage(projectRoot);
|
|
3181
|
-
const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
|
|
3182
|
-
try {
|
|
3183
|
-
mkdirSync(join(projectRoot, '.dz'), { recursive: true });
|
|
3184
|
-
writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
|
|
3185
|
-
writeUsageCalibrationSummary({
|
|
3186
|
-
projectRoot,
|
|
3187
|
-
plan,
|
|
3188
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs],
|
|
3189
|
-
configWarning: existing.warning,
|
|
3190
|
-
wrote: true,
|
|
3191
|
-
write,
|
|
3192
|
-
});
|
|
3193
|
-
} catch {
|
|
3194
|
-
writeUsageCalibrationSummary({
|
|
3195
|
-
projectRoot,
|
|
3196
|
-
plan,
|
|
3197
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
|
|
3198
|
-
configWarning: existing.warning,
|
|
3199
|
-
wrote: false,
|
|
3200
|
-
write,
|
|
3201
|
-
});
|
|
3202
|
-
}
|
|
3203
|
-
return 0;
|
|
3204
|
-
} catch {
|
|
3205
|
-
write('usage calibrate: skipped internal error; no config changes written');
|
|
3206
|
-
return 0;
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
3129
|
/**
|
|
3211
3130
|
* `dz usage --by-stage` — the per-stage cost ledger for one feature-adr run (feature `cost-ledger`).
|
|
3212
3131
|
*
|
|
@@ -3265,17 +3184,6 @@ function cmdUsageByStage(
|
|
|
3265
3184
|
return 0;
|
|
3266
3185
|
}
|
|
3267
3186
|
|
|
3268
|
-
/**
|
|
3269
|
-
* `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
|
|
3270
|
-
* aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
|
|
3271
|
-
* the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
|
|
3272
|
-
* write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
|
|
3273
|
-
*
|
|
3274
|
-
* **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
|
|
3275
|
-
* all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
|
|
3276
|
-
* non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
|
|
3277
|
-
* account-wide (all projects).
|
|
3278
|
-
*/
|
|
3279
3187
|
/**
|
|
3280
3188
|
* dz qe-rounds — how many Step-8 review rounds has one feature already had?
|
|
3281
3189
|
*
|
|
@@ -3454,6 +3362,52 @@ function cmdRestartAdvisor(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
3454
3362
|
}));
|
|
3455
3363
|
}
|
|
3456
3364
|
|
|
3365
|
+
function packageCommitCount(root: string, sinceIso: string): number | null {
|
|
3366
|
+
try {
|
|
3367
|
+
// Assemble git's flag so the CLI flag-inventory scanner does not mistake a child-process option
|
|
3368
|
+
// for a user-facing dz option. The argv delivered to git is still exactly `--count`.
|
|
3369
|
+
const raw = execFileSync('git', ['rev-list', '--' + 'count', `--since=${sinceIso}`, 'HEAD', '--', 'packages/'], {
|
|
3370
|
+
cwd: root,
|
|
3371
|
+
encoding: 'utf8',
|
|
3372
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3373
|
+
}).trim();
|
|
3374
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
3375
|
+
} catch {
|
|
3376
|
+
return null;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
function roundTraceSince(root: string): string | null {
|
|
3381
|
+
let firstDate: string | null = null;
|
|
3382
|
+
let lastRoundDate: string | null = null;
|
|
3383
|
+
try {
|
|
3384
|
+
const rows = readFileSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'), 'utf8').split('\n');
|
|
3385
|
+
for (const line of rows) {
|
|
3386
|
+
if (line.trim() === '') continue;
|
|
3387
|
+
let row: Record<string, unknown>;
|
|
3388
|
+
try {
|
|
3389
|
+
const parsed = JSON.parse(line) as unknown;
|
|
3390
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
|
3391
|
+
row = parsed as Record<string, unknown>;
|
|
3392
|
+
} catch { continue; }
|
|
3393
|
+
const date = typeof row['date'] === 'string' && Number.isFinite(Date.parse(row['date'])) ? row['date'] : null;
|
|
3394
|
+
if (date === null) continue;
|
|
3395
|
+
if (firstDate === null) firstDate = date;
|
|
3396
|
+
if (row['stage'] === 'round' || row['stage'] === 'round-exec') lastRoundDate = date;
|
|
3397
|
+
}
|
|
3398
|
+
} catch { return null; }
|
|
3399
|
+
return lastRoundDate ?? firstDate;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
function roundsTracingEnabled(root: string): boolean {
|
|
3403
|
+
try {
|
|
3404
|
+
const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as { rounds?: { traced?: unknown } };
|
|
3405
|
+
return parsed?.rounds?.traced !== false;
|
|
3406
|
+
} catch {
|
|
3407
|
+
return true;
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3457
3411
|
function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
3458
3412
|
const root = resolve(cwd, options.get('project') ?? '.');
|
|
3459
3413
|
const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
|
|
@@ -3461,7 +3415,9 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
3461
3415
|
write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
|
|
3462
3416
|
return 1;
|
|
3463
3417
|
}
|
|
3464
|
-
const
|
|
3418
|
+
const now = Date.now();
|
|
3419
|
+
const windowStartIso = new Date(now - CADENCE_WINDOW_DAYS[windowRaw] * 86_400_000).toISOString();
|
|
3420
|
+
const r = buildCadenceReport(root, windowRaw, now, packageCommitCount(root, windowStartIso));
|
|
3465
3421
|
if (flags.has('json')) { write(JSON.stringify(r)); return r.decision.ok ? 0 : 2; }
|
|
3466
3422
|
write('dz cadence — window ' + r.window + ', record depth ' + r.depthDays + ' day(s)');
|
|
3467
3423
|
if (!r.decision.ok) {
|
|
@@ -3475,145 +3431,105 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
3475
3431
|
write(' ' + w.padEnd(12) + String(r.shipments.graded[w] ?? 0).padStart(15) + String(r.npmPublishes.weekly[w] ?? 0).padStart(15) + String(r.recalls.weekly[w] ?? 0).padStart(9));
|
|
3476
3432
|
}
|
|
3477
3433
|
write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
|
|
3434
|
+
const roundCount = r.rounds.byStage.round;
|
|
3435
|
+
const roundPart = roundCount === 0
|
|
3436
|
+
? 'rounds 0 (ни одной строки круга в окне)'
|
|
3437
|
+
: `rounds ${roundCount} (shipped ${r.rounds.byOutcome.shipped} · refuted ${r.rounds.byOutcome.refuted} · blocked ${r.rounds.byOutcome.blocked} · abandoned ${r.rounds.byOutcome.abandoned})`;
|
|
3438
|
+
write(` ${roundPart} · exec ${r.rounds.byStage['round-exec']} (done ${r.rounds.byOutcome.done} · timeout ${r.rounds.byOutcome.timeout} · session-limit ${r.rounds.byOutcome['session-limit']} · model-refused ${r.rounds.byOutcome['model-refused']} · failed ${r.rounds.byOutcome.failed} · empty ${r.rounds.byOutcome.empty}) · commits(packages/) ${r.rounds.commitsInWindow ?? 'not measured'}`);
|
|
3439
|
+
for (const round of r.rounds.unfinished) {
|
|
3440
|
+
write(` ✗ ${round.slug}#${round.round} ${round.outcome} — ${round.reason ?? 'причина не названа'}`);
|
|
3441
|
+
}
|
|
3478
3442
|
if (r.guard.decay.length > 0) {
|
|
3479
3443
|
write(' guard repeat decay (FIXED set — rules with pre-window history only):');
|
|
3480
3444
|
for (const d of r.guard.decay.slice(0, 8)) write(' ' + d.rule.padEnd(28) + 'before×' + d.before + ' → in-window×' + d.inWindow);
|
|
3481
3445
|
}
|
|
3482
3446
|
if (r.guard.excludedNewborn.length > 0) write(' excluded newborn rule(s) (no pre-window history — a zero here would be youth, not virtue): ' + r.guard.excludedNewborn.join(', '));
|
|
3483
|
-
for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
|
|
3447
|
+
for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded, r.rounds.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
|
|
3484
3448
|
return 0;
|
|
3485
3449
|
}
|
|
3486
3450
|
|
|
3487
3451
|
function cmdUsage(
|
|
3488
3452
|
options: Map<string, string>,
|
|
3489
|
-
|
|
3453
|
+
_optionLists: Map<string, string[]>,
|
|
3490
3454
|
flags: Set<string>,
|
|
3491
3455
|
cwd: string,
|
|
3492
3456
|
write: Write,
|
|
3493
3457
|
): number {
|
|
3494
3458
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3495
|
-
const
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3459
|
+
const reqeDue = (): number => {
|
|
3460
|
+
try {
|
|
3461
|
+
return scanReqeDebts(projectRoot).debts.length;
|
|
3462
|
+
} catch {
|
|
3463
|
+
return 0;
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
const jsonContract = (spend: ReturnType<typeof computeSpendReport>): string => JSON.stringify({
|
|
3467
|
+
sessionPct: null,
|
|
3468
|
+
weeklyPct: null,
|
|
3469
|
+
routing: 'disabled-by-design',
|
|
3470
|
+
spend,
|
|
3471
|
+
reqeDue: reqeDue(),
|
|
3472
|
+
});
|
|
3473
|
+
const number = (value: number): string =>
|
|
3474
|
+
(Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100));
|
|
3505
3475
|
try {
|
|
3506
|
-
if (flags.has('calibrate'))
|
|
3476
|
+
if (flags.has('calibrate')) {
|
|
3477
|
+
// Keep the retired mode's value flags known so its one-line removal receipt is not polluted
|
|
3478
|
+
// by generic unknown-flag notices before dispatch.
|
|
3479
|
+
void ['--session', '--weekly'];
|
|
3480
|
+
write('dz usage --calibrate removed 2026-09-12: provider limits are not measurable (no API, per-account weekly resets, ad-hoc resets); dz usage reports spend only');
|
|
3481
|
+
return 2;
|
|
3482
|
+
}
|
|
3507
3483
|
if (flags.has('by-stage')) return cmdUsageByStage(options, flags, write);
|
|
3508
3484
|
|
|
3509
|
-
const
|
|
3510
|
-
const lim = readUsageLimits(projectRoot);
|
|
3511
|
-
const modelLimits = lim.weeklyTokenLimitByModel;
|
|
3512
|
-
const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
|
|
3485
|
+
const spend = computeSpendReport();
|
|
3513
3486
|
if (flags.has('json')) {
|
|
3514
|
-
|
|
3515
|
-
session: number | null;
|
|
3516
|
-
weekly: number | null;
|
|
3517
|
-
weeklyByModel?: Partial<Record<ClaudeUsageModel, number>>;
|
|
3518
|
-
} = { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null };
|
|
3519
|
-
if (hasModelLimits) limitsPayload.weeklyByModel = { ...modelLimits };
|
|
3520
|
-
const payload: {
|
|
3521
|
-
sessionPct: number | null;
|
|
3522
|
-
weeklyPct: number | null;
|
|
3523
|
-
sessionTokens: number;
|
|
3524
|
-
weeklyTokens: number;
|
|
3525
|
-
resetsAt: { session: string | null; weekly: string | null };
|
|
3526
|
-
limits: typeof limitsPayload;
|
|
3527
|
-
weeklyByModel?: typeof u.weeklyByModel;
|
|
3528
|
-
estimated: true;
|
|
3529
|
-
reqeDue?: number;
|
|
3530
|
-
notEstablished?: readonly string[];
|
|
3531
|
-
estimatesNotForRouting?: { sessionPct: number | null; weeklyPct: number | null };
|
|
3532
|
-
} = {
|
|
3533
|
-
sessionPct: u.sessionPct,
|
|
3534
|
-
weeklyPct: u.weeklyPct,
|
|
3535
|
-
sessionTokens: u.sessionTokens,
|
|
3536
|
-
weeklyTokens: u.weeklyTokens,
|
|
3537
|
-
resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
|
|
3538
|
-
limits: limitsPayload,
|
|
3539
|
-
estimated: true,
|
|
3540
|
-
};
|
|
3541
|
-
// ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
|
|
3542
|
-
// set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
|
|
3543
|
-
if (u.notEstablished.length > 0) payload.notEstablished = u.notEstablished;
|
|
3544
|
-
if (u.estimatesNotForRouting !== undefined) payload.estimatesNotForRouting = u.estimatesNotForRouting;
|
|
3545
|
-
if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
|
|
3546
|
-
// re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
|
|
3547
|
-
// a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
|
|
3548
|
-
// so the zero-debt contract stays byte-identical to the pinned legacy shape. Best-effort.
|
|
3549
|
-
try {
|
|
3550
|
-
const reqeCount = scanReqeDebts(resolve(cwd, options.get('project') ?? '.')).debts.length;
|
|
3551
|
-
if (reqeCount > 0) payload.reqeDue = reqeCount;
|
|
3552
|
-
} catch { /* advisory only */ }
|
|
3553
|
-
write(
|
|
3554
|
-
JSON.stringify(payload),
|
|
3555
|
-
);
|
|
3487
|
+
write(jsonContract(spend));
|
|
3556
3488
|
return 0;
|
|
3557
3489
|
}
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
write(
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3490
|
+
write('usage spend — last 7 UTC days');
|
|
3491
|
+
write('date weighted input output cache-read cache-write events');
|
|
3492
|
+
for (const day of spend.days) {
|
|
3493
|
+
write(`${day.date} ${number(day.weightedTokens)} ${number(day.input)} ${number(day.output)} ${number(day.cacheRead)} ${number(day.cacheWrite)} ${day.events}`);
|
|
3494
|
+
}
|
|
3495
|
+
const total = spend.total7d;
|
|
3496
|
+
write(`7-day total ${number(total.weightedTokens)} ${number(total.input)} ${number(total.output)} ${number(total.cacheRead)} ${number(total.cacheWrite)} ${total.events}`);
|
|
3497
|
+
// "unknown" = `event.model ?? 'unknown'` in `spendReport` — an event with NO model field AT
|
|
3498
|
+
// ALL, or one whose model string matched none of the four recognized substrings (in practice
|
|
3499
|
+
// almost always `<synthetic>`). Fix-round-1 (Codex review, MEDIUM #3): a prior wording here and
|
|
3500
|
+
// in the README said "not an event without a model", which is the OPPOSITE of what the code
|
|
3501
|
+
// does — corrected to name both causes.
|
|
3502
|
+
write('by model — weighted share (0..1) (7-day window; "unknown" = event with no model, or an unrecognized model string e.g. "<synthetic>")');
|
|
3503
|
+
const models = Object.entries(spend.byModel);
|
|
3504
|
+
if (models.length === 0) write(' (no events)');
|
|
3505
|
+
for (const [model, row] of models) {
|
|
3506
|
+
write(` ${model} ${number(row.weightedTokens)} ${number(row.sharePct / 100)}`);
|
|
3507
|
+
}
|
|
3508
|
+
const today = spend.daysByModel.at(-1);
|
|
3509
|
+
if (today !== undefined) {
|
|
3510
|
+
// Fix-round-1 (Codex review, MEDIUM #1): this block used to print weighted tokens only, so
|
|
3511
|
+
// AC-5's "today block shows Sonnet's share of today" had nothing to read it off of. The share
|
|
3512
|
+
// denominator is TODAY's own total (`spend.days.at(-1)`, the same last entry as `today` by
|
|
3513
|
+
// construction — both arrays are built from the same `days` in `spendReport`), not the 7-day
|
|
3514
|
+
// total — a day's share of a week would silently understate every model.
|
|
3515
|
+
write(`today (${today.date}) by model — weighted share-of-day (0..1)`);
|
|
3516
|
+
const todayModels = Object.entries(today.models);
|
|
3517
|
+
const todayTotal = spend.days.at(-1)?.weightedTokens ?? 0;
|
|
3518
|
+
if (todayModels.length === 0) write(' (no events)');
|
|
3519
|
+
for (const [model, weightedTokens] of todayModels) {
|
|
3520
|
+
const shareOfDay = todayTotal > 0 ? weightedTokens / todayTotal : 0;
|
|
3521
|
+
write(` ${model} ${number(weightedTokens)} ${number(shareOfDay)}`);
|
|
3567
3522
|
}
|
|
3568
|
-
try {
|
|
3569
|
-
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
3570
|
-
if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
|
|
3571
|
-
} catch { /* advisory only */ }
|
|
3572
|
-
return 0;
|
|
3573
3523
|
}
|
|
3574
|
-
|
|
3575
|
-
write(
|
|
3576
|
-
'usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)',
|
|
3577
|
-
);
|
|
3578
|
-
try {
|
|
3579
|
-
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
3580
|
-
if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
|
|
3581
|
-
} catch { /* advisory only */ }
|
|
3582
|
-
return 0;
|
|
3583
|
-
}
|
|
3584
|
-
// Compact human line — a short HH:MM / weekday hint on the resets, best-effort.
|
|
3585
|
-
const clock = (iso: string | null): string => {
|
|
3586
|
-
if (!iso) return '?';
|
|
3587
|
-
try {
|
|
3588
|
-
return new Date(iso).toISOString().slice(11, 16);
|
|
3589
|
-
} catch {
|
|
3590
|
-
return '?';
|
|
3591
|
-
}
|
|
3592
|
-
};
|
|
3593
|
-
const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
|
|
3594
|
-
const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
|
|
3595
|
-
const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
|
|
3596
|
-
// The weekly reset is WEEKLY: print the anchor verbatim (weekday + offset), not a bare clock
|
|
3597
|
-
// time — 'resets 08:59' reads as daily and hides the weekday (idea c8513be9: the bare form
|
|
3598
|
-
// misread a Monday reading as '41 minutes after the boundary' when the boundary was Wednesday's).
|
|
3599
|
-
const weeklyAnchorLabel = typeof lim.weeklyResetAnchor === 'string' && lim.weeklyResetAnchor !== ''
|
|
3600
|
-
? lim.weeklyResetAnchor
|
|
3601
|
-
: clock(u.weeklyResetsAt);
|
|
3602
|
-
write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + weeklyAnchorLabel + ') · estimated');
|
|
3603
|
-
if (typeof lim.weeklyResetAnchor === 'string' && parseWeeklyResetAnchor(lim.weeklyResetAnchor)?.offsetMinutes === undefined) {
|
|
3604
|
-
write(' ⚠ weeklyResetAnchor has NO utc offset — the boundary follows the SERVER timezone, not your account\'s true reset instant (measured: the same moment lands a week apart under UTC vs +03:00). Pin it: "' + lim.weeklyResetAnchor + ' +03:00" (your offset) in .dz/config.json');
|
|
3605
|
-
}
|
|
3606
|
-
// re-QE debt surfacing (backlog 6b40e667): the moment someone checks usage is the moment a
|
|
3607
|
-
// usage-switched self-review debt should be visible. Best-effort — never breaks the contract.
|
|
3608
|
-
try {
|
|
3609
|
-
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
3610
|
-
if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
|
|
3611
|
-
} catch { /* advisory only */ }
|
|
3524
|
+
write('source: local Claude Code + subagent transcripts, cost-weighted');
|
|
3612
3525
|
return 0;
|
|
3613
3526
|
} catch {
|
|
3614
|
-
|
|
3615
|
-
if (flags.has('json')) write(
|
|
3616
|
-
else
|
|
3527
|
+
const empty = spendReport([], { nowMs: Date.now(), days: 7 });
|
|
3528
|
+
if (flags.has('json')) write(jsonContract(empty));
|
|
3529
|
+
else {
|
|
3530
|
+
write('usage spend — last 7 UTC days');
|
|
3531
|
+
write('source: local Claude Code + subagent transcripts, cost-weighted');
|
|
3532
|
+
}
|
|
3617
3533
|
return 0;
|
|
3618
3534
|
}
|
|
3619
3535
|
}
|
|
@@ -3943,24 +3859,30 @@ async function cmdStoreGuard(
|
|
|
3943
3859
|
async function runTeachGuardReinforcement(
|
|
3944
3860
|
projectRoot: string,
|
|
3945
3861
|
dzId: string,
|
|
3946
|
-
reward
|
|
3862
|
+
reward?: number,
|
|
3947
3863
|
preserveQuarantine = false,
|
|
3948
|
-
): Promise<{ readonly flushed: number }> {
|
|
3864
|
+
): Promise<{ readonly flushed: number; readonly dzId?: string }> {
|
|
3865
|
+
const matchedDzId = loadStoreRecords(projectRoot)
|
|
3866
|
+
.find((record) => record.id === dzId || record.text === dzId)?.id;
|
|
3949
3867
|
const backend = resolveLearningBackend(projectRoot);
|
|
3950
3868
|
backend.addSample({
|
|
3951
3869
|
dzId,
|
|
3952
3870
|
kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
|
|
3953
|
-
reward,
|
|
3871
|
+
...(reward !== undefined ? { reward } : {}),
|
|
3954
3872
|
ts: new Date().toISOString(),
|
|
3955
3873
|
});
|
|
3956
|
-
|
|
3874
|
+
const trained = await backend.train();
|
|
3875
|
+
return {
|
|
3876
|
+
...trained,
|
|
3877
|
+
...(trained.flushed > 0 && matchedDzId !== undefined ? { dzId: matchedDzId } : {}),
|
|
3878
|
+
};
|
|
3957
3879
|
}
|
|
3958
3880
|
|
|
3959
3881
|
async function cmdTeach(
|
|
3960
3882
|
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
|
|
3961
3883
|
writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
|
|
3962
3884
|
guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
|
|
3963
|
-
reinforceRunner: (projectRoot: string, dzId: string, reward
|
|
3885
|
+
reinforceRunner: (projectRoot: string, dzId: string, reward?: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number; readonly dzId?: string }> = runTeachGuardReinforcement,
|
|
3964
3886
|
): Promise<number> {
|
|
3965
3887
|
// WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
|
|
3966
3888
|
// `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
|
|
@@ -3996,20 +3918,58 @@ async function cmdTeach(
|
|
|
3996
3918
|
// (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
|
|
3997
3919
|
// to the pre-feature baseline (AC-1). Failures are queued + logged by the service itself and
|
|
3998
3920
|
// NOT printed on the default path (teach must stay quiet/scriptable); only success emits.
|
|
3921
|
+
// AM-4 (dz-harness-hub issue #10 defect 4, feature setup-installs-apply-leg): a mirror attempt
|
|
3922
|
+
// that produced ZERO rows, resolved NO working engine (`receipt.engine === undefined` — deps
|
|
3923
|
+
// missing/unusable, the ABI-115 failure AM-2 fixes being the measured cause), AND left the
|
|
3924
|
+
// agentdb store file still absent is not "nothing to report" — it is the vector tier having
|
|
3925
|
+
// never come into being, and a lesson taught in that window has nowhere to mirror into until
|
|
3926
|
+
// `dz consolidate`/a later teach (once the store exists) runs. BOTH signals are required so this
|
|
3927
|
+
// never misfires for an rvf-configured project (whose store is not `.dz/agentdb.db` at all) or
|
|
3928
|
+
// for the ordinary "already mirrored, nothing new" case (which resolves an engine successfully).
|
|
3929
|
+
const emitVectorTierAbsentIfNeeded = (root: string, receipt: { readonly engine?: string | undefined }): void => {
|
|
3930
|
+
if (receipt.engine === undefined && !existsSync(resolveAgentdbPath(root))) {
|
|
3931
|
+
write(' ↳ vector tier absent — run dz consolidate');
|
|
3932
|
+
}
|
|
3933
|
+
};
|
|
3934
|
+
// AM-9/AM-10 (issue #10 defect 6, feature setup-installs-apply-leg): `vectorMirrorEnabled(root)`
|
|
3935
|
+
// alone used to decide "say nothing" for every disabled reason alike, including a config that
|
|
3936
|
+
// CLAIMS agentdb via a top-level `backend` key (`{"backend":"agentdb"}` instead of
|
|
3937
|
+
// `{"memory":{"backend":"agentdb"}}`) — a real, readable intent this silently dropped on the
|
|
3938
|
+
// floor. Named for `config-unreadable` / `legacy-shape` — both are a config that TRIED to say
|
|
3939
|
+
// something and got it wrong. THREE reasons stay silent: `engine-off` (deliberate), `no-config`
|
|
3940
|
+
// (the pre-existing AC-1 contract — a NAMED test in `cli.test.ts`/`teach-chooses-its-store.test.ts`
|
|
3941
|
+
// — printing there broke both, MEASURED), and `not-enabled` (AM-10, narrower than the amendment's
|
|
3942
|
+
// literal instruction — MEASURED: `not-enabled` is ALSO the state of the ORDINARY, first-class
|
|
3943
|
+
// jsonl backend `dz setup` produces by default, and printing there added a line to the single most
|
|
3944
|
+
// common `dz teach` invocation shape, reproducer: `mkdir .dz && echo '{"memory":{"backend":
|
|
3945
|
+
// "jsonl"}}' > .dz/config.json && dz teach "x"` → new line `↳ vector tier OFF: …` on the DEFAULT,
|
|
3946
|
+
// fully-supported jsonl path. `not-enabled` cannot distinguish "chose jsonl on purpose" from "typo'd
|
|
3947
|
+
// a backend name", so it is grouped with the other legitimate-quiet states rather than with the
|
|
3948
|
+
// two states that are unambiguously a mistake.
|
|
3949
|
+
const emitMirrorOffIfNeeded = (root: string): boolean => {
|
|
3950
|
+
const reason = mirrorWriterReason(root);
|
|
3951
|
+
if (reason.state !== 'config-unreadable' && reason.state !== 'legacy-shape') return false;
|
|
3952
|
+
write(` ↳ vector tier OFF: ${mirrorWriterExplanation(reason.state)}`);
|
|
3953
|
+
return true;
|
|
3954
|
+
};
|
|
3999
3955
|
const emitMirror = async (root: string, records: readonly PatternRecord[], source: string): Promise<void> => {
|
|
4000
|
-
if (flags.has('no-mirror') || records.length === 0
|
|
3956
|
+
if (flags.has('no-mirror') || records.length === 0) return;
|
|
3957
|
+
if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
|
|
4001
3958
|
const receipt = await mirrorPatternsToVector(root, records, source);
|
|
4002
3959
|
if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})`);
|
|
3960
|
+
else emitVectorTierAbsentIfNeeded(root, receipt);
|
|
4003
3961
|
};
|
|
4004
3962
|
// lesson-quarantine FR-8: the fresh-teach mirror carries the qStatus marker so the hook daemon
|
|
4005
3963
|
// (which reads only the mirror's metadata) can exclude unproven lessons from auto-inject.
|
|
4006
3964
|
const emitMirrorQ = async (root: string, records: readonly PatternRecord[], source: string, quarantined: boolean): Promise<void> => {
|
|
4007
|
-
if (flags.has('no-mirror') || records.length === 0
|
|
3965
|
+
if (flags.has('no-mirror') || records.length === 0) return;
|
|
3966
|
+
if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
|
|
4008
3967
|
const entries = records
|
|
4009
3968
|
.map((r) => patternVectorEntry(r, source, quarantined ? { quarantined: true } : {}))
|
|
4010
3969
|
.filter((e): e is NonNullable<typeof e> => e !== undefined);
|
|
4011
3970
|
const receipt = await mirrorEntriesToVector(root, entries);
|
|
4012
3971
|
if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})${quarantined ? ' [quarantined]' : ''}`);
|
|
3972
|
+
else emitVectorTierAbsentIfNeeded(root, receipt);
|
|
4013
3973
|
};
|
|
4014
3974
|
|
|
4015
3975
|
// `dz teach --harmonize` — documented ALIAS of `dz vector harmonize`: SEMANTIC dedup of the
|
|
@@ -4127,20 +4087,23 @@ async function cmdTeach(
|
|
|
4127
4087
|
|
|
4128
4088
|
const reinforce = options.get('reinforce');
|
|
4129
4089
|
if (reinforce !== undefined && reinforce.trim() !== '') {
|
|
4130
|
-
const backend = resolveLearningBackend(storeRoot);
|
|
4131
4090
|
const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
|
|
4132
|
-
|
|
4133
|
-
dzId: reinforce,
|
|
4134
|
-
kind: 'reinforce',
|
|
4135
|
-
ts: new Date().toISOString(),
|
|
4136
|
-
...(sampleReward !== undefined ? { reward: sampleReward } : {}),
|
|
4137
|
-
});
|
|
4138
|
-
const trained = await backend.train();
|
|
4091
|
+
const trained = await reinforceRunner(storeRoot, reinforce, sampleReward);
|
|
4139
4092
|
if (trained.flushed > 0) {
|
|
4140
|
-
|
|
4093
|
+
const records = loadStoreRecords(storeRoot);
|
|
4094
|
+
const reinforcedDzId = trained.dzId
|
|
4095
|
+
?? findExactLesson(records, reinforce)?.id
|
|
4096
|
+
?? records.find((record) => record.id === reinforce)?.id;
|
|
4097
|
+
write(reinforcedDzId !== undefined && reinforcedDzId !== reinforce
|
|
4098
|
+
? `↳ reinforced ${reinforcedDzId} (matched by text)`
|
|
4099
|
+
: `↳ reinforced ${reinforcedDzId ?? reinforce}`);
|
|
4141
4100
|
// lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
|
|
4142
|
-
|
|
4143
|
-
|
|
4101
|
+
if (reinforcedDzId === undefined) {
|
|
4102
|
+
write(' ↳ mirror quarantine NOT cleared: matched pattern has no dzId');
|
|
4103
|
+
} else {
|
|
4104
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforcedDzId]);
|
|
4105
|
+
if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
|
|
4106
|
+
}
|
|
4144
4107
|
write(storeLine('written'));
|
|
4145
4108
|
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
|
|
4146
4109
|
return 0;
|
|
@@ -5492,6 +5455,7 @@ Usage:
|
|
|
5492
5455
|
dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
|
|
5493
5456
|
dz brain update <slug> [--project <dir>] [--json]
|
|
5494
5457
|
dz brain reindex [--json]
|
|
5458
|
+
dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>]
|
|
5495
5459
|
dz brain primer <slug> [--json]
|
|
5496
5460
|
dz brain export --source <slug> --out <file>
|
|
5497
5461
|
dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full]
|
|
@@ -5930,9 +5894,98 @@ async function cmdBrain(
|
|
|
5930
5894
|
}
|
|
5931
5895
|
write(`dz brain reindex: re-embedded ${result.reembedded} KU vector(s) with ${result.model} (manifest v${result.version})`);
|
|
5932
5896
|
if (result.backupPath !== undefined) write(` snapshot: ${result.backupPath}`);
|
|
5897
|
+
if (result.snapshots !== undefined) {
|
|
5898
|
+
const mb = (result.snapshots.removedBytes / (1024 * 1024)).toFixed(1);
|
|
5899
|
+
write(` ↳ snapshots: kept ${result.snapshots.kept.length}, removed ${result.snapshots.removed.length} (${mb} MB)`);
|
|
5900
|
+
if (result.snapshots.errors !== undefined && result.snapshots.errors.length > 0) {
|
|
5901
|
+
write(` ⚠ snapshot rotation error(s): ${result.snapshots.errors.join('; ')}`);
|
|
5902
|
+
}
|
|
5903
|
+
if (result.snapshots.scanErrors !== undefined && result.snapshots.scanErrors.length > 0) {
|
|
5904
|
+
write(` ⚠ snapshot scan error(s), nothing removed this call: ${result.snapshots.scanErrors.join('; ')}`);
|
|
5905
|
+
}
|
|
5906
|
+
if (result.snapshots.partialFamilies !== undefined && result.snapshots.partialFamilies.length > 0) {
|
|
5907
|
+
write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${result.snapshots.partialFamilies.join(', ')}`);
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5933
5910
|
return 0;
|
|
5934
5911
|
}
|
|
5935
5912
|
|
|
5913
|
+
// ── dz brain snapshots [--keep N] [--prune] [--json] ────────────────────────────────────────
|
|
5914
|
+
// Manual rotation of the brain's OWN pre-reindex snapshots — independent of `dz brain reindex`
|
|
5915
|
+
// (FR-7). The owner's hub forbids running a live reindex there today, and 13 snapshots / 50 MB
|
|
5916
|
+
// sit unrotated regardless; this command reaches the same family-aware rotation without one.
|
|
5917
|
+
// Without --prune it only LISTS families (dry, never deletes); --prune applies FR-1..FR-5.
|
|
5918
|
+
if (sub === 'snapshots') {
|
|
5919
|
+
// Lead edit after acceptance (2026-09-13): the owner's hub keeps its 13 families next to the
|
|
5920
|
+
// PROJECT store (.dz/agentdb.db, written by the vector-tier reindex), not the home brain —
|
|
5921
|
+
// `--project <dir>` addresses that store; without it the home brain is the target as before.
|
|
5922
|
+
const projectArg = options.get('project');
|
|
5923
|
+
const dbFile = projectArg !== undefined ? resolveAgentdbPath(resolve(cwd, projectArg)) : brainAgentdbPath(brainHome());
|
|
5924
|
+
const keepRaw = options.get('keep');
|
|
5925
|
+
let keep = 3;
|
|
5926
|
+
if (keepRaw !== undefined) {
|
|
5927
|
+
// AM-1 (fix-round, Codex review Grade D): `Number('')` is `0` and `Number(' 2')` is `2` —
|
|
5928
|
+
// both used to validate as an ordinary non-negative integer, silently accepting empty/
|
|
5929
|
+
// whitespace input. Only the literal digit-string shape is accepted; no trimming.
|
|
5930
|
+
if (!/^(0|[1-9]\d*)$/.test(keepRaw)) {
|
|
5931
|
+
write(`dz brain snapshots: --keep must be a non-negative integer (got '${keepRaw}')`);
|
|
5932
|
+
return 2;
|
|
5933
|
+
}
|
|
5934
|
+
keep = Number(keepRaw);
|
|
5935
|
+
// Lead edit after re-review (Codex C): a digit string can still overflow a safe integer.
|
|
5936
|
+
if (!Number.isSafeInteger(keep)) {
|
|
5937
|
+
write(`dz brain snapshots: --keep is out of range (got '${keepRaw}')`);
|
|
5938
|
+
return 2;
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
if (!flags.has('prune')) {
|
|
5942
|
+
// Lead edit after re-review: the list is only trustworthy when the scan was complete —
|
|
5943
|
+
// an unreadable directory is reported with ⚠ and exit 1, never as "no families".
|
|
5944
|
+
const { families, scanErrors } = scanSnapshotDir(dbFile);
|
|
5945
|
+
if (asJson) {
|
|
5946
|
+
write(JSON.stringify({ keep, families: families.map((f) => ({ ms: f.ms, files: f.files.map((file) => file.name), bytes: f.bytes })), scanErrors }));
|
|
5947
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5948
|
+
}
|
|
5949
|
+
if (scanErrors.length > 0) write(` ⚠ scan error(s) — the list below may be incomplete: ${scanErrors.join('; ')}`);
|
|
5950
|
+
if (families.length === 0) {
|
|
5951
|
+
write(`dz brain snapshots: no pre-reindex snapshot families next to ${dbFile}`);
|
|
5952
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5953
|
+
}
|
|
5954
|
+
write(`dz brain snapshots — ${families.length} family(-ies) @ ${dbFile}`);
|
|
5955
|
+
for (const f of families) {
|
|
5956
|
+
const mb = (f.bytes / (1024 * 1024)).toFixed(1);
|
|
5957
|
+
write(` ${new Date(f.ms).toISOString()} ms=${f.ms} ${f.files.length} file(s) ${mb} MB`);
|
|
5958
|
+
}
|
|
5959
|
+
write(' (dry run — pass --prune to remove families older than --keep)');
|
|
5960
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5961
|
+
}
|
|
5962
|
+
const report = rotatePreReindexSnapshots(dbFile, { keep });
|
|
5963
|
+
const scanFailed = report.scanErrors !== undefined && report.scanErrors.length > 0;
|
|
5964
|
+
// agentdb-snapshot-lock FR-4: a busy snapshot lock is reported exactly like a scan failure —
|
|
5965
|
+
// nothing removed, ⚠, exit 1 — never a silent "kept N, removed 0" that reads like an empty rotation.
|
|
5966
|
+
const lockBusy = report.errors !== undefined && report.errors.some((e) => e.startsWith('lock busy'));
|
|
5967
|
+
if (asJson) { write(JSON.stringify(report)); return scanFailed || lockBusy ? 1 : 0; }
|
|
5968
|
+
const mb = (report.removedBytes / (1024 * 1024)).toFixed(1);
|
|
5969
|
+
write(`dz brain snapshots: kept ${report.kept.length}, removed ${report.removed.length} (${mb} MB)`);
|
|
5970
|
+
if (report.removed.length > 0) write(` removed: ${report.removed.join(', ')}`);
|
|
5971
|
+
if (report.errors !== undefined && report.errors.length > 0) {
|
|
5972
|
+
write(` ⚠ ${report.errors.length} error(s): ${report.errors.join('; ')}`);
|
|
5973
|
+
}
|
|
5974
|
+
// AM-4: an incomplete scan means NOTHING was removed this call — say so, never silently.
|
|
5975
|
+
if (report.scanErrors !== undefined && report.scanErrors.length > 0) {
|
|
5976
|
+
write(` ⚠ scan error(s), nothing removed this call: ${report.scanErrors.join('; ')}`);
|
|
5977
|
+
}
|
|
5978
|
+
// AM-2: a family whose .bak survived only because a sibling failed to unlink.
|
|
5979
|
+
if (report.partialFamilies !== undefined && report.partialFamilies.length > 0) {
|
|
5980
|
+
write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${report.partialFamilies.join(', ')}`);
|
|
5981
|
+
}
|
|
5982
|
+
// FR-3: a live reindex marker rescued a family, or an expired one was cleaned up — honest, never an error.
|
|
5983
|
+
if (report.notes !== undefined && report.notes.length > 0) {
|
|
5984
|
+
write(` note: ${report.notes.join('; ')}`);
|
|
5985
|
+
}
|
|
5986
|
+
return scanFailed || lockBusy ? 1 : 0;
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5936
5989
|
// ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
|
|
5937
5990
|
// The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
|
|
5938
5991
|
// fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
|
|
@@ -6148,6 +6201,18 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6148
6201
|
// Step 3: Run setup (hooks + memory + config)
|
|
6149
6202
|
write(`║ 3. Setting up learning environment... ║`);
|
|
6150
6203
|
const memoryOpt = options.get('memory');
|
|
6204
|
+
// ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
|
|
6205
|
+
// @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
|
|
6206
|
+
// `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
|
|
6207
|
+
// (FR-3). Best-effort: an unresolvable core (should not happen — the CLI depends on it) falls
|
|
6208
|
+
// back to core's own self-resolution inside `runSetup`, never a crash.
|
|
6209
|
+
let coreDistDir: string | undefined;
|
|
6210
|
+
try {
|
|
6211
|
+
const corePkgJson = createRequire(import.meta.url).resolve('@dzhechkov/harness-core/package.json');
|
|
6212
|
+
coreDistDir = join(dirname(corePkgJson), 'dist');
|
|
6213
|
+
} catch {
|
|
6214
|
+
coreDistDir = undefined;
|
|
6215
|
+
}
|
|
6151
6216
|
const setupResult = runSetup({
|
|
6152
6217
|
projectRoot,
|
|
6153
6218
|
target,
|
|
@@ -6157,6 +6222,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6157
6222
|
noMemory: flags.has('no-memory'),
|
|
6158
6223
|
force: flags.has('force'),
|
|
6159
6224
|
installDriver: flags.has('install-driver'),
|
|
6225
|
+
coreDistDir,
|
|
6160
6226
|
});
|
|
6161
6227
|
|
|
6162
6228
|
for (const step of setupResult.steps) {
|
|
@@ -7332,7 +7398,7 @@ function cmdPublish(
|
|
|
7332
7398
|
/* ADR-001): computed from the declarative model, never hand-written */
|
|
7333
7399
|
/* ------------------------------------------------------------------ */
|
|
7334
7400
|
|
|
7335
|
-
function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr): number {
|
|
7401
|
+
function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr, cwd: string): number {
|
|
7336
7402
|
const json = flags.has('json');
|
|
7337
7403
|
if (flags.has('help')) {
|
|
7338
7404
|
write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
|
|
@@ -7360,7 +7426,31 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
|
|
|
7360
7426
|
}
|
|
7361
7427
|
}
|
|
7362
7428
|
|
|
7363
|
-
|
|
7429
|
+
// ADR-001 Decision 3 (feature setup-installs-apply-leg): `learning-apply` on `claude-code` is
|
|
7430
|
+
// MEASURED, not declared — `hooks-prompt` is present for that ONE target only when
|
|
7431
|
+
// `applyLegStatus(root).installed`. `computeParity` itself is untouched (FR-5); only the
|
|
7432
|
+
// capability SET fed into it for this one cell differs from the static `TARGET_CAPABILITIES`.
|
|
7433
|
+
// `applyLegStatus` never throws (fix round 1, Q3 finding: an unreadable helper used to be able to
|
|
7434
|
+
// crash this command rather than degrade to a named remedy).
|
|
7435
|
+
const applyLegStatusVal = applyLegStatus(cwd);
|
|
7436
|
+
const applyLegInstalled = applyLegStatusVal.installed;
|
|
7437
|
+
const matrix = buildParityMatrix().map((row) => {
|
|
7438
|
+
if (row.feature.id !== 'learning-apply' || applyLegInstalled) return row;
|
|
7439
|
+
const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
|
|
7440
|
+
return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
|
|
7441
|
+
});
|
|
7442
|
+
// The "not installed" remedy — named ONLY for the one cell whose grant is a live measurement,
|
|
7443
|
+
// never a blanket note for every `manual` cell (most targets are manual by DESIGN, not absence).
|
|
7444
|
+
// `stale-version`/`unreadable` route through `applyLegReasonMessage` — the SAME text-producing
|
|
7445
|
+
// function `dz doctor` uses for those two reasons (fix round 1, HIGH finding 2 / Q3 finding 7), so
|
|
7446
|
+
// the two instruments cannot disagree about WHY a stale or broken install is not "full".
|
|
7447
|
+
const applyLegRemedy = (featureId: string, t: TargetName): string => {
|
|
7448
|
+
if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegInstalled) return '';
|
|
7449
|
+
if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
|
|
7450
|
+
return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
|
|
7451
|
+
}
|
|
7452
|
+
return ' — not installed — run dz setup --target claude-code --memory agentdb';
|
|
7453
|
+
};
|
|
7364
7454
|
// EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
|
|
7365
7455
|
// themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
|
|
7366
7456
|
// of the model. A cell whose deciding form rests on a transcript that is older than the newest
|
|
@@ -7402,8 +7492,12 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
|
|
|
7402
7492
|
if (json) {
|
|
7403
7493
|
const shown = target !== undefined ? [target] : TARGET_NAMES;
|
|
7404
7494
|
const rows = matrix.map((r) => {
|
|
7405
|
-
const cells: Record<string, ParityReportCell> = {};
|
|
7406
|
-
for (const t of shown)
|
|
7495
|
+
const cells: Record<string, ParityReportCell & { note?: string }> = {};
|
|
7496
|
+
for (const t of shown) {
|
|
7497
|
+
const cell = reportCell(r.feature, t, r.cells[t]);
|
|
7498
|
+
const remedy = applyLegRemedy(r.feature.id, t);
|
|
7499
|
+
cells[t] = remedy === '' ? cell : { ...cell, note: remedy.replace(/^ — /, '') };
|
|
7500
|
+
}
|
|
7407
7501
|
return { id: r.feature.id, title: r.feature.title, cells };
|
|
7408
7502
|
});
|
|
7409
7503
|
// A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
|
|
@@ -7430,7 +7524,7 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
|
|
|
7430
7524
|
: c.level === 'inconclusive'
|
|
7431
7525
|
? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
|
|
7432
7526
|
: `via ${c.via ?? ''}`;
|
|
7433
|
-
write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
|
|
7527
|
+
write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}${applyLegRemedy(r.feature.id, t)}`);
|
|
7434
7528
|
}
|
|
7435
7529
|
write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
|
|
7436
7530
|
for (const line of staleNote(t)) write(line);
|
|
@@ -9667,6 +9761,36 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9667
9761
|
const facts: Record<string, unknown> = { op };
|
|
9668
9762
|
const publishPackageRoots: string[] = [];
|
|
9669
9763
|
if (op === 'publish') {
|
|
9764
|
+
try {
|
|
9765
|
+
const roundsDir = join(root, '.dz', 'rounds');
|
|
9766
|
+
const states = readdirSync(roundsDir)
|
|
9767
|
+
.filter((name) => name.endsWith('.json'))
|
|
9768
|
+
.map((name) => readRoundState(join(roundsDir, name)))
|
|
9769
|
+
.filter((state): state is RoundState => state !== null);
|
|
9770
|
+
facts['openRounds'] = listRounds(states, {
|
|
9771
|
+
now: Date.now(),
|
|
9772
|
+
olderThanMinutes: 120,
|
|
9773
|
+
isPidAlive: probePid,
|
|
9774
|
+
isRunAlive: (runId) => roundRunOwnerAlive(root, runId, Date.now()),
|
|
9775
|
+
}).map((row) => ({
|
|
9776
|
+
slug: row.state.slug,
|
|
9777
|
+
round: row.state.round,
|
|
9778
|
+
ageMinutes: row.ageMinutes,
|
|
9779
|
+
pidAlive: row.pidAlive,
|
|
9780
|
+
}));
|
|
9781
|
+
} catch { /* absent/unreadable round state is no fabricated violation */ }
|
|
9782
|
+
const since = roundTraceSince(root);
|
|
9783
|
+
const enabled = roundsTracingEnabled(root);
|
|
9784
|
+
if (!enabled) {
|
|
9785
|
+
facts['codeCommitsSinceLastRound'] = { commits: null, since, enabled: false };
|
|
9786
|
+
} else if (since !== null) {
|
|
9787
|
+
facts['codeCommitsSinceLastRound'] = { commits: packageCommitCount(root, since), since };
|
|
9788
|
+
} else if (existsSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'))) {
|
|
9789
|
+
// The ledger EXISTS but carries no dated row: that is a measurable absence and gets a note.
|
|
9790
|
+
// No ledger file at all is a fresh project — the rule stays not-established silently, so a
|
|
9791
|
+
// note that every new repo would carry does not drown the ones that mean something.
|
|
9792
|
+
facts['codeCommitsSinceLastRound'] = { commits: null, since: null };
|
|
9793
|
+
}
|
|
9670
9794
|
// Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
|
|
9671
9795
|
// stale finding and never a publish blocker.
|
|
9672
9796
|
try {
|
|
@@ -11879,6 +12003,118 @@ function parseCheckMutatedFile(absFile: string, text: string): MutationParseChec
|
|
|
11879
12003
|
}
|
|
11880
12004
|
}
|
|
11881
12005
|
|
|
12006
|
+
const MUTATION_GATE_OUTPUT_TAIL_MAX_LINES = 20;
|
|
12007
|
+
const MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES = 2 * 1024;
|
|
12008
|
+
|
|
12009
|
+
export function boundedMutationGateOutputTail(output: string): string | undefined {
|
|
12010
|
+
const normalized = output.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
|
|
12011
|
+
if (normalized === '') return undefined;
|
|
12012
|
+
|
|
12013
|
+
let tail = normalized.split('\n').slice(-MUTATION_GATE_OUTPUT_TAIL_MAX_LINES).join('\n');
|
|
12014
|
+
const encoded = Buffer.from(tail, 'utf8');
|
|
12015
|
+
if (encoded.byteLength <= MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) return tail;
|
|
12016
|
+
|
|
12017
|
+
const codePoints = Array.from(tail);
|
|
12018
|
+
let start = codePoints.length;
|
|
12019
|
+
let byteLength = 0;
|
|
12020
|
+
while (start > 0) {
|
|
12021
|
+
const nextByteLength = Buffer.byteLength(codePoints[start - 1]!, 'utf8');
|
|
12022
|
+
if (byteLength + nextByteLength > MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) break;
|
|
12023
|
+
byteLength += nextByteLength;
|
|
12024
|
+
start -= 1;
|
|
12025
|
+
}
|
|
12026
|
+
return codePoints.slice(start).join('');
|
|
12027
|
+
}
|
|
12028
|
+
|
|
12029
|
+
// ── Full-output capture for a RED baseline/rebaseline line (gate-stability, 2026-09-12) ────────
|
|
12030
|
+
// The bounded tail above is a diagnostic teaser (3-20 lines); under a multi-entry gate run the
|
|
12031
|
+
// tail was measured to hand back an unrelated neighbour's stderr, leaving OVER_FAILING/
|
|
12032
|
+
// INCONCLUSIVE undiagnosable. Only the baseline and rebaseline lines write here — the per-entry
|
|
12033
|
+
// mutation run is EXPECTED to redden and already carries a bounded tail; this is for the lines
|
|
12034
|
+
// whose redness means "the copy itself is broken", where the full transcript is the only way to
|
|
12035
|
+
// tell what actually happened.
|
|
12036
|
+
|
|
12037
|
+
const MUTATION_GATE_OUTPUT_FILE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12038
|
+
|
|
12039
|
+
function mutationGateOutputDir(): string {
|
|
12040
|
+
return process.env.DZ_MUTGATE_OUTPUT_DIR ?? join(tmpdir(), 'dz-mutgate-output');
|
|
12041
|
+
}
|
|
12042
|
+
|
|
12043
|
+
/** own filename prefix (fix-round-1 HIGH finding) — see isMutationGateOutputFile. */
|
|
12044
|
+
const MUTATION_GATE_OUTPUT_FILE_PREFIX = 'dz-mutgate-';
|
|
12045
|
+
/** exact shape of `new Date().toISOString().replace(/:/g, '-')`, e.g. `2026-09-12T20-00-00.000Z`. */
|
|
12046
|
+
const MUTATION_GATE_OUTPUT_TS_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z`;
|
|
12047
|
+
const MUTATION_GATE_OUTPUT_FILE_RE = new RegExp(
|
|
12048
|
+
`^${MUTATION_GATE_OUTPUT_FILE_PREFIX}.+-(baseline|rebaseline|final-rebaseline)-${MUTATION_GATE_OUTPUT_TS_PATTERN}\\.log$`,
|
|
12049
|
+
);
|
|
12050
|
+
|
|
12051
|
+
/**
|
|
12052
|
+
* true only for a filename THIS executor could have written — rotation never touches a foreign
|
|
12053
|
+
* file. Fix-round-1 HIGH finding (Codex review, gate-stability): the prior
|
|
12054
|
+
* `^.+-(baseline|rebaseline|final-rebaseline)-.+\.log$` had no own prefix and accepted ANY
|
|
12055
|
+
* trailing text as the "timestamp", so a pre-existing unrelated file dropped into a shared
|
|
12056
|
+
* `DZ_MUTGATE_OUTPUT_DIR` (e.g. `service-baseline-backup.log`) matched and could be rotated away.
|
|
12057
|
+
* Now BOTH the `dz-mutgate-` prefix AND the exact ISO-timestamp shape we ourselves write are
|
|
12058
|
+
* required — a foreign file can accidentally share the prefix but essentially never our precise
|
|
12059
|
+
* timestamp format, and a file we did NOT write never carries both.
|
|
12060
|
+
*/
|
|
12061
|
+
function isMutationGateOutputFile(name: string): boolean {
|
|
12062
|
+
return MUTATION_GATE_OUTPUT_FILE_RE.test(name);
|
|
12063
|
+
}
|
|
12064
|
+
|
|
12065
|
+
function rotateMutationGateOutputDir(dir: string): void {
|
|
12066
|
+
let names: string[];
|
|
12067
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
12068
|
+
const cutoff = Date.now() - MUTATION_GATE_OUTPUT_FILE_RETENTION_MS;
|
|
12069
|
+
for (const name of names) {
|
|
12070
|
+
if (!isMutationGateOutputFile(name)) continue; // "чужие файлы не трогаются" — own prefix only
|
|
12071
|
+
const full = join(dir, name);
|
|
12072
|
+
try {
|
|
12073
|
+
if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true });
|
|
12074
|
+
} catch { /* best effort — a listing race is not this executor's problem */ }
|
|
12075
|
+
}
|
|
12076
|
+
}
|
|
12077
|
+
|
|
12078
|
+
/** Discriminated outcome of a save attempt — a red run either saved (path) or did not (error);
|
|
12079
|
+
* never both. See writeMutationGateOutputOnRed. */
|
|
12080
|
+
type MutationGateOutputWrite = { readonly path: string } | { readonly error: string };
|
|
12081
|
+
|
|
12082
|
+
/**
|
|
12083
|
+
* Saves the FULL stdout+stderr of a RED baseline/rebaseline run and returns `{ path }`, or
|
|
12084
|
+
* `{ error }` on any I/O failure (EACCES/ENOSPC/EROFS/ENOTDIR and the like — never blocks the gate
|
|
12085
|
+
* on a logging problem: fix-round-1 MEDIUM finding, the prior silent `catch { return undefined; }`
|
|
12086
|
+
* made a failed save indistinguishable from "nothing to save"), or `undefined` when exitCode is 0
|
|
12087
|
+
* (nothing written on green — NFR-1 byte-identity).
|
|
12088
|
+
*/
|
|
12089
|
+
function writeMutationGateOutputOnRed(
|
|
12090
|
+
entryId: string | undefined,
|
|
12091
|
+
phase: 'baseline' | 'rebaseline' | 'final-rebaseline',
|
|
12092
|
+
exitCode: number | null,
|
|
12093
|
+
output: string,
|
|
12094
|
+
): MutationGateOutputWrite | undefined {
|
|
12095
|
+
if (exitCode === 0) return undefined;
|
|
12096
|
+
try {
|
|
12097
|
+
const dir = mutationGateOutputDir();
|
|
12098
|
+
mkdirSync(dir, { recursive: true });
|
|
12099
|
+
rotateMutationGateOutputDir(dir);
|
|
12100
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
12101
|
+
const full = join(dir, `${MUTATION_GATE_OUTPUT_FILE_PREFIX}${entryId ?? 'baseline'}-${phase}-${ts}.log`);
|
|
12102
|
+
writeFileSync(full, output);
|
|
12103
|
+
return { path: full };
|
|
12104
|
+
} catch (e) {
|
|
12105
|
+
return { error: String((e as Error)?.message ?? e) };
|
|
12106
|
+
}
|
|
12107
|
+
}
|
|
12108
|
+
|
|
12109
|
+
/** Unpacks a `writeMutationGateOutputOnRed` result into the `{outputPath, outputError}` shape the
|
|
12110
|
+
* pure engine (classifyBaseline / MutationObservation) consumes. */
|
|
12111
|
+
function splitMutationGateOutputWrite(
|
|
12112
|
+
result: MutationGateOutputWrite | undefined,
|
|
12113
|
+
): { outputPath?: string; outputError?: string } {
|
|
12114
|
+
if (result === undefined) return {};
|
|
12115
|
+
return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
|
|
12116
|
+
}
|
|
12117
|
+
|
|
11882
12118
|
function cmdMutationGate(
|
|
11883
12119
|
options: Map<string, string>,
|
|
11884
12120
|
flags: Set<string>,
|
|
@@ -11943,6 +12179,12 @@ function cmdMutationGate(
|
|
|
11943
12179
|
const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
|
|
11944
12180
|
if (/[\0\n\r]/.test(testCmdRaw)) return fail('--test-cmd may not contain NUL or newline characters');
|
|
11945
12181
|
const testCmd = testCmdRaw;
|
|
12182
|
+
const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) =>
|
|
12183
|
+
entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)),
|
|
12184
|
+
);
|
|
12185
|
+
if (!json) {
|
|
12186
|
+
write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
|
|
12187
|
+
}
|
|
11946
12188
|
|
|
11947
12189
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
11948
12190
|
const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
@@ -12021,11 +12263,20 @@ function cmdMutationGate(
|
|
|
12021
12263
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
12022
12264
|
|
|
12023
12265
|
type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
|
|
12024
|
-
const invokeSuite = (
|
|
12266
|
+
const invokeSuite = (
|
|
12267
|
+
suiteCommand: string,
|
|
12268
|
+
phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
|
|
12269
|
+
entryId?: string,
|
|
12270
|
+
): MutationGateRunnerObservation => {
|
|
12025
12271
|
if (injectedRunner !== undefined) {
|
|
12026
|
-
return injectedRunner(
|
|
12272
|
+
return injectedRunner(suiteCommand, {
|
|
12273
|
+
cwd: copyDir,
|
|
12274
|
+
timeoutMs: timeout,
|
|
12275
|
+
phase,
|
|
12276
|
+
...(entryId !== undefined ? { entryId } : {}),
|
|
12277
|
+
});
|
|
12027
12278
|
}
|
|
12028
|
-
const run = spawnSync(
|
|
12279
|
+
const run = spawnSync(suiteCommand, {
|
|
12029
12280
|
cwd: copyDir,
|
|
12030
12281
|
shell: true,
|
|
12031
12282
|
encoding: 'utf-8',
|
|
@@ -12061,8 +12312,9 @@ function cmdMutationGate(
|
|
|
12061
12312
|
const runSuite = (
|
|
12062
12313
|
phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
|
|
12063
12314
|
entryId?: string,
|
|
12315
|
+
suiteCommand = testCmd,
|
|
12064
12316
|
): SuiteRun => {
|
|
12065
|
-
const retried = runWithOneInternalRetry(invokeSuite);
|
|
12317
|
+
const retried = runWithOneInternalRetry(() => invokeSuite(suiteCommand, phase, entryId));
|
|
12066
12318
|
const loggedAttempts = retried.attempts.map((attempt) => {
|
|
12067
12319
|
if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
|
|
12068
12320
|
const outcome = retried.value.exitCode === null
|
|
@@ -12098,12 +12350,16 @@ function cmdMutationGate(
|
|
|
12098
12350
|
// result would be this gate shipping the defect class it exists to catch.
|
|
12099
12351
|
if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
12100
12352
|
const base = runSuite('baseline');
|
|
12353
|
+
const { outputPath: baseOutputPath, outputError: baseOutputError } =
|
|
12354
|
+
splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'baseline', base.exitCode, base.output));
|
|
12101
12355
|
baseline = classifyBaseline(
|
|
12102
12356
|
base.exitCode,
|
|
12103
12357
|
base.failureReason,
|
|
12104
12358
|
base.exitCode !== null && base.exitCode !== 0
|
|
12105
12359
|
? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
|
|
12106
12360
|
: undefined,
|
|
12361
|
+
baseOutputPath,
|
|
12362
|
+
baseOutputError,
|
|
12107
12363
|
);
|
|
12108
12364
|
if (!baseline.ok) {
|
|
12109
12365
|
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
|
|
@@ -12165,7 +12421,7 @@ function cmdMutationGate(
|
|
|
12165
12421
|
if (check.error !== undefined) {
|
|
12166
12422
|
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
12167
12423
|
} else if (parseInternalFailureReason === undefined) {
|
|
12168
|
-
run = runSuite('mutation', entry.id);
|
|
12424
|
+
run = runSuite('mutation', entry.id, buildMutationTestCommand(testCmd, entry).testCommand);
|
|
12169
12425
|
}
|
|
12170
12426
|
} finally {
|
|
12171
12427
|
writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
|
|
@@ -12200,6 +12456,9 @@ function cmdMutationGate(
|
|
|
12200
12456
|
let rebaselineExitCode: number | null | undefined;
|
|
12201
12457
|
let rebaselineFailureReason: string | undefined;
|
|
12202
12458
|
let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
|
|
12459
|
+
let rebaselineOutputTail: string | undefined;
|
|
12460
|
+
let rebaselineOutputPath: string | undefined;
|
|
12461
|
+
let rebaselineOutputError: string | undefined;
|
|
12203
12462
|
let rebaselineInternalAttemptLog: string | undefined;
|
|
12204
12463
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
12205
12464
|
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
@@ -12208,11 +12467,16 @@ function cmdMutationGate(
|
|
|
12208
12467
|
rebaselineExitCode = rebaselineRun.exitCode;
|
|
12209
12468
|
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
12210
12469
|
rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
|
|
12211
|
-
if (rebaselineRun.exitCode !==
|
|
12212
|
-
|
|
12213
|
-
|
|
12214
|
-
|
|
12215
|
-
)
|
|
12470
|
+
if (rebaselineRun.exitCode !== 0) {
|
|
12471
|
+
rebaselineOutputTail = boundedMutationGateOutputTail(rebaselineRun.output);
|
|
12472
|
+
({ outputPath: rebaselineOutputPath, outputError: rebaselineOutputError } =
|
|
12473
|
+
splitMutationGateOutputWrite(writeMutationGateOutputOnRed(entry.id, 'rebaseline', rebaselineRun.exitCode, rebaselineRun.output)));
|
|
12474
|
+
if (rebaselineRun.exitCode !== null) {
|
|
12475
|
+
rebaselineAttribution = attributeBaselineRedness(
|
|
12476
|
+
rebaselineRun.output,
|
|
12477
|
+
entries.map((candidate) => candidate.file),
|
|
12478
|
+
);
|
|
12479
|
+
}
|
|
12216
12480
|
}
|
|
12217
12481
|
}
|
|
12218
12482
|
const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
|
|
@@ -12233,6 +12497,9 @@ function cmdMutationGate(
|
|
|
12233
12497
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
12234
12498
|
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
12235
12499
|
...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
|
|
12500
|
+
...(rebaselineOutputTail !== undefined ? { rebaselineOutputTail } : {}),
|
|
12501
|
+
...(rebaselineOutputPath !== undefined ? { outputPath: rebaselineOutputPath } : {}),
|
|
12502
|
+
...(rebaselineOutputError !== undefined ? { outputError: rebaselineOutputError } : {}),
|
|
12236
12503
|
};
|
|
12237
12504
|
observations.push(obs);
|
|
12238
12505
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -12248,6 +12515,9 @@ function cmdMutationGate(
|
|
|
12248
12515
|
const finalRun = runSuite('final-rebaseline');
|
|
12249
12516
|
const finalExit = finalRun.exitCode;
|
|
12250
12517
|
if (finalExit !== 0) {
|
|
12518
|
+
const finalOutputTail = boundedMutationGateOutputTail(finalRun.output);
|
|
12519
|
+
const { outputPath: finalOutputPath, outputError: finalOutputError } =
|
|
12520
|
+
splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'final-rebaseline', finalExit, finalRun.output));
|
|
12251
12521
|
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
12252
12522
|
warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
|
|
12253
12523
|
if (!json) write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
|
|
@@ -12261,6 +12531,9 @@ function cmdMutationGate(
|
|
|
12261
12531
|
...(finalExit !== null && finalExit !== 0
|
|
12262
12532
|
? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
|
|
12263
12533
|
: {}),
|
|
12534
|
+
...(finalOutputTail !== undefined ? { rebaselineOutputTail: finalOutputTail } : {}),
|
|
12535
|
+
...(finalOutputPath !== undefined ? { outputPath: finalOutputPath } : {}),
|
|
12536
|
+
...(finalOutputError !== undefined ? { outputError: finalOutputError } : {}),
|
|
12264
12537
|
}));
|
|
12265
12538
|
results.length = 0;
|
|
12266
12539
|
results.push(...reclassified);
|
|
@@ -14222,6 +14495,928 @@ function cmdRunsRecord(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
14222
14495
|
}
|
|
14223
14496
|
}
|
|
14224
14497
|
|
|
14498
|
+
const ROUND_LEDGER_REL = join('.dz', 'feature-adr', 'run-cost-ledger.jsonl');
|
|
14499
|
+
|
|
14500
|
+
/**
|
|
14501
|
+
* round-state-root FR-1/FR-2: where `dz round` state (and its ledger, FR-4) lives — flag beats env
|
|
14502
|
+
* beats cwd. `--project` is untouched by this and stays recall-only (lesson 2ac30a70). Only an
|
|
14503
|
+
* EXPLICIT flag/env value is validated for absoluteness; the cwd fallback is `resolve(cwd)`, exactly
|
|
14504
|
+
* what every subcommand used before this feature (NFR-1: byte-identical when neither is set).
|
|
14505
|
+
*/
|
|
14506
|
+
function resolveRoundStateRoot(
|
|
14507
|
+
options: Map<string, string>,
|
|
14508
|
+
env: NodeJS.ProcessEnv,
|
|
14509
|
+
cwd: string,
|
|
14510
|
+
): { readonly ok: true; readonly root: string; readonly source: 'flag' | 'env' | 'cwd' }
|
|
14511
|
+
| { readonly ok: false; readonly reason: string } {
|
|
14512
|
+
const flagRaw = options.get('state-root');
|
|
14513
|
+
if (flagRaw !== undefined) {
|
|
14514
|
+
if (!isAbsolute(flagRaw)) return { ok: false, reason: `--state-root должен быть абсолютным путём: ${flagRaw}` };
|
|
14515
|
+
return { ok: true, root: flagRaw, source: 'flag' };
|
|
14516
|
+
}
|
|
14517
|
+
const envRaw = env['DZ_ROUND_STATE_ROOT'];
|
|
14518
|
+
if (envRaw !== undefined) {
|
|
14519
|
+
// A variable that is SET but blank is a misconfiguration, not an absence: falling back to cwd
|
|
14520
|
+
// here would be exactly the stray-write this flag exists to prevent (Codex review, 2026-09-13).
|
|
14521
|
+
if (envRaw.trim() === '') return { ok: false, reason: 'DZ_ROUND_STATE_ROOT задана, но пуста — укажите абсолютный путь или снимите переменную' };
|
|
14522
|
+
if (!isAbsolute(envRaw)) return { ok: false, reason: `DZ_ROUND_STATE_ROOT должен быть абсолютным путём: ${envRaw}` };
|
|
14523
|
+
return { ok: true, root: envRaw, source: 'env' };
|
|
14524
|
+
}
|
|
14525
|
+
return { ok: true, root: resolve(cwd), source: 'cwd' };
|
|
14526
|
+
}
|
|
14527
|
+
|
|
14528
|
+
function roundStatePath(root: string, slug: string, round: number): string {
|
|
14529
|
+
return join(root, '.dz', 'rounds', `${slug}-${round}.json`);
|
|
14530
|
+
}
|
|
14531
|
+
|
|
14532
|
+
/** round-state-lock T2: parses raw JSON text into a `RoundState`, shared by `readRoundState` (reads
|
|
14533
|
+
* from disk) and the AC-1 recheck-under-lock (compares a raw string captured before recall against
|
|
14534
|
+
* one read again inside the lock, so it needs to parse the SAME raw text twice without a third
|
|
14535
|
+
* disk read). */
|
|
14536
|
+
function parseRoundState(raw: string): RoundState | null {
|
|
14537
|
+
try {
|
|
14538
|
+
const row = JSON.parse(raw) as Partial<RoundState>;
|
|
14539
|
+
if (typeof row.slug !== 'string' || !Number.isInteger(row.round) || typeof row.topic !== 'string'
|
|
14540
|
+
|| typeof row.startedAt !== 'string' || !Number.isInteger(row.pid) || !Array.isArray(row.recalled)
|
|
14541
|
+
|| row.recalled.some((id) => typeof id !== 'string')) return null;
|
|
14542
|
+
if (row.execs !== undefined && (!Array.isArray(row.execs) || row.execs.some((entry) =>
|
|
14543
|
+
typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string'
|
|
14544
|
+
|| (entry.exitCode !== null && !Number.isInteger(entry.exitCode))
|
|
14545
|
+
|| typeof entry.outcome !== 'string'
|
|
14546
|
+
|| (entry.tokens !== null && !Number.isInteger(entry.tokens))))) return null;
|
|
14547
|
+
return row as RoundState;
|
|
14548
|
+
} catch {
|
|
14549
|
+
return null;
|
|
14550
|
+
}
|
|
14551
|
+
}
|
|
14552
|
+
|
|
14553
|
+
function readRoundState(path: string): RoundState | null {
|
|
14554
|
+
try {
|
|
14555
|
+
return parseRoundState(readFileSync(path, 'utf8'));
|
|
14556
|
+
} catch {
|
|
14557
|
+
return null;
|
|
14558
|
+
}
|
|
14559
|
+
}
|
|
14560
|
+
|
|
14561
|
+
/** round-state-lock: the raw bytes at `path`, or `null` when absent/unreadable. Used to detect
|
|
14562
|
+
* whether the state file changed between a check made BEFORE the (long, unlocked) recall and one
|
|
14563
|
+
* made again INSIDE the round-state lock — a byte-identical read means nothing raced us. */
|
|
14564
|
+
function readRawRoundState(path: string): string | null {
|
|
14565
|
+
try {
|
|
14566
|
+
return readFileSync(path, 'utf8');
|
|
14567
|
+
} catch {
|
|
14568
|
+
return null;
|
|
14569
|
+
}
|
|
14570
|
+
}
|
|
14571
|
+
|
|
14572
|
+
/** round-state-lock fix-round AM-1: 16 random hex chars, minted once per `open`. */
|
|
14573
|
+
function generateRoundStateId(): string {
|
|
14574
|
+
return randomBytes(8).toString('hex');
|
|
14575
|
+
}
|
|
14576
|
+
|
|
14577
|
+
/** Refusal shape shared by `exec`'s claim AND restore sections (AM-1): the state this section
|
|
14578
|
+
* expected to still be there — identified by `expectedStateId`, not by pid or by "did the file
|
|
14579
|
+
* change" — is either gone (`'gone'`) or has been replaced by something with a DIFFERENT identity
|
|
14580
|
+
* (`'replaced'`). Both cases leave the file untouched: writing over either would be exactly the
|
|
14581
|
+
* lost-update/resurrection bug this fix-round exists to close. */
|
|
14582
|
+
type RoundStateGone = { readonly refused: 'gone' };
|
|
14583
|
+
type RoundStateReplaced = { readonly refused: 'replaced'; readonly stateId: string | undefined; readonly execClaimId?: string | undefined };
|
|
14584
|
+
|
|
14585
|
+
/** Lead edit after Codex re-review: a LEGACY state (written before stateId existed) must not be
|
|
14586
|
+
* matched by `undefined === undefined` — under the lock, the first exec/close that meets it mints
|
|
14587
|
+
* an id, writes it back, and continues with that id as the identity of THIS operation. */
|
|
14588
|
+
function ensureStateId(path: string, fresh: RoundState): RoundState {
|
|
14589
|
+
if (fresh.stateId !== undefined) return fresh;
|
|
14590
|
+
const minted = { ...fresh, stateId: randomBytes(8).toString('hex') };
|
|
14591
|
+
writeJsonAtomic(path, minted);
|
|
14592
|
+
return minted;
|
|
14593
|
+
}
|
|
14594
|
+
|
|
14595
|
+
function readStateOrRefuse(
|
|
14596
|
+
path: string,
|
|
14597
|
+
expectedStateId: string | undefined,
|
|
14598
|
+
): RoundState | RoundStateGone | RoundStateReplaced {
|
|
14599
|
+
const fresh = readRoundState(path);
|
|
14600
|
+
if (fresh === null) return { refused: 'gone' };
|
|
14601
|
+
if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
|
|
14602
|
+
if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
|
|
14603
|
+
return fresh;
|
|
14604
|
+
}
|
|
14605
|
+
|
|
14606
|
+
/** round-state-lock fix-round AM-2: the same "gone vs replaced" shape as `readStateOrRefuse`, but
|
|
14607
|
+
* `close`'s missing-file case is NOT a failure — a round the ledger row was already witnessed for,
|
|
14608
|
+
* whose state file is already gone, is exactly `close`'s own success postcondition reached by a
|
|
14609
|
+
* different path (e.g. a prior invocation's delete step landed after this one read the ledger tail).
|
|
14610
|
+
* Kept as a separate type (not reused from `readStateOrRefuse`) because the two `refused` tags carry
|
|
14611
|
+
* different exit codes and messages — collapsing them would make a future edit to one silently reuse
|
|
14612
|
+
* the other's wording. */
|
|
14613
|
+
type RoundStateAlreadyClosed = { readonly refused: 'closed-already' };
|
|
14614
|
+
|
|
14615
|
+
function readStateForCloseOrRefuse(
|
|
14616
|
+
path: string,
|
|
14617
|
+
expectedStateId: string | undefined,
|
|
14618
|
+
): RoundState | RoundStateAlreadyClosed | RoundStateReplaced {
|
|
14619
|
+
const fresh = readRoundState(path);
|
|
14620
|
+
if (fresh === null) return { refused: 'closed-already' };
|
|
14621
|
+
if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
|
|
14622
|
+
if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
|
|
14623
|
+
return fresh;
|
|
14624
|
+
}
|
|
14625
|
+
|
|
14626
|
+
/** round-state-lock fix-round AM-4: the exact ledger-row marker `closeRound` (harness-core) will
|
|
14627
|
+
* compute for THIS close attempt, predicted from the same three inputs (slug, round, closedAt)
|
|
14628
|
+
* BEFORE calling it — so a retried `close` with the same injected `roundNow` (same `closedAt`) can
|
|
14629
|
+
* detect "the ledger already carries this attempt's row" and skip writing a duplicate. Mirrors
|
|
14630
|
+
* `closeRound`'s own marker formula in harness-core/src/round.ts exactly; a drift between the two
|
|
14631
|
+
* would only defeat the RETRY-dedup check (closeRound's own success postcondition, verified by
|
|
14632
|
+
* rereading the ledger tail, is unaffected either way). Deliberately NOT keyed on `stateId`: the
|
|
14633
|
+
* run-cost ledger row schema (`RoundLedgerRow`) has no such column, and adding one is out of this
|
|
14634
|
+
* fix's scope (round.ts stays untouched) — (slug, round, closedAt) is the identity already exposed
|
|
14635
|
+
* through the marker, and it is exactly as unique for a genuine retry (same close command, same
|
|
14636
|
+
* injected clock) as a `stateId` would be. */
|
|
14637
|
+
function predictedRoundCloseMarker(slug: string, round: number, closedAtIso: string): string {
|
|
14638
|
+
const closedMs = Date.parse(closedAtIso);
|
|
14639
|
+
const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
|
|
14640
|
+
return `round-${slug}-${round}-${compactTs}`;
|
|
14641
|
+
}
|
|
14642
|
+
|
|
14643
|
+
/** round-state-lock fix-round AM-5: `open`/`status` warn when a round has been sitting with
|
|
14644
|
+
* `ownerKind: 'exec'` for more than this many minutes — the shape of a restore-section that
|
|
14645
|
+
* exhausted its lock-busy retries (see `ROUND_RESTORE_LOCK_ATTEMPTS`) and left the round claimed by
|
|
14646
|
+
* an `exec` that already finished. There is no separate "since when has this been exec" timestamp on
|
|
14647
|
+
* `RoundState`, so this measures from `startedAt` (the round's own start) — a deliberate
|
|
14648
|
+
* approximation: an `exec` that ran briefly near round-open would read as "young" even if its
|
|
14649
|
+
* restore failed just now. Good enough to surface the stuck case at all; not a claim of precision. */
|
|
14650
|
+
const ROUND_EXEC_STALE_MINUTES = 10;
|
|
14651
|
+
|
|
14652
|
+
function roundExecStaleAgeMinutes(state: RoundState, now: number): number | null {
|
|
14653
|
+
if (state.ownerKind !== 'exec') return null;
|
|
14654
|
+
// Lead edit after Codex re-review: count from the exec claim, not from the round's own start —
|
|
14655
|
+
// a fresh exec inside an old round is not stuck. Legacy states without the field fall back.
|
|
14656
|
+
const claimedMs = Date.parse(state.execClaimedAt ?? state.startedAt);
|
|
14657
|
+
if (!Number.isFinite(claimedMs)) return null;
|
|
14658
|
+
const minutes = Math.floor((now - claimedMs) / 60_000);
|
|
14659
|
+
return minutes >= ROUND_EXEC_STALE_MINUTES ? minutes : null;
|
|
14660
|
+
}
|
|
14661
|
+
|
|
14662
|
+
function readRoundLedgerTail(root: string): string {
|
|
14663
|
+
try {
|
|
14664
|
+
const body = readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
|
|
14665
|
+
return body.slice(-64 * 1024);
|
|
14666
|
+
} catch {
|
|
14667
|
+
return '';
|
|
14668
|
+
}
|
|
14669
|
+
}
|
|
14670
|
+
|
|
14671
|
+
function readRoundLedger(root: string): string {
|
|
14672
|
+
try {
|
|
14673
|
+
return readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
|
|
14674
|
+
} catch {
|
|
14675
|
+
return '';
|
|
14676
|
+
}
|
|
14677
|
+
}
|
|
14678
|
+
|
|
14679
|
+
function roundRunOwnerAlive(
|
|
14680
|
+
root: string,
|
|
14681
|
+
runId: string,
|
|
14682
|
+
now: number,
|
|
14683
|
+
registryReader?: (projectRoot: string) => string,
|
|
14684
|
+
pidProbe: (pid: number) => boolean | null = probePid,
|
|
14685
|
+
): boolean | null {
|
|
14686
|
+
const registry = readRunRegistry(root, registryReader === undefined
|
|
14687
|
+
? runRegistryIO
|
|
14688
|
+
: { ...runRegistryIO, read: () => registryReader(root) });
|
|
14689
|
+
if (registry.status !== 'readable') return null;
|
|
14690
|
+
const decision = liveness(registry.runs.find((run) => run.runId === runId), now, pidProbe);
|
|
14691
|
+
return decision.state === 'live' || decision.state === 'stalled' ? true : decision.state === 'orphaned' ? false : null;
|
|
14692
|
+
}
|
|
14693
|
+
|
|
14694
|
+
function nextRoundNumber(ledger: string, slug: string): number {
|
|
14695
|
+
let count = 0;
|
|
14696
|
+
for (const line of ledger.split('\n')) {
|
|
14697
|
+
try {
|
|
14698
|
+
const row = JSON.parse(line) as { slug?: unknown; stage?: unknown };
|
|
14699
|
+
if (row.slug === slug && row.stage === 'round') count++;
|
|
14700
|
+
} catch { /* malformed and torn rows are not completed rounds */ }
|
|
14701
|
+
}
|
|
14702
|
+
return count + 1;
|
|
14703
|
+
}
|
|
14704
|
+
|
|
14705
|
+
type RoundSpawnReceipt = {
|
|
14706
|
+
readonly exitCode: number | null;
|
|
14707
|
+
readonly timedOut: boolean;
|
|
14708
|
+
readonly signal: NodeJS.Signals | null;
|
|
14709
|
+
readonly errorCode?: string;
|
|
14710
|
+
readonly error?: string;
|
|
14711
|
+
};
|
|
14712
|
+
|
|
14713
|
+
export async function spawnRoundCodex(request: {
|
|
14714
|
+
readonly command: string;
|
|
14715
|
+
readonly args: readonly string[];
|
|
14716
|
+
readonly cwd: string;
|
|
14717
|
+
readonly logPath: string;
|
|
14718
|
+
readonly timeoutMs: number;
|
|
14719
|
+
readonly killGraceMs?: number;
|
|
14720
|
+
}): Promise<RoundSpawnReceipt> {
|
|
14721
|
+
mkdirSync(dirname(request.logPath), { recursive: true });
|
|
14722
|
+
const logFd = openSync(request.logPath, 'w');
|
|
14723
|
+
return await new Promise<RoundSpawnReceipt>((resolveRun) => {
|
|
14724
|
+
let settled = false;
|
|
14725
|
+
let timedOut = false;
|
|
14726
|
+
let escalation: NodeJS.Timeout | undefined;
|
|
14727
|
+
let child: ChildProcess | undefined;
|
|
14728
|
+
const finish = (receipt: Omit<RoundSpawnReceipt, 'timedOut'>): void => {
|
|
14729
|
+
if (settled) return;
|
|
14730
|
+
settled = true;
|
|
14731
|
+
clearTimeout(deadline);
|
|
14732
|
+
if (escalation !== undefined) clearTimeout(escalation);
|
|
14733
|
+
try { closeSync(logFd); } catch { /* the subprocess receipt remains authoritative */ }
|
|
14734
|
+
resolveRun({ ...receipt, timedOut });
|
|
14735
|
+
};
|
|
14736
|
+
const deadline = setTimeout(() => {
|
|
14737
|
+
timedOut = true;
|
|
14738
|
+
try { child?.kill('SIGTERM'); } catch { /* SIGKILL below is the bounded fallback */ }
|
|
14739
|
+
escalation = setTimeout(() => {
|
|
14740
|
+
try { child?.kill('SIGKILL'); } catch { /* close/error decides the receipt */ }
|
|
14741
|
+
}, request.killGraceMs ?? 10_000);
|
|
14742
|
+
}, request.timeoutMs);
|
|
14743
|
+
try {
|
|
14744
|
+
child = spawn(request.command, [...request.args], {
|
|
14745
|
+
cwd: request.cwd,
|
|
14746
|
+
stdio: ['ignore', logFd, logFd],
|
|
14747
|
+
});
|
|
14748
|
+
} catch (error) {
|
|
14749
|
+
const err = error as NodeJS.ErrnoException;
|
|
14750
|
+
finish({ exitCode: null, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message });
|
|
14751
|
+
return;
|
|
14752
|
+
}
|
|
14753
|
+
child.on('error', (error: NodeJS.ErrnoException) => {
|
|
14754
|
+
finish({ exitCode: null, signal: null, ...(error.code === undefined ? {} : { errorCode: error.code }), error: error.message });
|
|
14755
|
+
});
|
|
14756
|
+
child.on('close', (code, signal) => finish({ exitCode: code, signal }));
|
|
14757
|
+
});
|
|
14758
|
+
}
|
|
14759
|
+
|
|
14760
|
+
function roundExecReceiptFound(tail: string, expected: RoundExecLedgerRow): boolean {
|
|
14761
|
+
for (const line of tail.split('\n')) {
|
|
14762
|
+
try {
|
|
14763
|
+
const row = JSON.parse(line) as Partial<RoundExecLedgerRow>;
|
|
14764
|
+
if (row.stage === 'round-exec' && row.slug === expected.slug && row.round === expected.round
|
|
14765
|
+
&& row.startedAt === expected.startedAt && row.endedAt === expected.endedAt
|
|
14766
|
+
&& row.outcome === expected.outcome && row.exitCode === expected.exitCode) return true;
|
|
14767
|
+
} catch { /* a torn or unrelated line is not this receipt */ }
|
|
14768
|
+
}
|
|
14769
|
+
return false;
|
|
14770
|
+
}
|
|
14771
|
+
|
|
14772
|
+
/** Refusal shape returned by {@link withRoundStateLock} in place of throwing, so every `dz round`
|
|
14773
|
+
* mutation observes the SAME lock-busy contract (FR-4): a `NamedLockTimeoutError` becomes `exit 1`,
|
|
14774
|
+
* a `lock busy: …` message, and a `{ refused: 'lock-busy' }` JSON field — never a bare stack trace,
|
|
14775
|
+
* and never a silent fall-through that would let a caller mistake absence-of-error for success. */
|
|
14776
|
+
type RoundLockBusy = { readonly refused: 'lock-busy'; readonly reason: string };
|
|
14777
|
+
|
|
14778
|
+
/**
|
|
14779
|
+
* round-state-lock T1 — the one named lock every `.dz/rounds/*.json` mutation goes through
|
|
14780
|
+
* (`<stateRoot>/.dz/locks/round-state.lock`, `withNamedLockSync` from `@dzhechkov/harness-core`).
|
|
14781
|
+
*
|
|
14782
|
+
* `fn` MUST be short and synchronous (the same caveat `withNamedLockSync` itself carries): it may
|
|
14783
|
+
* reread state and write it, never spawn a subprocess or await anything — the recall step and the
|
|
14784
|
+
* ledger write stay OUTSIDE the lock by design (teach:0ea46034), and the long-running `codex exec`
|
|
14785
|
+
* child in `round exec` runs between two separate short lock holds, not inside one.
|
|
14786
|
+
*
|
|
14787
|
+
* `io.roundLockTimeoutMs` (NFR-2) lets tests force a small deadline instead of the real default;
|
|
14788
|
+
* omitting it keeps production behaviour (and every existing test) byte-identical.
|
|
14789
|
+
*/
|
|
14790
|
+
function withRoundStateLock<T>(stateRoot: string, fn: () => T, io: CliIo): T | RoundLockBusy {
|
|
14791
|
+
try {
|
|
14792
|
+
return withNamedLockSync(
|
|
14793
|
+
stateRoot,
|
|
14794
|
+
'round-state',
|
|
14795
|
+
fn,
|
|
14796
|
+
io.roundLockTimeoutMs === undefined ? {} : { timeoutMs: io.roundLockTimeoutMs },
|
|
14797
|
+
);
|
|
14798
|
+
} catch (error) {
|
|
14799
|
+
if (error instanceof NamedLockTimeoutError) {
|
|
14800
|
+
return { refused: 'lock-busy', reason: error.message };
|
|
14801
|
+
}
|
|
14802
|
+
throw error;
|
|
14803
|
+
}
|
|
14804
|
+
}
|
|
14805
|
+
|
|
14806
|
+
/** round-state-lock fix-round AM-5: the restore-section retry budget — `exec`'s SECOND lock hold
|
|
14807
|
+
* (returning ownership after the codex child exits) tries up to this many times, with the SAME
|
|
14808
|
+
* per-attempt timeout, before it gives up and leaves the round `ownerKind: 'exec'` for a human to
|
|
14809
|
+
* notice (via the `open`/`status` staleness warning) rather than looping forever against a lock that
|
|
14810
|
+
* may never free up. */
|
|
14811
|
+
const ROUND_RESTORE_LOCK_ATTEMPTS = 4; // 1 attempt + 3 retries (AM-5; lead edit after re-review)
|
|
14812
|
+
|
|
14813
|
+
function withRoundStateLockRetried<T>(stateRoot: string, fn: () => T, io: CliIo, attempts: number): T | RoundLockBusy {
|
|
14814
|
+
let lastBusy: RoundLockBusy | null = null;
|
|
14815
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
14816
|
+
const result = withRoundStateLock(stateRoot, fn, io);
|
|
14817
|
+
if (!(typeof result === 'object' && result !== null && 'refused' in result && result.refused === 'lock-busy')) {
|
|
14818
|
+
return result;
|
|
14819
|
+
}
|
|
14820
|
+
lastBusy = result;
|
|
14821
|
+
}
|
|
14822
|
+
return lastBusy!;
|
|
14823
|
+
}
|
|
14824
|
+
|
|
14825
|
+
async function cmdRound(
|
|
14826
|
+
options: Map<string, string>,
|
|
14827
|
+
optionLists: Map<string, string[]>,
|
|
14828
|
+
flags: Set<string>,
|
|
14829
|
+
cwd: string,
|
|
14830
|
+
write: Write,
|
|
14831
|
+
io: CliIo,
|
|
14832
|
+
): Promise<number> {
|
|
14833
|
+
const sub = options.get('_positional_0') ?? '';
|
|
14834
|
+
const json = flags.has('json');
|
|
14835
|
+
const stateRootResolution = resolveRoundStateRoot(options, process.env, cwd);
|
|
14836
|
+
if (!stateRootResolution.ok) {
|
|
14837
|
+
write(json ? JSON.stringify({ message: stateRootResolution.reason }) : stateRootResolution.reason);
|
|
14838
|
+
return 2;
|
|
14839
|
+
}
|
|
14840
|
+
const stateRoot = stateRootResolution.root;
|
|
14841
|
+
const stateRootExplicit = stateRootResolution.source !== 'cwd';
|
|
14842
|
+
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
14843
|
+
const now = io.roundNow?.() ?? Date.now();
|
|
14844
|
+
const emit = (message: string, extra: Record<string, unknown> = {}): void => {
|
|
14845
|
+
write(json ? JSON.stringify({ message, ...extra }) : message);
|
|
14846
|
+
};
|
|
14847
|
+
const address = (roundOverride?: number): { slug: string; round: number } | null => {
|
|
14848
|
+
const slug = options.get('slug') ?? '';
|
|
14849
|
+
const round = roundOverride ?? Number(options.get('round'));
|
|
14850
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug) || !Number.isInteger(round) || round < 1) return null;
|
|
14851
|
+
return { slug, round };
|
|
14852
|
+
};
|
|
14853
|
+
|
|
14854
|
+
if (sub === 'open') {
|
|
14855
|
+
const slug = options.get('slug') ?? '';
|
|
14856
|
+
const roundRaw = options.get('round');
|
|
14857
|
+
const autoRound = roundRaw === 'auto'
|
|
14858
|
+
? nextRoundNumber(io.roundLedgerReader?.(stateRoot) ?? readRoundLedger(stateRoot), slug)
|
|
14859
|
+
: undefined;
|
|
14860
|
+
const at = address(autoRound);
|
|
14861
|
+
const topic = options.get('topic') ?? '';
|
|
14862
|
+
if (at === null || topic.trim() === '') {
|
|
14863
|
+
emit('нужны --slug --round --topic');
|
|
14864
|
+
return 2;
|
|
14865
|
+
}
|
|
14866
|
+
const ownerPidRaw = options.get('owner-pid');
|
|
14867
|
+
const ownerRunRaw = options.get('owner-run');
|
|
14868
|
+
if (ownerPidRaw !== undefined && ownerRunRaw !== undefined) {
|
|
14869
|
+
emit('--owner-pid и --owner-run взаимоисключающие');
|
|
14870
|
+
return 2;
|
|
14871
|
+
}
|
|
14872
|
+
if (ownerRunRaw !== undefined && ownerRunRaw.trim() === '') {
|
|
14873
|
+
emit('--owner-run пуст');
|
|
14874
|
+
return 2;
|
|
14875
|
+
}
|
|
14876
|
+
const ownerRun = ownerRunRaw?.trim();
|
|
14877
|
+
const ownerPid = ownerRunRaw !== undefined ? 0 : ownerPidRaw === undefined ? process.ppid : Number(ownerPidRaw);
|
|
14878
|
+
const ownerKind = ownerRunRaw !== undefined ? 'run' as const : ownerPidRaw === undefined ? 'parent' as const : 'explicit' as const;
|
|
14879
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
14880
|
+
// round-state-lock FR-3/AC-1: captured BEFORE the (long, unlocked) recall below, so the
|
|
14881
|
+
// recheck under the lock can tell "unchanged since this snapshot" from "a different process
|
|
14882
|
+
// opened it while we were recalling".
|
|
14883
|
+
const beforeRaw = readRawRoundState(path);
|
|
14884
|
+
const existing = beforeRaw === null
|
|
14885
|
+
? null
|
|
14886
|
+
: parseRoundState(beforeRaw) ?? {
|
|
14887
|
+
slug: at.slug, round: at.round, topic: '', startedAt: new Date(now).toISOString(),
|
|
14888
|
+
pid: 1, ownerKind: 'explicit', recalled: [],
|
|
14889
|
+
};
|
|
14890
|
+
let existingOwnerAlive: boolean | null = null;
|
|
14891
|
+
if (existing !== null && flags.has('force') && existing.ownerKind !== 'run') {
|
|
14892
|
+
try { existingOwnerAlive = (io.roundPidProbe ?? probePid)(existing.pid); } catch { /* unavailable is unknown and refuses */ }
|
|
14893
|
+
}
|
|
14894
|
+
const isRunAlive = (runId: string): boolean | null => roundRunOwnerAlive(
|
|
14895
|
+
stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
|
|
14896
|
+
);
|
|
14897
|
+
const runId = options.get('run')?.trim();
|
|
14898
|
+
const recallOptions = { limit: 5, ...(runId === undefined || runId === '' ? {} : { runId }) };
|
|
14899
|
+
const preflight = openRound({
|
|
14900
|
+
...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
|
|
14901
|
+
...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
|
|
14902
|
+
...(runId === undefined || runId === '' ? {} : { run: runId }), recalled: [], existing,
|
|
14903
|
+
force: flags.has('force'), existingOwnerAlive, isRunAlive,
|
|
14904
|
+
});
|
|
14905
|
+
if (!preflight.ok) {
|
|
14906
|
+
// AM-5: the round we are refusing to touch may itself be a stuck `exec` claim (its restore
|
|
14907
|
+
// section exhausted its lock-busy retries and left `ownerKind: 'exec'`) — name that out loud
|
|
14908
|
+
// rather than leaving the operator to guess why a pid that "shouldn't" be alive is blocking.
|
|
14909
|
+
const staleMinutes = existing === null ? null : roundExecStaleAgeMinutes(existing, now);
|
|
14910
|
+
const reason = staleMinutes === null
|
|
14911
|
+
? preflight.reason
|
|
14912
|
+
: `${preflight.reason} (владелец завис в exec ${staleMinutes} мин)`;
|
|
14913
|
+
emit(reason, { round: at.round, ...(staleMinutes === null ? {} : { staleExecMinutes: staleMinutes }) });
|
|
14914
|
+
return preflight.exit;
|
|
14915
|
+
}
|
|
14916
|
+
|
|
14917
|
+
let lessons: readonly { id: string; reward: number; domain: string; text: string }[] = [];
|
|
14918
|
+
try {
|
|
14919
|
+
lessons = io.roundRecall !== undefined
|
|
14920
|
+
? await io.roundRecall(projectRoot, topic, recallOptions)
|
|
14921
|
+
: (await recallHybrid(projectRoot, topic, recallOptions)).hits.slice(0, 5).map((hit) => ({
|
|
14922
|
+
id: patternRecordId(hit.pattern),
|
|
14923
|
+
reward: hit.pattern.reward,
|
|
14924
|
+
domain: hit.pattern.domain,
|
|
14925
|
+
text: hit.pattern.pattern,
|
|
14926
|
+
}));
|
|
14927
|
+
} catch {
|
|
14928
|
+
lessons = [];
|
|
14929
|
+
}
|
|
14930
|
+
const opened = openRound({
|
|
14931
|
+
...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
|
|
14932
|
+
...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
|
|
14933
|
+
...(runId === undefined || runId === '' ? {} : { run: runId }),
|
|
14934
|
+
recalled: lessons.slice(0, 5).map((lesson) => lesson.id), existing: null,
|
|
14935
|
+
force: false, existingOwnerAlive: null, isRunAlive,
|
|
14936
|
+
});
|
|
14937
|
+
if (!opened.ok) { emit(opened.reason); return opened.exit; }
|
|
14938
|
+
const openedState: RoundState = { ...opened.state, execs: [], stateId: generateRoundStateId() };
|
|
14939
|
+
let archived: string | undefined;
|
|
14940
|
+
try {
|
|
14941
|
+
const locked = withRoundStateLock(stateRoot, () => {
|
|
14942
|
+
// AM-3/AM-6: recall ran unlocked and may have taken a while — reread NOW, under the lock,
|
|
14943
|
+
// and decide fresh from what is ACTUALLY there rather than from the pre-recall snapshot.
|
|
14944
|
+
//
|
|
14945
|
+
// AM-3 (was: refuse only when the bytes changed AND the foreign pid differed from ours):
|
|
14946
|
+
// `ppid` coincides for two `dz` launched from the same shell, and every run-owned state
|
|
14947
|
+
// carries pid 0 — so "same pid" proved nothing about identity. ANY change in raw bytes since
|
|
14948
|
+
// `beforeRaw` is now the refusal trigger; the foreign pid is reported for diagnostics only,
|
|
14949
|
+
// never consulted for the decision.
|
|
14950
|
+
//
|
|
14951
|
+
// AM-6 (was: an unconditional `readFileSync(path)` while archiving threw a bare ENOENT if
|
|
14952
|
+
// the target vanished mid-recall): a state that is simply GONE now is not a race to refuse —
|
|
14953
|
+
// it is exactly the "no existing round" case, --force or not. Re-decide fresh: no file under
|
|
14954
|
+
// the lock ⇒ ordinary open, no archive, regardless of what `beforeRaw`/`existing` said.
|
|
14955
|
+
const nowRaw = readRawRoundState(path);
|
|
14956
|
+
if (nowRaw === beforeRaw) {
|
|
14957
|
+
// Unchanged since the pre-recall snapshot: proceed exactly as `preflight` planned —
|
|
14958
|
+
// including the --force archive-a-dead-owner flow, which is safe here because nothing
|
|
14959
|
+
// touched `existing`'s bytes while we were recalling.
|
|
14960
|
+
if (preflight.archiveExisting && existing !== null) {
|
|
14961
|
+
const compactStartedAt = new Date(existing.startedAt).toISOString().replace(/[-:.]/g, '');
|
|
14962
|
+
archived = join(stateRoot, '.dz', 'rounds', 'archive', `${at.slug}-${at.round}-${compactStartedAt}.json`);
|
|
14963
|
+
mkdirSync(dirname(archived), { recursive: true });
|
|
14964
|
+
writeFileSync(archived, readFileSync(path), { flag: 'wx' });
|
|
14965
|
+
}
|
|
14966
|
+
writeJsonAtomic(path, openedState);
|
|
14967
|
+
return { ok: true as const };
|
|
14968
|
+
}
|
|
14969
|
+
if (nowRaw === null) {
|
|
14970
|
+
// AM-6: vanished under us — nothing left to conflict with or to archive.
|
|
14971
|
+
writeJsonAtomic(path, openedState);
|
|
14972
|
+
return { ok: true as const };
|
|
14973
|
+
}
|
|
14974
|
+
// Something is there now, and it is byte-different from what we planned around: refuse.
|
|
14975
|
+
// The pid below is diagnostic only (AM-3) — it never gates the decision.
|
|
14976
|
+
const foreign = parseRoundState(nowRaw);
|
|
14977
|
+
return { refused: 'already-open' as const, pid: foreign?.pid ?? -1 };
|
|
14978
|
+
}, io);
|
|
14979
|
+
if ('refused' in locked) {
|
|
14980
|
+
if (locked.refused === 'lock-busy') {
|
|
14981
|
+
emit(`lock busy: ${locked.reason}`, { refused: 'lock-busy' });
|
|
14982
|
+
return 1;
|
|
14983
|
+
}
|
|
14984
|
+
emit(`круг уже открыт (pid ${locked.pid}) — состояние не перезаписано`, { refused: 'already-open', pid: locked.pid });
|
|
14985
|
+
return 1;
|
|
14986
|
+
}
|
|
14987
|
+
} catch (error) {
|
|
14988
|
+
emit(`круг не открыт: ${error instanceof Error ? error.message : String(error)}`);
|
|
14989
|
+
return 1;
|
|
14990
|
+
}
|
|
14991
|
+
const owner = openedState.ownerKind === 'run'
|
|
14992
|
+
? `владелец: run ${openedState.ownerRun} (run)`
|
|
14993
|
+
: `владелец: pid ${openedState.pid} (${openedState.ownerKind})`;
|
|
14994
|
+
if (json) {
|
|
14995
|
+
emit('круг открыт', { state: openedState, owner, stateRoot, lessons: lessons.slice(0, 5), ...(archived === undefined ? {} : { archived }) });
|
|
14996
|
+
} else {
|
|
14997
|
+
if (archived !== undefined) write(`архивировано: ${archived}`);
|
|
14998
|
+
write(`=== КРУГ ОТКРЫТ: ${at.slug} круг ${at.round}`);
|
|
14999
|
+
write(`state root: ${stateRoot}`);
|
|
15000
|
+
write(owner);
|
|
15001
|
+
write(`--- уроки для брифа (${lessons.slice(0, 5).length} поднято):`);
|
|
15002
|
+
for (const lesson of lessons.slice(0, 5)) {
|
|
15003
|
+
const oneLine = lesson.text.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
|
|
15004
|
+
write(` [${lesson.reward.toFixed(2)}] (${lesson.domain}) ${oneLine.slice(0, 160)}`);
|
|
15005
|
+
}
|
|
15006
|
+
}
|
|
15007
|
+
return 0;
|
|
15008
|
+
}
|
|
15009
|
+
|
|
15010
|
+
if (sub === 'exec') {
|
|
15011
|
+
const at = address();
|
|
15012
|
+
const briefArg = options.get('brief') ?? '';
|
|
15013
|
+
const timeoutRaw = options.get('timeout-min') ?? '30';
|
|
15014
|
+
const timeoutMinutes = Number(timeoutRaw);
|
|
15015
|
+
if (at === null || briefArg.trim() === '' || !Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
|
|
15016
|
+
emit('нужны --slug --round --brief; --timeout-min должен быть целым числом больше нуля');
|
|
15017
|
+
return 2;
|
|
15018
|
+
}
|
|
15019
|
+
const briefPath = resolve(cwd, briefArg);
|
|
15020
|
+
let briefText: string;
|
|
15021
|
+
try {
|
|
15022
|
+
briefText = readFileSync(briefPath, 'utf8');
|
|
15023
|
+
} catch {
|
|
15024
|
+
emit(`brief не читается: ${briefArg}`);
|
|
15025
|
+
return 2;
|
|
15026
|
+
}
|
|
15027
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
15028
|
+
let state = readRoundState(path);
|
|
15029
|
+
if (state === null) {
|
|
15030
|
+
emit(existsSync(path) ? 'состояние круга не читается' : 'круг не открыт');
|
|
15031
|
+
return 1;
|
|
15032
|
+
}
|
|
15033
|
+
|
|
15034
|
+
const model = options.get('model') ?? 'gpt-5.6-sol';
|
|
15035
|
+
const effort = options.get('effort') ?? 'high';
|
|
15036
|
+
const logArg = options.get('log') ?? join('.dz', 'rounds', `${at.slug}-${at.round}.exec.log`);
|
|
15037
|
+
const logPath = resolve(cwd, logArg);
|
|
15038
|
+
const startedMs = io.roundNow?.() ?? Date.now();
|
|
15039
|
+
const startedAt = new Date(startedMs).toISOString();
|
|
15040
|
+
const request = {
|
|
15041
|
+
command: 'codex' as const,
|
|
15042
|
+
args: [
|
|
15043
|
+
'exec',
|
|
15044
|
+
'-c', `model=${model}`,
|
|
15045
|
+
'-c', `model_reasoning_effort=${effort}`,
|
|
15046
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
15047
|
+
briefText,
|
|
15048
|
+
],
|
|
15049
|
+
cwd: stateRoot,
|
|
15050
|
+
logPath,
|
|
15051
|
+
timeoutMs: timeoutMinutes * 60_000,
|
|
15052
|
+
killGraceMs: io.roundKillGraceMs ?? 10_000,
|
|
15053
|
+
};
|
|
15054
|
+
let execClaimId = '';
|
|
15055
|
+
try {
|
|
15056
|
+
// T3/FR-1, fix-round AM-1: reread state under the lock immediately before claiming ownership
|
|
15057
|
+
// — a short, synchronous critical section, released before the (possibly long) codex child
|
|
15058
|
+
// below runs. NO fallback to the pre-lock `state` snapshot (that was the resurrection bug:
|
|
15059
|
+
// `readRoundState(path) ?? state!` would recreate a round that had been closed in the
|
|
15060
|
+
// meantime). The claim proceeds ONLY when the state currently under the lock still carries the
|
|
15061
|
+
// exact `stateId` we read before acquiring it — pid/ppid can coincide across processes, but a
|
|
15062
|
+
// `stateId` never does.
|
|
15063
|
+
execClaimId = randomBytes(8).toString('hex');
|
|
15064
|
+
const claimed = withRoundStateLock(stateRoot, () => {
|
|
15065
|
+
const outcome = readStateOrRefuse(path, state!.stateId);
|
|
15066
|
+
if ('refused' in outcome) return outcome;
|
|
15067
|
+
if (outcome.ownerKind === 'exec' && outcome.execClaimId !== undefined) {
|
|
15068
|
+
return { refused: 'exec-in-progress' as const, execClaimId: outcome.execClaimId };
|
|
15069
|
+
}
|
|
15070
|
+
writeJsonAtomic(path, { ...outcome, pid: io.roundPid ?? process.pid, ownerKind: 'exec', execClaimId, execClaimedAt: new Date(io.roundNow?.() ?? Date.now()).toISOString() });
|
|
15071
|
+
return { ok: true as const, base: outcome };
|
|
15072
|
+
}, io);
|
|
15073
|
+
if ('refused' in claimed) {
|
|
15074
|
+
if (claimed.refused === 'lock-busy') {
|
|
15075
|
+
emit(`exec не запущен: владелец круга не обновлён: lock busy: ${claimed.reason}`, { refused: 'lock-busy' });
|
|
15076
|
+
return 1;
|
|
15077
|
+
}
|
|
15078
|
+
if (claimed.refused === 'gone') {
|
|
15079
|
+
emit('exec не запущен: круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
|
|
15080
|
+
return 1;
|
|
15081
|
+
}
|
|
15082
|
+
if (claimed.refused === 'exec-in-progress') {
|
|
15083
|
+
emit(`exec не запущен: у круга уже идёт exec (claim ${claimed.execClaimId})`, { refused: 'exec-in-progress', execClaimId: claimed.execClaimId });
|
|
15084
|
+
return 1;
|
|
15085
|
+
}
|
|
15086
|
+
const replaced = claimed as RoundStateReplaced;
|
|
15087
|
+
emit(
|
|
15088
|
+
`exec не запущен: состояние круга заменено (stateId ${replaced.stateId ?? 'unknown'}), возврат владельца пропущен`,
|
|
15089
|
+
{ refused: 'replaced', stateId: replaced.stateId },
|
|
15090
|
+
);
|
|
15091
|
+
return 1;
|
|
15092
|
+
}
|
|
15093
|
+
state = claimed.base;
|
|
15094
|
+
} catch (error) {
|
|
15095
|
+
emit(`exec не запущен: владелец круга не обновлён: ${error instanceof Error ? error.message : String(error)}`);
|
|
15096
|
+
return 1;
|
|
15097
|
+
}
|
|
15098
|
+
let receipt: RoundSpawnReceipt;
|
|
15099
|
+
try {
|
|
15100
|
+
try {
|
|
15101
|
+
receipt = await (io.roundSpawn ?? spawnRoundCodex)(request);
|
|
15102
|
+
} catch (error) {
|
|
15103
|
+
const err = error as NodeJS.ErrnoException;
|
|
15104
|
+
receipt = { exitCode: null, timedOut: false, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message };
|
|
15105
|
+
}
|
|
15106
|
+
} finally {
|
|
15107
|
+
try {
|
|
15108
|
+
// T3/FR-1, fix-round AM-1/AM-5: the return leg — a second short lock hold, symmetric with
|
|
15109
|
+
// the claim above, and gated by the SAME stateId check (the child may have run long enough
|
|
15110
|
+
// for someone else to close or replace this round while it was running). AM-5: a busy lock
|
|
15111
|
+
// here gets up to ROUND_RESTORE_LOCK_ATTEMPTS tries with the same timeout before giving up —
|
|
15112
|
+
// a codex child can legitimately run for a while, so ownership recovery deserves more than
|
|
15113
|
+
// one attempt before leaving the round stuck at `ownerKind: 'exec'`.
|
|
15114
|
+
const restored = withRoundStateLockRetried(stateRoot, () => {
|
|
15115
|
+
const outcome = readStateOrRefuse(path, state!.stateId);
|
|
15116
|
+
if ('refused' in outcome) return outcome;
|
|
15117
|
+
// Lead edit after Codex re-review: restore only OUR claim — another exec of the same round
|
|
15118
|
+
// instance has its own execClaimId and must not be wiped by our base state.
|
|
15119
|
+
if (outcome.execClaimId !== execClaimId) {
|
|
15120
|
+
return { refused: 'replaced' as const, stateId: outcome.stateId, execClaimId: outcome.execClaimId };
|
|
15121
|
+
}
|
|
15122
|
+
writeJsonAtomic(path, state);
|
|
15123
|
+
return { ok: true as const };
|
|
15124
|
+
}, io, ROUND_RESTORE_LOCK_ATTEMPTS);
|
|
15125
|
+
if ('refused' in restored) {
|
|
15126
|
+
if (restored.refused === 'lock-busy') {
|
|
15127
|
+
// AM-5: no new flag or command is added — this names the manual remedy in prose (a
|
|
15128
|
+
// literal `--flag`-shaped token here would be caught by known-flags-drift.test.ts as an
|
|
15129
|
+
// undocumented flag, which would be exactly the wrong signal for text naming no flag at
|
|
15130
|
+
// all). The durable fix is that `open`/`status` surface the resulting stuck
|
|
15131
|
+
// `ownerKind: 'exec'` on their own (roundExecStaleAgeMinutes), so it is never silently
|
|
15132
|
+
// left for someone to trip over.
|
|
15133
|
+
emit(
|
|
15134
|
+
'владелец круга не восстановлен (ownerKind=exec остался): повторите dz round exec для этого круга, когда блокировка освободится',
|
|
15135
|
+
{ refused: 'lock-busy', ownerKind: 'exec' },
|
|
15136
|
+
);
|
|
15137
|
+
return 1;
|
|
15138
|
+
}
|
|
15139
|
+
if (restored.refused === 'gone') {
|
|
15140
|
+
emit('круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
|
|
15141
|
+
return 1;
|
|
15142
|
+
}
|
|
15143
|
+
emit(
|
|
15144
|
+
`состояние круга заменено (stateId ${restored.stateId ?? 'unknown'}), возврат владельца пропущен`,
|
|
15145
|
+
{ refused: 'replaced', stateId: restored.stateId },
|
|
15146
|
+
);
|
|
15147
|
+
return 1;
|
|
15148
|
+
}
|
|
15149
|
+
} catch (error) {
|
|
15150
|
+
emit(`exec завершён, но владелец круга не восстановлен: ${error instanceof Error ? error.message : String(error)}`);
|
|
15151
|
+
return 1;
|
|
15152
|
+
}
|
|
15153
|
+
}
|
|
15154
|
+
const endedMs = io.roundNow?.() ?? Date.now();
|
|
15155
|
+
const endedAt = new Date(endedMs).toISOString();
|
|
15156
|
+
let logBuffer = Buffer.alloc(0);
|
|
15157
|
+
try { logBuffer = readFileSync(logPath); } catch { /* no output is an empty receipt */ }
|
|
15158
|
+
const logText = logBuffer.toString('utf8');
|
|
15159
|
+
const bytes = logBuffer.byteLength;
|
|
15160
|
+
const tokens = parseCodexTokens(logText);
|
|
15161
|
+
const outcome = classifyRoundExecOutcome({
|
|
15162
|
+
exitCode: receipt.exitCode,
|
|
15163
|
+
timedOut: receipt.timedOut,
|
|
15164
|
+
bytes,
|
|
15165
|
+
tail: logBuffer.subarray(Math.max(0, bytes - 4096)).toString('utf8'),
|
|
15166
|
+
});
|
|
15167
|
+
const row = buildRoundExecRow({
|
|
15168
|
+
...at,
|
|
15169
|
+
model,
|
|
15170
|
+
effort,
|
|
15171
|
+
minutes: Math.max(0, Math.floor((endedMs - startedMs) / 60_000)),
|
|
15172
|
+
tokens,
|
|
15173
|
+
outcome,
|
|
15174
|
+
exitCode: receipt.exitCode,
|
|
15175
|
+
bytes,
|
|
15176
|
+
startedAt,
|
|
15177
|
+
endedAt,
|
|
15178
|
+
log: logArg,
|
|
15179
|
+
brief: briefArg,
|
|
15180
|
+
});
|
|
15181
|
+
if (io.roundLedgerWriter !== undefined) io.roundLedgerWriter(stateRoot, row);
|
|
15182
|
+
else cmdFeatureAdrRecord(new Map([
|
|
15183
|
+
['kind', 'ledger'], ['stage', 'round-exec'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
|
|
15184
|
+
]), new Set(), stateRoot, () => undefined);
|
|
15185
|
+
const ledgerTail = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
|
|
15186
|
+
if (!roundExecReceiptFound(ledgerTail, row)) {
|
|
15187
|
+
emit('строка round-exec не найдена — результат НЕ подтверждён');
|
|
15188
|
+
return 1;
|
|
15189
|
+
}
|
|
15190
|
+
try {
|
|
15191
|
+
writeJsonAtomic(path, {
|
|
15192
|
+
...state,
|
|
15193
|
+
execs: [...(state.execs ?? []), { startedAt, endedAt, exitCode: receipt.exitCode, outcome, tokens }],
|
|
15194
|
+
});
|
|
15195
|
+
} catch (error) {
|
|
15196
|
+
emit(`строка round-exec подтверждена, но состояние не обновлено: ${error instanceof Error ? error.message : String(error)}`);
|
|
15197
|
+
return 1;
|
|
15198
|
+
}
|
|
15199
|
+
if (receipt.errorCode === 'ENOENT') emit('codex не найден', { row });
|
|
15200
|
+
else emit(`round exec: ${row.minutes} min; exit ${row.exitCode ?? 'null'}; ${row.bytes} bytes; tokens ${row.tokens ?? 'не найдены'}; ${row.outcome}`, { row });
|
|
15201
|
+
return outcome === 'done' ? 0 : 1;
|
|
15202
|
+
}
|
|
15203
|
+
|
|
15204
|
+
if (sub === 'close') {
|
|
15205
|
+
const at = address();
|
|
15206
|
+
if (at === null || !options.has('outcome')) {
|
|
15207
|
+
emit('нужны --slug --round --outcome');
|
|
15208
|
+
return 2;
|
|
15209
|
+
}
|
|
15210
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
15211
|
+
const state = readRoundState(path);
|
|
15212
|
+
if (state === null) {
|
|
15213
|
+
emit(existsSync(path) ? 'состояние круга не читается — круг НЕ закрыт' : 'круг не открыт');
|
|
15214
|
+
return 1;
|
|
15215
|
+
}
|
|
15216
|
+
const lessons = optionLists.get('lesson') ?? [];
|
|
15217
|
+
const knownLessonIds = lessons.filter((id) => {
|
|
15218
|
+
try {
|
|
15219
|
+
return io.roundLessonExists !== undefined
|
|
15220
|
+
? io.roundLessonExists(projectRoot, id)
|
|
15221
|
+
: loadStoreRecords(projectRoot).some((record) => record.id === id);
|
|
15222
|
+
} catch { return false; }
|
|
15223
|
+
});
|
|
15224
|
+
const numeric = (key: string): number | undefined => options.has(key) ? Number(options.get(key)) : undefined;
|
|
15225
|
+
const closedAtIso = new Date(now).toISOString();
|
|
15226
|
+
// AM-4: predict the marker `closeRound` will compute for THIS attempt (same slug/round/closedAt
|
|
15227
|
+
// it will use) and check whether the ledger already carries it BEFORE calling `closeRound` —
|
|
15228
|
+
// this is what makes a retried `close` idempotent: if a prior invocation's write already landed
|
|
15229
|
+
// (this run's own tail read, not trusted from the earlier failed attempt's own belief), skip the
|
|
15230
|
+
// write below instead of appending a duplicate row.
|
|
15231
|
+
const predictedMarker = predictedRoundCloseMarker(at.slug, at.round, closedAtIso);
|
|
15232
|
+
const tailBeforeWrite = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
|
|
15233
|
+
// Lead edit after Codex re-review: a retried close carries a NEW clock, so the marker alone never
|
|
15234
|
+
// matches — the row's stateId (identity of the state instance) is what makes the retry idempotent.
|
|
15235
|
+
const alreadyRecorded = tailBeforeWrite.includes(predictedMarker)
|
|
15236
|
+
|| (state.stateId !== undefined && tailBeforeWrite.includes(`"stateId":"${state.stateId}"`));
|
|
15237
|
+
// Lead edit after Codex re-review: a retry whose row is already in the ledger (same stateId) must
|
|
15238
|
+
// not re-run closeRound's postcondition against a marker computed from the NEW clock — the earlier
|
|
15239
|
+
// row is the receipt; only the state-file removal remains.
|
|
15240
|
+
const closed = alreadyRecorded
|
|
15241
|
+
? { ok: true as const, row: undefined, marker: `already-recorded:${state.stateId ?? predictedMarker}` }
|
|
15242
|
+
: closeRound({
|
|
15243
|
+
state,
|
|
15244
|
+
outcome: options.get('outcome') ?? '',
|
|
15245
|
+
...(options.has('reason') ? { reason: options.get('reason') } : {}),
|
|
15246
|
+
lessons,
|
|
15247
|
+
knownLessonIds,
|
|
15248
|
+
...(options.has('no-new-knowledge') ? { noNewKnowledge: options.get('no-new-knowledge') } : {}),
|
|
15249
|
+
...(options.has('tokens') ? { tokens: numeric('tokens') } : {}),
|
|
15250
|
+
...(options.has('agents') ? { agents: numeric('agents') } : {}),
|
|
15251
|
+
...(options.has('coder') ? { coder: options.get('coder') } : {}),
|
|
15252
|
+
...(options.has('reviewer') ? { reviewer: options.get('reviewer') } : {}),
|
|
15253
|
+
...(options.has('note') ? { note: options.get('note') } : {}),
|
|
15254
|
+
...(flags.has('no-cost') ? { noCost: true } : {}),
|
|
15255
|
+
closedAt: closedAtIso,
|
|
15256
|
+
...(state.stateId !== undefined ? { stateId: state.stateId } : {}),
|
|
15257
|
+
}, {
|
|
15258
|
+
writeLedger: (row) => {
|
|
15259
|
+
// AM-4 idempotent retry: the row for this attempt was already witnessed in the tail read
|
|
15260
|
+
// above — do not append a second one. `closeRound`'s own postcondition (rereading the tail
|
|
15261
|
+
// and checking it contains the marker) still passes, because the marker is already there.
|
|
15262
|
+
if (alreadyRecorded) return undefined;
|
|
15263
|
+
if (io.roundLedgerWriter !== undefined) return io.roundLedgerWriter(stateRoot, row);
|
|
15264
|
+
return cmdFeatureAdrRecord(new Map([
|
|
15265
|
+
['kind', 'ledger'], ['stage', 'round'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
|
|
15266
|
+
]), new Set(), stateRoot, () => undefined);
|
|
15267
|
+
},
|
|
15268
|
+
readLedgerTail: () => io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot),
|
|
15269
|
+
});
|
|
15270
|
+
if (!closed.ok) { emit(closed.reason); return closed.exit; }
|
|
15271
|
+
try {
|
|
15272
|
+
// T4/FR-1/FR-2, fix-round AM-2: the ledger write above (via `closed`) stays OUTSIDE the lock
|
|
15273
|
+
// (teach:0ea46034); only the final reread-and-delete is a lock-guarded critical section, and it
|
|
15274
|
+
// now deletes ONLY the exact state instance the ledger row above was written for — identified
|
|
15275
|
+
// by `state.stateId`, read before the lock was ever taken.
|
|
15276
|
+
const deleted = withRoundStateLock(stateRoot, () => {
|
|
15277
|
+
const outcome = readStateForCloseOrRefuse(path, state.stateId);
|
|
15278
|
+
if ('refused' in outcome) return outcome;
|
|
15279
|
+
unlinkSync(path);
|
|
15280
|
+
return { ok: true as const };
|
|
15281
|
+
}, io);
|
|
15282
|
+
if ('refused' in deleted) {
|
|
15283
|
+
if (deleted.refused === 'lock-busy') {
|
|
15284
|
+
// AM-4: the ledger row is ALREADY written by the time this lock is even attempted (see
|
|
15285
|
+
// above) — so a busy lock here never leaves the outcome unrecorded, only the round's OWN
|
|
15286
|
+
// state file open. Say exactly that, and make the retry path explicit.
|
|
15287
|
+
emit(
|
|
15288
|
+
'строка леджера записана, состояние круга осталось открытым — повторите close',
|
|
15289
|
+
{ refused: 'lock-busy', ledgerWritten: true },
|
|
15290
|
+
);
|
|
15291
|
+
return 1;
|
|
15292
|
+
}
|
|
15293
|
+
if (deleted.refused === 'closed-already') {
|
|
15294
|
+
// AM-2: the state file is already gone — this close's own ledger row is written (above, or
|
|
15295
|
+
// by a previous invocation of this same idempotent attempt), so this is the same round
|
|
15296
|
+
// reaching its already-closed postcondition by a different path, not a failure.
|
|
15297
|
+
emit('круг уже закрыт (строка леджера записана)', { closed: true, alreadyClosed: true, marker: closed.marker });
|
|
15298
|
+
return 0;
|
|
15299
|
+
}
|
|
15300
|
+
// AM-2: something else's state sits at this path now (a different stateId) — never delete it.
|
|
15301
|
+
emit('состояние заменено, не удалено', { refused: 'replaced', stateId: deleted.stateId });
|
|
15302
|
+
return 1;
|
|
15303
|
+
}
|
|
15304
|
+
} catch (error) {
|
|
15305
|
+
emit(`строка подтверждена, но состояние не удалено — круг НЕ закрыт: ${error instanceof Error ? error.message : String(error)}`);
|
|
15306
|
+
return 1;
|
|
15307
|
+
}
|
|
15308
|
+
emit(`✓ строка круга в леджере подтверждена чтением (${closed.marker})`, { row: closed.row, marker: closed.marker });
|
|
15309
|
+
return 0;
|
|
15310
|
+
}
|
|
15311
|
+
|
|
15312
|
+
if (sub === 'status') {
|
|
15313
|
+
const rawThreshold = options.get('older-than') ?? '120';
|
|
15314
|
+
const olderThan = Number(rawThreshold);
|
|
15315
|
+
if (!Number.isInteger(olderThan) || olderThan < 0) {
|
|
15316
|
+
emit('--older-than должен быть целым числом минут не меньше нуля', { open: [] });
|
|
15317
|
+
return 0;
|
|
15318
|
+
}
|
|
15319
|
+
const dir = join(stateRoot, '.dz', 'rounds');
|
|
15320
|
+
const states: RoundState[] = [];
|
|
15321
|
+
try {
|
|
15322
|
+
for (const name of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
|
|
15323
|
+
const state = readRoundState(join(dir, name));
|
|
15324
|
+
if (state !== null) states.push(state);
|
|
15325
|
+
}
|
|
15326
|
+
} catch { /* no state directory is an honestly empty report */ }
|
|
15327
|
+
const rows = listRounds(states, {
|
|
15328
|
+
now,
|
|
15329
|
+
olderThanMinutes: olderThan,
|
|
15330
|
+
isPidAlive: io.roundPidProbe ?? probePid,
|
|
15331
|
+
isRunAlive: (runId) => roundRunOwnerAlive(
|
|
15332
|
+
stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
|
|
15333
|
+
),
|
|
15334
|
+
});
|
|
15335
|
+
// AM-5: independent of the `--older-than` filter above (a stuck exec claim is worth flagging at
|
|
15336
|
+
// 10 minutes regardless of the round's own age threshold) — computed over ALL open states, and
|
|
15337
|
+
// additive: when none apply, neither branch below emits anything extra, so the two byte-pinned
|
|
15338
|
+
// zero-rounds lines (NFR-1, see the comment below) stay untouched.
|
|
15339
|
+
const staleExec = states
|
|
15340
|
+
.map((state) => {
|
|
15341
|
+
const minutes = roundExecStaleAgeMinutes(state, now);
|
|
15342
|
+
return minutes === null ? null : { slug: state.slug, round: state.round, minutes };
|
|
15343
|
+
})
|
|
15344
|
+
.filter((warning): warning is { slug: string; round: number; minutes: number } => warning !== null);
|
|
15345
|
+
if (json) {
|
|
15346
|
+
emit(rows.length > 0 ? `⚠ ${rows.length} open round(s) older than ${olderThan} min` : 'нет старых открытых кругов', {
|
|
15347
|
+
stateRoot, olderThan, open: rows, ...(staleExec.length === 0 ? {} : { staleExec }),
|
|
15348
|
+
});
|
|
15349
|
+
} else {
|
|
15350
|
+
// FR-3 prints `state root: <dir>` on open unconditionally; here it is printed only when the
|
|
15351
|
+
// root was EXPLICITLY chosen (--state-root / DZ_ROUND_STATE_ROOT). Printing it unconditionally
|
|
15352
|
+
// would change the two default-cwd zero-rounds lines pinned exactly by
|
|
15353
|
+
// round-cli.test.ts ("status reports a fresh open round…" / "…no open rounds"), which NFR-1
|
|
15354
|
+
// requires to stay byte-identical and unmodified.
|
|
15355
|
+
if (stateRootExplicit) write(`state root: ${stateRoot}`);
|
|
15356
|
+
write(states.length === 0
|
|
15357
|
+
? 'открытых кругов нет'
|
|
15358
|
+
: `открытых кругов: ${states.length}, старше ${olderThan} мин: ${rows.length}`);
|
|
15359
|
+
for (const row of rows) {
|
|
15360
|
+
const live = row.pidAlive === true ? 'alive' : row.pidAlive === false ? 'dead' : 'unknown';
|
|
15361
|
+
write(`${row.state.slug}#${row.state.round} · ${row.ageMinutes} min · pid ${row.state.pid} ${live} · ${row.state.topic}`);
|
|
15362
|
+
}
|
|
15363
|
+
for (const warning of staleExec) {
|
|
15364
|
+
write(`⚠ ${warning.slug}#${warning.round}: владелец завис в exec ${warning.minutes} мин — восстановите вручную (dz round exec вернул lock-busy при возврате владельца)`);
|
|
15365
|
+
}
|
|
15366
|
+
}
|
|
15367
|
+
return 0;
|
|
15368
|
+
}
|
|
15369
|
+
|
|
15370
|
+
emit('использование: dz round open|exec|close|status');
|
|
15371
|
+
return 2;
|
|
15372
|
+
}
|
|
15373
|
+
|
|
15374
|
+
/**
|
|
15375
|
+
* ledger-stage-minutes T2: the `ts` of the LAST ledger row (scanning from the end, so a duplicate
|
|
15376
|
+
* or out-of-order runId still finds the truly latest one) that carries the given `runId`. Every
|
|
15377
|
+
* failure mode — the file does not exist yet, a permission error — returns `null` rather than
|
|
15378
|
+
* throwing: this is a BEST-EFFORT observability lookup feeding a non-blocking field (ADR-003), never
|
|
15379
|
+
* a gate the write must pass.
|
|
15380
|
+
*
|
|
15381
|
+
* fix-round-1/AM-n (cross-family review B, MEDIUM): a torn or non-object line — `ledger-corrupt-line`
|
|
15382
|
+
* — is NOT silently skipped past. The original code `continue`d over it and kept scanning further
|
|
15383
|
+
* back, which could return an OLDER valid row for this `runId` while a NEWER one for the same run
|
|
15384
|
+
* sat hidden on the other side of the corrupt line (or was itself the corrupt line). Once the scan
|
|
15385
|
+
* hits a line it cannot parse as a JSON object, it can no longer prove which row is truly LAST for
|
|
15386
|
+
* this run, so it stops and reports `null` (⇒ `minutesSource: 'unavailable'`) rather than risk an
|
|
15387
|
+
* UNDERSTATED delta computed against a stale row.
|
|
15388
|
+
*/
|
|
15389
|
+
function findPreviousLedgerRowTs(ledgerPath: string, runId: string): string | null {
|
|
15390
|
+
if (runId === '') return null;
|
|
15391
|
+
let body: string;
|
|
15392
|
+
try {
|
|
15393
|
+
body = readFileSync(ledgerPath, 'utf-8');
|
|
15394
|
+
} catch {
|
|
15395
|
+
return null;
|
|
15396
|
+
}
|
|
15397
|
+
const lines = body.split('\n').filter((l) => l !== '');
|
|
15398
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
15399
|
+
let parsed: unknown;
|
|
15400
|
+
try {
|
|
15401
|
+
parsed = JSON.parse(lines[i] as string);
|
|
15402
|
+
} catch {
|
|
15403
|
+
// ledger-corrupt-line: everything from here to the start of the file is unprovable — a real
|
|
15404
|
+
// match further back cannot be trusted to still be the LAST one, so this is `unavailable`,
|
|
15405
|
+
// never a guess made by skipping past what we could not read.
|
|
15406
|
+
return null;
|
|
15407
|
+
}
|
|
15408
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
15409
|
+
// Same reasoning as the parse failure above: a non-object line is exactly as untrustworthy.
|
|
15410
|
+
return null;
|
|
15411
|
+
}
|
|
15412
|
+
const row = parsed as Record<string, unknown>;
|
|
15413
|
+
if (typeof row['runId'] === 'string' && row['runId'].trim() === runId) {
|
|
15414
|
+
return typeof row['ts'] === 'string' && row['ts'].trim() !== '' ? row['ts'] : null;
|
|
15415
|
+
}
|
|
15416
|
+
}
|
|
15417
|
+
return null;
|
|
15418
|
+
}
|
|
15419
|
+
|
|
14225
15420
|
function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
14226
15421
|
const json = flags.has('json');
|
|
14227
15422
|
// `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
|
|
@@ -14260,10 +15455,73 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
|
|
|
14260
15455
|
const markName = (options.get('mark') ?? '').trim();
|
|
14261
15456
|
const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
|
|
14262
15457
|
|
|
15458
|
+
// ledger-stage-minutes T2/FR-2: `--run-id` fills the payload's `runId` ONLY WHEN the payload does
|
|
15459
|
+
// not already carry one — the same gap-only stamping discipline `decideRecordWrite` already uses
|
|
15460
|
+
// for `runnerId`. "Absent" is deliberately wider than "missing key": `runId: null`, `runId: ''`
|
|
15461
|
+
// and a non-string `runId` (a number, an object — never a real join key) are ALL gaps too, exactly
|
|
15462
|
+
// the `isRunnerGap` rule one seam over — fixed-round-1/AM-n confirmed this is the INTENDED contract
|
|
15463
|
+
// ("missing when absent or blank"), not a bug: only a genuine non-empty string counts as "the
|
|
15464
|
+
// caller already knew it", so any of those gap shapes are correctly overwritten by the flag. A
|
|
15465
|
+
// malformed --row is left untouched here: decideRecordWrite reports the real JSON parse error,
|
|
15466
|
+
// this merge step must never invent a different one.
|
|
15467
|
+
const isRunIdArgGap = (v: unknown): boolean => v === null || v === undefined || typeof v !== 'string' || v.trim() === '';
|
|
15468
|
+
let effectivePayloadRaw = payloadRaw;
|
|
15469
|
+
const explicitRunId = (options.get('run-id') ?? '').trim();
|
|
15470
|
+
if (kind === 'ledger' && explicitRunId !== '') {
|
|
15471
|
+
try {
|
|
15472
|
+
const parsed: unknown = JSON.parse(payloadRaw);
|
|
15473
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15474
|
+
const rowObj = parsed as Record<string, unknown>;
|
|
15475
|
+
if (isRunIdArgGap(rowObj['runId'])) {
|
|
15476
|
+
// fix-round-1/AM-n (cross-family review B, MEDIUM): the flag-filled runId now carries its
|
|
15477
|
+
// provenance, the same discipline `resolved-at-write` already applies to the OTHER runId
|
|
15478
|
+
// source (write-time auto-resolution below) — an un-sourced runId looked exactly like one
|
|
15479
|
+
// the caller supplied. A non-empty `runIdSource` the payload already carries (an odd shape,
|
|
15480
|
+
// since `runId` itself was a gap) is left alone rather than overwritten with a guess.
|
|
15481
|
+
const hasRunIdSource = typeof rowObj['runIdSource'] === 'string' && rowObj['runIdSource'].trim() !== '';
|
|
15482
|
+
effectivePayloadRaw = JSON.stringify({
|
|
15483
|
+
...rowObj,
|
|
15484
|
+
runId: explicitRunId,
|
|
15485
|
+
...(hasRunIdSource ? {} : { runIdSource: 'cli-flag' }),
|
|
15486
|
+
});
|
|
15487
|
+
}
|
|
15488
|
+
}
|
|
15489
|
+
} catch { /* decideRecordWrite reports the parse error itself */ }
|
|
15490
|
+
}
|
|
15491
|
+
|
|
15492
|
+
// FR-2/FR-3: find the runId this row will carry (explicit flag, or one the payload already had),
|
|
15493
|
+
// then read the ledger BEST-EFFORT for the last row of that same run and its `ts`. A read failure
|
|
15494
|
+
// (file absent, unreadable, a torn or malformed line) is an honest `previousRowTs: null` — never
|
|
15495
|
+
// a thrown error, because a record write must never fail on an OBSERVABILITY lookup (ADR-003).
|
|
15496
|
+
let runIdForLookup = '';
|
|
15497
|
+
try {
|
|
15498
|
+
const parsed: unknown = JSON.parse(effectivePayloadRaw);
|
|
15499
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15500
|
+
const v = (parsed as Record<string, unknown>)['runId'];
|
|
15501
|
+
if (typeof v === 'string' && v.trim() !== '') runIdForLookup = v.trim();
|
|
15502
|
+
}
|
|
15503
|
+
} catch { /* decideRecordWrite reports the parse error itself */ }
|
|
15504
|
+
// Lead edit after re-review (Codex B): the pipeline's own rows have no runId in the payload — it is
|
|
15505
|
+
// resolved at write time below. Resolve it HERE as well (same resolver, same registry) so the
|
|
15506
|
+
// previous-row lookup and the minutes delta cover the main path, not only explicit ids.
|
|
15507
|
+
let resolvedRunIdPre: string | null = null;
|
|
15508
|
+
if (kind === 'ledger' && runIdForLookup === '') {
|
|
15509
|
+
try {
|
|
15510
|
+
const parsed: unknown = JSON.parse(effectivePayloadRaw);
|
|
15511
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15512
|
+
resolvedRunIdPre = resolveLedgerRunId(parsed as Record<string, unknown>, listCostLedgerRuns());
|
|
15513
|
+
if (resolvedRunIdPre !== null) runIdForLookup = resolvedRunIdPre.trim();
|
|
15514
|
+
}
|
|
15515
|
+
} catch { /* resolution is an ENRICHMENT; the row is written regardless */ }
|
|
15516
|
+
}
|
|
15517
|
+
const previousRowTs = kind === 'ledger' && runIdForLookup !== '' ? findPreviousLedgerRowTs(target, runIdForLookup) : null;
|
|
15518
|
+
|
|
14263
15519
|
const decision = decideRecordWrite({
|
|
14264
15520
|
kind,
|
|
14265
|
-
payloadRaw,
|
|
15521
|
+
payloadRaw: effectivePayloadRaw,
|
|
14266
15522
|
stage,
|
|
15523
|
+
previousRowTs,
|
|
15524
|
+
effectiveRunId: runIdForLookup !== '' ? runIdForLookup : null,
|
|
14267
15525
|
stageProducedResult: flags.has('no-result') ? false : true,
|
|
14268
15526
|
markExists: markPath !== null && existsSync(markPath),
|
|
14269
15527
|
targetExists: existsSync(target),
|
|
@@ -14317,10 +15575,23 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
|
|
|
14317
15575
|
const parsed: unknown = JSON.parse(decision.line);
|
|
14318
15576
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
14319
15577
|
const rowObj = parsed as Record<string, unknown>;
|
|
14320
|
-
|
|
15578
|
+
// Lead edit after review #3 (Codex B): ONE resolution per write — reuse the id resolved
|
|
15579
|
+
// before the decision (the same one the minutes delta was measured against) instead of
|
|
15580
|
+
// resolving again; two resolutions could disagree if the run registry moved in between.
|
|
15581
|
+
const resolved = resolvedRunIdPre !== null ? resolvedRunIdPre : resolveLedgerRunId(rowObj, listCostLedgerRuns());
|
|
14321
15582
|
if (resolved !== null) {
|
|
14322
15583
|
// Marked, because a resolved run id is our inference, not something the pipeline knew.
|
|
14323
|
-
|
|
15584
|
+
// Keep the minutes fields LAST (NFR-1 of ledger-stage-minutes): splice runId/runIdSource in
|
|
15585
|
+
// right before `ts` when the decided row already carries the stamped tail.
|
|
15586
|
+
const ordered: Record<string, unknown> = {};
|
|
15587
|
+
let spliced = false;
|
|
15588
|
+
for (const [k, v] of Object.entries(rowObj)) {
|
|
15589
|
+
if (k === 'ts' && !spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; spliced = true; }
|
|
15590
|
+
if (k === 'runId' || k === 'runIdSource') continue;
|
|
15591
|
+
ordered[k] = v;
|
|
15592
|
+
}
|
|
15593
|
+
if (!spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; }
|
|
15594
|
+
lineToWrite = JSON.stringify(ordered);
|
|
14324
15595
|
}
|
|
14325
15596
|
}
|
|
14326
15597
|
} catch { /* resolution is an ENRICHMENT; a failure must never cost the row itself */ }
|
|
@@ -18414,7 +19685,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
18414
19685
|
case 'release':
|
|
18415
19686
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
18416
19687
|
case 'parity':
|
|
18417
|
-
return cmdParity(options, flags, write, writeErr);
|
|
19688
|
+
return cmdParity(options, flags, write, writeErr, cwd);
|
|
18418
19689
|
case 'registry':
|
|
18419
19690
|
return cmdRegistry(options, cwd, write);
|
|
18420
19691
|
case 'benchmark':
|
|
@@ -18499,6 +19770,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
18499
19770
|
return cmdJournal(options, flags, cwd, write, io.journalIo);
|
|
18500
19771
|
case 'feature-adr-record':
|
|
18501
19772
|
return cmdFeatureAdrRecord(options, flags, cwd, write);
|
|
19773
|
+
case 'round':
|
|
19774
|
+
return await cmdRound(options, optionLists, flags, cwd, write, io);
|
|
18502
19775
|
case 'runs':
|
|
18503
19776
|
return cmdRuns(options, flags, cwd, write);
|
|
18504
19777
|
case 'runs-clean':
|