@dzhechkov/harness-cli 0.8.10 → 0.8.16
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 +42 -22
- package/LICENSE +13 -0
- package/README.md +312 -17
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +3 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +33 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1078 -88
- package/dist/cli.js.map +1 -1
- package/dist/command-inventory.d.ts +149 -0
- package/dist/command-inventory.d.ts.map +1 -0
- package/dist/command-inventory.js +405 -0
- package/dist/command-inventory.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +5 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +15 -14
- package/sbom.json +71 -21
- package/src/boolean-flags.ts +3 -0
- package/src/cli.ts +1071 -80
- package/src/command-inventory.ts +342 -0
- package/src/index.ts +8 -0
- package/src/known-flags.ts +5 -0
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { request as httpsRequest } from 'node:https';
|
|
|
11
11
|
import { KNOWN_CLI_FLAGS } from './known-flags.js';
|
|
12
12
|
import { isBooleanFlag } from './boolean-flags.js';
|
|
13
13
|
import { resolveInstallSpec } from './install-spec.js';
|
|
14
|
+
import { dispatchedCommands, documentedCommands } from './command-inventory.js';
|
|
14
15
|
import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
15
16
|
import { createHash, randomBytes } from 'node:crypto';
|
|
16
17
|
import { homedir, hostname, tmpdir } from 'node:os';
|
|
@@ -146,8 +147,17 @@ import {
|
|
|
146
147
|
reindexVectorStore,
|
|
147
148
|
harmonizeVectorStore,
|
|
148
149
|
importRvfCheckpoint,
|
|
150
|
+
renderFeatureAdrPhaseLine,
|
|
149
151
|
statuslineData,
|
|
152
|
+
countLearningStoreRowsReadonly,
|
|
153
|
+
readStoreMark,
|
|
154
|
+
writeStoreMark,
|
|
155
|
+
resetStoreMark,
|
|
156
|
+
checkStoreHealth,
|
|
157
|
+
storeGuardPath,
|
|
158
|
+
storeSnapshotPath,
|
|
150
159
|
writeFeatureAdrState,
|
|
160
|
+
writeFeatureAdrStateDetailed,
|
|
151
161
|
CHECKPOINT_STAGES,
|
|
152
162
|
estimateEta,
|
|
153
163
|
extractStageSamples,
|
|
@@ -157,6 +167,9 @@ import {
|
|
|
157
167
|
type CheckpointStage,
|
|
158
168
|
type EtaEstimate,
|
|
159
169
|
type FeatureAdrState,
|
|
170
|
+
type StoreHealth,
|
|
171
|
+
type StoreMark,
|
|
172
|
+
type StoreCountSnapshot,
|
|
160
173
|
type RunSegment,
|
|
161
174
|
type StageSample,
|
|
162
175
|
computeUsage,
|
|
@@ -187,6 +200,9 @@ import {
|
|
|
187
200
|
patternRecordId,
|
|
188
201
|
patternIdentityOf,
|
|
189
202
|
mergeLessonMatchedForms,
|
|
203
|
+
SWARM_BRIEF_CONTRACT,
|
|
204
|
+
checkSwarmBrief,
|
|
205
|
+
visibleText,
|
|
190
206
|
loadStoreRecords,
|
|
191
207
|
recordToPattern,
|
|
192
208
|
bundleSkills,
|
|
@@ -219,6 +235,7 @@ import {
|
|
|
219
235
|
resolveTrustRoot,
|
|
220
236
|
decideVerifyPolicy,
|
|
221
237
|
generateSigningKeypair,
|
|
238
|
+
appendTransition,
|
|
222
239
|
evaluateGuard,
|
|
223
240
|
resolveRules,
|
|
224
241
|
auditRecord,
|
|
@@ -259,6 +276,7 @@ import {
|
|
|
259
276
|
isInsideTree,
|
|
260
277
|
signManifest,
|
|
261
278
|
verifyManifest,
|
|
279
|
+
listPackFiles,
|
|
262
280
|
listSignablePackFiles,
|
|
263
281
|
assertKeyOutsideTree,
|
|
264
282
|
decidePublishGate,
|
|
@@ -293,12 +311,14 @@ import {
|
|
|
293
311
|
DEFAULT_RAKE_THRESHOLDS,
|
|
294
312
|
streamSessionEvents,
|
|
295
313
|
findLatestTranscript,
|
|
314
|
+
resolveScanTailTranscript,
|
|
296
315
|
detectProcessRakes,
|
|
297
316
|
buildRetro,
|
|
298
317
|
renderRetro,
|
|
299
318
|
retroLessonText,
|
|
300
319
|
PROCESS_SIGNATURES,
|
|
301
320
|
RETRO_DOMAIN,
|
|
321
|
+
runRetroTailScan,
|
|
302
322
|
scanForSetup,
|
|
303
323
|
buildSetupPlan,
|
|
304
324
|
scaffoldFromSpec,
|
|
@@ -507,6 +527,8 @@ import {
|
|
|
507
527
|
planImport,
|
|
508
528
|
decideCheckpointWrite,
|
|
509
529
|
amendmentSection,
|
|
530
|
+
amendmentSectionCount,
|
|
531
|
+
amendmentDeclarationAmbiguity,
|
|
510
532
|
planSaysNoAmendments,
|
|
511
533
|
parseAmendments,
|
|
512
534
|
resolveAmendments,
|
|
@@ -569,11 +591,36 @@ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, Reca
|
|
|
569
591
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
570
592
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
571
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Область, по которой гейт дрейфа собирает факты. ПОЛНАЯ, а не только корни установки.
|
|
596
|
+
*
|
|
597
|
+
* ПОЧЕМУ. Под областью `installs` сравниваются лишь корни установки (`.claude/skills`,
|
|
598
|
+
* `.agents/skills` и далее). Навык, чьи копии лежат в РАЗНЫХ корнях — канон в `packages/`, живая
|
|
599
|
+
* копия в `.claude/skills` — имеет там ОДНУ копию, а одну копию не с чем сравнивать: она
|
|
600
|
+
* отбрасывается как не дублированная. То есть главный класс расхождения был для гейта невидим.
|
|
601
|
+
*
|
|
602
|
+
* ИЗМЕРЕНО 2026-09-03: `brutal-honesty-review` разошёлся ровно так (канон в skills-qe, копия в
|
|
603
|
+
* бандле p-replicator, живая в .claude/skills), и гейт не мог увидеть это В ПРИНЦИПЕ. Я тогда
|
|
604
|
+
* написал в отчёте «гейт разблокирован» — он никогда не был на этом заблокирован.
|
|
605
|
+
*
|
|
606
|
+
* Узкая область давала 19 дублирующихся навыков, полная даёт 211. Безопасность расширения
|
|
607
|
+
* проверена ДО правки: с полной областью и списком исключений дрейфа сегодня НОЛЬ.
|
|
608
|
+
*/
|
|
609
|
+
const DRIFT_SWEEP_SCOPE = 'all' as const;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Базовая дата правила `backlog-covers-features`. Каталоги фич, заведённые ДО неё, правило не
|
|
613
|
+
* трогает: они появились раньше самого правила. ИЗМЕРЕНО 2026-09-03 — без базы правило даёт 236
|
|
614
|
+
* нарушений из 336 каталогов, и проверка, изобретающая полсотни нарушений в первый день, учит
|
|
615
|
+
* людей себя игнорировать. Дата = день, когда правило принято владельцем.
|
|
616
|
+
*/
|
|
617
|
+
const BACKLOG_COVERAGE_BASELINE = '2026-09-03';
|
|
618
|
+
|
|
572
619
|
/** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
|
|
573
620
|
export const DZ_COMMANDS: readonly string[] = [
|
|
574
621
|
'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
|
|
575
622
|
'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
|
|
576
|
-
'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
|
|
623
|
+
'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline', 'store-guard',
|
|
577
624
|
'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
|
|
578
625
|
'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
|
|
579
626
|
'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
|
|
@@ -582,7 +629,7 @@ export const DZ_COMMANDS: readonly string[] = [
|
|
|
582
629
|
'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
|
|
583
630
|
'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
|
|
584
631
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
585
|
-
'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
|
|
632
|
+
'name-check', 'brief-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
|
|
586
633
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
587
634
|
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
588
635
|
];
|
|
@@ -633,13 +680,14 @@ Usage:
|
|
|
633
680
|
dz restart-advisor --slug <s> [--threshold C|D] [--rounds N] [--json] (read-only advisory decision over features/<slug>/.fa-state/checkpoints.jsonl and .dz/fa-training/<slug>/qe.jsonl. Defaults: threshold D, rounds 2 — both origins are printed. Equal sources corroborate; conflicts, torn/unreadable evidence, gaps, and unsafe paths are NOT ESTABLISHED. RECOMMENDATION ONLY: autoAction=false; never invokes feature-adr, deletes a stage, or writes advisor state. exit 0 established recommendation/no-recommendation / 2 NOT ESTABLISHED or invalid input / 1 unexpected runtime failure)
|
|
634
681
|
dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
|
|
635
682
|
dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
|
|
683
|
+
dz brief-check <file> [--json] (does a swarm brief declare OUTPUT_DIR, UNITS and ASSEMBLY_UNIT? parsed as DATA, refused by name; verifies the brief DECLARED the contract, not that the agent follows it. exit 0 ok / 1 refused / 2 unreadable)
|
|
636
684
|
dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
|
|
637
685
|
dz project-skills [--project <dir>] [--json] [--stages-json] (polymorphic feature-adr: resolve architecture/project-skills.json — fixed roles product-vision/critic/brand/impl-bar plus an open extra[] — into per-stage guidance. READ-ONLY. --project names the root explicitly, so it works from any cwd; without it the manifest is read from the current repo. No manifest ⇒ a byte-identical generic run)
|
|
638
686
|
dz discrimination-check --slug <slug> [--base <ref>] [--json] (does the ADR's named test actually DISCRIMINATE? Re-runs it on a worktree at the pre-feature commit, where it MUST go red. A test that passes with the feature removed proves nothing; dz amendment-check proves the test exists, this proves it bites)
|
|
639
687
|
dz guard [check|promote|init] [--json] [--force] (HARD/SOFT repo rules — readme-first, lockfile-in-sync, claim tagging — run automatically as a pre-flight inside dz publish. HARD blocks, SOFT warns)
|
|
640
688
|
dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
|
|
641
689
|
dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
|
|
642
|
-
dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; 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. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
|
|
690
|
+
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)
|
|
643
691
|
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)
|
|
644
692
|
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 timestamp is stamped before serialising; 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)
|
|
645
693
|
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)
|
|
@@ -647,7 +695,7 @@ Usage:
|
|
|
647
695
|
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)
|
|
648
696
|
dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 17 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
|
|
649
697
|
dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
|
|
650
|
-
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
|
|
698
|
+
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--allow-cold-start] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
|
|
651
699
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
652
700
|
dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
|
|
653
701
|
dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
|
|
@@ -661,7 +709,7 @@ Usage:
|
|
|
661
709
|
dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
|
|
662
710
|
dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
|
|
663
711
|
dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--no-verify] [--install-driver] [--force] [--enrich] (--target codex ALSO installs + LIVE-verifies the codex hooks; an unverified hook exits non-zero WITHOUT aborting the rest of setup)
|
|
664
|
-
dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
|
|
712
|
+
dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] [--allow-cold-start] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
|
|
665
713
|
dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
|
|
666
714
|
dz consolidate [--sessions-dir <dir>] [--project <dir>] [--no-mirror] [--prune-noise [--apply]] [--prune-quarantine [--apply]] (both prunes: DRY-RUN by default; --apply snapshots then deletes; prune-quarantine = expired unproven lessons ONLY, never coupled to noise)
|
|
667
715
|
dz recall "<query>" [--limit <N>] [--domain <name>] [--semantic | --no-semantic] [--books [--book <slug>]] [--project <dir>] | dz recall --all [--json] | dz recall --usage [--json] | dz recall --forget <dzId>[,<dzId>] [--apply] | dz recall --promote <dzId>[,<dzId>] [--apply] (--domain <name> BOOSTS lessons of that domain without dropping foreign ones — a shared store keeps its cross-domain transfers; forget/promote: dry-run default; forget snapshots before removing; promote lifts lesson-quarantine)
|
|
@@ -681,7 +729,8 @@ Usage:
|
|
|
681
729
|
dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
|
|
682
730
|
dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
|
|
683
731
|
dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
|
|
684
|
-
dz
|
|
732
|
+
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)
|
|
733
|
+
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)
|
|
685
734
|
dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
|
|
686
735
|
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)
|
|
687
736
|
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)
|
|
@@ -709,6 +758,10 @@ Usage:
|
|
|
709
758
|
dz dashboard
|
|
710
759
|
dz roam [--apply] [--slug <slug>]
|
|
711
760
|
dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
|
|
761
|
+
dz retro [transcript-path] [--json] [--threshold N] [--no-teach] [--project <dir>] [--install-hook] (per-session retrospective + co-learning: mines the session transcript for recurring PROCESS rakes, drills you, and teaches the same lesson to the store)
|
|
762
|
+
dz feature-adr-setup [--plan] [--from-spec <spec.json>] [--guards [--loc-cap <n>]] [--gates [--target <name>]] [--apply] [--json] (scaffold the project-awareness files feature-adr reads — vision / map / testing / project-skills — plus the deterministic project guards and the portable delivery gates; --guards and --gates work STANDALONE or with --from-spec, and --apply writes for all three; without --apply everything is a preview)
|
|
763
|
+
dz mr-rakes [--json] [--candidate N] [--confirmed N] [--teach] [--gen-critic <path> [--apply]] (experimental: mine the review corpus — features' QE reports + REVIEW files — for RECURRING mistakes and close them into self-learning)
|
|
764
|
+
dz bto-optimize --split | --plan | --select | --scope-check | --diff [--json] (experimental: deterministic tune/holdout split, budget plan and holdout-no-regress winner selection behind the /bto-optimize skill)
|
|
712
765
|
dz help
|
|
713
766
|
|
|
714
767
|
Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
|
|
@@ -1396,7 +1449,7 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
1396
1449
|
|
|
1397
1450
|
try {
|
|
1398
1451
|
const scanTopics = topicsArg ? topicsArg.split(',').map((t) => t.trim()) : undefined;
|
|
1399
|
-
const { results: repos, totalBySource } = await scanAllSources({
|
|
1452
|
+
const { results: repos, totalBySource, statusBySource } = await scanAllSources({
|
|
1400
1453
|
token,
|
|
1401
1454
|
topics: scanTopics,
|
|
1402
1455
|
since,
|
|
@@ -1410,10 +1463,16 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
1410
1463
|
.join(', ');
|
|
1411
1464
|
write(`Sources: ${sourceLines}`);
|
|
1412
1465
|
|
|
1413
|
-
// Memory: diff with previous scan
|
|
1466
|
+
// Memory: diff with previous scan.
|
|
1467
|
+
//
|
|
1468
|
+
// СОСТОЯНИЕ ИСТОЧНИКОВ ПЕРЕДАЁТСЯ ОБЯЗАТЕЛЬНО. Без него разность не выводит исчезновений
|
|
1469
|
+
// вообще — и это правильно: источник, ответивший кодом ошибки, раньше делал ВСЕ свои записи
|
|
1470
|
+
// «пропавшими» на экране, то есть отчёт печатал факт о нашей сети как факт о мире.
|
|
1414
1471
|
if (showDiff || memory.size > 0) {
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1472
|
+
const health: Record<string, string> = {};
|
|
1473
|
+
for (const [source, status] of Object.entries(statusBySource)) health[source] = status.health;
|
|
1474
|
+
const diff = memory.diff(repos, health);
|
|
1475
|
+
if (diff.newRepos.length > 0 || diff.goneRepos.length > 0 || diff.changedScore.length > 0 || diff.goneOmittedReason !== undefined) {
|
|
1417
1476
|
write(memory.diffMarkdown(diff));
|
|
1418
1477
|
} else if (memory.size > 0) {
|
|
1419
1478
|
write(`\nNo changes since last scan (${memory.size} repos tracked).\n`);
|
|
@@ -2216,6 +2275,49 @@ function cmdBundle(options: Map<string, string>, flags: Set<string>, cwd: string
|
|
|
2216
2275
|
return 0;
|
|
2217
2276
|
}
|
|
2218
2277
|
|
|
2278
|
+
/**
|
|
2279
|
+
* Команда npm для установки пакета В ЦЕЛЕВОЙ КАТАЛОГ, а не куда решит npm.
|
|
2280
|
+
*
|
|
2281
|
+
* ЗАЧЕМ `--prefix`. Без него npm при отсутствии `package.json` в текущем каталоге поднимается по
|
|
2282
|
+
* дереву до первого найденного и мутирует ЕГО — а `dz` потом ищет пакет в
|
|
2283
|
+
* `<цель>/node_modules` и не находит. Место установки и место проверки были двумя независимыми
|
|
2284
|
+
* предположениями, и совпадали они только по удаче.
|
|
2285
|
+
*
|
|
2286
|
+
* ИЗМЕРЕНО 2026-09-03 (полевой случай владельца): установка в каталог без `package.json`
|
|
2287
|
+
* записала в `/home`, где лежит ЧУЖОЙ проект; ручной откат вернул `package.json`, а запись
|
|
2288
|
+
* `extraneous` в `/home/package-lock.json` пережила откат.
|
|
2289
|
+
*
|
|
2290
|
+
* ПОЧЕМУ НЕ ОТКАЗ (ADR-001, вариант A отвергнут). Отказ запретил бы законный сценарий: проект
|
|
2291
|
+
* внутри монорепо, намеренно не имеющий своего `package.json` и опирающийся на родительский
|
|
2292
|
+
* воркспейс. `--prefix` согласует установку с проверкой ПО ПОСТРОЕНИЮ и сценарий сохраняет.
|
|
2293
|
+
*
|
|
2294
|
+
* ЧИСТАЯ: ни файловой системы, ни запуска npm — проверяется без обоих. Путь экранируется, потому
|
|
2295
|
+
* что каталоги с пробелом в имени встречаются в наших же тестах.
|
|
2296
|
+
*/
|
|
2297
|
+
export function buildInstallArgs(npmSpec: string, projectRoot: string): readonly string[] {
|
|
2298
|
+
return ['install', npmSpec, '--prefix', projectRoot, '--save-dev', '--no-fund', '--no-audit'];
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* Та же команда СТРОКОЙ — только для показа человеку и для тестового шва.
|
|
2303
|
+
*
|
|
2304
|
+
* НЕ ДЛЯ ИСПОЛНЕНИЯ, и это не стилистическая оговорка. `JSON.stringify` НЕ является экранированием
|
|
2305
|
+
* для оболочки: внутри двойных кавычек оболочка по-прежнему выполняет `$(...)` и обратные кавычки.
|
|
2306
|
+
* ИЗМЕРЕНО 2026-09-03 — `execSync('echo ' + JSON.stringify('pkg$(touch ФАЙЛ)'))` создал файл.
|
|
2307
|
+
* Прежняя редакция этого комментария утверждала «путь экранируется»; это было неверно, и находку
|
|
2308
|
+
* предъявило кросс-семейное ревью (gpt-5.6-sol), а я подтвердил её пробой.
|
|
2309
|
+
*
|
|
2310
|
+
* Боевой путь исполняется через `execFileSync` массивом аргументов — оболочки в цепочке нет вовсе,
|
|
2311
|
+
* поэтому подставлять некуда. Это структурное лечение, а не более хитрое экранирование.
|
|
2312
|
+
*/
|
|
2313
|
+
export function buildInstallCommand(npmSpec: string, projectRoot: string): string {
|
|
2314
|
+
// ЗНАЧЕНИЯ в кавычках, ФЛАГИ без — та же форма, что печаталась до этой фичи, чтобы читатель
|
|
2315
|
+
// (и закреплённые тесты) видели знакомую строку. Кавычки здесь — ЧИТАЕМОСТЬ, а не безопасность:
|
|
2316
|
+
// безопасность даёт отсутствие оболочки на боевом пути.
|
|
2317
|
+
return `npm install ${JSON.stringify(npmSpec)} --prefix ${JSON.stringify(projectRoot)}`
|
|
2318
|
+
+ ' --save-dev --no-fund --no-audit';
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2219
2321
|
async function cmdInstall(
|
|
2220
2322
|
options: Map<string, string>,
|
|
2221
2323
|
flags: Set<string>,
|
|
@@ -2267,14 +2369,36 @@ async function cmdInstall(
|
|
|
2267
2369
|
|
|
2268
2370
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2269
2371
|
|
|
2372
|
+
// ПРЕДПОСЫЛКА НАЗЫВАЕТСЯ ДО ПОБОЧНОГО ЭФФЕКТА, А НЕ ПОСЛЕ (ADR-001, FR-2).
|
|
2373
|
+
//
|
|
2374
|
+
// Проверка стоит ЗДЕСЬ, до развилки installRunner/execSync, и это не стилистика. Поставить её
|
|
2375
|
+
// внутрь ветки execSync значило бы оставить боевой путь непокрытым при зелёных тестах — ровно
|
|
2376
|
+
// то состояние, из которого фича и родилась.
|
|
2377
|
+
const hasOwnManifest = existsSync(join(projectRoot, 'package.json'));
|
|
2378
|
+
if (!hasOwnManifest) {
|
|
2379
|
+
write(`dz install: ${projectRoot} — не npm-проект (нет своего package.json).`);
|
|
2380
|
+
write(` Ставлю ЛОКАЛЬНО в него: npm получит --prefix, package.json и node_modules появятся здесь.`);
|
|
2381
|
+
write(` Без --prefix npm поднялся бы по дереву и записал в ЧУЖОЙ проект выше — измерено 2026-09-03.`);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2270
2384
|
// Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
|
|
2271
2385
|
write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
|
|
2272
|
-
const installCmd =
|
|
2386
|
+
const installCmd = buildInstallCommand(specResolution.npmSpec, projectRoot);
|
|
2273
2387
|
try {
|
|
2274
2388
|
if (installRunner) installRunner(installCmd, projectRoot);
|
|
2275
|
-
|
|
2389
|
+
// БЕЗ ОБОЛОЧКИ. execFileSync с массивом аргументов не запускает shell, поэтому имя пакета или
|
|
2390
|
+
// путь с `$(...)` подставить нечему. Строка выше — для показа и для тестового шва, не для
|
|
2391
|
+
// исполнения (см. докстринг buildInstallCommand).
|
|
2392
|
+
else execFileSync('npm', [...buildInstallArgs(specResolution.npmSpec, projectRoot)], { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
2276
2393
|
} catch (err) {
|
|
2277
2394
|
write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
2395
|
+
// НЕАТОМАРНЫЙ ОТКАЗ НАЗЫВАЕТСЯ ВСЛУХ (ADR-001, FR-4). Названо кросс-семейной проверкой
|
|
2396
|
+
// 2026-09-03: npm мог успеть изменить package.json, файл замков и node_modules и упасть уже
|
|
2397
|
+
// после этого. Отката у нас нет — и молчать об этом хуже, чем не откатывать: пользователь
|
|
2398
|
+
// считает каталог нетронутым. Полевой случай: ручной откат вернул package.json, а запись
|
|
2399
|
+
// extraneous в файле замков пережила его.
|
|
2400
|
+
write(` npm мог успеть изменить файлы ДО падения — проверьте ${join(projectRoot, 'package.json')},`);
|
|
2401
|
+
write(` ${join(projectRoot, 'package-lock.json')} и ${join(projectRoot, 'node_modules')}: отката dz не делает.`);
|
|
2278
2402
|
return 1;
|
|
2279
2403
|
}
|
|
2280
2404
|
|
|
@@ -2535,7 +2659,7 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
|
|
|
2535
2659
|
* `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
|
|
2536
2660
|
* other value rather than silently weakening panel arbitration.
|
|
2537
2661
|
*/
|
|
2538
|
-
function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write): number {
|
|
2662
|
+
function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): number {
|
|
2539
2663
|
const slug = (options.get('slug') ?? '').trim();
|
|
2540
2664
|
const step = (options.get('step') ?? '').trim();
|
|
2541
2665
|
|
|
@@ -2595,19 +2719,44 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
|
|
|
2595
2719
|
return 1;
|
|
2596
2720
|
}
|
|
2597
2721
|
|
|
2722
|
+
// fa-phase-statusline (acid A1): --tier drives done/total on the phase line — an invalid tier is
|
|
2723
|
+
// REJECTED before anything is written (nothing slotted, nothing ledgered), never silently dropped.
|
|
2724
|
+
const tierRaw = options.get('tier');
|
|
2725
|
+
const tier = tierRaw?.trim();
|
|
2726
|
+
if (tier !== undefined && tier !== 'S' && tier !== 'M' && tier !== 'L' && tier !== 'XL') {
|
|
2727
|
+
write(`dz statusline --fa-record: --tier must be S, M, L or XL (got "${tierRaw}")`);
|
|
2728
|
+
write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 7 Code" --tier M');
|
|
2729
|
+
return 1;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2598
2732
|
const mode = options.get('mode');
|
|
2733
|
+
const runId = options.get('run-id')?.trim();
|
|
2599
2734
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2600
|
-
const
|
|
2735
|
+
const outcome = writeFeatureAdrStateDetailed(projectRoot, {
|
|
2601
2736
|
kind: kindRaw, slug, step, recalled, stored,
|
|
2602
2737
|
...(reinforced > 0 ? { reinforced } : {}),
|
|
2603
2738
|
...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
|
|
2739
|
+
...(tier !== undefined ? { tier } : {}),
|
|
2740
|
+
...(runId !== undefined && runId !== '' ? { runId } : {}),
|
|
2604
2741
|
});
|
|
2742
|
+
const state = outcome.state;
|
|
2605
2743
|
|
|
2606
2744
|
if (state === undefined) {
|
|
2745
|
+
// [AM-5] A REFUSED slot write is LOUD. A held `fa-phase-slot` lock or an unwritable `.dz` used
|
|
2746
|
+
// to return a bare `undefined`, and a caller reading silence as success is exactly the
|
|
2747
|
+
// "absence of a receipt is not success" class. The reason goes to stderr — diagnosis, not
|
|
2748
|
+
// data — and the EXIT CODE is unchanged for a refusal, because the panel must never break the
|
|
2749
|
+
// pipeline that is only reporting to it.
|
|
2750
|
+
if (outcome.refused !== undefined) {
|
|
2751
|
+
writeErr(`fa-record: slot write refused (${slug}): ${outcome.refused}`);
|
|
2752
|
+
return 0;
|
|
2753
|
+
}
|
|
2607
2754
|
write(`dz statusline --fa-record: could not write learning state under ${projectRoot}/.dz/feature-adr/`);
|
|
2608
2755
|
return 1;
|
|
2609
2756
|
}
|
|
2610
|
-
|
|
2757
|
+
// state.step, not the flag: the monotonic guard may have kept a LATER step against a stale
|
|
2758
|
+
// duplicate record (fa-phase-statusline P1) — print what actually stands in the slot.
|
|
2759
|
+
write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${state.step}) — 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
|
|
2611
2760
|
return 0;
|
|
2612
2761
|
}
|
|
2613
2762
|
|
|
@@ -2759,8 +2908,12 @@ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: numbe
|
|
|
2759
2908
|
* least a minimal `dz` even on total failure.
|
|
2760
2909
|
*
|
|
2761
2910
|
* Flags: `--install` wires it into settings.json; `--fa-record` records a live `/feature-adr`
|
|
2762
|
-
* learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
|
|
2763
|
-
*
|
|
2911
|
+
* learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
|
|
2912
|
+
* (plus `featureAdrLine`, the rendered phase line, when a fresh /feature-adr run is in flight);
|
|
2913
|
+
* default prints the status line, with the 📐 phase panel as its OWN SECOND LINE (format B —
|
|
2914
|
+
* fa-phase-statusline ADR-001 D1; Claude Code renders every stdout line of a statusline command).
|
|
2915
|
+
* The ETA fragment main shipped for that panel rides the SECOND line with it (fa-phase-statusline ADR-001 D1) — the panel
|
|
2916
|
+
* moved, the estimate was not dropped.
|
|
2764
2917
|
*/
|
|
2765
2918
|
function cmdStatusline(
|
|
2766
2919
|
options: Map<string, string>,
|
|
@@ -2768,12 +2921,14 @@ function cmdStatusline(
|
|
|
2768
2921
|
cwd: string,
|
|
2769
2922
|
write: Write,
|
|
2770
2923
|
readStdin: () => string,
|
|
2924
|
+
writeErr: WriteErr,
|
|
2771
2925
|
): number {
|
|
2772
2926
|
if (flags.has('install')) return cmdStatuslineInstall(options, cwd, write);
|
|
2773
|
-
if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write);
|
|
2927
|
+
if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write, writeErr);
|
|
2774
2928
|
|
|
2775
2929
|
try {
|
|
2776
2930
|
const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
|
|
2931
|
+
warnLearningStoreRead(projectRoot, writeErr, 'dz statusline');
|
|
2777
2932
|
const data = statuslineData(projectRoot);
|
|
2778
2933
|
const fa = data.featureAdr;
|
|
2779
2934
|
let eta: EtaEstimate | undefined;
|
|
@@ -2789,27 +2944,64 @@ function cmdStatusline(
|
|
|
2789
2944
|
}
|
|
2790
2945
|
}
|
|
2791
2946
|
|
|
2947
|
+
// fa-phase-statusline (ADR-001 D1): the phase line renders from the slot ALONE — a pure
|
|
2948
|
+
// function over data.featureAdr, computed once here for both the plain and --json surfaces.
|
|
2949
|
+
const phaseLine = fa !== undefined ? renderFeatureAdrPhaseLine(fa) : undefined;
|
|
2950
|
+
|
|
2792
2951
|
if (flags.has('json')) {
|
|
2793
|
-
write(JSON.stringify({
|
|
2952
|
+
write(JSON.stringify({
|
|
2953
|
+
...data,
|
|
2954
|
+
...(eta !== undefined ? { eta } : {}),
|
|
2955
|
+
...(phaseLine !== undefined ? { featureAdrLine: phaseLine } : {}),
|
|
2956
|
+
}));
|
|
2794
2957
|
return 0;
|
|
2795
2958
|
}
|
|
2796
2959
|
|
|
2797
|
-
|
|
2960
|
+
// ТЕКСТ ПАНЕЛИ — ПО-АНГЛИЙСКИ по просьбе владельца 2026-09-09: строка статуса узкая,
|
|
2961
|
+
// английские слова в ней короче русских при той же ясности. Комментарии остаются русскими.
|
|
2962
|
+
const breakdown = data.patternBreakdown;
|
|
2963
|
+
let line = breakdown === undefined
|
|
2964
|
+
? `🎓 dz: ${data.patterns} patterns`
|
|
2965
|
+
: `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
|
|
2966
|
+
? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
|
|
2967
|
+
: ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
|
|
2968
|
+
// Показатель зеркала печатается и здесь: `dz statusline` — та же панель, и показатель,
|
|
2969
|
+
// живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
|
|
2970
|
+
if (data.patternMirror?.state === 'unavailable') line += ' (mirror unreadable ⚠)';
|
|
2971
|
+
else if (data.patternMirror?.state === 'different') line += ` (mirror ${data.patternMirror.vector} ⚠)`;
|
|
2972
|
+
if (data.storeHealth?.verdict === 'collapsed') {
|
|
2973
|
+
line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
|
|
2974
|
+
} else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
|
|
2975
|
+
line += ` ⛔ STORE EMPTY: was ${data.storeHealth.previousMax ?? '?'} · restore from snapshots ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
|
|
2976
|
+
} else if (data.storeHealth?.verdict === 'unreadable') {
|
|
2977
|
+
line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
|
|
2978
|
+
? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
|
|
2979
|
+
} else if (data.storeHealth?.verdict === 'source-changed') {
|
|
2980
|
+
line += ' ⚠ store source changed';
|
|
2981
|
+
}
|
|
2982
|
+
// Рядом с числом источников — объём каждого через «/» (просьба владельца 2026-09-09):
|
|
2983
|
+
// четыре источника по 300 единиц и четыре по три — разные корпуса, а число одно и то же.
|
|
2984
|
+
const ku = data.brainKuCounts.length > 0 ? ` (${data.brainKuCounts.join('/')})` : '';
|
|
2985
|
+
line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources${ku}`;
|
|
2798
2986
|
const branch = statuslineGitBranch(projectRoot);
|
|
2799
2987
|
if (branch !== undefined) line += ` · ⎇ ${branch}`;
|
|
2800
2988
|
if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
|
|
2801
2989
|
|
|
2802
|
-
// Live
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
} else {
|
|
2808
|
-
line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
|
|
2809
|
-
}
|
|
2990
|
+
// Live loop run in flight → PREPEND its segment to the base dz line (unchanged). A live
|
|
2991
|
+
// /feature-adr run no longer glues into line 1: its 📐 segment IS the second line (format B) —
|
|
2992
|
+
// Claude Code renders every stdout line of a statusline command (fa-phase-statusline ADR-001 D1).
|
|
2993
|
+
if (fa !== undefined && fa.kind === 'loop') {
|
|
2994
|
+
line = `🔁 loop ${fa.step} · ${line}`;
|
|
2810
2995
|
}
|
|
2811
2996
|
|
|
2812
2997
|
write(line);
|
|
2998
|
+
// fa-phase-statusline ADR-001 D1: the phase panel moved to line 2 and TOOK main's ETA fragment with it. The move is
|
|
2999
|
+
// the point of format B (ADR-001 D1); dropping the estimate would have been a silent
|
|
3000
|
+
// regression of a feature `main` shipped while this branch was stranded, so it rides here
|
|
3001
|
+
// instead. A phase line that renders (fresh, non-terminal, non-loop slot) is the only gate.
|
|
3002
|
+
if (phaseLine !== undefined) {
|
|
3003
|
+
write(`${phaseLine}${etaFragment !== undefined ? ` · ${etaFragment}` : ''}`);
|
|
3004
|
+
}
|
|
2813
3005
|
return 0;
|
|
2814
3006
|
} catch {
|
|
2815
3007
|
// A garbled status bar is worse than a terse one — print SOMETHING minimal, never throw.
|
|
@@ -3432,6 +3624,282 @@ function learningStoreLine(
|
|
|
3432
3624
|
) + (reason ? ' [' + reason + ']' : '');
|
|
3433
3625
|
}
|
|
3434
3626
|
|
|
3627
|
+
function inspectLearningStore(projectRoot: string): {
|
|
3628
|
+
mark: StoreMark | undefined;
|
|
3629
|
+
health: StoreHealth;
|
|
3630
|
+
rows: ReturnType<typeof countLearningStoreRowsReadonly>;
|
|
3631
|
+
} {
|
|
3632
|
+
const mark = readStoreMark(projectRoot);
|
|
3633
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3634
|
+
return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
function shellQuote(value: string): string {
|
|
3638
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function storeGuardResetCommand(projectRoot: string): string {
|
|
3642
|
+
return `dz store-guard --reset --project ${shellQuote(projectRoot)}`;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
function lexicalSourceLines(rows: ReturnType<typeof countLearningStoreRowsReadonly>): string[] {
|
|
3646
|
+
return [
|
|
3647
|
+
` lexical selected: ${rows.lexicalSourcePath} (${rows.lexicalSource}, ${rows.lexicalRows} rows)`,
|
|
3648
|
+
...(rows.lexicalIgnoredSourcePath === undefined ? [] : [
|
|
3649
|
+
` lexical ignored: ${rows.lexicalIgnoredSourcePath} (${rows.lexicalIgnoredRows} rows)`,
|
|
3650
|
+
]),
|
|
3651
|
+
];
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
function storeGuardRecoveryLines(
|
|
3655
|
+
projectRoot: string,
|
|
3656
|
+
mark: StoreMark | undefined,
|
|
3657
|
+
health: StoreHealth,
|
|
3658
|
+
rows?: ReturnType<typeof countLearningStoreRowsReadonly>,
|
|
3659
|
+
): string[] {
|
|
3660
|
+
const snapshots = storeSnapshotPath(projectRoot);
|
|
3661
|
+
const markPath = storeGuardPath(projectRoot);
|
|
3662
|
+
let lexicalSnapshot: 'sqlite' | 'jsonl' | undefined;
|
|
3663
|
+
let vectorSnapshot = false;
|
|
3664
|
+
try {
|
|
3665
|
+
const names = existsSync(snapshots) ? readdirSync(snapshots) : [];
|
|
3666
|
+
const hasSqlite = names.some((name) => /^lexical\..+\.sqlite$/.test(name));
|
|
3667
|
+
const hasJsonl = names.some((name) => /^lexical\..+\.jsonl$/.test(name));
|
|
3668
|
+
const preferred = mark?.lexicalSource === 'sqlite' || mark?.lexicalSource === 'jsonl'
|
|
3669
|
+
? mark.lexicalSource
|
|
3670
|
+
: rows?.lexicalSource;
|
|
3671
|
+
if (preferred === 'jsonl' && hasJsonl) lexicalSnapshot = 'jsonl';
|
|
3672
|
+
else if (preferred === 'sqlite' && hasSqlite) lexicalSnapshot = 'sqlite';
|
|
3673
|
+
else if (hasSqlite) lexicalSnapshot = 'sqlite';
|
|
3674
|
+
else if (hasJsonl) lexicalSnapshot = 'jsonl';
|
|
3675
|
+
vectorSnapshot = names.some((name) => /^vector\..+\.sqlite$/.test(name));
|
|
3676
|
+
} catch {
|
|
3677
|
+
lexicalSnapshot = undefined;
|
|
3678
|
+
vectorSnapshot = false;
|
|
3679
|
+
}
|
|
3680
|
+
const lines = [
|
|
3681
|
+
`dz store guard: REFUSED — ${health.reason}`,
|
|
3682
|
+
` mark: ${markPath}`,
|
|
3683
|
+
...(mark === undefined ? [] : [` recorded rows: lexical=${mark.lexicalMax} (${mark.lexicalSource}), vector=${mark.vectorMax}`]),
|
|
3684
|
+
...(rows === undefined ? [] : lexicalSourceLines(rows)),
|
|
3685
|
+
];
|
|
3686
|
+
if (lexicalSnapshot !== undefined && vectorSnapshot) {
|
|
3687
|
+
const lexicalDestination = lexicalSnapshot === 'sqlite'
|
|
3688
|
+
? join(projectRoot, '.dz', 'memory', 'patterns.sqlite')
|
|
3689
|
+
: join(projectRoot, '.dz', 'patterns.jsonl');
|
|
3690
|
+
lines.push(
|
|
3691
|
+
` snapshots: ${snapshots}/`,
|
|
3692
|
+
` restore: mkdir -p ${shellQuote(dirname(lexicalDestination))} && cp ${shellQuote(join(snapshots, `lexical.<timestamp>.${lexicalSnapshot}`))} ${shellQuote(lexicalDestination)} && cp ${shellQuote(join(snapshots, 'vector.<timestamp>.sqlite'))} ${shellQuote(join(projectRoot, '.dz', 'agentdb.db'))}`,
|
|
3693
|
+
);
|
|
3694
|
+
} else {
|
|
3695
|
+
lines.push(
|
|
3696
|
+
` snapshots: none found in ${snapshots}/`,
|
|
3697
|
+
` create one manually: scripts/dz-store-snapshot.sh --project ${shellQuote(projectRoot)}`,
|
|
3698
|
+
);
|
|
3699
|
+
}
|
|
3700
|
+
lines.push(
|
|
3701
|
+
` accept current counts: ${storeGuardResetCommand(projectRoot)}`,
|
|
3702
|
+
' continue intentionally: set DZ_ALLOW_COLD_START=1 or pass --allow-cold-start',
|
|
3703
|
+
);
|
|
3704
|
+
return lines;
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
function observedRows(
|
|
3708
|
+
rows: ReturnType<typeof countLearningStoreRowsReadonly>,
|
|
3709
|
+
): StoreCountSnapshot | undefined {
|
|
3710
|
+
return typeof rows.lexicalRows === 'number' && typeof rows.vectorRows === 'number'
|
|
3711
|
+
? { lexicalRows: rows.lexicalRows, vectorRows: rows.vectorRows, lexicalSource: rows.lexicalSource }
|
|
3712
|
+
: undefined;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
interface MarkRefreshOptions {
|
|
3716
|
+
readonly reader?: boolean;
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
/** Mark maintenance is diagnostic: the store operation already completed and must keep its exit code. */
|
|
3720
|
+
function refreshLearningStoreMark(
|
|
3721
|
+
projectRoot: string,
|
|
3722
|
+
writeErr: WriteErr,
|
|
3723
|
+
command: string,
|
|
3724
|
+
options: MarkRefreshOptions = {},
|
|
3725
|
+
): void {
|
|
3726
|
+
try {
|
|
3727
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3728
|
+
const counts = observedRows(rows);
|
|
3729
|
+
if (counts === undefined) {
|
|
3730
|
+
writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
writeStoreMark(projectRoot, {
|
|
3734
|
+
...counts,
|
|
3735
|
+
observedAt: new Date().toISOString(),
|
|
3736
|
+
command,
|
|
3737
|
+
}, options.reader ? { timeoutMs: 0 } : {});
|
|
3738
|
+
} catch (error) {
|
|
3739
|
+
if (options.reader && error instanceof NamedLockTimeoutError) return;
|
|
3740
|
+
writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark could not be updated — ${error instanceof Error ? error.message : String(error)}`);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
function storeGuardResetReminder(projectRoot: string, writeErr: WriteErr, command: string): void {
|
|
3745
|
+
try {
|
|
3746
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3747
|
+
const mark = readStoreMark(projectRoot);
|
|
3748
|
+
writeErr(`⚠ DZ STORE GUARD — ${command}: store now lexical=${rows.lexicalRows}, vector=${rows.vectorRows}; maximum remains lexical=${mark?.lexicalMax ?? 'none'}, vector=${mark?.vectorMax ?? 'none'}; reconcile explicitly: ${storeGuardResetCommand(projectRoot)}`);
|
|
3749
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3750
|
+
} catch (error) {
|
|
3751
|
+
writeErr(`⚠ DZ STORE GUARD — ${command}: store changed; inspect it and reconcile explicitly with ${storeGuardResetCommand(projectRoot)} (${error instanceof Error ? error.message : String(error)})`);
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
/** Fail closed for store writers, except for an explicit per-process/per-command override. */
|
|
3756
|
+
function allowLearningStoreWrite(
|
|
3757
|
+
projectRoot: string,
|
|
3758
|
+
flags: Set<string>,
|
|
3759
|
+
writeErr: WriteErr,
|
|
3760
|
+
command: string,
|
|
3761
|
+
): boolean {
|
|
3762
|
+
let inspection: ReturnType<typeof inspectLearningStore>;
|
|
3763
|
+
try {
|
|
3764
|
+
inspection = inspectLearningStore(projectRoot);
|
|
3765
|
+
} catch (error) {
|
|
3766
|
+
const health: StoreHealth = {
|
|
3767
|
+
verdict: 'unreadable',
|
|
3768
|
+
reason: `cannot read the external mark (${error instanceof Error ? error.message : String(error)})`,
|
|
3769
|
+
};
|
|
3770
|
+
for (const line of storeGuardRecoveryLines(projectRoot, undefined, health)) writeErr(line);
|
|
3771
|
+
return false;
|
|
3772
|
+
}
|
|
3773
|
+
if (inspection.health.verdict === 'no-mark' || inspection.health.verdict === 'ok') {
|
|
3774
|
+
// The successful write path records the resulting counts. Refreshing here as
|
|
3775
|
+
// well would emit the same telemetry failure twice when the external mark is
|
|
3776
|
+
// unavailable, and would receipt a source transition before the command's
|
|
3777
|
+
// own row had landed.
|
|
3778
|
+
return true;
|
|
3779
|
+
}
|
|
3780
|
+
if (inspection.health.verdict === 'source-changed') {
|
|
3781
|
+
const counts = observedRows(inspection.rows);
|
|
3782
|
+
if (counts === undefined) return false;
|
|
3783
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — SOURCE CHANGED: ${inspection.health.reason}; ${storeGuardResetCommand(projectRoot)}`);
|
|
3784
|
+
for (const line of lexicalSourceLines(inspection.rows)) writeErr(line);
|
|
3785
|
+
try {
|
|
3786
|
+
// Consume the single migration allowance BEFORE the store write. If the
|
|
3787
|
+
// following command fails, the safe result is a consumed allowance that
|
|
3788
|
+
// requires an explicit reset, never a silently reusable permission.
|
|
3789
|
+
writeStoreMark(projectRoot, { ...counts, observedAt: new Date().toISOString(), command }, {
|
|
3790
|
+
expectedPreviousLexicalSource: inspection.mark?.lexicalSource ?? 'unknown',
|
|
3791
|
+
});
|
|
3792
|
+
return true;
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
writeErr(`dz store guard: REFUSED — source-change allowance could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
|
|
3795
|
+
return false;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
const allowed = process.env.DZ_ALLOW_COLD_START === '1' || flags.has('allow-cold-start');
|
|
3800
|
+
if (allowed) {
|
|
3801
|
+
const label = inspection.health.verdict.replaceAll('-', ' ').toUpperCase();
|
|
3802
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — ${label}: ${inspection.health.reason}; explicit cold-start override accepted`);
|
|
3803
|
+
return true;
|
|
3804
|
+
}
|
|
3805
|
+
for (const line of storeGuardRecoveryLines(projectRoot, inspection.mark, inspection.health, inspection.rows)) writeErr(line);
|
|
3806
|
+
return false;
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
/** Readers warn on damage and bootstrap/refresh a healthy non-empty store mark. */
|
|
3810
|
+
function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command: string): void {
|
|
3811
|
+
try {
|
|
3812
|
+
const { health, rows, mark } = inspectLearningStore(projectRoot);
|
|
3813
|
+
if (health.verdict === 'collapsed' || health.verdict === 'cold-start-over-existing' || health.verdict === 'unreadable'
|
|
3814
|
+
|| health.verdict === 'source-changed') {
|
|
3815
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — ${health.verdict.replaceAll('-', ' ').toUpperCase()}: ${health.reason}`);
|
|
3816
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
const counts = observedRows(rows);
|
|
3820
|
+
if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
|
|
3821
|
+
&& (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
|
|
3822
|
+
|| mark.lexicalSource !== counts.lexicalSource
|
|
3823
|
+
|| mark.lexicalMax < counts.lexicalRows || mark.vectorMax < counts.vectorRows)) {
|
|
3824
|
+
refreshLearningStoreMark(projectRoot, writeErr, command, { reader: true });
|
|
3825
|
+
}
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — external mark unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
async function cmdStoreGuard(
|
|
3832
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
3833
|
+
stdinText: string | undefined, interactive: boolean,
|
|
3834
|
+
): Promise<number> {
|
|
3835
|
+
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3836
|
+
const path = storeGuardPath(projectRoot);
|
|
3837
|
+
const reset = flags.has('reset');
|
|
3838
|
+
const status = flags.has('status') || options.has('status');
|
|
3839
|
+
if (reset && status) {
|
|
3840
|
+
writeErr('dz store-guard: --status and --reset are mutually exclusive');
|
|
3841
|
+
return 2;
|
|
3842
|
+
}
|
|
3843
|
+
if (reset) {
|
|
3844
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3845
|
+
const counts = observedRows(rows);
|
|
3846
|
+
if (counts === undefined) {
|
|
3847
|
+
writeErr(`dz store-guard: REFUSED — cannot reset from an unreadable store; mark: ${path}`);
|
|
3848
|
+
return 1;
|
|
3849
|
+
}
|
|
3850
|
+
try {
|
|
3851
|
+
const previous = readStoreMark(projectRoot);
|
|
3852
|
+
const beforeLexical = previous?.lexicalMax ?? counts.lexicalRows;
|
|
3853
|
+
const beforeVector = previous?.vectorMax ?? counts.vectorRows;
|
|
3854
|
+
writeErr('⚠ dz store-guard --reset: manual operator decision required; this lowers the recorded high-water evidence');
|
|
3855
|
+
writeErr(` old maximum: lexical=${beforeLexical}, vector=${beforeVector}`);
|
|
3856
|
+
writeErr(` new observed: lexical=${counts.lexicalRows} (${counts.lexicalSource}), vector=${counts.vectorRows}`);
|
|
3857
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3858
|
+
let answer = stdinText?.trim().split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
|
|
3859
|
+
if (!flags.has('yes') && answer === '' && interactive && process.stdin.isTTY) {
|
|
3860
|
+
const { createInterface } = await import('node:readline/promises');
|
|
3861
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3862
|
+
try { answer = (await rl.question(' type yes to continue: ')).trim().toLowerCase(); }
|
|
3863
|
+
finally { rl.close(); }
|
|
3864
|
+
}
|
|
3865
|
+
if (!flags.has('yes') && !['y', 'yes', 'да'].includes(answer)) {
|
|
3866
|
+
writeErr(`dz store-guard: REFUSED — reset was not confirmed; re-run with --yes or answer yes`);
|
|
3867
|
+
return 1;
|
|
3868
|
+
}
|
|
3869
|
+
const current = countLearningStoreRowsReadonly(projectRoot);
|
|
3870
|
+
const currentCounts = observedRows(current);
|
|
3871
|
+
if (currentCounts === undefined || !isDeepStrictEqual(currentCounts, counts)) {
|
|
3872
|
+
writeErr('dz store-guard: REFUSED — store counts changed after confirmation; inspect and confirm again');
|
|
3873
|
+
return 1;
|
|
3874
|
+
}
|
|
3875
|
+
const mark = resetStoreMark(projectRoot, {
|
|
3876
|
+
...counts,
|
|
3877
|
+
observedAt: new Date().toISOString(),
|
|
3878
|
+
command: 'dz store-guard --reset',
|
|
3879
|
+
});
|
|
3880
|
+
write(`dz store-guard: RESET — accepted lexical=${mark.lexicalMax}, vector=${mark.vectorMax}`);
|
|
3881
|
+
write(` mark: ${path}`);
|
|
3882
|
+
write(` receipt: ${mark.resetAt?.at} — ${mark.resetAt?.reason}`);
|
|
3883
|
+
return 0;
|
|
3884
|
+
} catch (error) {
|
|
3885
|
+
writeErr(`dz store-guard: reset failed — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
|
|
3886
|
+
return 1;
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
try {
|
|
3890
|
+
const inspection = inspectLearningStore(projectRoot);
|
|
3891
|
+
write(`dz store-guard: ${inspection.health.verdict.toUpperCase()} — ${inspection.health.reason}`);
|
|
3892
|
+
write(` mark: ${path}`);
|
|
3893
|
+
write(` current rows: lexical=${inspection.rows.lexicalRows}, vector=${inspection.rows.vectorRows}`);
|
|
3894
|
+
for (const line of lexicalSourceLines(inspection.rows)) write(line);
|
|
3895
|
+
write(` recorded: ${inspection.mark === undefined ? 'none' : JSON.stringify(inspection.mark)}`);
|
|
3896
|
+
return 0;
|
|
3897
|
+
} catch (error) {
|
|
3898
|
+
writeErr(`dz store-guard: mark unreadable — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
|
|
3899
|
+
return 1;
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3435
3903
|
async function runTeachGuardReinforcement(
|
|
3436
3904
|
projectRoot: string,
|
|
3437
3905
|
dzId: string,
|
|
@@ -3465,6 +3933,12 @@ async function cmdTeach(
|
|
|
3465
3933
|
// repo's own store holds 361 records written under that behaviour, and every other user's store
|
|
3466
3934
|
// is the same. Only an explicit choice moves it.
|
|
3467
3935
|
const { storeRoot, target: teachTarget } = resolved;
|
|
3936
|
+
const teachWillWrite = flags.has('harmonize')
|
|
3937
|
+
? false
|
|
3938
|
+
: options.has('from-json')
|
|
3939
|
+
|| (options.get('reinforce') ?? '').trim() !== ''
|
|
3940
|
+
|| (options.get('_positional_0') ?? '').trim() !== '';
|
|
3941
|
+
if (teachWillWrite && !allowLearningStoreWrite(storeRoot, flags, writeErr, 'dz teach')) return 1;
|
|
3468
3942
|
// The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
|
|
3469
3943
|
// the store and change nothing, so saying "written" there is a false claim about what happened
|
|
3470
3944
|
// (cross-family QE round 2, 2026-08-27).
|
|
@@ -3501,10 +3975,11 @@ async function cmdTeach(
|
|
|
3501
3975
|
// Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
|
|
3502
3976
|
// worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
|
|
3503
3977
|
if (!flags.has('json')) write(storeLine(flags.has('apply') ? 'written' : 'read'));
|
|
3504
|
-
|
|
3978
|
+
const code = await runHarmonize(storeRoot, options, flags, write, writeErr, {
|
|
3505
3979
|
store: join(storeRoot, '.dz'),
|
|
3506
3980
|
storeChosenBy: teachTarget.reason,
|
|
3507
3981
|
});
|
|
3982
|
+
return code;
|
|
3508
3983
|
}
|
|
3509
3984
|
|
|
3510
3985
|
// Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
|
|
@@ -3600,6 +4075,7 @@ async function cmdTeach(
|
|
|
3600
4075
|
const report = await harmonizeVectorStore(storeRoot, {});
|
|
3601
4076
|
write(` ℹ ${imported} imported — ${report.clusters.length} near-duplicate cluster(s): review with dz vector harmonize (dry-run); merge with dz vector harmonize --apply after backup`);
|
|
3602
4077
|
}
|
|
4078
|
+
if (imported > 0) refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --from-json');
|
|
3603
4079
|
return 0;
|
|
3604
4080
|
}
|
|
3605
4081
|
|
|
@@ -3620,6 +4096,7 @@ async function cmdTeach(
|
|
|
3620
4096
|
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
|
|
3621
4097
|
if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
|
|
3622
4098
|
write(storeLine('written'));
|
|
4099
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
|
|
3623
4100
|
return 0;
|
|
3624
4101
|
}
|
|
3625
4102
|
// HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
|
|
@@ -3667,6 +4144,7 @@ async function cmdTeach(
|
|
|
3667
4144
|
write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
|
|
3668
4145
|
const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
|
|
3669
4146
|
if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
|
|
4147
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --guard');
|
|
3670
4148
|
return 0;
|
|
3671
4149
|
}
|
|
3672
4150
|
write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
|
|
@@ -3780,10 +4258,16 @@ async function cmdTeach(
|
|
|
3780
4258
|
}
|
|
3781
4259
|
// The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
|
|
3782
4260
|
await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
|
|
4261
|
+
if (stored.records.length > 0) {
|
|
4262
|
+
write(` ID: ${stored.records.map((record) => patternRecordId(record)).join(', ')}`);
|
|
4263
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach');
|
|
4264
|
+
}
|
|
3783
4265
|
return commandFailed ? 1 : 0;
|
|
3784
4266
|
}
|
|
3785
4267
|
|
|
3786
|
-
async function cmdConsolidate(
|
|
4268
|
+
async function cmdConsolidate(
|
|
4269
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
4270
|
+
): Promise<number> {
|
|
3787
4271
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3788
4272
|
const sessionsDirOpt = options.get('sessions-dir');
|
|
3789
4273
|
const pruneNoise = flags.has('prune-noise');
|
|
@@ -3802,6 +4286,8 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
|
|
|
3802
4286
|
if (res.error !== undefined) { write(`dz consolidate --prune-quarantine: ${res.error}`); return 1; }
|
|
3803
4287
|
write(`dz consolidate --prune-quarantine: removed ${res.removed} expired quarantined lesson(s)`);
|
|
3804
4288
|
if (res.snapshot !== undefined) write(` snapshot: ${res.snapshot}`);
|
|
4289
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
|
|
4290
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
|
|
3805
4291
|
return 0;
|
|
3806
4292
|
}
|
|
3807
4293
|
|
|
@@ -3899,6 +4385,10 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
|
|
|
3899
4385
|
}
|
|
3900
4386
|
} catch { /* best-effort — the ranking is advisory, never fails the consolidate */ }
|
|
3901
4387
|
|
|
4388
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate');
|
|
4389
|
+
if (pruneNoise && applyPrune) {
|
|
4390
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-noise --apply');
|
|
4391
|
+
}
|
|
3902
4392
|
return 0;
|
|
3903
4393
|
}
|
|
3904
4394
|
|
|
@@ -4011,6 +4501,7 @@ async function cmdRecallForget(
|
|
|
4011
4501
|
flags: Set<string>,
|
|
4012
4502
|
projectRoot: string,
|
|
4013
4503
|
write: Write,
|
|
4504
|
+
writeErr: WriteErr,
|
|
4014
4505
|
): Promise<number> {
|
|
4015
4506
|
const raw = options.get('forget') ?? '';
|
|
4016
4507
|
const ids = new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''));
|
|
@@ -4039,7 +4530,7 @@ async function cmdRecallForget(
|
|
|
4039
4530
|
return 0;
|
|
4040
4531
|
}
|
|
4041
4532
|
|
|
4042
|
-
const dest = join(projectRoot,
|
|
4533
|
+
const dest = join(storeSnapshotPath(projectRoot), `forget-${Date.now()}.json`);
|
|
4043
4534
|
const snap = snapshotStore(projectRoot, dest);
|
|
4044
4535
|
if (snap.error !== undefined) {
|
|
4045
4536
|
write(`dz recall --forget: snapshot failed (${snap.error}) — nothing removed; the store is not versioned`);
|
|
@@ -4050,6 +4541,8 @@ async function cmdRecallForget(
|
|
|
4050
4541
|
write(` snapshot: ${snap.path} (${snap.count} record(s))`);
|
|
4051
4542
|
if (result.error !== undefined) write(` ⚠ ${result.error}`);
|
|
4052
4543
|
write(' the vector mirror still holds them — run `dz vector reindex` to resync');
|
|
4544
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz recall --forget --apply');
|
|
4545
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz recall --forget --apply');
|
|
4053
4546
|
return 0;
|
|
4054
4547
|
}
|
|
4055
4548
|
|
|
@@ -4141,10 +4634,15 @@ async function cmdRecall(
|
|
|
4141
4634
|
classMatcher?: RecallPatternsOptions['classMatcher'],
|
|
4142
4635
|
): Promise<number> {
|
|
4143
4636
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
4637
|
+
warnLearningStoreRead(projectRoot, writeErr, 'dz recall');
|
|
4638
|
+
const globalRootForGuard = globalStoreRoot();
|
|
4639
|
+
if (!sameStore(projectRoot, globalRootForGuard) && existsSync(join(globalRootForGuard, '.dz', 'memory'))) {
|
|
4640
|
+
warnLearningStoreRead(globalRootForGuard, writeErr, 'dz recall');
|
|
4641
|
+
}
|
|
4144
4642
|
const asJson = flags.has('json');
|
|
4145
4643
|
const all = flags.has('all');
|
|
4146
4644
|
if (flags.has('usage')) return cmdRecallUsage(options, flags, projectRoot, write);
|
|
4147
|
-
if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write);
|
|
4645
|
+
if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write, writeErr);
|
|
4148
4646
|
if (options.has('promote')) return cmdRecallPromote(options, flags, projectRoot, write);
|
|
4149
4647
|
|
|
4150
4648
|
// --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
|
|
@@ -4706,7 +5204,7 @@ function renderHarmonize(report: HarmonizeReport, write: Write): void {
|
|
|
4706
5204
|
* `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
|
|
4707
5205
|
*/
|
|
4708
5206
|
async function runHarmonize(
|
|
4709
|
-
projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write,
|
|
5207
|
+
projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr,
|
|
4710
5208
|
/**
|
|
4711
5209
|
* Where this harmonize is pointed and what chose it. Under `--json` the human store line is
|
|
4712
5210
|
* suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
|
|
@@ -4730,6 +5228,10 @@ async function runHarmonize(
|
|
|
4730
5228
|
}
|
|
4731
5229
|
}
|
|
4732
5230
|
const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
|
|
5231
|
+
if (apply && report.error === undefined) {
|
|
5232
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector harmonize --apply');
|
|
5233
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector harmonize --apply');
|
|
5234
|
+
}
|
|
4733
5235
|
if (flags.has('json')) {
|
|
4734
5236
|
write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
|
|
4735
5237
|
return report.error !== undefined ? 1 : 0;
|
|
@@ -4738,7 +5240,9 @@ async function runHarmonize(
|
|
|
4738
5240
|
return report.error !== undefined ? 1 : 0;
|
|
4739
5241
|
}
|
|
4740
5242
|
|
|
4741
|
-
async function cmdVector(
|
|
5243
|
+
async function cmdVector(
|
|
5244
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
5245
|
+
): Promise<number> {
|
|
4742
5246
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
4743
5247
|
const sub = options.get('_positional_0');
|
|
4744
5248
|
|
|
@@ -4793,6 +5297,10 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4793
5297
|
if (sub === 'reindex') {
|
|
4794
5298
|
const report = await reindexVectorStore(projectRoot);
|
|
4795
5299
|
if (flags.has('json')) {
|
|
5300
|
+
if (report.error === undefined) {
|
|
5301
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
|
|
5302
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
|
|
5303
|
+
}
|
|
4796
5304
|
write(JSON.stringify(report));
|
|
4797
5305
|
return report.error !== undefined ? 1 : 0;
|
|
4798
5306
|
}
|
|
@@ -4810,6 +5318,8 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4810
5318
|
write(` ⚠ still in the previous embedding space: ${report.staleTaskTypes.join(', ')}`);
|
|
4811
5319
|
if (report.staleTaskTypes.includes('book-knowledge')) write(' run \`dz brain reindex\` to rebuild the brain\'s book vectors');
|
|
4812
5320
|
}
|
|
5321
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
|
|
5322
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
|
|
4813
5323
|
return 0;
|
|
4814
5324
|
}
|
|
4815
5325
|
|
|
@@ -4866,7 +5376,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4866
5376
|
// harmonize (alias: dz teach --harmonize) — SEMANTIC dedup of the learned store, NON-DESTRUCTIVE:
|
|
4867
5377
|
// dry-run by default (previews clusters, writes nothing); --apply drops after a restorable backup.
|
|
4868
5378
|
if (sub === 'harmonize') {
|
|
4869
|
-
return runHarmonize(projectRoot, options, flags, write);
|
|
5379
|
+
return runHarmonize(projectRoot, options, flags, write, writeErr);
|
|
4870
5380
|
}
|
|
4871
5381
|
|
|
4872
5382
|
// import <file.rvf> — the missing HALF of the RVF cycle: UPSERT-BY-dzId, never overwrites.
|
|
@@ -4878,6 +5388,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4878
5388
|
}
|
|
4879
5389
|
const report = await importRvfCheckpoint(projectRoot, resolve(cwd, src), {});
|
|
4880
5390
|
if (flags.has('json')) {
|
|
5391
|
+
if (report.error === undefined) refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
|
|
4881
5392
|
write(JSON.stringify(report));
|
|
4882
5393
|
return report.error !== undefined ? 1 : 0;
|
|
4883
5394
|
}
|
|
@@ -4890,6 +5401,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4890
5401
|
if (report.skippedOrphans > 0) {
|
|
4891
5402
|
write(' ↳ orphan vectors have no local pattern — import the text first: dz teach --from-json <recall-export.json>, then re-run dz vector import');
|
|
4892
5403
|
}
|
|
5404
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
|
|
4893
5405
|
return 0;
|
|
4894
5406
|
}
|
|
4895
5407
|
|
|
@@ -6339,6 +6851,19 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6339
6851
|
}
|
|
6340
6852
|
}
|
|
6341
6853
|
|
|
6854
|
+
const filterStr = options.get('filter');
|
|
6855
|
+
// SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
|
|
6856
|
+
// Parse before the guard pre-flight so its packed-secret scan uses the SAME scoped package set
|
|
6857
|
+
// that publishPackages receives below; an empty resulting list remains an explicit error.
|
|
6858
|
+
let filter: string[] | undefined;
|
|
6859
|
+
if (filterStr !== undefined) {
|
|
6860
|
+
filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
6861
|
+
if (filter.length === 0) {
|
|
6862
|
+
write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
|
|
6863
|
+
return 1;
|
|
6864
|
+
}
|
|
6865
|
+
}
|
|
6866
|
+
|
|
6342
6867
|
// dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
|
|
6343
6868
|
// it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
|
|
6344
6869
|
// is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
|
|
@@ -6350,7 +6875,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6350
6875
|
write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
|
|
6351
6876
|
return 1;
|
|
6352
6877
|
}
|
|
6353
|
-
const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
|
|
6878
|
+
const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard, filter);
|
|
6354
6879
|
if (guardResult.verdict === 'block' && noGuard === undefined) {
|
|
6355
6880
|
write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
|
|
6356
6881
|
for (const v of guardResult.violations.filter((x) => x.severity === 'hard')) write(` [BLOCK] ${v.rule}: ${v.detail}`);
|
|
@@ -6391,21 +6916,6 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6391
6916
|
const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
|
|
6392
6917
|
|
|
6393
6918
|
const bumpOnly = flags.has('bump-only');
|
|
6394
|
-
const filterStr = options.get('filter');
|
|
6395
|
-
// SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
|
|
6396
|
-
// Without this, `--filter ""` (e.g. an unset shell var) or a stray comma yields
|
|
6397
|
-
// [''] / ['', 'core'], and publishPackages matches with name.includes(''), which
|
|
6398
|
-
// is true for EVERY package — silently turning a scoped publish into a
|
|
6399
|
-
// whole-monorepo publish. An empty resulting list is an explicit error, never
|
|
6400
|
-
// "match all".
|
|
6401
|
-
let filter: string[] | undefined;
|
|
6402
|
-
if (filterStr !== undefined) {
|
|
6403
|
-
filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
6404
|
-
if (filter.length === 0) {
|
|
6405
|
-
write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
|
|
6406
|
-
return 1;
|
|
6407
|
-
}
|
|
6408
|
-
}
|
|
6409
6919
|
|
|
6410
6920
|
// SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
|
|
6411
6921
|
// via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
|
|
@@ -6599,6 +7109,13 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6599
7109
|
if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
|
|
6600
7110
|
write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
|
|
6601
7111
|
}
|
|
7112
|
+
// A dry run stops before build/sign/pack, so it says NOTHING about the gates below that line.
|
|
7113
|
+
// Printing what it did not check is what keeps a clean preview from reading as a clean publish
|
|
7114
|
+
// (measured 2026-09-02: a clean dry run preceded a RED real gate).
|
|
7115
|
+
if (pkg.notVerified && pkg.notVerified.length > 0) {
|
|
7116
|
+
write(` ⓘ холостой прогон НЕ проверял (${pkg.notVerified.length}):`);
|
|
7117
|
+
for (const item of pkg.notVerified) write(` · ${item}`);
|
|
7118
|
+
}
|
|
6602
7119
|
}
|
|
6603
7120
|
return report.errors > 0 ? 1 : 0;
|
|
6604
7121
|
}
|
|
@@ -8357,7 +8874,18 @@ function cmdAgentsSync(
|
|
|
8357
8874
|
const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
|
|
8358
8875
|
writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
|
|
8359
8876
|
for (const finding of drifted) writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
|
|
8360
|
-
|
|
8877
|
+
const unregistered = drifted.filter((finding) => finding.status === 'unregistered-section');
|
|
8878
|
+
if (unregistered.length > 0) {
|
|
8879
|
+
// ПОДСКАЗКА, ВЕДУЩАЯ НЕ ТУДА, ХУЖЕ ОТСУТСТВУЮЩЕЙ. Повторный `dz agents-sync` эту находку
|
|
8880
|
+
// НЕ лечит: реестр POLICY_SOURCES ведётся руками, и секция, не вписанная в него, не
|
|
8881
|
+
// попадёт в проекцию сколько ни синхронизируй. Раньше здесь печаталась общая подсказка —
|
|
8882
|
+
// читатель прогнал бы её и снова увидел ту же ошибку.
|
|
8883
|
+
writeErr('→ heal with: объяви секцию в POLICY_SOURCES (packages/@dzhechkov/harness-core/src/agents-policy.ts):');
|
|
8884
|
+
for (const finding of unregistered) {
|
|
8885
|
+
writeErr(` { id: '${finding.id}', file: '${finding.file}', heading: '…', why: '…', operativeClause: '…' }`);
|
|
8886
|
+
}
|
|
8887
|
+
writeErr(' затем: dz agents-sync');
|
|
8888
|
+
} else if (drifted.some((finding) => finding.id === 'dz:policies')) {
|
|
8361
8889
|
writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
|
|
8362
8890
|
} else {
|
|
8363
8891
|
writeErr('→ heal with: dz agents-sync');
|
|
@@ -8448,8 +8976,11 @@ const DEFAULT_STORE_CAP = 5000;
|
|
|
8448
8976
|
*/
|
|
8449
8977
|
const MAX_STUB_SCAN_FILES = 400;
|
|
8450
8978
|
|
|
8451
|
-
/**
|
|
8452
|
-
|
|
8979
|
+
/** Maximum packed-file size read by the publish secret scan. */
|
|
8980
|
+
const SECRET_SCAN_MAX_BYTES = 512 * 1024;
|
|
8981
|
+
|
|
8982
|
+
/** Read the optional `.dz/guard.json` — `{ rules?, storeCap?, stubWaivers?, secretWaivers? }`. Missing/broken ⇒ defaults. */
|
|
8983
|
+
function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; secretWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
|
|
8453
8984
|
const p = join(root, '.dz', 'guard.json');
|
|
8454
8985
|
if (!existsSync(p)) return {};
|
|
8455
8986
|
try {
|
|
@@ -8475,6 +9006,51 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
|
|
|
8475
9006
|
// (target repo without sitedoc — missing-evidence contract). But a file that EXISTS and no longer
|
|
8476
9007
|
// matches its anchored pattern emits a MISMATCH pair (a: -1) — silent non-extraction is the exact
|
|
8477
9008
|
// disease this contour cures (AM-1 applies to the guard path too, not only the CI test).
|
|
9009
|
+
// ЗНАЧКИ ПРОТИВ ДЕРЕВА, а не только README против README (бэклог 9cb30764).
|
|
9010
|
+
//
|
|
9011
|
+
// Прежде правило сверяло только числа МЕЖДУ документами: два согласованных документа могли
|
|
9012
|
+
// хором утверждать одно и то же неверное число, и правило молчало. Значок «пакетов: 52» стоял
|
|
9013
|
+
// при 55 публикуемых на диске — ИЗМЕРЕНО 2026-09-03. Меню обещает 32 блюда, официант
|
|
9014
|
+
// перечисляет 30, кухня готовит 38, и никто в ресторане не знает правду.
|
|
9015
|
+
//
|
|
9016
|
+
// Сверяются только числа, ВЫЧИСЛИМЫЕ ИЗ ДЕРЕВА. Значок «опубликовано в npm» сюда НЕ входит и
|
|
9017
|
+
// это сказано вслух: его источник — реестр, а не рабочая копия, и пара, которая делает вид, что
|
|
9018
|
+
// проверила его, была бы хуже отсутствующей.
|
|
9019
|
+
const badge = (s: string, name: string): number | null => {
|
|
9020
|
+
const m = s.match(new RegExp(`img\\.shields\\.io/badge/${name}-(\\d+)`));
|
|
9021
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
9022
|
+
};
|
|
9023
|
+
const publishablePackages = ((): number | null => {
|
|
9024
|
+
const dir = join(root, 'packages', '@dzhechkov');
|
|
9025
|
+
if (!existsSync(dir)) return null;
|
|
9026
|
+
let n = 0;
|
|
9027
|
+
for (const name of readdirSync(dir)) {
|
|
9028
|
+
const pj = join(dir, name, 'package.json');
|
|
9029
|
+
if (!existsSync(pj)) continue;
|
|
9030
|
+
try {
|
|
9031
|
+
const j = JSON.parse(readFileSync(pj, 'utf8')) as { private?: unknown };
|
|
9032
|
+
if (j.private !== true) n += 1;
|
|
9033
|
+
} catch { /* нечитаемый манифест — не считается ни в одну сторону */ }
|
|
9034
|
+
}
|
|
9035
|
+
return n;
|
|
9036
|
+
})();
|
|
9037
|
+
const pkgBadge = badge(rootMd, 'packages');
|
|
9038
|
+
if (pkgBadge !== null && publishablePackages !== null) {
|
|
9039
|
+
pairs.push({ label: 'packages (root badge vs publishable package.json on disk)', a: pkgBadge, b: publishablePackages });
|
|
9040
|
+
}
|
|
9041
|
+
const presetBadge = badge(rootMd, 'presets');
|
|
9042
|
+
if (presetBadge !== null) {
|
|
9043
|
+
pairs.push({ label: 'presets (root badge vs PRESET_NAMES in the build)', a: presetBadge, b: PRESET_NAMES.length });
|
|
9044
|
+
}
|
|
9045
|
+
const targetBadge = badge(rootMd, 'targets');
|
|
9046
|
+
if (targetBadge !== null) {
|
|
9047
|
+
pairs.push({ label: 'targets (root badge vs TARGET_NAMES in the build)', a: targetBadge, b: TARGET_NAMES.length });
|
|
9048
|
+
}
|
|
9049
|
+
const cmdBadge = badge(rootMd, 'CLI%20commands');
|
|
9050
|
+
if (cmdBadge !== null && cliAll !== null) {
|
|
9051
|
+
pairs.push({ label: 'commands (root badge vs cli All Commands)', a: cmdBadge, b: cliAll });
|
|
9052
|
+
}
|
|
9053
|
+
|
|
8478
9054
|
const sitePair = (rel: string, re: RegExp, label: string): void => {
|
|
8479
9055
|
if (cliAll === null || !existsSync(join(root, rel))) return;
|
|
8480
9056
|
const found = num(read(rel), re);
|
|
@@ -8880,8 +9456,9 @@ function gatherVolumeShadowFacts(
|
|
|
8880
9456
|
}
|
|
8881
9457
|
|
|
8882
9458
|
/** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
|
|
8883
|
-
function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
|
|
9459
|
+
function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number, publishFilter?: readonly string[]): Record<string, unknown> {
|
|
8884
9460
|
const facts: Record<string, unknown> = { op };
|
|
9461
|
+
const publishPackageRoots: string[] = [];
|
|
8885
9462
|
if (op === 'publish') {
|
|
8886
9463
|
// Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
|
|
8887
9464
|
// stale finding and never a publish blocker.
|
|
@@ -9013,6 +9590,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9013
9590
|
} catch { /* not a git repo */ }
|
|
9014
9591
|
const versionByName = new Map<string, string>();
|
|
9015
9592
|
for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
|
|
9593
|
+
publishPackageRoots.push(...located
|
|
9594
|
+
.filter(({ dir, m }) => m.private !== true && (
|
|
9595
|
+
publishFilter === undefined
|
|
9596
|
+
|| publishFilter.length === 0
|
|
9597
|
+
|| publishFilter.some((filter) => (m.name ?? '').includes(filter) || dir.includes(filter))
|
|
9598
|
+
))
|
|
9599
|
+
.map(({ dir }) => dir));
|
|
9016
9600
|
const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
|
|
9017
9601
|
const packages: { name: string; deps: Record<string, string> }[] = [];
|
|
9018
9602
|
for (const m of manifests) {
|
|
@@ -9026,6 +9610,82 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9026
9610
|
packages.push({ name: m.name ?? '(unnamed)', deps });
|
|
9027
9611
|
}
|
|
9028
9612
|
facts['packages'] = packages;
|
|
9613
|
+
// sibling-dep-protocol: сырые спеки, БЕЗ подстановки версии. Подставленная версия выглядела бы
|
|
9614
|
+
// как обычный диапазон, и правило потеряло бы ровно то, что проверяет.
|
|
9615
|
+
const siblingDeps: { name: string; field: string; dep: string; spec: string }[] = [];
|
|
9616
|
+
for (const m of manifests) {
|
|
9617
|
+
if (m.private === true) continue;
|
|
9618
|
+
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
|
|
9619
|
+
for (const field of fields) {
|
|
9620
|
+
const table = (m as unknown as Record<string, unknown>)[field];
|
|
9621
|
+
if (typeof table !== 'object' || table === null) continue;
|
|
9622
|
+
for (const [dep, spec] of Object.entries(table as Record<string, unknown>)) {
|
|
9623
|
+
if (!dep.startsWith('@dzhechkov/') || typeof spec !== 'string') continue;
|
|
9624
|
+
siblingDeps.push({ name: m.name ?? '(unnamed)', field, dep, spec });
|
|
9625
|
+
}
|
|
9626
|
+
}
|
|
9627
|
+
}
|
|
9628
|
+
facts['siblingDeps'] = siblingDeps;
|
|
9629
|
+
// plugin-manifest-audit: каждый `.claude-plugin/plugin.json` в дереве. Обход ограничен по
|
|
9630
|
+
// глубине и не заходит в node_modules/dist — чужие манифесты не наши, и краснеть на них
|
|
9631
|
+
// значило бы отчитываться о том, чего мы не публикуем.
|
|
9632
|
+
const pluginManifests: { path: string; parseError?: string; name?: string; version?: string; description?: string }[] = [];
|
|
9633
|
+
const walkPlugins = (dir: string, depth: number): void => {
|
|
9634
|
+
if (depth > 4) return;
|
|
9635
|
+
let entries: Dirent[];
|
|
9636
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
9637
|
+
for (const e of entries) {
|
|
9638
|
+
// `out/` — СГЕНЕРИРОВАННОЕ публичное зеркало: те же манифесты, скопированные. Дефект в нём
|
|
9639
|
+
// есть дефект генератора, и он уже сообщается по источнику; вторая копия только удвоила бы
|
|
9640
|
+
// одну и ту же находку.
|
|
9641
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name === 'out') continue;
|
|
9642
|
+
const full = join(dir, e.name);
|
|
9643
|
+
if (e.name === '.claude-plugin') {
|
|
9644
|
+
const manifest = join(full, 'plugin.json');
|
|
9645
|
+
if (!existsSync(manifest)) continue;
|
|
9646
|
+
try {
|
|
9647
|
+
const j = JSON.parse(readFileSync(manifest, 'utf8')) as Record<string, unknown>;
|
|
9648
|
+
const pick = (k: string): string | undefined => (typeof j[k] === 'string' ? j[k] as string : undefined);
|
|
9649
|
+
// Состав навыков — ОБЕ стороны инвентаризации, и ТОЛЬКО для коробки ОДНОГО ПАКЕТА.
|
|
9650
|
+
//
|
|
9651
|
+
// Корневая витрина сюда НЕ входит, и это не упрощение: её состав собирается из всего
|
|
9652
|
+
// монорепозитория через реестр, «что лежит на складе» для неё — не обход одного дерева,
|
|
9653
|
+
// а весь реестр, и ровно это уже проверяет `marketplace-parity` регенерацией. Первая
|
|
9654
|
+
// редакция этой проверки обошла корень с ограничением глубины и выдала 27 ЛОЖНЫХ
|
|
9655
|
+
// «объявлено, но не найдено» — навыки лежали глубже границы обхода (ИЗМЕРЕНО 2026-09-04,
|
|
9656
|
+
// поймано до коммита прогоном стража на этом же дереве).
|
|
9657
|
+
//
|
|
9658
|
+
// Сравниваются ПУТИ, как их объявил манифест, а не имена: два навыка с одинаковым
|
|
9659
|
+
// именем в разных подкаталогах — законная вещь, и сведение к имени их бы склеило.
|
|
9660
|
+
const boxRoot = dirname(full);
|
|
9661
|
+
const isRepoRoot = resolve(boxRoot) === resolve(root);
|
|
9662
|
+
const declaredSkills = Array.isArray(j['skills']) && !isRepoRoot
|
|
9663
|
+
? (j['skills'] as unknown[]).filter((x): x is string => typeof x === 'string')
|
|
9664
|
+
.map((rel) => rel.replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean)
|
|
9665
|
+
: undefined;
|
|
9666
|
+
const skillsOnDisk = declaredSkills === undefined ? undefined : findSkillDirs(boxRoot);
|
|
9667
|
+
pluginManifests.push({
|
|
9668
|
+
path: relative(root, manifest),
|
|
9669
|
+
...(pick('name') !== undefined ? { name: pick('name')! } : {}),
|
|
9670
|
+
...(pick('version') !== undefined ? { version: pick('version')! } : {}),
|
|
9671
|
+
...(pick('description') !== undefined ? { description: pick('description')! } : {}),
|
|
9672
|
+
...(declaredSkills !== undefined ? { declaredSkills } : {}),
|
|
9673
|
+
...(skillsOnDisk !== undefined ? { skillsOnDisk } : {}),
|
|
9674
|
+
});
|
|
9675
|
+
} catch (error) {
|
|
9676
|
+
pluginManifests.push({
|
|
9677
|
+
path: relative(root, manifest),
|
|
9678
|
+
parseError: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 200),
|
|
9679
|
+
});
|
|
9680
|
+
}
|
|
9681
|
+
continue;
|
|
9682
|
+
}
|
|
9683
|
+
if (e.name.startsWith('.')) continue;
|
|
9684
|
+
walkPlugins(full, depth + 1);
|
|
9685
|
+
}
|
|
9686
|
+
};
|
|
9687
|
+
walkPlugins(root, 0);
|
|
9688
|
+
facts['pluginManifests'] = pluginManifests;
|
|
9029
9689
|
facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
|
|
9030
9690
|
dir,
|
|
9031
9691
|
name: m.name ?? dir,
|
|
@@ -9053,7 +9713,43 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9053
9713
|
}
|
|
9054
9714
|
facts['licenceHold'] = holds;
|
|
9055
9715
|
} catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
|
|
9056
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope:
|
|
9716
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
9717
|
+
// backlog-covers-features: каталоги фич, дата их ПОЯВЛЕНИЯ В ИСТОРИИ (не mtime — его двигает
|
|
9718
|
+
// любой посторонний процесс), оговорки из README фичи и тексты записей бэклога. Базовая дата
|
|
9719
|
+
// делает правило зелёным на приходе: 336 существующих каталогов заведены до правила.
|
|
9720
|
+
// Нечитаемое дерево ⇒ факт НЕ выставляется ⇒ правило молчит, а не выдумывает вердикт.
|
|
9721
|
+
try {
|
|
9722
|
+
const featDir = join(root, 'features');
|
|
9723
|
+
if (existsSync(featDir)) {
|
|
9724
|
+
const slugs = readdirSync(featDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
9725
|
+
const features = slugs.map((slug) => {
|
|
9726
|
+
let createdIso = '';
|
|
9727
|
+
try {
|
|
9728
|
+
createdIso = execSync(`git log --diff-filter=A --format=%aI -1 -- ${JSON.stringify('features/' + slug)}`,
|
|
9729
|
+
{ cwd: root, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).pop() ?? '';
|
|
9730
|
+
} catch { /* нет в истории — функция засчитает как новый, это верный дефолт */ }
|
|
9731
|
+
let waiver: string | undefined;
|
|
9732
|
+
for (const f of ['README.md', '07_code_changes/change_manifest.md']) {
|
|
9733
|
+
try {
|
|
9734
|
+
const m = readFileSync(join(featDir, slug, f), 'utf-8')
|
|
9735
|
+
.match(/^\s*Backlog:\s*не заведено\s*[—-]\s*(.+)$/m);
|
|
9736
|
+
if (m && m[1] && m[1].trim() !== '') { waiver = m[1].trim(); break; }
|
|
9737
|
+
} catch { /* нет файла — не оговорка */ }
|
|
9738
|
+
}
|
|
9739
|
+
return { slug, createdIso, waiver };
|
|
9740
|
+
});
|
|
9741
|
+
const backlogTexts: string[] = [];
|
|
9742
|
+
try {
|
|
9743
|
+
for (const line of readFileSync(join(root, '.dz', 'backlog', 'ideas.jsonl'), 'utf-8').split('\n')) {
|
|
9744
|
+
if (line.trim() === '') continue;
|
|
9745
|
+
try { const o = JSON.parse(line) as { text?: unknown }; if (typeof o.text === 'string') backlogTexts.push(o.text); } catch { /* рваная строка */ }
|
|
9746
|
+
}
|
|
9747
|
+
} catch { /* стора нет */ }
|
|
9748
|
+
if (backlogTexts.length > 0) {
|
|
9749
|
+
facts['featureBacklog'] = { baseline: BACKLOG_COVERAGE_BASELINE, features, backlogTexts };
|
|
9750
|
+
}
|
|
9751
|
+
}
|
|
9752
|
+
} catch { /* нечитаемо — правило молчит */ }
|
|
9057
9753
|
facts['counts'] = gatherReadmeCounts(root);
|
|
9058
9754
|
// readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
|
|
9059
9755
|
// change set contain its package.json (the version-bump signal) without its README.md?
|
|
@@ -9213,11 +9909,39 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9213
9909
|
|
|
9214
9910
|
// no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
|
|
9215
9911
|
// MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
|
|
9216
|
-
const
|
|
9912
|
+
const guardConfig = loadGuardConfig(root);
|
|
9913
|
+
const stubWaivers = guardConfig.stubWaivers;
|
|
9217
9914
|
if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
|
|
9915
|
+
const secretWaivers = guardConfig.secretWaivers;
|
|
9916
|
+
if (Array.isArray(secretWaivers)) facts['secretWaivers'] = secretWaivers;
|
|
9218
9917
|
}
|
|
9219
9918
|
if (op === 'consolidate') {
|
|
9220
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope:
|
|
9919
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
9920
|
+
}
|
|
9921
|
+
if (op === 'publish') {
|
|
9922
|
+
const secretTargets: { label: string; text: string }[] = [];
|
|
9923
|
+
let skipped = 0;
|
|
9924
|
+
for (const dir of publishPackageRoots) {
|
|
9925
|
+
const packageRoot = join(root, dir);
|
|
9926
|
+
let packed: string[];
|
|
9927
|
+
try { packed = listPackFiles(packageRoot); }
|
|
9928
|
+
catch { skipped++; continue; }
|
|
9929
|
+
for (const rel of packed) {
|
|
9930
|
+
const absolute = join(packageRoot, rel);
|
|
9931
|
+
try {
|
|
9932
|
+
const stat = lstatSync(absolute);
|
|
9933
|
+
if (!stat.isFile() || stat.size > SECRET_SCAN_MAX_BYTES) { skipped++; continue; }
|
|
9934
|
+
const content = readFileSync(absolute);
|
|
9935
|
+
if (content.subarray(0, 8 * 1024).includes(0)) { skipped++; continue; }
|
|
9936
|
+
secretTargets.push({
|
|
9937
|
+
label: relative(root, absolute).split(sep).join('/'),
|
|
9938
|
+
text: content.toString('utf8'),
|
|
9939
|
+
});
|
|
9940
|
+
} catch { skipped++; }
|
|
9941
|
+
}
|
|
9942
|
+
}
|
|
9943
|
+
if (secretTargets.length > 0) facts['secretTargets'] = secretTargets;
|
|
9944
|
+
if (skipped > 0) facts['secretScan'] = { skipped };
|
|
9221
9945
|
}
|
|
9222
9946
|
if (op === 'teach' || op === 'consolidate') {
|
|
9223
9947
|
if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
|
|
@@ -9233,13 +9957,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9233
9957
|
* shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
|
|
9234
9958
|
* `overrideReason` (when the caller forces through a block) is logged, never silent.
|
|
9235
9959
|
*/
|
|
9236
|
-
function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined): ReturnType<typeof evaluateGuard> {
|
|
9960
|
+
function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined, publishFilter?: readonly string[]): ReturnType<typeof evaluateGuard> {
|
|
9237
9961
|
const cfg = loadGuardConfig(root);
|
|
9238
9962
|
// Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
|
|
9239
9963
|
// silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
|
|
9240
9964
|
const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
|
|
9241
9965
|
const rules = resolveRules(Array.isArray(cfg.rules) ? (cfg.rules as never[]) : undefined);
|
|
9242
|
-
const facts = gatherGuardFacts(op, root, text, storeCap);
|
|
9966
|
+
const facts = gatherGuardFacts(op, root, text, storeCap, publishFilter);
|
|
9243
9967
|
const result = evaluateGuard(facts as never, rules);
|
|
9244
9968
|
// audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
|
|
9245
9969
|
try {
|
|
@@ -10038,15 +10762,52 @@ async function cmdMrRakes(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10038
10762
|
* --threshold N drill threshold (default 2 — anti-noise: a first-seen rake accrues, never drills)
|
|
10039
10763
|
* --no-teach drill only; do NOT write the store (skip the agent side)
|
|
10040
10764
|
* --project <dir> pin the teach ledger
|
|
10041
|
-
* --install-hook print the opt-in
|
|
10765
|
+
* --install-hook print the opt-in hook set to add (non-destructive): Stop scan-tail +
|
|
10766
|
+
* PreCompact/SessionEnd full retro (feature narrated-error-must-be-taught)
|
|
10767
|
+
* --scan-tail per-turn Stop-hook mode: incremental admission-debt scan, O(new bytes) —
|
|
10768
|
+
* writes/clears .dz/retro-pending.json; no ledger, no teach, no git subprocess
|
|
10769
|
+
* --transcript <p> the transcript --scan-tail must read. Without it the Stop hook's own stdin
|
|
10770
|
+
* payload (`transcript_path`) is used; with neither, the scan REFUSES
|
|
10771
|
+
* (NOT-ESTABLISHED) rather than guessing the newest file on disk
|
|
10042
10772
|
*/
|
|
10043
|
-
async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
|
|
10773
|
+
async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, readStdin: () => string = () => ''): Promise<number> {
|
|
10774
|
+
if (flags.has('scan-tail')) {
|
|
10775
|
+
// Per-turn primary detector (ADR-001 D2). Cost budget IS the design: no `git rev-parse`
|
|
10776
|
+
// subprocess (hooks run with cwd = project root; CLAUDE_PROJECT_DIR pins it), no store open,
|
|
10777
|
+
// O(new bytes) via the persisted offset. Always exit 0 — a Stop hook must never fail a turn.
|
|
10778
|
+
const root = process.env['CLAUDE_PROJECT_DIR'] ?? cwd;
|
|
10779
|
+
// WHICH transcript. Round 3, P1-3: this used to fall back to `findLatestTranscript(root)`, so with
|
|
10780
|
+
// several sessions and their subagents alive at once it scanned whichever file had the newest
|
|
10781
|
+
// mtime — routinely another session's, advancing that session's offset and never seeing this
|
|
10782
|
+
// turn's admission. The Stop hook hands the exact path on stdin; with no path from any source the
|
|
10783
|
+
// answer is a stated refusal, never a guess. Still exit 0: a Stop hook must never fail a turn.
|
|
10784
|
+
const picked = resolveScanTailTranscript({
|
|
10785
|
+
flag: options.get('transcript'),
|
|
10786
|
+
positional: options.get('_positional_0'),
|
|
10787
|
+
stdin: readStdin(),
|
|
10788
|
+
});
|
|
10789
|
+
if (picked.path === null) {
|
|
10790
|
+
const reason = picked.reason ?? 'no transcript path';
|
|
10791
|
+
if (flags.has('json')) write(JSON.stringify({ status: 'not-established', source: 'none', reason, scannedBytes: 0, offset: 0 }));
|
|
10792
|
+
else write(`retro scan-tail: NOT-ESTABLISHED — ${reason}. Wire the hook as \`dz retro --scan-tail\` (Claude Code pipes the payload on stdin) or pass \`--transcript <path>\`.`);
|
|
10793
|
+
return 0;
|
|
10794
|
+
}
|
|
10795
|
+
const outcome = runRetroTailScan(join(root, '.dz'), picked.path);
|
|
10796
|
+
if (flags.has('json')) write(JSON.stringify({ ...outcome, source: picked.source }));
|
|
10797
|
+
else if (outcome.status === 'pending') write(`retro scan-tail: unpaid admission — .dz/retro-pending.json armed («${(outcome.snippet ?? '').slice(0, 60)}…»)`);
|
|
10798
|
+
return 0;
|
|
10799
|
+
}
|
|
10800
|
+
|
|
10044
10801
|
let repoRoot = cwd;
|
|
10045
10802
|
try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
|
|
10046
10803
|
|
|
10047
10804
|
if (flags.has('install-hook')) {
|
|
10048
|
-
write('Add
|
|
10049
|
-
write(JSON.stringify({ hooks: {
|
|
10805
|
+
write('Add these opt-in hooks to .claude/settings.json (per-turn debt scan + retro at compaction AND session end — PreCompact covers the crash/disconnect sessions SessionEnd never sees):');
|
|
10806
|
+
write(JSON.stringify({ hooks: {
|
|
10807
|
+
Stop: [{ hooks: [{ type: 'command', command: 'dz retro --scan-tail', timeout: 10000, continueOnError: true }] }],
|
|
10808
|
+
PreCompact: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
|
|
10809
|
+
SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
|
|
10810
|
+
} }, null, 2));
|
|
10050
10811
|
return 0;
|
|
10051
10812
|
}
|
|
10052
10813
|
|
|
@@ -10283,6 +11044,43 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
|
|
|
10283
11044
|
* decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
|
|
10284
11045
|
* a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
|
|
10285
11046
|
*/
|
|
11047
|
+
/**
|
|
11048
|
+
* Есть ли в этом каталоге НЕЗАКОММИЧЕННЫЕ правки. Это и отличает «фича ещё в рабочем дереве»
|
|
11049
|
+
* (тогда `HEAD` — законная предфичевая база) от «фича уже закоммичена» (тогда `HEAD` её содержит).
|
|
11050
|
+
*
|
|
11051
|
+
* Не удалось спросить git — возвращается null, и вызывающий обязан считать положение НЕ
|
|
11052
|
+
* УСТАНОВЛЕННЫМ, а не выбрать удобный ответ.
|
|
11053
|
+
*/
|
|
11054
|
+
/**
|
|
11055
|
+
* Каталоги с `SKILL.md` внутри коробки ОДНОГО пакета — то, что РЕАЛЬНО лежит на складе.
|
|
11056
|
+
* Возвращаются ПУТИ относительно коробки, как их объявляет манифест, а не имена.
|
|
11057
|
+
*/
|
|
11058
|
+
function findSkillDirs(boxRoot: string): string[] {
|
|
11059
|
+
const found = new Set<string>();
|
|
11060
|
+
const walk = (dir: string, depth: number): void => {
|
|
11061
|
+
if (depth > 4) return;
|
|
11062
|
+
let entries: Dirent[];
|
|
11063
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
11064
|
+
for (const e of entries) {
|
|
11065
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
|
|
11066
|
+
const full = join(dir, e.name);
|
|
11067
|
+
if (existsSync(join(full, 'SKILL.md'))) found.add(relative(boxRoot, full));
|
|
11068
|
+
walk(full, depth + 1);
|
|
11069
|
+
}
|
|
11070
|
+
};
|
|
11071
|
+
walk(boxRoot, 0);
|
|
11072
|
+
return [...found];
|
|
11073
|
+
}
|
|
11074
|
+
|
|
11075
|
+
function hasUncommittedChangesIn(repoRoot: string, dir: string): boolean | null {
|
|
11076
|
+
try {
|
|
11077
|
+
const out = execSync(`git status --porcelain -- ${JSON.stringify(dir)}`, { cwd: repoRoot, encoding: 'utf-8' });
|
|
11078
|
+
return out.split('\n').some((line) => line.trim() !== '');
|
|
11079
|
+
} catch {
|
|
11080
|
+
return null;
|
|
11081
|
+
}
|
|
11082
|
+
}
|
|
11083
|
+
|
|
10286
11084
|
function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
10287
11085
|
let repoRoot = cwd;
|
|
10288
11086
|
try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
|
|
@@ -10295,7 +11093,22 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10295
11093
|
const nameFilter = options.get('name');
|
|
10296
11094
|
const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) =>
|
|
10297
11095
|
nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
|
|
10298
|
-
|
|
11096
|
+
/**
|
|
11097
|
+
* УМОЛЧАНИЕ БАЗЫ РАЗЛИЧАЕТ ДВА РАЗНЫХ ПОЛОЖЕНИЯ, потому что они и правда разные.
|
|
11098
|
+
*
|
|
11099
|
+
* В штатном ходе конвейера правка Шага 7 ЕЩЁ НЕ ЗАКОММИЧЕНА, и тогда `HEAD` — настоящая
|
|
11100
|
+
* предфичевая база; так конвейер и зовёт гейт (`--base HEAD`, причина записана в его тексте).
|
|
11101
|
+
*
|
|
11102
|
+
* Но если фича УЖЕ ЗАКОММИЧЕНА, `HEAD` её содержит, и гейт сравнивает фичу С САМОЙ СОБОЙ.
|
|
11103
|
+
* ИЗМЕРЕНО 2026-09-04: такой прогон дал уверенное `NON_DISCRIMINATING` с советом «усилить тест»
|
|
11104
|
+
* на тестах, которые дискриминируют; тот же прогон с верной базой дал `DISCRIMINATES_VIA_ERROR`.
|
|
11105
|
+
* Находка выглядела как утверждение О ТЕСТАХ, и автор пошёл бы чинить исправное.
|
|
11106
|
+
*
|
|
11107
|
+
* Различить эти положения можно ДЕТЕРМИНИРОВАННО: есть ли незакоммиченные правки в пакете, чьи
|
|
11108
|
+
* тесты названы. Есть — `HEAD` законен и используется как раньше. Нет — база не установлена, и
|
|
11109
|
+
* гейт ОТКАЗЫВАЕТСЯ (exit 3), вместо того чтобы измерять относительно самого себя.
|
|
11110
|
+
*/
|
|
11111
|
+
const explicitBase = options.get('base');
|
|
10299
11112
|
const runnerOpt = options.get('runner');
|
|
10300
11113
|
// R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
|
|
10301
11114
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
@@ -10338,9 +11151,35 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10338
11151
|
// The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
|
|
10339
11152
|
// absolute one — an absolute path is refused as unsafe-package-dir by design.
|
|
10340
11153
|
const packageDirRel = relative(repoRoot, packageDir) || '.';
|
|
11154
|
+
|
|
11155
|
+
// Умолчание базы решается ЗДЕСЬ, потому что только здесь известен пакет, чьи тесты названы.
|
|
11156
|
+
let baseRef: string;
|
|
11157
|
+
if (explicitBase !== undefined && explicitBase.trim() !== '') {
|
|
11158
|
+
baseRef = explicitBase.trim();
|
|
11159
|
+
} else {
|
|
11160
|
+
const dirty = hasUncommittedChangesIn(repoRoot, packageDirRel);
|
|
11161
|
+
if (dirty !== true) {
|
|
11162
|
+
write('dz discrimination-check: NOT-ESTABLISHED — предфичевая база не установлена.');
|
|
11163
|
+
write(dirty === null
|
|
11164
|
+
? ` Не удалось спросить git о состоянии ${packageDirRel}; выбирать удобный ответ вместо этого нельзя.`
|
|
11165
|
+
: ` В ${packageDirRel} нет незакоммиченных правок, значит фича УЖЕ в HEAD, и сравнение шло бы с самой собой.`);
|
|
11166
|
+
write(' Гейт сравнивает поведение ДО и ПОСЛЕ фичи; без базы сравнивать не с чем, а HEAD здесь');
|
|
11167
|
+
write(' дал бы уверенное NON_DISCRIMINATING на исправных тестах (ИЗМЕРЕНО 2026-09-04).');
|
|
11168
|
+
write(' Передайте --base <коммит перед фичей>, например `<sha коммита фичи>^`.');
|
|
11169
|
+
return 3;
|
|
11170
|
+
}
|
|
11171
|
+
// Штатный ход конвейера: правка Шага 7 ещё в рабочем дереве, HEAD — настоящая предфичевая база.
|
|
11172
|
+
baseRef = 'HEAD';
|
|
11173
|
+
write(`dz discrimination-check: база не задана; в ${packageDirRel} есть незакоммиченные правки, беру HEAD как предфичевую базу.`);
|
|
11174
|
+
}
|
|
11175
|
+
|
|
11176
|
+
// Провенанс базы едет в квитанцию вместе с самой базой: «явно указано» — утверждение о действии
|
|
11177
|
+
// человека, и писать его для HEAD, выбранного инструментом, значит подделывать происхождение
|
|
11178
|
+
// доказательства, на которое сошлются позже.
|
|
11179
|
+
const baseRefSupplied = explicitBase !== undefined && explicitBase.trim() !== '';
|
|
10341
11180
|
const planInput = runnerOpt !== undefined
|
|
10342
|
-
? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
|
|
10343
|
-
: { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
|
|
11181
|
+
? { baseRef, baseRefSupplied, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
|
|
11182
|
+
: { baseRef, baseRefSupplied, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
|
|
10344
11183
|
const plan = planDiscriminationCheck(planInput);
|
|
10345
11184
|
|
|
10346
11185
|
if (!plan.runnable) {
|
|
@@ -10720,13 +11559,33 @@ function cmdMutationGate(
|
|
|
10720
11559
|
}
|
|
10721
11560
|
|
|
10722
11561
|
let entries: readonly MutationRegistryEntry[] = parsed.registry.entries;
|
|
11562
|
+
let entryResults: readonly MutationEntryResult[] = parsed.entryResults;
|
|
10723
11563
|
const only = options.get('only');
|
|
10724
11564
|
if (only !== undefined) {
|
|
10725
11565
|
const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
|
|
10726
|
-
const known = new Set(
|
|
11566
|
+
const known = new Set([
|
|
11567
|
+
...entries.map((entry) => entry.id),
|
|
11568
|
+
...entryResults.map((result) => result.id),
|
|
11569
|
+
]);
|
|
10727
11570
|
const unknown = ids.filter((id) => !known.has(id));
|
|
10728
11571
|
if (unknown.length > 0) return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
|
|
10729
11572
|
entries = entries.filter((e) => ids.includes(e.id));
|
|
11573
|
+
entryResults = entryResults.filter((result) => ids.includes(result.id));
|
|
11574
|
+
}
|
|
11575
|
+
|
|
11576
|
+
if (entries.length === 0) {
|
|
11577
|
+
const scope = only === undefined ? 'registry' : 'selected registry entries';
|
|
11578
|
+
const error = `${scope} has no runnable entries after validation — nothing can be run; the registry is unusable`;
|
|
11579
|
+
const summary = summarizeMutationResults(entryResults);
|
|
11580
|
+
if (json) {
|
|
11581
|
+
write(JSON.stringify({ error, registryPath, results: entryResults, summary, exitCode: 2 }, null, 2));
|
|
11582
|
+
} else {
|
|
11583
|
+
write(`dz mutation-gate: ${error}`);
|
|
11584
|
+
for (const result of entryResults) {
|
|
11585
|
+
write(` - ${result.id}: ${result.verdict} — ${result.detail}`);
|
|
11586
|
+
}
|
|
11587
|
+
}
|
|
11588
|
+
return 2;
|
|
10730
11589
|
}
|
|
10731
11590
|
|
|
10732
11591
|
const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
|
|
@@ -10762,7 +11621,7 @@ function cmdMutationGate(
|
|
|
10762
11621
|
let gitTop: string | null = null;
|
|
10763
11622
|
try { gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null; } catch { /* not in a git repo */ }
|
|
10764
11623
|
let copyDir = join(scratchParent, 'pkg');
|
|
10765
|
-
const results: MutationEntryResult[] = [];
|
|
11624
|
+
const results: MutationEntryResult[] = [...entryResults];
|
|
10766
11625
|
const observations: MutationObservation[] = [];
|
|
10767
11626
|
const warnings: string[] = [];
|
|
10768
11627
|
const internalRetries: {
|
|
@@ -10895,8 +11754,8 @@ function cmdMutationGate(
|
|
|
10895
11754
|
: undefined,
|
|
10896
11755
|
);
|
|
10897
11756
|
if (!baseline.ok) {
|
|
10898
|
-
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results
|
|
10899
|
-
write(renderMutationReport(
|
|
11757
|
+
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
|
|
11758
|
+
write(renderMutationReport(results, baseline, pkgDir));
|
|
10900
11759
|
return 1;
|
|
10901
11760
|
}
|
|
10902
11761
|
|
|
@@ -12242,9 +13101,24 @@ function nameCheckScan(repoRoot: string): NameFacts {
|
|
|
12242
13101
|
// Command names come from the dispatcher AND from the help block: a name that dispatches but
|
|
12243
13102
|
// is undocumented is still taken, and so is the reverse.
|
|
12244
13103
|
if (f.name === 'cli.ts') {
|
|
12245
|
-
|
|
12246
|
-
|
|
12247
|
-
|
|
13104
|
+
// ONE enumeration, every consumer derives (ADR-001, feature command-count-triad). This
|
|
13105
|
+
// scan's question is "is the name TAKEN?", so taken = dispatched ∪ documented, which is
|
|
13106
|
+
// legitimately LARGER than the canonical command count — but it must be the SAME parse
|
|
13107
|
+
// the layer-1 parity test uses, not a second private regex that agrees by coincidence.
|
|
13108
|
+
// The any-indent `dispatchedCommandsIn` fallback stays for a cli.ts WITHOUT a main
|
|
13109
|
+
// `switch (command)` (none in this workspace today): a partial sweep would answer "free"
|
|
13110
|
+
// about a taken name, and that is the one answer this command may never give.
|
|
13111
|
+
let mainSwitchParsed = false;
|
|
13112
|
+
try {
|
|
13113
|
+
for (const c of dispatchedCommands(text)) commands.add(c);
|
|
13114
|
+
for (const c of documentedCommands(text)) commands.add(c);
|
|
13115
|
+
mainSwitchParsed = true;
|
|
13116
|
+
} catch { /* no main switch here — fall back to the broad regexes below */ }
|
|
13117
|
+
if (!mainSwitchParsed) {
|
|
13118
|
+
for (const c of dispatchedCommandsIn(text)) commands.add(c);
|
|
13119
|
+
const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
|
|
13120
|
+
for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
|
|
13121
|
+
}
|
|
12248
13122
|
}
|
|
12249
13123
|
}
|
|
12250
13124
|
}
|
|
@@ -12258,6 +13132,71 @@ function nameCheckScan(repoRoot: string): NameFacts {
|
|
|
12258
13132
|
return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
|
|
12259
13133
|
}
|
|
12260
13134
|
|
|
13135
|
+
/**
|
|
13136
|
+
* `dz brief-check <файл>` — проверить бриф роя на контракт вывода (ADR-001 swarm-brief-output-contract).
|
|
13137
|
+
*
|
|
13138
|
+
* Разбирает объявления брифа как ДАННЫЕ и отказывает поимённо: «бриф неверен» не говорит автору,
|
|
13139
|
+
* что чинить, поэтому каждое нарушение называет ключ и причину.
|
|
13140
|
+
*
|
|
13141
|
+
* ЧЕСТНЫЙ ПРЕДЕЛ печатается ВМЕСТЕ С ЗЕЛЁНЫМ ответом: проверено, что бриф ОБЪЯВИЛ каталог и
|
|
13142
|
+
* единицы, а не что агент им последует. Зелёная проверка, читаемая как гарантия поведения, хуже
|
|
13143
|
+
* её отсутствия.
|
|
13144
|
+
*/
|
|
13145
|
+
function cmdBriefCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
13146
|
+
const json = flags.has('json');
|
|
13147
|
+
/**
|
|
13148
|
+
* ВЕТКА «ПРОВЕРИТЬ НЕ УДАЛОСЬ» ТОЖЕ ОБЯЗАНА ОТВЕТИТЬ JSON-ом (находка 11).
|
|
13149
|
+
*
|
|
13150
|
+
* `--json` печатал человеческую строку на несуществующем файле, и потребитель, читающий вывод как
|
|
13151
|
+
* JSON, получал ошибку разбора вместо структурного «не проверено». Признак `checked` — тот же
|
|
13152
|
+
* трихотомический вердикт, что и коды выхода, только для машины: не «бриф плох», а «мы про него
|
|
13153
|
+
* ничего не установили».
|
|
13154
|
+
*/
|
|
13155
|
+
const unchecked = (reason: string, detail: string): number => {
|
|
13156
|
+
if (json) write(JSON.stringify({ ok: false, checked: false, reason, detail }));
|
|
13157
|
+
else write(detail);
|
|
13158
|
+
return 2;
|
|
13159
|
+
};
|
|
13160
|
+
// Только позиционный аргумент: `--file` был необъявленным псевдонимом, и страж дрейфа флагов
|
|
13161
|
+
// справедливо на него указал — лишняя поверхность, которой нет в справке.
|
|
13162
|
+
const file = options.get('_positional_0');
|
|
13163
|
+
if (file === undefined || file.trim() === '') {
|
|
13164
|
+
const code = unchecked('no-file', 'dz brief-check: name the brief file — dz brief-check <file> [--json]');
|
|
13165
|
+
if (!json) write(' A brief must declare: ' + SWARM_BRIEF_CONTRACT.map((c: { key: string }) => c.key).join(', '));
|
|
13166
|
+
return code;
|
|
13167
|
+
}
|
|
13168
|
+
let text: string;
|
|
13169
|
+
try {
|
|
13170
|
+
text = readFileSync(resolve(cwd, file), 'utf-8');
|
|
13171
|
+
} catch {
|
|
13172
|
+
// Нечитаемый файл — НЕ «бриф плох»: мы про него ничего не установили. Отдельный код выхода,
|
|
13173
|
+
// чтобы отказ прибора не смешивался с отказом брифа.
|
|
13174
|
+
return unchecked('unreadable', `dz brief-check: cannot read ${visibleText(file)}`);
|
|
13175
|
+
}
|
|
13176
|
+
const result = checkSwarmBrief(text);
|
|
13177
|
+
if (json) {
|
|
13178
|
+
// `checked: true` — вторая половина того же различителя: потребитель отличает «проверено и
|
|
13179
|
+
// отвергнуто» от «проверить не удалось» полем, а не отсутствием поля.
|
|
13180
|
+
write(JSON.stringify({ ...result, checked: true }));
|
|
13181
|
+
return result.ok ? 0 : 1;
|
|
13182
|
+
}
|
|
13183
|
+
if (result.ok) {
|
|
13184
|
+
// ЗНАЧЕНИЯ ИЗ БРИФА ОБЕЗВРЕЖИВАЮТСЯ И В ЗЕЛЁНОЙ СТРОКЕ (находка 10). Отказ их уже обезвредил,
|
|
13185
|
+
// но подделывается ровно эта строка: управляющая последовательность в имени каталога стирает
|
|
13186
|
+
// предыдущий вывод и печатает поверх него подделку.
|
|
13187
|
+
write(`dz brief-check: OK — dir ${visibleText(result.outputDir ?? '')}, ${result.units.length} unit(s), assembly "${visibleText(result.assemblyUnit ?? '')}"`);
|
|
13188
|
+
write(' LIMIT: this verifies the brief DECLARED the contract, not that the agent will follow it —');
|
|
13189
|
+
write(' only comparing the directory against the unit list on an orchestrator tick can show that.');
|
|
13190
|
+
write(' A filled-in template and an unedited one both pass: the parse cannot tell them apart.');
|
|
13191
|
+
return 0;
|
|
13192
|
+
}
|
|
13193
|
+
write(`dz brief-check: REFUSED — ${result.violations.length} violation(s)`);
|
|
13194
|
+
// Ядро уже обезвредило значения в причинах; повтор на слое печати — не суеверие, а граница:
|
|
13195
|
+
// печатающий слой не обязан знать, кто именно из его источников уже почистил текст.
|
|
13196
|
+
for (const v of result.violations) write(` ${v.rule}: ${visibleText(v.detail)}`);
|
|
13197
|
+
return 1;
|
|
13198
|
+
}
|
|
13199
|
+
|
|
12261
13200
|
function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
12262
13201
|
const repoRoot = resolve(options.get('project') ?? cwd);
|
|
12263
13202
|
const json = flags.has('json');
|
|
@@ -13141,12 +14080,24 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
13141
14080
|
// Paths in an amendment row are repo-relative, so they resolve against the repo root — not
|
|
13142
14081
|
// against the feature directory, and not against wherever the caller happened to stand.
|
|
13143
14082
|
const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
|
|
14083
|
+
// A document that opens `## Amendments` twice cannot have its first heading answer for the
|
|
14084
|
+
// rest (Codex round 6, P2). Counted per document and taken at its worst — one contradictory
|
|
14085
|
+
// input is enough to make the run inconclusive.
|
|
14086
|
+
const sectionCount = Math.max(
|
|
14087
|
+
ideation === null ? 0 : amendmentSectionCount(ideation),
|
|
14088
|
+
plan === null ? 0 : amendmentSectionCount(plan),
|
|
14089
|
+
);
|
|
13144
14090
|
const decision = decideAmendmentOutcome({
|
|
13145
14091
|
sectionPresent,
|
|
13146
14092
|
rows,
|
|
13147
14093
|
resolutions,
|
|
13148
14094
|
planSaysNone: plan !== null && planSaysNoAmendments(plan),
|
|
13149
14095
|
missingFromPlan,
|
|
14096
|
+
sectionCount,
|
|
14097
|
+
// Fail-closed: measured on the document the rows would have come from (the plan when it has
|
|
14098
|
+
// a section, otherwise the ideation report).
|
|
14099
|
+
ambiguity: (plan !== null ? amendmentDeclarationAmbiguity(plan) : null)
|
|
14100
|
+
?? (ideation !== null ? amendmentDeclarationAmbiguity(ideation) : null),
|
|
13150
14101
|
});
|
|
13151
14102
|
return { slug, decision, resolutions };
|
|
13152
14103
|
};
|
|
@@ -15459,7 +16410,9 @@ function cmdDeliveryCheck(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15459
16410
|
/* ------------------------------------------------------------------ */
|
|
15460
16411
|
|
|
15461
16412
|
/** Thin dispatcher — ALL logic lives in harness-core/src/backlog.ts (05 architecture: handlers stay dumb). */
|
|
15462
|
-
async function cmdBacklog(
|
|
16413
|
+
async function cmdBacklog(
|
|
16414
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
16415
|
+
): Promise<number> {
|
|
15463
16416
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
15464
16417
|
const json = flags.has('json');
|
|
15465
16418
|
const sub = options.get('_positional_0');
|
|
@@ -15477,6 +16430,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15477
16430
|
const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
|
|
15478
16431
|
if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
|
|
15479
16432
|
const dryRun = flags.has('dry-run');
|
|
16433
|
+
if (!dryRun && !allowLearningStoreWrite(projectRoot, flags, writeErr, 'dz backlog add')) return 1;
|
|
15480
16434
|
// Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
|
|
15481
16435
|
// bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
|
|
15482
16436
|
// (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
|
|
@@ -15505,6 +16459,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15505
16459
|
const ideas = readIdeas(projectRoot);
|
|
15506
16460
|
const match = ideas.find((i) => i.id === verdict.matchedId);
|
|
15507
16461
|
let absorbErr: string | undefined;
|
|
16462
|
+
let didWrite = false;
|
|
15508
16463
|
if (!dryRun && match !== undefined) {
|
|
15509
16464
|
const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
|
|
15510
16465
|
if (snap.error !== undefined) return emitErr(snap.error);
|
|
@@ -15520,7 +16475,9 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15520
16475
|
}).error;
|
|
15521
16476
|
match.uses += 1;
|
|
15522
16477
|
writeIdeas(projectRoot, ideas);
|
|
16478
|
+
didWrite = true;
|
|
15523
16479
|
}
|
|
16480
|
+
if (didWrite) refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
|
|
15524
16481
|
if (json) write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}), ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), ...(dryRun ? {} : { absorbedLogged: absorbErr === undefined, ...(absorbErr !== undefined ? { absorbedLogError: absorbErr } : {}) }), exitCode: 0 }, null, 2));
|
|
15525
16482
|
else {
|
|
15526
16483
|
const via = verdict.subsetMatch === true
|
|
@@ -15576,6 +16533,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15576
16533
|
ideas.push(rec);
|
|
15577
16534
|
writeIdeas(projectRoot, ideas);
|
|
15578
16535
|
const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
|
|
16536
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
|
|
15579
16537
|
if (json) write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
|
|
15580
16538
|
else {
|
|
15581
16539
|
write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
|
|
@@ -15742,8 +16700,18 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15742
16700
|
if (commitId !== undefined) {
|
|
15743
16701
|
// Validated above (safe id + known id) BEFORE any early return.
|
|
15744
16702
|
const idx = ideas.findIndex((i) => i.id === commitId);
|
|
16703
|
+
// ОТМЕТКА ВРЕМЕНИ И ЖУРНАЛ ставятся ЗДЕСЬ, а не «где-нибудь потом».
|
|
16704
|
+
// Измерено 2026-09-02: `grep -c statusTs cli.ts` давал НОЛЬ — оба пути смены статуса в этом
|
|
16705
|
+
// файле меняли поле и не отмечали, когда. Отсюда 22 терминальные записи без отметки и
|
|
16706
|
+
// невычислимое «сколько идея пробыла в работе».
|
|
16707
|
+
const spinFrom = ideas[idx]!.status;
|
|
16708
|
+
const spinTs = new Date().toISOString();
|
|
15745
16709
|
ideas[idx]!.status = 'in-progress';
|
|
16710
|
+
ideas[idx]!.statusTs = spinTs;
|
|
15746
16711
|
writeIdeas(projectRoot, ideas);
|
|
16712
|
+
if (!appendTransition(projectRoot, { id: commitId, from: spinFrom, to: 'in-progress', ts: spinTs, by: 'backlog roulette --commit' })) {
|
|
16713
|
+
write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
|
|
16714
|
+
}
|
|
15747
16715
|
pick = ideas[idx]!;
|
|
15748
16716
|
committed = true;
|
|
15749
16717
|
}
|
|
@@ -15807,6 +16775,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15807
16775
|
if (mirror.mirrored > 0 && mirror.error === undefined && clearEmbedStale(projectRoot, report.id)) embed = 'ok';
|
|
15808
16776
|
else embed = 'stale';
|
|
15809
16777
|
} else embed = 'stale';
|
|
16778
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog edit');
|
|
15810
16779
|
}
|
|
15811
16780
|
if (json) {
|
|
15812
16781
|
write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
|
|
@@ -15834,9 +16803,15 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15834
16803
|
const goalMap = readGoalMap(projectRoot);
|
|
15835
16804
|
const related = ideas.filter((i) => rec.relatedIds.includes(i.id));
|
|
15836
16805
|
const staging = stageEnrichment(projectRoot, rec, related, goalMap);
|
|
16806
|
+
const enrichFrom = rec.status;
|
|
16807
|
+
const enrichTs = new Date().toISOString();
|
|
15837
16808
|
rec.status = 'enriched';
|
|
16809
|
+
rec.statusTs = enrichTs;
|
|
15838
16810
|
rec.enrichedPath = `features/${staging.slug}`;
|
|
15839
16811
|
writeIdeas(projectRoot, ideas);
|
|
16812
|
+
if (!appendTransition(projectRoot, { id: rec.id, from: enrichFrom, to: 'enriched', ts: enrichTs, by: 'backlog enrich' })) {
|
|
16813
|
+
write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
|
|
16814
|
+
}
|
|
15840
16815
|
if (json) write(JSON.stringify({ slug: staging.slug, scaffoldPath: staging.scaffoldPath, handoff: 'idea2prd-manual', exitCode: 0 }, null, 2));
|
|
15841
16816
|
else {
|
|
15842
16817
|
write(`dz backlog enrich: staged ${rec.id} → ${staging.scaffoldPath}`);
|
|
@@ -15876,6 +16851,10 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15876
16851
|
if (form.action === 'migrated' && !json) write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
|
|
15877
16852
|
else if (form.action === 'deferred' && !json) write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
|
|
15878
16853
|
const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
|
|
16854
|
+
if (apply) {
|
|
16855
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog harmonize --apply');
|
|
16856
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz backlog harmonize --apply');
|
|
16857
|
+
}
|
|
15879
16858
|
if (json) {
|
|
15880
16859
|
write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
|
|
15881
16860
|
return 0;
|
|
@@ -16693,15 +17672,25 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16693
17672
|
io.teachReinforceRunner ?? runTeachGuardReinforcement,
|
|
16694
17673
|
);
|
|
16695
17674
|
case 'consolidate':
|
|
16696
|
-
return await cmdConsolidate(options, flags, cwd, write);
|
|
17675
|
+
return await cmdConsolidate(options, flags, cwd, write, writeErr);
|
|
16697
17676
|
case 'recall':
|
|
16698
17677
|
return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
|
|
16699
17678
|
case 'vector':
|
|
16700
|
-
return await cmdVector(options, flags, cwd, write);
|
|
17679
|
+
return await cmdVector(options, flags, cwd, write, writeErr);
|
|
16701
17680
|
case 'brain':
|
|
16702
17681
|
return await cmdBrain(options, flags, cwd, write, readStdin);
|
|
16703
17682
|
case 'statusline':
|
|
16704
|
-
return cmdStatusline(options, flags, cwd, write, readStdin);
|
|
17683
|
+
return cmdStatusline(options, flags, cwd, write, readStdin, writeErr);
|
|
17684
|
+
case 'store-guard':
|
|
17685
|
+
return await cmdStoreGuard(
|
|
17686
|
+
options,
|
|
17687
|
+
flags,
|
|
17688
|
+
cwd,
|
|
17689
|
+
write,
|
|
17690
|
+
writeErr,
|
|
17691
|
+
io.stdin,
|
|
17692
|
+
io.interactive ?? process.stdin.isTTY === true,
|
|
17693
|
+
);
|
|
16705
17694
|
case 'usage':
|
|
16706
17695
|
return cmdUsage(options, optionLists, flags, cwd, write);
|
|
16707
17696
|
case 'chain':
|
|
@@ -16769,7 +17758,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16769
17758
|
case 'mr-rakes':
|
|
16770
17759
|
return await cmdMrRakes(options, flags, cwd, write);
|
|
16771
17760
|
case 'retro':
|
|
16772
|
-
return await cmdRetro(options, flags, cwd, write);
|
|
17761
|
+
return await cmdRetro(options, flags, cwd, write, readStdin);
|
|
16773
17762
|
case 'feature-adr-setup':
|
|
16774
17763
|
return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
|
|
16775
17764
|
case 'challenge':
|
|
@@ -16814,6 +17803,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16814
17803
|
return cmdTgPost(options, flags, cwd, write);
|
|
16815
17804
|
case 'name-check':
|
|
16816
17805
|
return cmdNameCheck(options, flags, cwd, write);
|
|
17806
|
+
case 'brief-check':
|
|
17807
|
+
return cmdBriefCheck(options, flags, cwd, write);
|
|
16817
17808
|
case 'provenance-check':
|
|
16818
17809
|
return cmdProvenanceCheck(options, flags, cwd, write);
|
|
16819
17810
|
case 'feature-adr-record':
|
|
@@ -16831,7 +17822,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16831
17822
|
case 'qe-bridge':
|
|
16832
17823
|
return await cmdQeBridge(options, flags, cwd, write);
|
|
16833
17824
|
case 'backlog':
|
|
16834
|
-
return await cmdBacklog(options, flags, cwd, write);
|
|
17825
|
+
return await cmdBacklog(options, flags, cwd, write, writeErr);
|
|
16835
17826
|
case 'routing':
|
|
16836
17827
|
return cmdRouting(options, flags, cwd, write);
|
|
16837
17828
|
case 'bto-optimize':
|