@dzhechkov/harness-cli 0.8.9 → 0.8.15
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 +291 -20
- 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 +44 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1635 -191
- 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 +14 -13
- package/sbom.json +71 -21
- package/src/boolean-flags.ts +3 -0
- package/src/cli.ts +1681 -138
- 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,9 +11,10 @@ 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
|
-
import { homedir, tmpdir } from 'node:os';
|
|
17
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
17
18
|
import { createRequire } from 'node:module';
|
|
18
19
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
20
|
|
|
@@ -30,6 +31,8 @@ import {
|
|
|
30
31
|
runDoctor,
|
|
31
32
|
runInit,
|
|
32
33
|
discoverSkillIds,
|
|
34
|
+
resolveSelection,
|
|
35
|
+
formatSelectRefusal,
|
|
33
36
|
runIntegrationsVerify,
|
|
34
37
|
resolvePackageSkillRoots,
|
|
35
38
|
PACKAGE_SKILL_LAYOUTS,
|
|
@@ -144,8 +147,17 @@ import {
|
|
|
144
147
|
reindexVectorStore,
|
|
145
148
|
harmonizeVectorStore,
|
|
146
149
|
importRvfCheckpoint,
|
|
150
|
+
renderFeatureAdrPhaseLine,
|
|
147
151
|
statuslineData,
|
|
152
|
+
countLearningStoreRowsReadonly,
|
|
153
|
+
readStoreMark,
|
|
154
|
+
writeStoreMark,
|
|
155
|
+
resetStoreMark,
|
|
156
|
+
checkStoreHealth,
|
|
157
|
+
storeGuardPath,
|
|
158
|
+
storeSnapshotPath,
|
|
148
159
|
writeFeatureAdrState,
|
|
160
|
+
writeFeatureAdrStateDetailed,
|
|
149
161
|
CHECKPOINT_STAGES,
|
|
150
162
|
estimateEta,
|
|
151
163
|
extractStageSamples,
|
|
@@ -155,6 +167,9 @@ import {
|
|
|
155
167
|
type CheckpointStage,
|
|
156
168
|
type EtaEstimate,
|
|
157
169
|
type FeatureAdrState,
|
|
170
|
+
type StoreHealth,
|
|
171
|
+
type StoreMark,
|
|
172
|
+
type StoreCountSnapshot,
|
|
158
173
|
type RunSegment,
|
|
159
174
|
type StageSample,
|
|
160
175
|
computeUsage,
|
|
@@ -185,6 +200,9 @@ import {
|
|
|
185
200
|
patternRecordId,
|
|
186
201
|
patternIdentityOf,
|
|
187
202
|
mergeLessonMatchedForms,
|
|
203
|
+
SWARM_BRIEF_CONTRACT,
|
|
204
|
+
checkSwarmBrief,
|
|
205
|
+
visibleText,
|
|
188
206
|
loadStoreRecords,
|
|
189
207
|
recordToPattern,
|
|
190
208
|
bundleSkills,
|
|
@@ -210,11 +228,14 @@ import {
|
|
|
210
228
|
readTailInfo,
|
|
211
229
|
appendChainedLines,
|
|
212
230
|
verifyEventChainText,
|
|
231
|
+
classifyChainDefects,
|
|
232
|
+
CHAINED_JOURNALS,
|
|
213
233
|
buildManifest,
|
|
214
234
|
buildSbom,
|
|
215
235
|
resolveTrustRoot,
|
|
216
236
|
decideVerifyPolicy,
|
|
217
237
|
generateSigningKeypair,
|
|
238
|
+
appendTransition,
|
|
218
239
|
evaluateGuard,
|
|
219
240
|
resolveRules,
|
|
220
241
|
auditRecord,
|
|
@@ -255,6 +276,7 @@ import {
|
|
|
255
276
|
isInsideTree,
|
|
256
277
|
signManifest,
|
|
257
278
|
verifyManifest,
|
|
279
|
+
listPackFiles,
|
|
258
280
|
listSignablePackFiles,
|
|
259
281
|
assertKeyOutsideTree,
|
|
260
282
|
decidePublishGate,
|
|
@@ -289,12 +311,14 @@ import {
|
|
|
289
311
|
DEFAULT_RAKE_THRESHOLDS,
|
|
290
312
|
streamSessionEvents,
|
|
291
313
|
findLatestTranscript,
|
|
314
|
+
resolveScanTailTranscript,
|
|
292
315
|
detectProcessRakes,
|
|
293
316
|
buildRetro,
|
|
294
317
|
renderRetro,
|
|
295
318
|
retroLessonText,
|
|
296
319
|
PROCESS_SIGNATURES,
|
|
297
320
|
RETRO_DOMAIN,
|
|
321
|
+
runRetroTailScan,
|
|
298
322
|
scanForSetup,
|
|
299
323
|
buildSetupPlan,
|
|
300
324
|
scaffoldFromSpec,
|
|
@@ -371,6 +395,11 @@ import {
|
|
|
371
395
|
type EpochOutcome,
|
|
372
396
|
scoreRun,
|
|
373
397
|
readQeGrade,
|
|
398
|
+
scoreReceiptToAggregateRow,
|
|
399
|
+
readScoreAggregateRows,
|
|
400
|
+
dedupeScoreAggregateRows,
|
|
401
|
+
buildScoreAggregateReport,
|
|
402
|
+
renderScoreAggregateReport,
|
|
374
403
|
recapWindow,
|
|
375
404
|
decideHorizon,
|
|
376
405
|
withinWindow,
|
|
@@ -478,6 +507,7 @@ import {
|
|
|
478
507
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
479
508
|
parseMutationRegistry,
|
|
480
509
|
applyMutationToText,
|
|
510
|
+
attributeBaselineRedness,
|
|
481
511
|
countFailingTests,
|
|
482
512
|
detectSuiteCompletionReceipt,
|
|
483
513
|
detectSuiteReceiptMismatch,
|
|
@@ -487,6 +517,7 @@ import {
|
|
|
487
517
|
mutationGateExitCode,
|
|
488
518
|
summarizeMutationResults,
|
|
489
519
|
renderMutationReport,
|
|
520
|
+
runWithOneInternalRetry,
|
|
490
521
|
TRACE_BUNDLE_LEDGER_PATH,
|
|
491
522
|
TRACE_BUNDLE_SCHEMA,
|
|
492
523
|
TRACE_BUNDLE_RUN_META_FILE,
|
|
@@ -496,6 +527,8 @@ import {
|
|
|
496
527
|
planImport,
|
|
497
528
|
decideCheckpointWrite,
|
|
498
529
|
amendmentSection,
|
|
530
|
+
amendmentSectionCount,
|
|
531
|
+
amendmentDeclarationAmbiguity,
|
|
499
532
|
planSaysNoAmendments,
|
|
500
533
|
parseAmendments,
|
|
501
534
|
resolveAmendments,
|
|
@@ -558,11 +591,36 @@ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, Reca
|
|
|
558
591
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
559
592
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
560
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
|
+
|
|
561
619
|
/** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
|
|
562
620
|
export const DZ_COMMANDS: readonly string[] = [
|
|
563
621
|
'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
|
|
564
622
|
'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
|
|
565
|
-
'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
|
|
623
|
+
'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline', 'store-guard',
|
|
566
624
|
'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
|
|
567
625
|
'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
|
|
568
626
|
'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
|
|
@@ -571,9 +629,9 @@ export const DZ_COMMANDS: readonly string[] = [
|
|
|
571
629
|
'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
|
|
572
630
|
'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
|
|
573
631
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
574
|
-
'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',
|
|
575
633
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
576
|
-
'bto-optimize', 'dashboard', 'roam', 'import-ecc',
|
|
634
|
+
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
577
635
|
];
|
|
578
636
|
|
|
579
637
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -615,27 +673,29 @@ Usage:
|
|
|
615
673
|
dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
|
|
616
674
|
dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
|
|
617
675
|
dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
|
|
676
|
+
dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
|
|
618
677
|
dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
|
|
619
678
|
dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
|
|
620
679
|
dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
|
|
621
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)
|
|
622
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)
|
|
623
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)
|
|
624
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)
|
|
625
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)
|
|
626
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)
|
|
627
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)
|
|
628
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)
|
|
629
689
|
dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
|
|
630
|
-
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)
|
|
631
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)
|
|
632
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)
|
|
633
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)
|
|
634
694
|
dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
|
|
635
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)
|
|
636
|
-
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
|
|
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)
|
|
637
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)
|
|
638
|
-
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)
|
|
639
699
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
640
700
|
dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
|
|
641
701
|
dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
|
|
@@ -649,7 +709,7 @@ Usage:
|
|
|
649
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)
|
|
650
710
|
dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
|
|
651
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)
|
|
652
|
-
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)
|
|
653
713
|
dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
|
|
654
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)
|
|
655
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)
|
|
@@ -669,9 +729,11 @@ Usage:
|
|
|
669
729
|
dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
|
|
670
730
|
dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
|
|
671
731
|
dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
|
|
672
|
-
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>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; a plain "Step <n>" label that goes BACKWARDS against a slot younger than 90 min is absorbed as a stale duplicate — prefix the label with ⛔ or ⏸ to record a legitimate regression)
|
|
673
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)
|
|
674
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)
|
|
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)
|
|
675
737
|
dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
|
|
676
738
|
dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
|
|
677
739
|
dz pretrain [--project <dir>]
|
|
@@ -696,6 +758,10 @@ Usage:
|
|
|
696
758
|
dz dashboard
|
|
697
759
|
dz roam [--apply] [--slug <slug>]
|
|
698
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)
|
|
699
765
|
dz help
|
|
700
766
|
|
|
701
767
|
Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
|
|
@@ -717,6 +783,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
|
|
|
717
783
|
Targets: ${TARGET_NAMES.join(', ')}
|
|
718
784
|
Presets: ${PRESET_NAMES.join(', ')}`;
|
|
719
785
|
|
|
786
|
+
export interface MutationGateRunnerObservation {
|
|
787
|
+
readonly exitCode: number | null;
|
|
788
|
+
readonly output: string;
|
|
789
|
+
readonly failureReason?: string;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export type MutationGateRunner = (
|
|
793
|
+
command: string,
|
|
794
|
+
options: { readonly cwd: string; readonly timeoutMs: number },
|
|
795
|
+
) => MutationGateRunnerObservation;
|
|
796
|
+
|
|
720
797
|
/** Output sink + working directory — injectable so the CLI is testable. */
|
|
721
798
|
export interface CliIo {
|
|
722
799
|
readonly cwd?: string;
|
|
@@ -761,6 +838,8 @@ export interface CliIo {
|
|
|
761
838
|
* offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
|
|
762
839
|
*/
|
|
763
840
|
readonly installRunner?: (command: string, cwd: string) => void;
|
|
841
|
+
/** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
|
|
842
|
+
readonly mutationGateRunner?: MutationGateRunner;
|
|
764
843
|
}
|
|
765
844
|
|
|
766
845
|
/** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
|
|
@@ -850,6 +929,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
|
|
|
850
929
|
|
|
851
930
|
/** Outcome of {@link installSkills}. */
|
|
852
931
|
interface InstallSkillsResult {
|
|
932
|
+
/**
|
|
933
|
+
* Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
|
|
934
|
+
* caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
|
|
935
|
+
* set nothing has been written yet — the refusal is decided before the first byte.
|
|
936
|
+
*/
|
|
937
|
+
readonly selectRefusal?: string;
|
|
853
938
|
readonly results: { id: string; written: number; skipped: number }[];
|
|
854
939
|
readonly dirsSearched: number;
|
|
855
940
|
readonly written: number;
|
|
@@ -885,11 +970,44 @@ async function installSkills(opts: {
|
|
|
885
970
|
noIntegrations?: boolean;
|
|
886
971
|
noVerify?: boolean;
|
|
887
972
|
allowIntegrations?: string;
|
|
973
|
+
writeErr?: (line: string) => void;
|
|
888
974
|
}): Promise<InstallSkillsResult> {
|
|
889
975
|
const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
|
|
890
976
|
|
|
891
977
|
const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
|
|
892
978
|
|
|
979
|
+
// PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
|
|
980
|
+
//
|
|
981
|
+
// Two defects lived in asking each root independently instead of resolving the request: a skill
|
|
982
|
+
// present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
|
|
983
|
+
// one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
|
|
984
|
+
// `0 skill(s)` reading as success. Both are gone once the decision happens here.
|
|
985
|
+
//
|
|
986
|
+
// Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
|
|
987
|
+
// half-configured project, which is worse than either clean outcome. This runs before the loop
|
|
988
|
+
// below and before every target adapter.
|
|
989
|
+
//
|
|
990
|
+
// Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
|
|
991
|
+
// count and the exit contract, and gives that work a base it can trust.
|
|
992
|
+
if (select !== undefined) {
|
|
993
|
+
const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
994
|
+
const resolution = resolveSelection(select, roots);
|
|
995
|
+
for (const shadow of resolution.shadowed) {
|
|
996
|
+
opts.writeErr?.(
|
|
997
|
+
`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
|
|
998
|
+
`installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
1002
|
+
if (refusal !== null) {
|
|
1003
|
+
return {
|
|
1004
|
+
selectRefusal: refusal,
|
|
1005
|
+
results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
|
|
1006
|
+
missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
893
1011
|
// agents-md and gemini are FLATTENING single-file targets: each must aggregate
|
|
894
1012
|
// every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
|
|
895
1013
|
// GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
|
|
@@ -1014,6 +1132,7 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
1014
1132
|
cwd,
|
|
1015
1133
|
explicitSkillsDir,
|
|
1016
1134
|
select,
|
|
1135
|
+
writeErr,
|
|
1017
1136
|
force: flags.has('force'),
|
|
1018
1137
|
enrich: flags.has('enrich'),
|
|
1019
1138
|
noHooks: flags.has('no-hooks'),
|
|
@@ -1022,6 +1141,13 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
1022
1141
|
...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
|
|
1023
1142
|
});
|
|
1024
1143
|
|
|
1144
|
+
// PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
|
|
1145
|
+
// Printed and returned here, before any target adapter runs — nothing has been written yet.
|
|
1146
|
+
if (r.selectRefusal !== undefined) {
|
|
1147
|
+
writeErr(r.selectRefusal);
|
|
1148
|
+
return 1;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1025
1151
|
// Codex keeps its established user-registry writer, but its result is normalized into the same
|
|
1026
1152
|
// two-outcome contract before JSON/human rendering. A write without a live ready observation is
|
|
1027
1153
|
// a refusal with applied=true, never a second success channel.
|
|
@@ -1323,7 +1449,7 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
1323
1449
|
|
|
1324
1450
|
try {
|
|
1325
1451
|
const scanTopics = topicsArg ? topicsArg.split(',').map((t) => t.trim()) : undefined;
|
|
1326
|
-
const { results: repos, totalBySource } = await scanAllSources({
|
|
1452
|
+
const { results: repos, totalBySource, statusBySource } = await scanAllSources({
|
|
1327
1453
|
token,
|
|
1328
1454
|
topics: scanTopics,
|
|
1329
1455
|
since,
|
|
@@ -1337,10 +1463,16 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
1337
1463
|
.join(', ');
|
|
1338
1464
|
write(`Sources: ${sourceLines}`);
|
|
1339
1465
|
|
|
1340
|
-
// Memory: diff with previous scan
|
|
1466
|
+
// Memory: diff with previous scan.
|
|
1467
|
+
//
|
|
1468
|
+
// СОСТОЯНИЕ ИСТОЧНИКОВ ПЕРЕДАЁТСЯ ОБЯЗАТЕЛЬНО. Без него разность не выводит исчезновений
|
|
1469
|
+
// вообще — и это правильно: источник, ответивший кодом ошибки, раньше делал ВСЕ свои записи
|
|
1470
|
+
// «пропавшими» на экране, то есть отчёт печатал факт о нашей сети как факт о мире.
|
|
1341
1471
|
if (showDiff || memory.size > 0) {
|
|
1342
|
-
const
|
|
1343
|
-
|
|
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) {
|
|
1344
1476
|
write(memory.diffMarkdown(diff));
|
|
1345
1477
|
} else if (memory.size > 0) {
|
|
1346
1478
|
write(`\nNo changes since last scan (${memory.size} repos tracked).\n`);
|
|
@@ -2143,6 +2275,49 @@ function cmdBundle(options: Map<string, string>, flags: Set<string>, cwd: string
|
|
|
2143
2275
|
return 0;
|
|
2144
2276
|
}
|
|
2145
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
|
+
|
|
2146
2321
|
async function cmdInstall(
|
|
2147
2322
|
options: Map<string, string>,
|
|
2148
2323
|
flags: Set<string>,
|
|
@@ -2194,14 +2369,36 @@ async function cmdInstall(
|
|
|
2194
2369
|
|
|
2195
2370
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2196
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
|
+
|
|
2197
2384
|
// Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
|
|
2198
2385
|
write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
|
|
2199
|
-
const installCmd =
|
|
2386
|
+
const installCmd = buildInstallCommand(specResolution.npmSpec, projectRoot);
|
|
2200
2387
|
try {
|
|
2201
2388
|
if (installRunner) installRunner(installCmd, projectRoot);
|
|
2202
|
-
|
|
2389
|
+
// БЕЗ ОБОЛОЧКИ. execFileSync с массивом аргументов не запускает shell, поэтому имя пакета или
|
|
2390
|
+
// путь с `$(...)` подставить нечему. Строка выше — для показа и для тестового шва, не для
|
|
2391
|
+
// исполнения (см. докстринг buildInstallCommand).
|
|
2392
|
+
else execFileSync('npm', [...buildInstallArgs(specResolution.npmSpec, projectRoot)], { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
2203
2393
|
} catch (err) {
|
|
2204
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 не делает.`);
|
|
2205
2402
|
return 1;
|
|
2206
2403
|
}
|
|
2207
2404
|
|
|
@@ -2462,7 +2659,7 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
|
|
|
2462
2659
|
* `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
|
|
2463
2660
|
* other value rather than silently weakening panel arbitration.
|
|
2464
2661
|
*/
|
|
2465
|
-
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 {
|
|
2466
2663
|
const slug = (options.get('slug') ?? '').trim();
|
|
2467
2664
|
const step = (options.get('step') ?? '').trim();
|
|
2468
2665
|
|
|
@@ -2522,19 +2719,42 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
|
|
|
2522
2719
|
return 1;
|
|
2523
2720
|
}
|
|
2524
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
|
+
|
|
2525
2732
|
const mode = options.get('mode');
|
|
2526
2733
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2527
|
-
const
|
|
2734
|
+
const outcome = writeFeatureAdrStateDetailed(projectRoot, {
|
|
2528
2735
|
kind: kindRaw, slug, step, recalled, stored,
|
|
2529
2736
|
...(reinforced > 0 ? { reinforced } : {}),
|
|
2530
2737
|
...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
|
|
2738
|
+
...(tier !== undefined ? { tier } : {}),
|
|
2531
2739
|
});
|
|
2740
|
+
const state = outcome.state;
|
|
2532
2741
|
|
|
2533
2742
|
if (state === undefined) {
|
|
2743
|
+
// [AM-5] A REFUSED slot write is LOUD. A held `fa-phase-slot` lock or an unwritable `.dz` used
|
|
2744
|
+
// to return a bare `undefined`, and a caller reading silence as success is exactly the
|
|
2745
|
+
// "absence of a receipt is not success" class. The reason goes to stderr — diagnosis, not
|
|
2746
|
+
// data — and the EXIT CODE is unchanged for a refusal, because the panel must never break the
|
|
2747
|
+
// pipeline that is only reporting to it.
|
|
2748
|
+
if (outcome.refused !== undefined) {
|
|
2749
|
+
writeErr(`fa-record: slot write refused (${slug}): ${outcome.refused}`);
|
|
2750
|
+
return 0;
|
|
2751
|
+
}
|
|
2534
2752
|
write(`dz statusline --fa-record: could not write learning state under ${projectRoot}/.dz/feature-adr/`);
|
|
2535
2753
|
return 1;
|
|
2536
2754
|
}
|
|
2537
|
-
|
|
2755
|
+
// state.step, not the flag: the monotonic guard may have kept a LATER step against a stale
|
|
2756
|
+
// duplicate record (fa-phase-statusline P1) — print what actually stands in the slot.
|
|
2757
|
+
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`);
|
|
2538
2758
|
return 0;
|
|
2539
2759
|
}
|
|
2540
2760
|
|
|
@@ -2686,8 +2906,12 @@ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: numbe
|
|
|
2686
2906
|
* least a minimal `dz` even on total failure.
|
|
2687
2907
|
*
|
|
2688
2908
|
* Flags: `--install` wires it into settings.json; `--fa-record` records a live `/feature-adr`
|
|
2689
|
-
* learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
|
|
2690
|
-
*
|
|
2909
|
+
* learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
|
|
2910
|
+
* (plus `featureAdrLine`, the rendered phase line, when a fresh /feature-adr run is in flight);
|
|
2911
|
+
* default prints the status line, with the 📐 phase panel as its OWN SECOND LINE (format B —
|
|
2912
|
+
* fa-phase-statusline ADR-001 D1; Claude Code renders every stdout line of a statusline command).
|
|
2913
|
+
* The ETA fragment main shipped for that panel rides the SECOND line with it (fa-phase-statusline ADR-001 D1) — the panel
|
|
2914
|
+
* moved, the estimate was not dropped.
|
|
2691
2915
|
*/
|
|
2692
2916
|
function cmdStatusline(
|
|
2693
2917
|
options: Map<string, string>,
|
|
@@ -2695,12 +2919,14 @@ function cmdStatusline(
|
|
|
2695
2919
|
cwd: string,
|
|
2696
2920
|
write: Write,
|
|
2697
2921
|
readStdin: () => string,
|
|
2922
|
+
writeErr: WriteErr,
|
|
2698
2923
|
): number {
|
|
2699
2924
|
if (flags.has('install')) return cmdStatuslineInstall(options, cwd, write);
|
|
2700
|
-
if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write);
|
|
2925
|
+
if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write, writeErr);
|
|
2701
2926
|
|
|
2702
2927
|
try {
|
|
2703
2928
|
const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
|
|
2929
|
+
warnLearningStoreRead(projectRoot, writeErr, 'dz statusline');
|
|
2704
2930
|
const data = statuslineData(projectRoot);
|
|
2705
2931
|
const fa = data.featureAdr;
|
|
2706
2932
|
let eta: EtaEstimate | undefined;
|
|
@@ -2716,27 +2942,55 @@ function cmdStatusline(
|
|
|
2716
2942
|
}
|
|
2717
2943
|
}
|
|
2718
2944
|
|
|
2945
|
+
// fa-phase-statusline (ADR-001 D1): the phase line renders from the slot ALONE — a pure
|
|
2946
|
+
// function over data.featureAdr, computed once here for both the plain and --json surfaces.
|
|
2947
|
+
const phaseLine = fa !== undefined ? renderFeatureAdrPhaseLine(fa) : undefined;
|
|
2948
|
+
|
|
2719
2949
|
if (flags.has('json')) {
|
|
2720
|
-
write(JSON.stringify({
|
|
2950
|
+
write(JSON.stringify({
|
|
2951
|
+
...data,
|
|
2952
|
+
...(eta !== undefined ? { eta } : {}),
|
|
2953
|
+
...(phaseLine !== undefined ? { featureAdrLine: phaseLine } : {}),
|
|
2954
|
+
}));
|
|
2721
2955
|
return 0;
|
|
2722
2956
|
}
|
|
2723
2957
|
|
|
2724
|
-
|
|
2958
|
+
const breakdown = data.patternBreakdown;
|
|
2959
|
+
let line = breakdown === undefined
|
|
2960
|
+
? `🎓 dz: ${data.patterns} patterns`
|
|
2961
|
+
: `🎓 dz: ${data.patterns} (${breakdown.active} актив${breakdown.quarantined > 0
|
|
2962
|
+
? ` · ${breakdown.quarantined} карантин${breakdown.attention ? ' ⚠' : ''}`
|
|
2963
|
+
: ''})${breakdown.tierDelta !== undefined ? ` ⚠ тиры Δ${breakdown.tierDelta}` : ''}`;
|
|
2964
|
+
if (data.storeHealth?.verdict === 'collapsed') {
|
|
2965
|
+
line += ` ⛔ ОБВАЛ: было ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
|
|
2966
|
+
} else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
|
|
2967
|
+
line += ` ⛔ СТОР ПУСТ: было ${data.storeHealth.previousMax ?? '?'} · восстановить из снимков ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
|
|
2968
|
+
} else if (data.storeHealth?.verdict === 'unreadable') {
|
|
2969
|
+
line += ` ⛔ СТОР НЕЧИТАЕМ${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
|
|
2970
|
+
? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
|
|
2971
|
+
} else if (data.storeHealth?.verdict === 'source-changed') {
|
|
2972
|
+
line += ' ⚠ смена источника хранения';
|
|
2973
|
+
}
|
|
2974
|
+
line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
|
|
2725
2975
|
const branch = statuslineGitBranch(projectRoot);
|
|
2726
2976
|
if (branch !== undefined) line += ` · ⎇ ${branch}`;
|
|
2727
2977
|
if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
|
|
2728
2978
|
|
|
2729
|
-
// Live
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
} else {
|
|
2735
|
-
line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
|
|
2736
|
-
}
|
|
2979
|
+
// Live loop run in flight → PREPEND its segment to the base dz line (unchanged). A live
|
|
2980
|
+
// /feature-adr run no longer glues into line 1: its 📐 segment IS the second line (format B) —
|
|
2981
|
+
// Claude Code renders every stdout line of a statusline command (fa-phase-statusline ADR-001 D1).
|
|
2982
|
+
if (fa !== undefined && fa.kind === 'loop') {
|
|
2983
|
+
line = `🔁 loop ${fa.step} · ${line}`;
|
|
2737
2984
|
}
|
|
2738
2985
|
|
|
2739
2986
|
write(line);
|
|
2987
|
+
// fa-phase-statusline ADR-001 D1: the phase panel moved to line 2 and TOOK main's ETA fragment with it. The move is
|
|
2988
|
+
// the point of format B (ADR-001 D1); dropping the estimate would have been a silent
|
|
2989
|
+
// regression of a feature `main` shipped while this branch was stranded, so it rides here
|
|
2990
|
+
// instead. A phase line that renders (fresh, non-terminal, non-loop slot) is the only gate.
|
|
2991
|
+
if (phaseLine !== undefined) {
|
|
2992
|
+
write(`${phaseLine}${etaFragment !== undefined ? ` · ${etaFragment}` : ''}`);
|
|
2993
|
+
}
|
|
2740
2994
|
return 0;
|
|
2741
2995
|
} catch {
|
|
2742
2996
|
// A garbled status bar is worse than a terse one — print SOMETHING minimal, never throw.
|
|
@@ -3359,6 +3613,282 @@ function learningStoreLine(
|
|
|
3359
3613
|
) + (reason ? ' [' + reason + ']' : '');
|
|
3360
3614
|
}
|
|
3361
3615
|
|
|
3616
|
+
function inspectLearningStore(projectRoot: string): {
|
|
3617
|
+
mark: StoreMark | undefined;
|
|
3618
|
+
health: StoreHealth;
|
|
3619
|
+
rows: ReturnType<typeof countLearningStoreRowsReadonly>;
|
|
3620
|
+
} {
|
|
3621
|
+
const mark = readStoreMark(projectRoot);
|
|
3622
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3623
|
+
return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
function shellQuote(value: string): string {
|
|
3627
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
function storeGuardResetCommand(projectRoot: string): string {
|
|
3631
|
+
return `dz store-guard --reset --project ${shellQuote(projectRoot)}`;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
function lexicalSourceLines(rows: ReturnType<typeof countLearningStoreRowsReadonly>): string[] {
|
|
3635
|
+
return [
|
|
3636
|
+
` lexical selected: ${rows.lexicalSourcePath} (${rows.lexicalSource}, ${rows.lexicalRows} rows)`,
|
|
3637
|
+
...(rows.lexicalIgnoredSourcePath === undefined ? [] : [
|
|
3638
|
+
` lexical ignored: ${rows.lexicalIgnoredSourcePath} (${rows.lexicalIgnoredRows} rows)`,
|
|
3639
|
+
]),
|
|
3640
|
+
];
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
function storeGuardRecoveryLines(
|
|
3644
|
+
projectRoot: string,
|
|
3645
|
+
mark: StoreMark | undefined,
|
|
3646
|
+
health: StoreHealth,
|
|
3647
|
+
rows?: ReturnType<typeof countLearningStoreRowsReadonly>,
|
|
3648
|
+
): string[] {
|
|
3649
|
+
const snapshots = storeSnapshotPath(projectRoot);
|
|
3650
|
+
const markPath = storeGuardPath(projectRoot);
|
|
3651
|
+
let lexicalSnapshot: 'sqlite' | 'jsonl' | undefined;
|
|
3652
|
+
let vectorSnapshot = false;
|
|
3653
|
+
try {
|
|
3654
|
+
const names = existsSync(snapshots) ? readdirSync(snapshots) : [];
|
|
3655
|
+
const hasSqlite = names.some((name) => /^lexical\..+\.sqlite$/.test(name));
|
|
3656
|
+
const hasJsonl = names.some((name) => /^lexical\..+\.jsonl$/.test(name));
|
|
3657
|
+
const preferred = mark?.lexicalSource === 'sqlite' || mark?.lexicalSource === 'jsonl'
|
|
3658
|
+
? mark.lexicalSource
|
|
3659
|
+
: rows?.lexicalSource;
|
|
3660
|
+
if (preferred === 'jsonl' && hasJsonl) lexicalSnapshot = 'jsonl';
|
|
3661
|
+
else if (preferred === 'sqlite' && hasSqlite) lexicalSnapshot = 'sqlite';
|
|
3662
|
+
else if (hasSqlite) lexicalSnapshot = 'sqlite';
|
|
3663
|
+
else if (hasJsonl) lexicalSnapshot = 'jsonl';
|
|
3664
|
+
vectorSnapshot = names.some((name) => /^vector\..+\.sqlite$/.test(name));
|
|
3665
|
+
} catch {
|
|
3666
|
+
lexicalSnapshot = undefined;
|
|
3667
|
+
vectorSnapshot = false;
|
|
3668
|
+
}
|
|
3669
|
+
const lines = [
|
|
3670
|
+
`dz store guard: REFUSED — ${health.reason}`,
|
|
3671
|
+
` mark: ${markPath}`,
|
|
3672
|
+
...(mark === undefined ? [] : [` recorded rows: lexical=${mark.lexicalMax} (${mark.lexicalSource}), vector=${mark.vectorMax}`]),
|
|
3673
|
+
...(rows === undefined ? [] : lexicalSourceLines(rows)),
|
|
3674
|
+
];
|
|
3675
|
+
if (lexicalSnapshot !== undefined && vectorSnapshot) {
|
|
3676
|
+
const lexicalDestination = lexicalSnapshot === 'sqlite'
|
|
3677
|
+
? join(projectRoot, '.dz', 'memory', 'patterns.sqlite')
|
|
3678
|
+
: join(projectRoot, '.dz', 'patterns.jsonl');
|
|
3679
|
+
lines.push(
|
|
3680
|
+
` snapshots: ${snapshots}/`,
|
|
3681
|
+
` 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'))}`,
|
|
3682
|
+
);
|
|
3683
|
+
} else {
|
|
3684
|
+
lines.push(
|
|
3685
|
+
` snapshots: none found in ${snapshots}/`,
|
|
3686
|
+
` create one manually: scripts/dz-store-snapshot.sh --project ${shellQuote(projectRoot)}`,
|
|
3687
|
+
);
|
|
3688
|
+
}
|
|
3689
|
+
lines.push(
|
|
3690
|
+
` accept current counts: ${storeGuardResetCommand(projectRoot)}`,
|
|
3691
|
+
' continue intentionally: set DZ_ALLOW_COLD_START=1 or pass --allow-cold-start',
|
|
3692
|
+
);
|
|
3693
|
+
return lines;
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function observedRows(
|
|
3697
|
+
rows: ReturnType<typeof countLearningStoreRowsReadonly>,
|
|
3698
|
+
): StoreCountSnapshot | undefined {
|
|
3699
|
+
return typeof rows.lexicalRows === 'number' && typeof rows.vectorRows === 'number'
|
|
3700
|
+
? { lexicalRows: rows.lexicalRows, vectorRows: rows.vectorRows, lexicalSource: rows.lexicalSource }
|
|
3701
|
+
: undefined;
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
interface MarkRefreshOptions {
|
|
3705
|
+
readonly reader?: boolean;
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
/** Mark maintenance is diagnostic: the store operation already completed and must keep its exit code. */
|
|
3709
|
+
function refreshLearningStoreMark(
|
|
3710
|
+
projectRoot: string,
|
|
3711
|
+
writeErr: WriteErr,
|
|
3712
|
+
command: string,
|
|
3713
|
+
options: MarkRefreshOptions = {},
|
|
3714
|
+
): void {
|
|
3715
|
+
try {
|
|
3716
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3717
|
+
const counts = observedRows(rows);
|
|
3718
|
+
if (counts === undefined) {
|
|
3719
|
+
writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
writeStoreMark(projectRoot, {
|
|
3723
|
+
...counts,
|
|
3724
|
+
observedAt: new Date().toISOString(),
|
|
3725
|
+
command,
|
|
3726
|
+
}, options.reader ? { timeoutMs: 0 } : {});
|
|
3727
|
+
} catch (error) {
|
|
3728
|
+
if (options.reader && error instanceof NamedLockTimeoutError) return;
|
|
3729
|
+
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)}`);
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
function storeGuardResetReminder(projectRoot: string, writeErr: WriteErr, command: string): void {
|
|
3734
|
+
try {
|
|
3735
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3736
|
+
const mark = readStoreMark(projectRoot);
|
|
3737
|
+
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)}`);
|
|
3738
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3739
|
+
} catch (error) {
|
|
3740
|
+
writeErr(`⚠ DZ STORE GUARD — ${command}: store changed; inspect it and reconcile explicitly with ${storeGuardResetCommand(projectRoot)} (${error instanceof Error ? error.message : String(error)})`);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
/** Fail closed for store writers, except for an explicit per-process/per-command override. */
|
|
3745
|
+
function allowLearningStoreWrite(
|
|
3746
|
+
projectRoot: string,
|
|
3747
|
+
flags: Set<string>,
|
|
3748
|
+
writeErr: WriteErr,
|
|
3749
|
+
command: string,
|
|
3750
|
+
): boolean {
|
|
3751
|
+
let inspection: ReturnType<typeof inspectLearningStore>;
|
|
3752
|
+
try {
|
|
3753
|
+
inspection = inspectLearningStore(projectRoot);
|
|
3754
|
+
} catch (error) {
|
|
3755
|
+
const health: StoreHealth = {
|
|
3756
|
+
verdict: 'unreadable',
|
|
3757
|
+
reason: `cannot read the external mark (${error instanceof Error ? error.message : String(error)})`,
|
|
3758
|
+
};
|
|
3759
|
+
for (const line of storeGuardRecoveryLines(projectRoot, undefined, health)) writeErr(line);
|
|
3760
|
+
return false;
|
|
3761
|
+
}
|
|
3762
|
+
if (inspection.health.verdict === 'no-mark' || inspection.health.verdict === 'ok') {
|
|
3763
|
+
// The successful write path records the resulting counts. Refreshing here as
|
|
3764
|
+
// well would emit the same telemetry failure twice when the external mark is
|
|
3765
|
+
// unavailable, and would receipt a source transition before the command's
|
|
3766
|
+
// own row had landed.
|
|
3767
|
+
return true;
|
|
3768
|
+
}
|
|
3769
|
+
if (inspection.health.verdict === 'source-changed') {
|
|
3770
|
+
const counts = observedRows(inspection.rows);
|
|
3771
|
+
if (counts === undefined) return false;
|
|
3772
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — SOURCE CHANGED: ${inspection.health.reason}; ${storeGuardResetCommand(projectRoot)}`);
|
|
3773
|
+
for (const line of lexicalSourceLines(inspection.rows)) writeErr(line);
|
|
3774
|
+
try {
|
|
3775
|
+
// Consume the single migration allowance BEFORE the store write. If the
|
|
3776
|
+
// following command fails, the safe result is a consumed allowance that
|
|
3777
|
+
// requires an explicit reset, never a silently reusable permission.
|
|
3778
|
+
writeStoreMark(projectRoot, { ...counts, observedAt: new Date().toISOString(), command }, {
|
|
3779
|
+
expectedPreviousLexicalSource: inspection.mark?.lexicalSource ?? 'unknown',
|
|
3780
|
+
});
|
|
3781
|
+
return true;
|
|
3782
|
+
} catch (error) {
|
|
3783
|
+
writeErr(`dz store guard: REFUSED — source-change allowance could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
|
|
3784
|
+
return false;
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
const allowed = process.env.DZ_ALLOW_COLD_START === '1' || flags.has('allow-cold-start');
|
|
3789
|
+
if (allowed) {
|
|
3790
|
+
const label = inspection.health.verdict.replaceAll('-', ' ').toUpperCase();
|
|
3791
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — ${label}: ${inspection.health.reason}; explicit cold-start override accepted`);
|
|
3792
|
+
return true;
|
|
3793
|
+
}
|
|
3794
|
+
for (const line of storeGuardRecoveryLines(projectRoot, inspection.mark, inspection.health, inspection.rows)) writeErr(line);
|
|
3795
|
+
return false;
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
/** Readers warn on damage and bootstrap/refresh a healthy non-empty store mark. */
|
|
3799
|
+
function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command: string): void {
|
|
3800
|
+
try {
|
|
3801
|
+
const { health, rows, mark } = inspectLearningStore(projectRoot);
|
|
3802
|
+
if (health.verdict === 'collapsed' || health.verdict === 'cold-start-over-existing' || health.verdict === 'unreadable'
|
|
3803
|
+
|| health.verdict === 'source-changed') {
|
|
3804
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — ${health.verdict.replaceAll('-', ' ').toUpperCase()}: ${health.reason}`);
|
|
3805
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
const counts = observedRows(rows);
|
|
3809
|
+
if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
|
|
3810
|
+
&& (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
|
|
3811
|
+
|| mark.lexicalSource !== counts.lexicalSource
|
|
3812
|
+
|| mark.lexicalMax < counts.lexicalRows || mark.vectorMax < counts.vectorRows)) {
|
|
3813
|
+
refreshLearningStoreMark(projectRoot, writeErr, command, { reader: true });
|
|
3814
|
+
}
|
|
3815
|
+
} catch (error) {
|
|
3816
|
+
writeErr(`⚠ DZ STORE GUARD WARNING — external mark unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
async function cmdStoreGuard(
|
|
3821
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
3822
|
+
stdinText: string | undefined, interactive: boolean,
|
|
3823
|
+
): Promise<number> {
|
|
3824
|
+
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3825
|
+
const path = storeGuardPath(projectRoot);
|
|
3826
|
+
const reset = flags.has('reset');
|
|
3827
|
+
const status = flags.has('status') || options.has('status');
|
|
3828
|
+
if (reset && status) {
|
|
3829
|
+
writeErr('dz store-guard: --status and --reset are mutually exclusive');
|
|
3830
|
+
return 2;
|
|
3831
|
+
}
|
|
3832
|
+
if (reset) {
|
|
3833
|
+
const rows = countLearningStoreRowsReadonly(projectRoot);
|
|
3834
|
+
const counts = observedRows(rows);
|
|
3835
|
+
if (counts === undefined) {
|
|
3836
|
+
writeErr(`dz store-guard: REFUSED — cannot reset from an unreadable store; mark: ${path}`);
|
|
3837
|
+
return 1;
|
|
3838
|
+
}
|
|
3839
|
+
try {
|
|
3840
|
+
const previous = readStoreMark(projectRoot);
|
|
3841
|
+
const beforeLexical = previous?.lexicalMax ?? counts.lexicalRows;
|
|
3842
|
+
const beforeVector = previous?.vectorMax ?? counts.vectorRows;
|
|
3843
|
+
writeErr('⚠ dz store-guard --reset: manual operator decision required; this lowers the recorded high-water evidence');
|
|
3844
|
+
writeErr(` old maximum: lexical=${beforeLexical}, vector=${beforeVector}`);
|
|
3845
|
+
writeErr(` new observed: lexical=${counts.lexicalRows} (${counts.lexicalSource}), vector=${counts.vectorRows}`);
|
|
3846
|
+
for (const line of lexicalSourceLines(rows)) writeErr(line);
|
|
3847
|
+
let answer = stdinText?.trim().split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
|
|
3848
|
+
if (!flags.has('yes') && answer === '' && interactive && process.stdin.isTTY) {
|
|
3849
|
+
const { createInterface } = await import('node:readline/promises');
|
|
3850
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3851
|
+
try { answer = (await rl.question(' type yes to continue: ')).trim().toLowerCase(); }
|
|
3852
|
+
finally { rl.close(); }
|
|
3853
|
+
}
|
|
3854
|
+
if (!flags.has('yes') && !['y', 'yes', 'да'].includes(answer)) {
|
|
3855
|
+
writeErr(`dz store-guard: REFUSED — reset was not confirmed; re-run with --yes or answer yes`);
|
|
3856
|
+
return 1;
|
|
3857
|
+
}
|
|
3858
|
+
const current = countLearningStoreRowsReadonly(projectRoot);
|
|
3859
|
+
const currentCounts = observedRows(current);
|
|
3860
|
+
if (currentCounts === undefined || !isDeepStrictEqual(currentCounts, counts)) {
|
|
3861
|
+
writeErr('dz store-guard: REFUSED — store counts changed after confirmation; inspect and confirm again');
|
|
3862
|
+
return 1;
|
|
3863
|
+
}
|
|
3864
|
+
const mark = resetStoreMark(projectRoot, {
|
|
3865
|
+
...counts,
|
|
3866
|
+
observedAt: new Date().toISOString(),
|
|
3867
|
+
command: 'dz store-guard --reset',
|
|
3868
|
+
});
|
|
3869
|
+
write(`dz store-guard: RESET — accepted lexical=${mark.lexicalMax}, vector=${mark.vectorMax}`);
|
|
3870
|
+
write(` mark: ${path}`);
|
|
3871
|
+
write(` receipt: ${mark.resetAt?.at} — ${mark.resetAt?.reason}`);
|
|
3872
|
+
return 0;
|
|
3873
|
+
} catch (error) {
|
|
3874
|
+
writeErr(`dz store-guard: reset failed — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
|
|
3875
|
+
return 1;
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
try {
|
|
3879
|
+
const inspection = inspectLearningStore(projectRoot);
|
|
3880
|
+
write(`dz store-guard: ${inspection.health.verdict.toUpperCase()} — ${inspection.health.reason}`);
|
|
3881
|
+
write(` mark: ${path}`);
|
|
3882
|
+
write(` current rows: lexical=${inspection.rows.lexicalRows}, vector=${inspection.rows.vectorRows}`);
|
|
3883
|
+
for (const line of lexicalSourceLines(inspection.rows)) write(line);
|
|
3884
|
+
write(` recorded: ${inspection.mark === undefined ? 'none' : JSON.stringify(inspection.mark)}`);
|
|
3885
|
+
return 0;
|
|
3886
|
+
} catch (error) {
|
|
3887
|
+
writeErr(`dz store-guard: mark unreadable — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
|
|
3888
|
+
return 1;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3362
3892
|
async function runTeachGuardReinforcement(
|
|
3363
3893
|
projectRoot: string,
|
|
3364
3894
|
dzId: string,
|
|
@@ -3392,6 +3922,12 @@ async function cmdTeach(
|
|
|
3392
3922
|
// repo's own store holds 361 records written under that behaviour, and every other user's store
|
|
3393
3923
|
// is the same. Only an explicit choice moves it.
|
|
3394
3924
|
const { storeRoot, target: teachTarget } = resolved;
|
|
3925
|
+
const teachWillWrite = flags.has('harmonize')
|
|
3926
|
+
? false
|
|
3927
|
+
: options.has('from-json')
|
|
3928
|
+
|| (options.get('reinforce') ?? '').trim() !== ''
|
|
3929
|
+
|| (options.get('_positional_0') ?? '').trim() !== '';
|
|
3930
|
+
if (teachWillWrite && !allowLearningStoreWrite(storeRoot, flags, writeErr, 'dz teach')) return 1;
|
|
3395
3931
|
// The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
|
|
3396
3932
|
// the store and change nothing, so saying "written" there is a false claim about what happened
|
|
3397
3933
|
// (cross-family QE round 2, 2026-08-27).
|
|
@@ -3428,10 +3964,11 @@ async function cmdTeach(
|
|
|
3428
3964
|
// Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
|
|
3429
3965
|
// worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
|
|
3430
3966
|
if (!flags.has('json')) write(storeLine(flags.has('apply') ? 'written' : 'read'));
|
|
3431
|
-
|
|
3967
|
+
const code = await runHarmonize(storeRoot, options, flags, write, writeErr, {
|
|
3432
3968
|
store: join(storeRoot, '.dz'),
|
|
3433
3969
|
storeChosenBy: teachTarget.reason,
|
|
3434
3970
|
});
|
|
3971
|
+
return code;
|
|
3435
3972
|
}
|
|
3436
3973
|
|
|
3437
3974
|
// Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
|
|
@@ -3527,6 +4064,7 @@ async function cmdTeach(
|
|
|
3527
4064
|
const report = await harmonizeVectorStore(storeRoot, {});
|
|
3528
4065
|
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`);
|
|
3529
4066
|
}
|
|
4067
|
+
if (imported > 0) refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --from-json');
|
|
3530
4068
|
return 0;
|
|
3531
4069
|
}
|
|
3532
4070
|
|
|
@@ -3547,6 +4085,7 @@ async function cmdTeach(
|
|
|
3547
4085
|
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
|
|
3548
4086
|
if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
|
|
3549
4087
|
write(storeLine('written'));
|
|
4088
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
|
|
3550
4089
|
return 0;
|
|
3551
4090
|
}
|
|
3552
4091
|
// HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
|
|
@@ -3594,6 +4133,7 @@ async function cmdTeach(
|
|
|
3594
4133
|
write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
|
|
3595
4134
|
const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
|
|
3596
4135
|
if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
|
|
4136
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --guard');
|
|
3597
4137
|
return 0;
|
|
3598
4138
|
}
|
|
3599
4139
|
write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
|
|
@@ -3707,10 +4247,16 @@ async function cmdTeach(
|
|
|
3707
4247
|
}
|
|
3708
4248
|
// The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
|
|
3709
4249
|
await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
|
|
4250
|
+
if (stored.records.length > 0) {
|
|
4251
|
+
write(` ID: ${stored.records.map((record) => patternRecordId(record)).join(', ')}`);
|
|
4252
|
+
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach');
|
|
4253
|
+
}
|
|
3710
4254
|
return commandFailed ? 1 : 0;
|
|
3711
4255
|
}
|
|
3712
4256
|
|
|
3713
|
-
async function cmdConsolidate(
|
|
4257
|
+
async function cmdConsolidate(
|
|
4258
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
4259
|
+
): Promise<number> {
|
|
3714
4260
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3715
4261
|
const sessionsDirOpt = options.get('sessions-dir');
|
|
3716
4262
|
const pruneNoise = flags.has('prune-noise');
|
|
@@ -3729,6 +4275,8 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
|
|
|
3729
4275
|
if (res.error !== undefined) { write(`dz consolidate --prune-quarantine: ${res.error}`); return 1; }
|
|
3730
4276
|
write(`dz consolidate --prune-quarantine: removed ${res.removed} expired quarantined lesson(s)`);
|
|
3731
4277
|
if (res.snapshot !== undefined) write(` snapshot: ${res.snapshot}`);
|
|
4278
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
|
|
4279
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
|
|
3732
4280
|
return 0;
|
|
3733
4281
|
}
|
|
3734
4282
|
|
|
@@ -3826,6 +4374,10 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
|
|
|
3826
4374
|
}
|
|
3827
4375
|
} catch { /* best-effort — the ranking is advisory, never fails the consolidate */ }
|
|
3828
4376
|
|
|
4377
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate');
|
|
4378
|
+
if (pruneNoise && applyPrune) {
|
|
4379
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-noise --apply');
|
|
4380
|
+
}
|
|
3829
4381
|
return 0;
|
|
3830
4382
|
}
|
|
3831
4383
|
|
|
@@ -3938,6 +4490,7 @@ async function cmdRecallForget(
|
|
|
3938
4490
|
flags: Set<string>,
|
|
3939
4491
|
projectRoot: string,
|
|
3940
4492
|
write: Write,
|
|
4493
|
+
writeErr: WriteErr,
|
|
3941
4494
|
): Promise<number> {
|
|
3942
4495
|
const raw = options.get('forget') ?? '';
|
|
3943
4496
|
const ids = new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''));
|
|
@@ -3966,7 +4519,7 @@ async function cmdRecallForget(
|
|
|
3966
4519
|
return 0;
|
|
3967
4520
|
}
|
|
3968
4521
|
|
|
3969
|
-
const dest = join(projectRoot,
|
|
4522
|
+
const dest = join(storeSnapshotPath(projectRoot), `forget-${Date.now()}.json`);
|
|
3970
4523
|
const snap = snapshotStore(projectRoot, dest);
|
|
3971
4524
|
if (snap.error !== undefined) {
|
|
3972
4525
|
write(`dz recall --forget: snapshot failed (${snap.error}) — nothing removed; the store is not versioned`);
|
|
@@ -3977,6 +4530,8 @@ async function cmdRecallForget(
|
|
|
3977
4530
|
write(` snapshot: ${snap.path} (${snap.count} record(s))`);
|
|
3978
4531
|
if (result.error !== undefined) write(` ⚠ ${result.error}`);
|
|
3979
4532
|
write(' the vector mirror still holds them — run `dz vector reindex` to resync');
|
|
4533
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz recall --forget --apply');
|
|
4534
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz recall --forget --apply');
|
|
3980
4535
|
return 0;
|
|
3981
4536
|
}
|
|
3982
4537
|
|
|
@@ -4068,10 +4623,15 @@ async function cmdRecall(
|
|
|
4068
4623
|
classMatcher?: RecallPatternsOptions['classMatcher'],
|
|
4069
4624
|
): Promise<number> {
|
|
4070
4625
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
4626
|
+
warnLearningStoreRead(projectRoot, writeErr, 'dz recall');
|
|
4627
|
+
const globalRootForGuard = globalStoreRoot();
|
|
4628
|
+
if (!sameStore(projectRoot, globalRootForGuard) && existsSync(join(globalRootForGuard, '.dz', 'memory'))) {
|
|
4629
|
+
warnLearningStoreRead(globalRootForGuard, writeErr, 'dz recall');
|
|
4630
|
+
}
|
|
4071
4631
|
const asJson = flags.has('json');
|
|
4072
4632
|
const all = flags.has('all');
|
|
4073
4633
|
if (flags.has('usage')) return cmdRecallUsage(options, flags, projectRoot, write);
|
|
4074
|
-
if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write);
|
|
4634
|
+
if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write, writeErr);
|
|
4075
4635
|
if (options.has('promote')) return cmdRecallPromote(options, flags, projectRoot, write);
|
|
4076
4636
|
|
|
4077
4637
|
// --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
|
|
@@ -4633,7 +5193,7 @@ function renderHarmonize(report: HarmonizeReport, write: Write): void {
|
|
|
4633
5193
|
* `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
|
|
4634
5194
|
*/
|
|
4635
5195
|
async function runHarmonize(
|
|
4636
|
-
projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write,
|
|
5196
|
+
projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr,
|
|
4637
5197
|
/**
|
|
4638
5198
|
* Where this harmonize is pointed and what chose it. Under `--json` the human store line is
|
|
4639
5199
|
* suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
|
|
@@ -4657,6 +5217,10 @@ async function runHarmonize(
|
|
|
4657
5217
|
}
|
|
4658
5218
|
}
|
|
4659
5219
|
const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
|
|
5220
|
+
if (apply && report.error === undefined) {
|
|
5221
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector harmonize --apply');
|
|
5222
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector harmonize --apply');
|
|
5223
|
+
}
|
|
4660
5224
|
if (flags.has('json')) {
|
|
4661
5225
|
write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
|
|
4662
5226
|
return report.error !== undefined ? 1 : 0;
|
|
@@ -4665,7 +5229,9 @@ async function runHarmonize(
|
|
|
4665
5229
|
return report.error !== undefined ? 1 : 0;
|
|
4666
5230
|
}
|
|
4667
5231
|
|
|
4668
|
-
async function cmdVector(
|
|
5232
|
+
async function cmdVector(
|
|
5233
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
5234
|
+
): Promise<number> {
|
|
4669
5235
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
4670
5236
|
const sub = options.get('_positional_0');
|
|
4671
5237
|
|
|
@@ -4720,6 +5286,10 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4720
5286
|
if (sub === 'reindex') {
|
|
4721
5287
|
const report = await reindexVectorStore(projectRoot);
|
|
4722
5288
|
if (flags.has('json')) {
|
|
5289
|
+
if (report.error === undefined) {
|
|
5290
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
|
|
5291
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
|
|
5292
|
+
}
|
|
4723
5293
|
write(JSON.stringify(report));
|
|
4724
5294
|
return report.error !== undefined ? 1 : 0;
|
|
4725
5295
|
}
|
|
@@ -4737,6 +5307,8 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4737
5307
|
write(` ⚠ still in the previous embedding space: ${report.staleTaskTypes.join(', ')}`);
|
|
4738
5308
|
if (report.staleTaskTypes.includes('book-knowledge')) write(' run \`dz brain reindex\` to rebuild the brain\'s book vectors');
|
|
4739
5309
|
}
|
|
5310
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
|
|
5311
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
|
|
4740
5312
|
return 0;
|
|
4741
5313
|
}
|
|
4742
5314
|
|
|
@@ -4793,7 +5365,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4793
5365
|
// harmonize (alias: dz teach --harmonize) — SEMANTIC dedup of the learned store, NON-DESTRUCTIVE:
|
|
4794
5366
|
// dry-run by default (previews clusters, writes nothing); --apply drops after a restorable backup.
|
|
4795
5367
|
if (sub === 'harmonize') {
|
|
4796
|
-
return runHarmonize(projectRoot, options, flags, write);
|
|
5368
|
+
return runHarmonize(projectRoot, options, flags, write, writeErr);
|
|
4797
5369
|
}
|
|
4798
5370
|
|
|
4799
5371
|
// import <file.rvf> — the missing HALF of the RVF cycle: UPSERT-BY-dzId, never overwrites.
|
|
@@ -4805,6 +5377,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4805
5377
|
}
|
|
4806
5378
|
const report = await importRvfCheckpoint(projectRoot, resolve(cwd, src), {});
|
|
4807
5379
|
if (flags.has('json')) {
|
|
5380
|
+
if (report.error === undefined) refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
|
|
4808
5381
|
write(JSON.stringify(report));
|
|
4809
5382
|
return report.error !== undefined ? 1 : 0;
|
|
4810
5383
|
}
|
|
@@ -4817,6 +5390,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4817
5390
|
if (report.skippedOrphans > 0) {
|
|
4818
5391
|
write(' ↳ orphan vectors have no local pattern — import the text first: dz teach --from-json <recall-export.json>, then re-run dz vector import');
|
|
4819
5392
|
}
|
|
5393
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
|
|
4820
5394
|
return 0;
|
|
4821
5395
|
}
|
|
4822
5396
|
|
|
@@ -5471,6 +6045,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5471
6045
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
5472
6046
|
const presetName = options.get('preset');
|
|
5473
6047
|
|
|
6048
|
+
// PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
|
|
6049
|
+
// environment and step 4 installs skills, so refusing at step 4 would leave a project that has
|
|
6050
|
+
// memory and hooks but not the skills the operator asked for — a half-configured state worse than
|
|
6051
|
+
// either clean outcome. The request is therefore resolved HERE, before the banner's first step.
|
|
6052
|
+
//
|
|
6053
|
+
// Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
|
|
6054
|
+
// there is our packaging defect, not the operator's typo, and it is reported by the existing
|
|
6055
|
+
// missing-list rather than by refusing the whole run.
|
|
6056
|
+
const setupSelectRaw = options.get('select');
|
|
6057
|
+
if (setupSelectRaw !== undefined) {
|
|
6058
|
+
const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
|
|
6059
|
+
const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
6060
|
+
const resolution = resolveSelection(requested, roots);
|
|
6061
|
+
for (const shadow of resolution.shadowed) {
|
|
6062
|
+
writeErr(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
|
|
6063
|
+
}
|
|
6064
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
6065
|
+
if (refusal !== null) { writeErr(refusal); return 1; }
|
|
6066
|
+
}
|
|
6067
|
+
|
|
5474
6068
|
write(`\n╔══════════════════════════════════════════════════════╗`);
|
|
5475
6069
|
write(`║ DZ SETUP — Full Environment ║`);
|
|
5476
6070
|
write(`╠══════════════════════════════════════════════════════╣`);
|
|
@@ -5622,7 +6216,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
|
|
|
5622
6216
|
return 0;
|
|
5623
6217
|
}
|
|
5624
6218
|
|
|
5625
|
-
function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
|
|
6219
|
+
function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
5626
6220
|
const task = options.get('_positional_0');
|
|
5627
6221
|
if (!task) {
|
|
5628
6222
|
write('dz recommend: task description required');
|
|
@@ -5632,17 +6226,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5632
6226
|
|
|
5633
6227
|
const registry = buildRegistry(cwd);
|
|
5634
6228
|
const report = recommend(task, registry, cwd);
|
|
6229
|
+
if (flags.has('json')) {
|
|
6230
|
+
write(JSON.stringify(report, null, 2));
|
|
6231
|
+
return 0;
|
|
6232
|
+
}
|
|
5635
6233
|
|
|
5636
6234
|
write(`\n╔══════════════════════════════════════════════════════════════╗`);
|
|
5637
6235
|
write(`║ DZ RECOMMEND — Task Advisor ║`);
|
|
5638
6236
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5639
6237
|
write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
|
|
5640
|
-
|
|
5641
|
-
|
|
6238
|
+
if (report.topicSource === 'task') {
|
|
6239
|
+
write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
|
|
6240
|
+
} else if (report.topicSource === 'project-stack') {
|
|
6241
|
+
write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
|
|
6242
|
+
} else {
|
|
6243
|
+
write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
|
|
6244
|
+
}
|
|
5642
6245
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5643
6246
|
|
|
6247
|
+
if (report.topicSource === 'project-stack') {
|
|
6248
|
+
write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
|
|
6249
|
+
write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
|
|
6250
|
+
write(`PROJECT-STACK SUGGESTIONS`);
|
|
6251
|
+
} else if (report.topicSource === 'none') {
|
|
6252
|
+
write(`Тема запроса не распознана; рекомендаций нет.`);
|
|
6253
|
+
write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
|
|
6254
|
+
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
6255
|
+
return 0;
|
|
6256
|
+
}
|
|
6257
|
+
|
|
6258
|
+
const stackDerived = report.topicSource === 'project-stack';
|
|
6259
|
+
|
|
5644
6260
|
if (report.presets.length > 0) {
|
|
5645
|
-
write(
|
|
6261
|
+
write(stackDerived
|
|
6262
|
+
? `║ PROJECT-STACK PRESETS ║`
|
|
6263
|
+
: `║ RECOMMENDED PRESETS ║`);
|
|
5646
6264
|
for (const p of report.presets) {
|
|
5647
6265
|
const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
|
|
5648
6266
|
write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
|
|
@@ -5651,7 +6269,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5651
6269
|
}
|
|
5652
6270
|
|
|
5653
6271
|
if (report.skills.length > 0) {
|
|
5654
|
-
write(
|
|
6272
|
+
write(stackDerived
|
|
6273
|
+
? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
|
|
6274
|
+
: `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
|
|
5655
6275
|
for (const s of report.skills.slice(0, 8)) {
|
|
5656
6276
|
const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
|
|
5657
6277
|
write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
|
|
@@ -5660,7 +6280,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5660
6280
|
}
|
|
5661
6281
|
|
|
5662
6282
|
if (report.toolkits.length > 0) {
|
|
5663
|
-
write(
|
|
6283
|
+
write(stackDerived
|
|
6284
|
+
? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
|
|
6285
|
+
: `║ FULL PIPELINE (npx toolkits) ║`);
|
|
5664
6286
|
for (const tk of report.toolkits) {
|
|
5665
6287
|
const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
|
|
5666
6288
|
write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
|
|
@@ -5671,13 +6293,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5671
6293
|
}
|
|
5672
6294
|
|
|
5673
6295
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5674
|
-
write(
|
|
6296
|
+
write(stackDerived
|
|
6297
|
+
? `║ PROJECT-STACK PLAN ║`
|
|
6298
|
+
: `║ STEP-BY-STEP PLAN ║`);
|
|
5675
6299
|
for (const step of report.plan) {
|
|
5676
6300
|
const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
|
|
5677
6301
|
write(`║ ${line.padEnd(58)}║`);
|
|
5678
6302
|
}
|
|
5679
6303
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5680
|
-
write(
|
|
6304
|
+
write(stackDerived
|
|
6305
|
+
? `║ PROJECT-STACK QUICK INSTALL ║`
|
|
6306
|
+
: `║ QUICK INSTALL ║`);
|
|
5681
6307
|
const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
|
|
5682
6308
|
write(`║ ${cmd.padEnd(58)}║`);
|
|
5683
6309
|
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
@@ -6214,6 +6840,19 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6214
6840
|
}
|
|
6215
6841
|
}
|
|
6216
6842
|
|
|
6843
|
+
const filterStr = options.get('filter');
|
|
6844
|
+
// SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
|
|
6845
|
+
// Parse before the guard pre-flight so its packed-secret scan uses the SAME scoped package set
|
|
6846
|
+
// that publishPackages receives below; an empty resulting list remains an explicit error.
|
|
6847
|
+
let filter: string[] | undefined;
|
|
6848
|
+
if (filterStr !== undefined) {
|
|
6849
|
+
filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
6850
|
+
if (filter.length === 0) {
|
|
6851
|
+
write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
|
|
6852
|
+
return 1;
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6217
6856
|
// dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
|
|
6218
6857
|
// it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
|
|
6219
6858
|
// is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
|
|
@@ -6225,7 +6864,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6225
6864
|
write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
|
|
6226
6865
|
return 1;
|
|
6227
6866
|
}
|
|
6228
|
-
const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
|
|
6867
|
+
const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard, filter);
|
|
6229
6868
|
if (guardResult.verdict === 'block' && noGuard === undefined) {
|
|
6230
6869
|
write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
|
|
6231
6870
|
for (const v of guardResult.violations.filter((x) => x.severity === 'hard')) write(` [BLOCK] ${v.rule}: ${v.detail}`);
|
|
@@ -6266,21 +6905,6 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6266
6905
|
const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
|
|
6267
6906
|
|
|
6268
6907
|
const bumpOnly = flags.has('bump-only');
|
|
6269
|
-
const filterStr = options.get('filter');
|
|
6270
|
-
// SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
|
|
6271
|
-
// Without this, `--filter ""` (e.g. an unset shell var) or a stray comma yields
|
|
6272
|
-
// [''] / ['', 'core'], and publishPackages matches with name.includes(''), which
|
|
6273
|
-
// is true for EVERY package — silently turning a scoped publish into a
|
|
6274
|
-
// whole-monorepo publish. An empty resulting list is an explicit error, never
|
|
6275
|
-
// "match all".
|
|
6276
|
-
let filter: string[] | undefined;
|
|
6277
|
-
if (filterStr !== undefined) {
|
|
6278
|
-
filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
6279
|
-
if (filter.length === 0) {
|
|
6280
|
-
write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
|
|
6281
|
-
return 1;
|
|
6282
|
-
}
|
|
6283
|
-
}
|
|
6284
6908
|
|
|
6285
6909
|
// SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
|
|
6286
6910
|
// via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
|
|
@@ -6474,6 +7098,13 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
6474
7098
|
if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
|
|
6475
7099
|
write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
|
|
6476
7100
|
}
|
|
7101
|
+
// A dry run stops before build/sign/pack, so it says NOTHING about the gates below that line.
|
|
7102
|
+
// Printing what it did not check is what keeps a clean preview from reading as a clean publish
|
|
7103
|
+
// (measured 2026-09-02: a clean dry run preceded a RED real gate).
|
|
7104
|
+
if (pkg.notVerified && pkg.notVerified.length > 0) {
|
|
7105
|
+
write(` ⓘ холостой прогон НЕ проверял (${pkg.notVerified.length}):`);
|
|
7106
|
+
for (const item of pkg.notVerified) write(` · ${item}`);
|
|
7107
|
+
}
|
|
6477
7108
|
}
|
|
6478
7109
|
return report.errors > 0 ? 1 : 0;
|
|
6479
7110
|
}
|
|
@@ -7426,6 +8057,84 @@ function looksBinaryText(text: string): boolean {
|
|
|
7426
8057
|
* per-file findings (each enriched with its `file`), and applies the exit-code contract.
|
|
7427
8058
|
* `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
|
|
7428
8059
|
*/
|
|
8060
|
+
/**
|
|
8061
|
+
* `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
|
|
8062
|
+
*
|
|
8063
|
+
* The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
|
|
8064
|
+
* verification lived inside two consumers, each carrying its own hardcoded list of which files are
|
|
8065
|
+
* chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
|
|
8066
|
+
* DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
|
|
8067
|
+
* report by construction.
|
|
8068
|
+
*
|
|
8069
|
+
* An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
|
|
8070
|
+
* indistinguishable in a report, and that indistinguishability is how the original blind spot
|
|
8071
|
+
* survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
|
|
8072
|
+
* reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
|
|
8073
|
+
*
|
|
8074
|
+
* A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
|
|
8075
|
+
* predate the chain) and therefore does not fail the command. Calling it a defect would train the
|
|
8076
|
+
* reader to ignore the output — the failure mode already measured once on the doctor's own line.
|
|
8077
|
+
*/
|
|
8078
|
+
function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
8079
|
+
const root = options.get('project') ?? cwd;
|
|
8080
|
+
const journals = CHAINED_JOURNALS.map((journal) => {
|
|
8081
|
+
const path = join(root, journal.rel);
|
|
8082
|
+
if (!existsSync(path)) {
|
|
8083
|
+
return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
|
|
8084
|
+
}
|
|
8085
|
+
let text = '';
|
|
8086
|
+
try {
|
|
8087
|
+
text = readFileSync(path, 'utf-8');
|
|
8088
|
+
} catch {
|
|
8089
|
+
// Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
|
|
8090
|
+
// "nothing to report": we did not look, so we know nothing.
|
|
8091
|
+
return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
|
|
8092
|
+
}
|
|
8093
|
+
const v = verifyEventChainText(text);
|
|
8094
|
+
if (v.chained === 0) {
|
|
8095
|
+
return { rel: journal.rel, decides: journal.decides, status: 'unchained' as const, chained: 0, defects: 0, detail: 'present, but no record carries a chain (legal — the log predates chaining)' };
|
|
8096
|
+
}
|
|
8097
|
+
const total = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
8098
|
+
const age = classifyChainDefects(v, total);
|
|
8099
|
+
if (v.ok) {
|
|
8100
|
+
return { rel: journal.rel, decides: journal.decides, status: 'ok' as const, chained: v.chained, defects: 0, detail: `${v.chained} chained record(s), ${v.resets} recorded restart(s)` };
|
|
8101
|
+
}
|
|
8102
|
+
// A break the current unbroken run has already outlived does not make TODAY's records unsound.
|
|
8103
|
+
// Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
|
|
8104
|
+
const historical = age.inRun.length === 0 && age.runRecords > 0;
|
|
8105
|
+
return {
|
|
8106
|
+
rel: journal.rel,
|
|
8107
|
+
decides: journal.decides,
|
|
8108
|
+
status: historical ? ('healed' as const) : ('broken' as const),
|
|
8109
|
+
chained: v.chained,
|
|
8110
|
+
defects: v.defects.length,
|
|
8111
|
+
detail: historical
|
|
8112
|
+
? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
|
|
8113
|
+
: `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
|
|
8114
|
+
};
|
|
8115
|
+
});
|
|
8116
|
+
|
|
8117
|
+
const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
|
|
8118
|
+
const ok = failed.length === 0;
|
|
8119
|
+
|
|
8120
|
+
if (flags.has('json')) {
|
|
8121
|
+
write(JSON.stringify({ ok, root, journals }, null, 2));
|
|
8122
|
+
return ok ? 0 : 1;
|
|
8123
|
+
}
|
|
8124
|
+
|
|
8125
|
+
write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
|
|
8126
|
+
write('');
|
|
8127
|
+
const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
|
|
8128
|
+
for (const j of journals) {
|
|
8129
|
+
write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
|
|
8130
|
+
write(` ${j.detail}`);
|
|
8131
|
+
write(` decides: ${j.decides}`);
|
|
8132
|
+
}
|
|
8133
|
+
write('');
|
|
8134
|
+
write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
|
|
8135
|
+
return ok ? 0 : 1;
|
|
8136
|
+
}
|
|
8137
|
+
|
|
7429
8138
|
function cmdClaimCheck(
|
|
7430
8139
|
options: Map<string, string>,
|
|
7431
8140
|
_optionLists: Map<string, string[]>,
|
|
@@ -8154,7 +8863,18 @@ function cmdAgentsSync(
|
|
|
8154
8863
|
const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
|
|
8155
8864
|
writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
|
|
8156
8865
|
for (const finding of drifted) writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
|
|
8157
|
-
|
|
8866
|
+
const unregistered = drifted.filter((finding) => finding.status === 'unregistered-section');
|
|
8867
|
+
if (unregistered.length > 0) {
|
|
8868
|
+
// ПОДСКАЗКА, ВЕДУЩАЯ НЕ ТУДА, ХУЖЕ ОТСУТСТВУЮЩЕЙ. Повторный `dz agents-sync` эту находку
|
|
8869
|
+
// НЕ лечит: реестр POLICY_SOURCES ведётся руками, и секция, не вписанная в него, не
|
|
8870
|
+
// попадёт в проекцию сколько ни синхронизируй. Раньше здесь печаталась общая подсказка —
|
|
8871
|
+
// читатель прогнал бы её и снова увидел ту же ошибку.
|
|
8872
|
+
writeErr('→ heal with: объяви секцию в POLICY_SOURCES (packages/@dzhechkov/harness-core/src/agents-policy.ts):');
|
|
8873
|
+
for (const finding of unregistered) {
|
|
8874
|
+
writeErr(` { id: '${finding.id}', file: '${finding.file}', heading: '…', why: '…', operativeClause: '…' }`);
|
|
8875
|
+
}
|
|
8876
|
+
writeErr(' затем: dz agents-sync');
|
|
8877
|
+
} else if (drifted.some((finding) => finding.id === 'dz:policies')) {
|
|
8158
8878
|
writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
|
|
8159
8879
|
} else {
|
|
8160
8880
|
writeErr('→ heal with: dz agents-sync');
|
|
@@ -8245,8 +8965,11 @@ const DEFAULT_STORE_CAP = 5000;
|
|
|
8245
8965
|
*/
|
|
8246
8966
|
const MAX_STUB_SCAN_FILES = 400;
|
|
8247
8967
|
|
|
8248
|
-
/**
|
|
8249
|
-
|
|
8968
|
+
/** Maximum packed-file size read by the publish secret scan. */
|
|
8969
|
+
const SECRET_SCAN_MAX_BYTES = 512 * 1024;
|
|
8970
|
+
|
|
8971
|
+
/** Read the optional `.dz/guard.json` — `{ rules?, storeCap?, stubWaivers?, secretWaivers? }`. Missing/broken ⇒ defaults. */
|
|
8972
|
+
function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; secretWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
|
|
8250
8973
|
const p = join(root, '.dz', 'guard.json');
|
|
8251
8974
|
if (!existsSync(p)) return {};
|
|
8252
8975
|
try {
|
|
@@ -8272,6 +8995,51 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
|
|
|
8272
8995
|
// (target repo without sitedoc — missing-evidence contract). But a file that EXISTS and no longer
|
|
8273
8996
|
// matches its anchored pattern emits a MISMATCH pair (a: -1) — silent non-extraction is the exact
|
|
8274
8997
|
// disease this contour cures (AM-1 applies to the guard path too, not only the CI test).
|
|
8998
|
+
// ЗНАЧКИ ПРОТИВ ДЕРЕВА, а не только README против README (бэклог 9cb30764).
|
|
8999
|
+
//
|
|
9000
|
+
// Прежде правило сверяло только числа МЕЖДУ документами: два согласованных документа могли
|
|
9001
|
+
// хором утверждать одно и то же неверное число, и правило молчало. Значок «пакетов: 52» стоял
|
|
9002
|
+
// при 55 публикуемых на диске — ИЗМЕРЕНО 2026-09-03. Меню обещает 32 блюда, официант
|
|
9003
|
+
// перечисляет 30, кухня готовит 38, и никто в ресторане не знает правду.
|
|
9004
|
+
//
|
|
9005
|
+
// Сверяются только числа, ВЫЧИСЛИМЫЕ ИЗ ДЕРЕВА. Значок «опубликовано в npm» сюда НЕ входит и
|
|
9006
|
+
// это сказано вслух: его источник — реестр, а не рабочая копия, и пара, которая делает вид, что
|
|
9007
|
+
// проверила его, была бы хуже отсутствующей.
|
|
9008
|
+
const badge = (s: string, name: string): number | null => {
|
|
9009
|
+
const m = s.match(new RegExp(`img\\.shields\\.io/badge/${name}-(\\d+)`));
|
|
9010
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
9011
|
+
};
|
|
9012
|
+
const publishablePackages = ((): number | null => {
|
|
9013
|
+
const dir = join(root, 'packages', '@dzhechkov');
|
|
9014
|
+
if (!existsSync(dir)) return null;
|
|
9015
|
+
let n = 0;
|
|
9016
|
+
for (const name of readdirSync(dir)) {
|
|
9017
|
+
const pj = join(dir, name, 'package.json');
|
|
9018
|
+
if (!existsSync(pj)) continue;
|
|
9019
|
+
try {
|
|
9020
|
+
const j = JSON.parse(readFileSync(pj, 'utf8')) as { private?: unknown };
|
|
9021
|
+
if (j.private !== true) n += 1;
|
|
9022
|
+
} catch { /* нечитаемый манифест — не считается ни в одну сторону */ }
|
|
9023
|
+
}
|
|
9024
|
+
return n;
|
|
9025
|
+
})();
|
|
9026
|
+
const pkgBadge = badge(rootMd, 'packages');
|
|
9027
|
+
if (pkgBadge !== null && publishablePackages !== null) {
|
|
9028
|
+
pairs.push({ label: 'packages (root badge vs publishable package.json on disk)', a: pkgBadge, b: publishablePackages });
|
|
9029
|
+
}
|
|
9030
|
+
const presetBadge = badge(rootMd, 'presets');
|
|
9031
|
+
if (presetBadge !== null) {
|
|
9032
|
+
pairs.push({ label: 'presets (root badge vs PRESET_NAMES in the build)', a: presetBadge, b: PRESET_NAMES.length });
|
|
9033
|
+
}
|
|
9034
|
+
const targetBadge = badge(rootMd, 'targets');
|
|
9035
|
+
if (targetBadge !== null) {
|
|
9036
|
+
pairs.push({ label: 'targets (root badge vs TARGET_NAMES in the build)', a: targetBadge, b: TARGET_NAMES.length });
|
|
9037
|
+
}
|
|
9038
|
+
const cmdBadge = badge(rootMd, 'CLI%20commands');
|
|
9039
|
+
if (cmdBadge !== null && cliAll !== null) {
|
|
9040
|
+
pairs.push({ label: 'commands (root badge vs cli All Commands)', a: cmdBadge, b: cliAll });
|
|
9041
|
+
}
|
|
9042
|
+
|
|
8275
9043
|
const sitePair = (rel: string, re: RegExp, label: string): void => {
|
|
8276
9044
|
if (cliAll === null || !existsSync(join(root, rel))) return;
|
|
8277
9045
|
const found = num(read(rel), re);
|
|
@@ -8677,8 +9445,9 @@ function gatherVolumeShadowFacts(
|
|
|
8677
9445
|
}
|
|
8678
9446
|
|
|
8679
9447
|
/** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
|
|
8680
|
-
function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
|
|
9448
|
+
function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number, publishFilter?: readonly string[]): Record<string, unknown> {
|
|
8681
9449
|
const facts: Record<string, unknown> = { op };
|
|
9450
|
+
const publishPackageRoots: string[] = [];
|
|
8682
9451
|
if (op === 'publish') {
|
|
8683
9452
|
// Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
|
|
8684
9453
|
// stale finding and never a publish blocker.
|
|
@@ -8810,6 +9579,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
8810
9579
|
} catch { /* not a git repo */ }
|
|
8811
9580
|
const versionByName = new Map<string, string>();
|
|
8812
9581
|
for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
|
|
9582
|
+
publishPackageRoots.push(...located
|
|
9583
|
+
.filter(({ dir, m }) => m.private !== true && (
|
|
9584
|
+
publishFilter === undefined
|
|
9585
|
+
|| publishFilter.length === 0
|
|
9586
|
+
|| publishFilter.some((filter) => (m.name ?? '').includes(filter) || dir.includes(filter))
|
|
9587
|
+
))
|
|
9588
|
+
.map(({ dir }) => dir));
|
|
8813
9589
|
const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
|
|
8814
9590
|
const packages: { name: string; deps: Record<string, string> }[] = [];
|
|
8815
9591
|
for (const m of manifests) {
|
|
@@ -8823,6 +9599,82 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
8823
9599
|
packages.push({ name: m.name ?? '(unnamed)', deps });
|
|
8824
9600
|
}
|
|
8825
9601
|
facts['packages'] = packages;
|
|
9602
|
+
// sibling-dep-protocol: сырые спеки, БЕЗ подстановки версии. Подставленная версия выглядела бы
|
|
9603
|
+
// как обычный диапазон, и правило потеряло бы ровно то, что проверяет.
|
|
9604
|
+
const siblingDeps: { name: string; field: string; dep: string; spec: string }[] = [];
|
|
9605
|
+
for (const m of manifests) {
|
|
9606
|
+
if (m.private === true) continue;
|
|
9607
|
+
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
|
|
9608
|
+
for (const field of fields) {
|
|
9609
|
+
const table = (m as unknown as Record<string, unknown>)[field];
|
|
9610
|
+
if (typeof table !== 'object' || table === null) continue;
|
|
9611
|
+
for (const [dep, spec] of Object.entries(table as Record<string, unknown>)) {
|
|
9612
|
+
if (!dep.startsWith('@dzhechkov/') || typeof spec !== 'string') continue;
|
|
9613
|
+
siblingDeps.push({ name: m.name ?? '(unnamed)', field, dep, spec });
|
|
9614
|
+
}
|
|
9615
|
+
}
|
|
9616
|
+
}
|
|
9617
|
+
facts['siblingDeps'] = siblingDeps;
|
|
9618
|
+
// plugin-manifest-audit: каждый `.claude-plugin/plugin.json` в дереве. Обход ограничен по
|
|
9619
|
+
// глубине и не заходит в node_modules/dist — чужие манифесты не наши, и краснеть на них
|
|
9620
|
+
// значило бы отчитываться о том, чего мы не публикуем.
|
|
9621
|
+
const pluginManifests: { path: string; parseError?: string; name?: string; version?: string; description?: string }[] = [];
|
|
9622
|
+
const walkPlugins = (dir: string, depth: number): void => {
|
|
9623
|
+
if (depth > 4) return;
|
|
9624
|
+
let entries: Dirent[];
|
|
9625
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
9626
|
+
for (const e of entries) {
|
|
9627
|
+
// `out/` — СГЕНЕРИРОВАННОЕ публичное зеркало: те же манифесты, скопированные. Дефект в нём
|
|
9628
|
+
// есть дефект генератора, и он уже сообщается по источнику; вторая копия только удвоила бы
|
|
9629
|
+
// одну и ту же находку.
|
|
9630
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name === 'out') continue;
|
|
9631
|
+
const full = join(dir, e.name);
|
|
9632
|
+
if (e.name === '.claude-plugin') {
|
|
9633
|
+
const manifest = join(full, 'plugin.json');
|
|
9634
|
+
if (!existsSync(manifest)) continue;
|
|
9635
|
+
try {
|
|
9636
|
+
const j = JSON.parse(readFileSync(manifest, 'utf8')) as Record<string, unknown>;
|
|
9637
|
+
const pick = (k: string): string | undefined => (typeof j[k] === 'string' ? j[k] as string : undefined);
|
|
9638
|
+
// Состав навыков — ОБЕ стороны инвентаризации, и ТОЛЬКО для коробки ОДНОГО ПАКЕТА.
|
|
9639
|
+
//
|
|
9640
|
+
// Корневая витрина сюда НЕ входит, и это не упрощение: её состав собирается из всего
|
|
9641
|
+
// монорепозитория через реестр, «что лежит на складе» для неё — не обход одного дерева,
|
|
9642
|
+
// а весь реестр, и ровно это уже проверяет `marketplace-parity` регенерацией. Первая
|
|
9643
|
+
// редакция этой проверки обошла корень с ограничением глубины и выдала 27 ЛОЖНЫХ
|
|
9644
|
+
// «объявлено, но не найдено» — навыки лежали глубже границы обхода (ИЗМЕРЕНО 2026-09-04,
|
|
9645
|
+
// поймано до коммита прогоном стража на этом же дереве).
|
|
9646
|
+
//
|
|
9647
|
+
// Сравниваются ПУТИ, как их объявил манифест, а не имена: два навыка с одинаковым
|
|
9648
|
+
// именем в разных подкаталогах — законная вещь, и сведение к имени их бы склеило.
|
|
9649
|
+
const boxRoot = dirname(full);
|
|
9650
|
+
const isRepoRoot = resolve(boxRoot) === resolve(root);
|
|
9651
|
+
const declaredSkills = Array.isArray(j['skills']) && !isRepoRoot
|
|
9652
|
+
? (j['skills'] as unknown[]).filter((x): x is string => typeof x === 'string')
|
|
9653
|
+
.map((rel) => rel.replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean)
|
|
9654
|
+
: undefined;
|
|
9655
|
+
const skillsOnDisk = declaredSkills === undefined ? undefined : findSkillDirs(boxRoot);
|
|
9656
|
+
pluginManifests.push({
|
|
9657
|
+
path: relative(root, manifest),
|
|
9658
|
+
...(pick('name') !== undefined ? { name: pick('name')! } : {}),
|
|
9659
|
+
...(pick('version') !== undefined ? { version: pick('version')! } : {}),
|
|
9660
|
+
...(pick('description') !== undefined ? { description: pick('description')! } : {}),
|
|
9661
|
+
...(declaredSkills !== undefined ? { declaredSkills } : {}),
|
|
9662
|
+
...(skillsOnDisk !== undefined ? { skillsOnDisk } : {}),
|
|
9663
|
+
});
|
|
9664
|
+
} catch (error) {
|
|
9665
|
+
pluginManifests.push({
|
|
9666
|
+
path: relative(root, manifest),
|
|
9667
|
+
parseError: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 200),
|
|
9668
|
+
});
|
|
9669
|
+
}
|
|
9670
|
+
continue;
|
|
9671
|
+
}
|
|
9672
|
+
if (e.name.startsWith('.')) continue;
|
|
9673
|
+
walkPlugins(full, depth + 1);
|
|
9674
|
+
}
|
|
9675
|
+
};
|
|
9676
|
+
walkPlugins(root, 0);
|
|
9677
|
+
facts['pluginManifests'] = pluginManifests;
|
|
8826
9678
|
facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
|
|
8827
9679
|
dir,
|
|
8828
9680
|
name: m.name ?? dir,
|
|
@@ -8850,7 +9702,43 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
8850
9702
|
}
|
|
8851
9703
|
facts['licenceHold'] = holds;
|
|
8852
9704
|
} catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
|
|
8853
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope:
|
|
9705
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
9706
|
+
// backlog-covers-features: каталоги фич, дата их ПОЯВЛЕНИЯ В ИСТОРИИ (не mtime — его двигает
|
|
9707
|
+
// любой посторонний процесс), оговорки из README фичи и тексты записей бэклога. Базовая дата
|
|
9708
|
+
// делает правило зелёным на приходе: 336 существующих каталогов заведены до правила.
|
|
9709
|
+
// Нечитаемое дерево ⇒ факт НЕ выставляется ⇒ правило молчит, а не выдумывает вердикт.
|
|
9710
|
+
try {
|
|
9711
|
+
const featDir = join(root, 'features');
|
|
9712
|
+
if (existsSync(featDir)) {
|
|
9713
|
+
const slugs = readdirSync(featDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
9714
|
+
const features = slugs.map((slug) => {
|
|
9715
|
+
let createdIso = '';
|
|
9716
|
+
try {
|
|
9717
|
+
createdIso = execSync(`git log --diff-filter=A --format=%aI -1 -- ${JSON.stringify('features/' + slug)}`,
|
|
9718
|
+
{ cwd: root, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).pop() ?? '';
|
|
9719
|
+
} catch { /* нет в истории — функция засчитает как новый, это верный дефолт */ }
|
|
9720
|
+
let waiver: string | undefined;
|
|
9721
|
+
for (const f of ['README.md', '07_code_changes/change_manifest.md']) {
|
|
9722
|
+
try {
|
|
9723
|
+
const m = readFileSync(join(featDir, slug, f), 'utf-8')
|
|
9724
|
+
.match(/^\s*Backlog:\s*не заведено\s*[—-]\s*(.+)$/m);
|
|
9725
|
+
if (m && m[1] && m[1].trim() !== '') { waiver = m[1].trim(); break; }
|
|
9726
|
+
} catch { /* нет файла — не оговорка */ }
|
|
9727
|
+
}
|
|
9728
|
+
return { slug, createdIso, waiver };
|
|
9729
|
+
});
|
|
9730
|
+
const backlogTexts: string[] = [];
|
|
9731
|
+
try {
|
|
9732
|
+
for (const line of readFileSync(join(root, '.dz', 'backlog', 'ideas.jsonl'), 'utf-8').split('\n')) {
|
|
9733
|
+
if (line.trim() === '') continue;
|
|
9734
|
+
try { const o = JSON.parse(line) as { text?: unknown }; if (typeof o.text === 'string') backlogTexts.push(o.text); } catch { /* рваная строка */ }
|
|
9735
|
+
}
|
|
9736
|
+
} catch { /* стора нет */ }
|
|
9737
|
+
if (backlogTexts.length > 0) {
|
|
9738
|
+
facts['featureBacklog'] = { baseline: BACKLOG_COVERAGE_BASELINE, features, backlogTexts };
|
|
9739
|
+
}
|
|
9740
|
+
}
|
|
9741
|
+
} catch { /* нечитаемо — правило молчит */ }
|
|
8854
9742
|
facts['counts'] = gatherReadmeCounts(root);
|
|
8855
9743
|
// readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
|
|
8856
9744
|
// change set contain its package.json (the version-bump signal) without its README.md?
|
|
@@ -9010,11 +9898,39 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9010
9898
|
|
|
9011
9899
|
// no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
|
|
9012
9900
|
// MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
|
|
9013
|
-
const
|
|
9901
|
+
const guardConfig = loadGuardConfig(root);
|
|
9902
|
+
const stubWaivers = guardConfig.stubWaivers;
|
|
9014
9903
|
if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
|
|
9904
|
+
const secretWaivers = guardConfig.secretWaivers;
|
|
9905
|
+
if (Array.isArray(secretWaivers)) facts['secretWaivers'] = secretWaivers;
|
|
9015
9906
|
}
|
|
9016
9907
|
if (op === 'consolidate') {
|
|
9017
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope:
|
|
9908
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
9909
|
+
}
|
|
9910
|
+
if (op === 'publish') {
|
|
9911
|
+
const secretTargets: { label: string; text: string }[] = [];
|
|
9912
|
+
let skipped = 0;
|
|
9913
|
+
for (const dir of publishPackageRoots) {
|
|
9914
|
+
const packageRoot = join(root, dir);
|
|
9915
|
+
let packed: string[];
|
|
9916
|
+
try { packed = listPackFiles(packageRoot); }
|
|
9917
|
+
catch { skipped++; continue; }
|
|
9918
|
+
for (const rel of packed) {
|
|
9919
|
+
const absolute = join(packageRoot, rel);
|
|
9920
|
+
try {
|
|
9921
|
+
const stat = lstatSync(absolute);
|
|
9922
|
+
if (!stat.isFile() || stat.size > SECRET_SCAN_MAX_BYTES) { skipped++; continue; }
|
|
9923
|
+
const content = readFileSync(absolute);
|
|
9924
|
+
if (content.subarray(0, 8 * 1024).includes(0)) { skipped++; continue; }
|
|
9925
|
+
secretTargets.push({
|
|
9926
|
+
label: relative(root, absolute).split(sep).join('/'),
|
|
9927
|
+
text: content.toString('utf8'),
|
|
9928
|
+
});
|
|
9929
|
+
} catch { skipped++; }
|
|
9930
|
+
}
|
|
9931
|
+
}
|
|
9932
|
+
if (secretTargets.length > 0) facts['secretTargets'] = secretTargets;
|
|
9933
|
+
if (skipped > 0) facts['secretScan'] = { skipped };
|
|
9018
9934
|
}
|
|
9019
9935
|
if (op === 'teach' || op === 'consolidate') {
|
|
9020
9936
|
if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
|
|
@@ -9030,13 +9946,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
9030
9946
|
* shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
|
|
9031
9947
|
* `overrideReason` (when the caller forces through a block) is logged, never silent.
|
|
9032
9948
|
*/
|
|
9033
|
-
function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined): ReturnType<typeof evaluateGuard> {
|
|
9949
|
+
function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined, publishFilter?: readonly string[]): ReturnType<typeof evaluateGuard> {
|
|
9034
9950
|
const cfg = loadGuardConfig(root);
|
|
9035
9951
|
// Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
|
|
9036
9952
|
// silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
|
|
9037
9953
|
const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
|
|
9038
9954
|
const rules = resolveRules(Array.isArray(cfg.rules) ? (cfg.rules as never[]) : undefined);
|
|
9039
|
-
const facts = gatherGuardFacts(op, root, text, storeCap);
|
|
9955
|
+
const facts = gatherGuardFacts(op, root, text, storeCap, publishFilter);
|
|
9040
9956
|
const result = evaluateGuard(facts as never, rules);
|
|
9041
9957
|
// audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
|
|
9042
9958
|
try {
|
|
@@ -9835,15 +10751,52 @@ async function cmdMrRakes(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9835
10751
|
* --threshold N drill threshold (default 2 — anti-noise: a first-seen rake accrues, never drills)
|
|
9836
10752
|
* --no-teach drill only; do NOT write the store (skip the agent side)
|
|
9837
10753
|
* --project <dir> pin the teach ledger
|
|
9838
|
-
* --install-hook print the opt-in
|
|
10754
|
+
* --install-hook print the opt-in hook set to add (non-destructive): Stop scan-tail +
|
|
10755
|
+
* PreCompact/SessionEnd full retro (feature narrated-error-must-be-taught)
|
|
10756
|
+
* --scan-tail per-turn Stop-hook mode: incremental admission-debt scan, O(new bytes) —
|
|
10757
|
+
* writes/clears .dz/retro-pending.json; no ledger, no teach, no git subprocess
|
|
10758
|
+
* --transcript <p> the transcript --scan-tail must read. Without it the Stop hook's own stdin
|
|
10759
|
+
* payload (`transcript_path`) is used; with neither, the scan REFUSES
|
|
10760
|
+
* (NOT-ESTABLISHED) rather than guessing the newest file on disk
|
|
9839
10761
|
*/
|
|
9840
|
-
async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
|
|
10762
|
+
async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, readStdin: () => string = () => ''): Promise<number> {
|
|
10763
|
+
if (flags.has('scan-tail')) {
|
|
10764
|
+
// Per-turn primary detector (ADR-001 D2). Cost budget IS the design: no `git rev-parse`
|
|
10765
|
+
// subprocess (hooks run with cwd = project root; CLAUDE_PROJECT_DIR pins it), no store open,
|
|
10766
|
+
// O(new bytes) via the persisted offset. Always exit 0 — a Stop hook must never fail a turn.
|
|
10767
|
+
const root = process.env['CLAUDE_PROJECT_DIR'] ?? cwd;
|
|
10768
|
+
// WHICH transcript. Round 3, P1-3: this used to fall back to `findLatestTranscript(root)`, so with
|
|
10769
|
+
// several sessions and their subagents alive at once it scanned whichever file had the newest
|
|
10770
|
+
// mtime — routinely another session's, advancing that session's offset and never seeing this
|
|
10771
|
+
// turn's admission. The Stop hook hands the exact path on stdin; with no path from any source the
|
|
10772
|
+
// answer is a stated refusal, never a guess. Still exit 0: a Stop hook must never fail a turn.
|
|
10773
|
+
const picked = resolveScanTailTranscript({
|
|
10774
|
+
flag: options.get('transcript'),
|
|
10775
|
+
positional: options.get('_positional_0'),
|
|
10776
|
+
stdin: readStdin(),
|
|
10777
|
+
});
|
|
10778
|
+
if (picked.path === null) {
|
|
10779
|
+
const reason = picked.reason ?? 'no transcript path';
|
|
10780
|
+
if (flags.has('json')) write(JSON.stringify({ status: 'not-established', source: 'none', reason, scannedBytes: 0, offset: 0 }));
|
|
10781
|
+
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>\`.`);
|
|
10782
|
+
return 0;
|
|
10783
|
+
}
|
|
10784
|
+
const outcome = runRetroTailScan(join(root, '.dz'), picked.path);
|
|
10785
|
+
if (flags.has('json')) write(JSON.stringify({ ...outcome, source: picked.source }));
|
|
10786
|
+
else if (outcome.status === 'pending') write(`retro scan-tail: unpaid admission — .dz/retro-pending.json armed («${(outcome.snippet ?? '').slice(0, 60)}…»)`);
|
|
10787
|
+
return 0;
|
|
10788
|
+
}
|
|
10789
|
+
|
|
9841
10790
|
let repoRoot = cwd;
|
|
9842
10791
|
try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
|
|
9843
10792
|
|
|
9844
10793
|
if (flags.has('install-hook')) {
|
|
9845
|
-
write('Add
|
|
9846
|
-
write(JSON.stringify({ hooks: {
|
|
10794
|
+
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):');
|
|
10795
|
+
write(JSON.stringify({ hooks: {
|
|
10796
|
+
Stop: [{ hooks: [{ type: 'command', command: 'dz retro --scan-tail', timeout: 10000, continueOnError: true }] }],
|
|
10797
|
+
PreCompact: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
|
|
10798
|
+
SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
|
|
10799
|
+
} }, null, 2));
|
|
9847
10800
|
return 0;
|
|
9848
10801
|
}
|
|
9849
10802
|
|
|
@@ -10080,6 +11033,43 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
|
|
|
10080
11033
|
* decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
|
|
10081
11034
|
* a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
|
|
10082
11035
|
*/
|
|
11036
|
+
/**
|
|
11037
|
+
* Есть ли в этом каталоге НЕЗАКОММИЧЕННЫЕ правки. Это и отличает «фича ещё в рабочем дереве»
|
|
11038
|
+
* (тогда `HEAD` — законная предфичевая база) от «фича уже закоммичена» (тогда `HEAD` её содержит).
|
|
11039
|
+
*
|
|
11040
|
+
* Не удалось спросить git — возвращается null, и вызывающий обязан считать положение НЕ
|
|
11041
|
+
* УСТАНОВЛЕННЫМ, а не выбрать удобный ответ.
|
|
11042
|
+
*/
|
|
11043
|
+
/**
|
|
11044
|
+
* Каталоги с `SKILL.md` внутри коробки ОДНОГО пакета — то, что РЕАЛЬНО лежит на складе.
|
|
11045
|
+
* Возвращаются ПУТИ относительно коробки, как их объявляет манифест, а не имена.
|
|
11046
|
+
*/
|
|
11047
|
+
function findSkillDirs(boxRoot: string): string[] {
|
|
11048
|
+
const found = new Set<string>();
|
|
11049
|
+
const walk = (dir: string, depth: number): void => {
|
|
11050
|
+
if (depth > 4) return;
|
|
11051
|
+
let entries: Dirent[];
|
|
11052
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
11053
|
+
for (const e of entries) {
|
|
11054
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
|
|
11055
|
+
const full = join(dir, e.name);
|
|
11056
|
+
if (existsSync(join(full, 'SKILL.md'))) found.add(relative(boxRoot, full));
|
|
11057
|
+
walk(full, depth + 1);
|
|
11058
|
+
}
|
|
11059
|
+
};
|
|
11060
|
+
walk(boxRoot, 0);
|
|
11061
|
+
return [...found];
|
|
11062
|
+
}
|
|
11063
|
+
|
|
11064
|
+
function hasUncommittedChangesIn(repoRoot: string, dir: string): boolean | null {
|
|
11065
|
+
try {
|
|
11066
|
+
const out = execSync(`git status --porcelain -- ${JSON.stringify(dir)}`, { cwd: repoRoot, encoding: 'utf-8' });
|
|
11067
|
+
return out.split('\n').some((line) => line.trim() !== '');
|
|
11068
|
+
} catch {
|
|
11069
|
+
return null;
|
|
11070
|
+
}
|
|
11071
|
+
}
|
|
11072
|
+
|
|
10083
11073
|
function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
10084
11074
|
let repoRoot = cwd;
|
|
10085
11075
|
try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
|
|
@@ -10092,15 +11082,121 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10092
11082
|
const nameFilter = options.get('name');
|
|
10093
11083
|
const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) =>
|
|
10094
11084
|
nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
|
|
10095
|
-
|
|
11085
|
+
/**
|
|
11086
|
+
* УМОЛЧАНИЕ БАЗЫ РАЗЛИЧАЕТ ДВА РАЗНЫХ ПОЛОЖЕНИЯ, потому что они и правда разные.
|
|
11087
|
+
*
|
|
11088
|
+
* В штатном ходе конвейера правка Шага 7 ЕЩЁ НЕ ЗАКОММИЧЕНА, и тогда `HEAD` — настоящая
|
|
11089
|
+
* предфичевая база; так конвейер и зовёт гейт (`--base HEAD`, причина записана в его тексте).
|
|
11090
|
+
*
|
|
11091
|
+
* Но если фича УЖЕ ЗАКОММИЧЕНА, `HEAD` её содержит, и гейт сравнивает фичу С САМОЙ СОБОЙ.
|
|
11092
|
+
* ИЗМЕРЕНО 2026-09-04: такой прогон дал уверенное `NON_DISCRIMINATING` с советом «усилить тест»
|
|
11093
|
+
* на тестах, которые дискриминируют; тот же прогон с верной базой дал `DISCRIMINATES_VIA_ERROR`.
|
|
11094
|
+
* Находка выглядела как утверждение О ТЕСТАХ, и автор пошёл бы чинить исправное.
|
|
11095
|
+
*
|
|
11096
|
+
* Различить эти положения можно ДЕТЕРМИНИРОВАННО: есть ли незакоммиченные правки в пакете, чьи
|
|
11097
|
+
* тесты названы. Есть — `HEAD` законен и используется как раньше. Нет — база не установлена, и
|
|
11098
|
+
* гейт ОТКАЗЫВАЕТСЯ (exit 3), вместо того чтобы измерять относительно самого себя.
|
|
11099
|
+
*/
|
|
11100
|
+
const explicitBase = options.get('base');
|
|
10096
11101
|
const runnerOpt = options.get('runner');
|
|
10097
11102
|
// R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
|
|
10098
11103
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
10099
11104
|
const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
10100
11105
|
|
|
10101
|
-
|
|
11106
|
+
// Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
|
|
11107
|
+
// package's own scripts.test, never from a global default. The package dir is the nearest
|
|
11108
|
+
// ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
|
|
11109
|
+
// because the pure half deliberately takes the script text as data and never touches the fs.
|
|
11110
|
+
let packageTestScript: string | null = null;
|
|
11111
|
+
let packageDevDependencies: string[] = [];
|
|
11112
|
+
let packageDir = repoRoot;
|
|
11113
|
+
{
|
|
11114
|
+
const firstTest = propertyTests[0]?.file;
|
|
11115
|
+
// QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
|
|
11116
|
+
// engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
|
|
11117
|
+
// repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
|
|
11118
|
+
// marker file). Containment first: a start point outside the repo root never gets walked,
|
|
11119
|
+
// the script stays null, and the engine's own path sanitation then refuses the test path.
|
|
11120
|
+
const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
|
|
11121
|
+
if (firstTest !== undefined && walkStart !== undefined
|
|
11122
|
+
&& (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
|
|
11123
|
+
let probe = walkStart;
|
|
11124
|
+
// walk up to the repo root looking for package.json (bounded by the fs root either way)
|
|
11125
|
+
for (;;) {
|
|
11126
|
+
if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
|
|
11127
|
+
const parent = dirname(probe);
|
|
11128
|
+
if (parent === probe || probe === repoRoot) break;
|
|
11129
|
+
probe = parent;
|
|
11130
|
+
}
|
|
11131
|
+
try {
|
|
11132
|
+
const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
|
|
11133
|
+
scripts?: Record<string, string>; devDependencies?: Record<string, string>;
|
|
11134
|
+
};
|
|
11135
|
+
packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
|
|
11136
|
+
packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
|
|
11137
|
+
} catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
|
|
11138
|
+
}
|
|
11139
|
+
}
|
|
11140
|
+
// The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
|
|
11141
|
+
// absolute one — an absolute path is refused as unsafe-package-dir by design.
|
|
11142
|
+
const packageDirRel = relative(repoRoot, packageDir) || '.';
|
|
11143
|
+
|
|
11144
|
+
// Умолчание базы решается ЗДЕСЬ, потому что только здесь известен пакет, чьи тесты названы.
|
|
11145
|
+
let baseRef: string;
|
|
11146
|
+
if (explicitBase !== undefined && explicitBase.trim() !== '') {
|
|
11147
|
+
baseRef = explicitBase.trim();
|
|
11148
|
+
} else {
|
|
11149
|
+
const dirty = hasUncommittedChangesIn(repoRoot, packageDirRel);
|
|
11150
|
+
if (dirty !== true) {
|
|
11151
|
+
write('dz discrimination-check: NOT-ESTABLISHED — предфичевая база не установлена.');
|
|
11152
|
+
write(dirty === null
|
|
11153
|
+
? ` Не удалось спросить git о состоянии ${packageDirRel}; выбирать удобный ответ вместо этого нельзя.`
|
|
11154
|
+
: ` В ${packageDirRel} нет незакоммиченных правок, значит фича УЖЕ в HEAD, и сравнение шло бы с самой собой.`);
|
|
11155
|
+
write(' Гейт сравнивает поведение ДО и ПОСЛЕ фичи; без базы сравнивать не с чем, а HEAD здесь');
|
|
11156
|
+
write(' дал бы уверенное NON_DISCRIMINATING на исправных тестах (ИЗМЕРЕНО 2026-09-04).');
|
|
11157
|
+
write(' Передайте --base <коммит перед фичей>, например `<sha коммита фичи>^`.');
|
|
11158
|
+
return 3;
|
|
11159
|
+
}
|
|
11160
|
+
// Штатный ход конвейера: правка Шага 7 ещё в рабочем дереве, HEAD — настоящая предфичевая база.
|
|
11161
|
+
baseRef = 'HEAD';
|
|
11162
|
+
write(`dz discrimination-check: база не задана; в ${packageDirRel} есть незакоммиченные правки, беру HEAD как предфичевую базу.`);
|
|
11163
|
+
}
|
|
11164
|
+
|
|
11165
|
+
// Провенанс базы едет в квитанцию вместе с самой базой: «явно указано» — утверждение о действии
|
|
11166
|
+
// человека, и писать его для HEAD, выбранного инструментом, значит подделывать происхождение
|
|
11167
|
+
// доказательства, на которое сошлются позже.
|
|
11168
|
+
const baseRefSupplied = explicitBase !== undefined && explicitBase.trim() !== '';
|
|
11169
|
+
const planInput = runnerOpt !== undefined
|
|
11170
|
+
? { baseRef, baseRefSupplied, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
|
|
11171
|
+
: { baseRef, baseRefSupplied, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
|
|
11172
|
+
const plan = planDiscriminationCheck(planInput);
|
|
10102
11173
|
|
|
10103
11174
|
if (!plan.runnable) {
|
|
11175
|
+
// QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
|
|
11176
|
+
// "no property test to check"/map-a-test framing — the operator-facing surface re-created the
|
|
11177
|
+
// exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
|
|
11178
|
+
// backlog entries existed. The plan's own named reason is the verdict; the generic classify
|
|
11179
|
+
// stays only for the genuinely-empty-target case.
|
|
11180
|
+
const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
|
|
11181
|
+
if (runnerRefusal) {
|
|
11182
|
+
const refusal = {
|
|
11183
|
+
aggregate: 'CANNOT_ISOLATE',
|
|
11184
|
+
measurementValid: false,
|
|
11185
|
+
primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
|
|
11186
|
+
finding: {
|
|
11187
|
+
severity: 'high',
|
|
11188
|
+
verdict: 'CANNOT_ISOLATE',
|
|
11189
|
+
files: plan.targets.map((t) => t.file),
|
|
11190
|
+
detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
|
|
11191
|
+
+ `declare scripts.test in the target package (or pass --runner) and re-run. `
|
|
11192
|
+
+ `This is NOT a statement about the tests.`,
|
|
11193
|
+
},
|
|
11194
|
+
};
|
|
11195
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
|
|
11196
|
+
write(`discrimination-check: REFUSED (${plan.reason})`);
|
|
11197
|
+
write(` → ${refusal.finding.detail}`);
|
|
11198
|
+
return 0;
|
|
11199
|
+
}
|
|
10104
11200
|
// No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
|
|
10105
11201
|
const result = classifyDiscrimination({ propertyTests: [], results: [] });
|
|
10106
11202
|
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
|
|
@@ -10189,10 +11285,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10189
11285
|
|
|
10190
11286
|
// t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
|
|
10191
11287
|
// still quote + `--` so a path can never be read as a runner option or split a word.
|
|
11288
|
+
// Runner honesty (ADR-001): the run executes FROM the target package dir with a
|
|
11289
|
+
// package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
|
|
11290
|
+
// (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
|
|
11291
|
+
// feature removes. The plan's own commands encode the same cd; this body mirrors it.
|
|
11292
|
+
const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
|
|
11293
|
+
const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
|
|
11294
|
+
const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
|
|
11295
|
+
const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
|
|
10192
11296
|
const nameArg = t.name ? ` -t '${t.name}'` : '';
|
|
10193
|
-
|
|
10194
|
-
|
|
10195
|
-
|
|
11297
|
+
// NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
|
|
11298
|
+
// filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
|
|
11299
|
+
// this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
|
|
11300
|
+
// leading dash, no metacharacters), so it can never be read as an option.
|
|
11301
|
+
const cmd = `${runner}${nameArg} '${fileInPkg}'`;
|
|
11302
|
+
const base = runCapturedTest(cmd, execDirBase, timeoutMs);
|
|
11303
|
+
// The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
|
|
11304
|
+
// paths, so it must be probed with the same form, or every hit reads as target-unseen.
|
|
11305
|
+
const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
|
|
10196
11306
|
const outcome = discriminationOutcomeOf(base.exitCode, evidence);
|
|
10197
11307
|
const row: Record<string, unknown> = t.name !== undefined
|
|
10198
11308
|
? { file: t.file, name: t.name, outcome, evidence }
|
|
@@ -10204,8 +11314,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10204
11314
|
// base rows per the matrix; running it is cheap and only ever on an already-broken path.
|
|
10205
11315
|
// Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
|
|
10206
11316
|
if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
|
|
10207
|
-
const tip = runCapturedTest(cmd,
|
|
10208
|
-
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode,
|
|
11317
|
+
const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
|
|
11318
|
+
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
|
|
10209
11319
|
row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
|
|
10210
11320
|
row['tipEvidence'] = tipEvidence;
|
|
10211
11321
|
// R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
|
|
@@ -10329,9 +11439,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
10329
11439
|
*
|
|
10330
11440
|
* Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
|
|
10331
11441
|
* below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
|
|
10332
|
-
* error (missing registry
|
|
10333
|
-
* read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
|
|
11442
|
+
* error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
|
|
10334
11443
|
* symlink escape is refused before anything is written, SPEC rule 3).
|
|
11444
|
+
* A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
|
|
10335
11445
|
*/
|
|
10336
11446
|
/**
|
|
10337
11447
|
* Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
|
|
@@ -10340,7 +11450,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
10340
11450
|
* redness says nothing about the named protection. Returns `{error}` when a parser ran and the
|
|
10341
11451
|
* text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
|
|
10342
11452
|
*/
|
|
10343
|
-
|
|
11453
|
+
interface MutationParseCheckResult {
|
|
11454
|
+
readonly error?: string;
|
|
11455
|
+
readonly skipped?: string;
|
|
11456
|
+
readonly internalFailureReason?: string;
|
|
11457
|
+
readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
11458
|
+
}
|
|
11459
|
+
|
|
11460
|
+
function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
|
|
10344
11461
|
interface TsLike {
|
|
10345
11462
|
transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
|
|
10346
11463
|
flattenDiagnosticMessageText(m: unknown, s: string): string;
|
|
@@ -10364,17 +11481,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
10364
11481
|
try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
|
|
10365
11482
|
}
|
|
10366
11483
|
if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
11484
|
+
const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
|
|
11485
|
+
try {
|
|
11486
|
+
// `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
|
|
11487
|
+
execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
|
|
11488
|
+
return {};
|
|
11489
|
+
} catch (e) {
|
|
11490
|
+
const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
|
|
11491
|
+
// A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
|
|
11492
|
+
// launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
|
|
11493
|
+
// and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
|
|
11494
|
+
if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
|
|
11495
|
+
const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
11496
|
+
const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
|
|
11497
|
+
?? stderrLines.at(-1)
|
|
11498
|
+
?? err.message
|
|
11499
|
+
?? 'node --check failed';
|
|
11500
|
+
return { error: msg.slice(0, 200) };
|
|
11501
|
+
}
|
|
11502
|
+
});
|
|
11503
|
+
if (checked.value === null) {
|
|
11504
|
+
return {
|
|
11505
|
+
internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
11506
|
+
internalAttempts: checked.attempts,
|
|
11507
|
+
};
|
|
10377
11508
|
}
|
|
11509
|
+
return checked.internalRetries === 1
|
|
11510
|
+
? { ...checked.value, internalAttempts: checked.attempts }
|
|
11511
|
+
: checked.value;
|
|
10378
11512
|
}
|
|
10379
11513
|
return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
|
|
10380
11514
|
} catch (e) {
|
|
@@ -10382,7 +11516,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
10382
11516
|
}
|
|
10383
11517
|
}
|
|
10384
11518
|
|
|
10385
|
-
function cmdMutationGate(
|
|
11519
|
+
function cmdMutationGate(
|
|
11520
|
+
options: Map<string, string>,
|
|
11521
|
+
flags: Set<string>,
|
|
11522
|
+
cwd: string,
|
|
11523
|
+
write: Write,
|
|
11524
|
+
injectedRunner?: MutationGateRunner,
|
|
11525
|
+
): number {
|
|
10386
11526
|
const json = flags.has('json');
|
|
10387
11527
|
const fail = (what: string): number => {
|
|
10388
11528
|
write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
|
|
@@ -10453,6 +11593,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10453
11593
|
const results: MutationEntryResult[] = [];
|
|
10454
11594
|
const observations: MutationObservation[] = [];
|
|
10455
11595
|
const warnings: string[] = [];
|
|
11596
|
+
const internalRetries: {
|
|
11597
|
+
readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
|
|
11598
|
+
readonly entryId?: string;
|
|
11599
|
+
readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
11600
|
+
}[] = [];
|
|
10456
11601
|
let baseline: ReturnType<typeof classifyBaseline>;
|
|
10457
11602
|
try {
|
|
10458
11603
|
if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
|
|
@@ -10492,7 +11637,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10492
11637
|
const realScratchRoot = realpathSync(copyDir);
|
|
10493
11638
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
10494
11639
|
|
|
10495
|
-
|
|
11640
|
+
type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
|
|
11641
|
+
const invokeSuite = (): MutationGateRunnerObservation => {
|
|
11642
|
+
if (injectedRunner !== undefined) {
|
|
11643
|
+
return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
|
|
11644
|
+
}
|
|
10496
11645
|
const run = spawnSync(testCmd, {
|
|
10497
11646
|
cwd: copyDir,
|
|
10498
11647
|
shell: true,
|
|
@@ -10504,6 +11653,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10504
11653
|
const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
|
|
10505
11654
|
? run.error.code
|
|
10506
11655
|
: undefined;
|
|
11656
|
+
// Node may populate both `error` and a numeric `status` for an internal spawn failure. The
|
|
11657
|
+
// error wins except for the two already-named resource observations: a status alongside
|
|
11658
|
+
// EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
|
|
11659
|
+
if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
|
|
11660
|
+
throw run.error;
|
|
11661
|
+
}
|
|
10507
11662
|
const signal = typeof run.signal === 'string' ? run.signal : undefined;
|
|
10508
11663
|
let failureReason: string | undefined;
|
|
10509
11664
|
if (typeof run.status !== 'number') {
|
|
@@ -10515,22 +11670,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10515
11670
|
}
|
|
10516
11671
|
return {
|
|
10517
11672
|
exitCode: typeof run.status === 'number' ? run.status : null,
|
|
10518
|
-
// Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
|
|
10519
|
-
// collection would silently lose a green-run marker.
|
|
10520
11673
|
output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
|
|
10521
11674
|
...(failureReason !== undefined ? { failureReason } : {}),
|
|
10522
11675
|
};
|
|
10523
11676
|
};
|
|
10524
11677
|
|
|
11678
|
+
const runSuite = (
|
|
11679
|
+
phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
|
|
11680
|
+
entryId?: string,
|
|
11681
|
+
): SuiteRun => {
|
|
11682
|
+
const retried = runWithOneInternalRetry(invokeSuite);
|
|
11683
|
+
const loggedAttempts = retried.attempts.map((attempt) => {
|
|
11684
|
+
if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
|
|
11685
|
+
const outcome = retried.value.exitCode === null
|
|
11686
|
+
? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
|
|
11687
|
+
: `exit ${retried.value.exitCode}`;
|
|
11688
|
+
return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
|
|
11689
|
+
});
|
|
11690
|
+
if (retried.internalRetries === 1) {
|
|
11691
|
+
const record = entryId === undefined
|
|
11692
|
+
? { phase, attempts: loggedAttempts }
|
|
11693
|
+
: { phase, entryId, attempts: loggedAttempts };
|
|
11694
|
+
internalRetries.push(record);
|
|
11695
|
+
if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
|
|
11696
|
+
}
|
|
11697
|
+
const internalAttemptLog = retried.internalRetries === 1
|
|
11698
|
+
? loggedAttempts.map((attempt) => attempt.detail).join('; ')
|
|
11699
|
+
: undefined;
|
|
11700
|
+
if (retried.value !== null) {
|
|
11701
|
+
return {
|
|
11702
|
+
...retried.value,
|
|
11703
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
11704
|
+
};
|
|
11705
|
+
}
|
|
11706
|
+
return {
|
|
11707
|
+
exitCode: null,
|
|
11708
|
+
output: '',
|
|
11709
|
+
failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
11710
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
11711
|
+
};
|
|
11712
|
+
};
|
|
11713
|
+
|
|
10525
11714
|
// Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
|
|
10526
11715
|
// result would be this gate shipping the defect class it exists to catch.
|
|
10527
11716
|
if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
10528
|
-
const base = runSuite();
|
|
10529
|
-
baseline = classifyBaseline(
|
|
11717
|
+
const base = runSuite('baseline');
|
|
11718
|
+
baseline = classifyBaseline(
|
|
11719
|
+
base.exitCode,
|
|
11720
|
+
base.failureReason,
|
|
11721
|
+
base.exitCode !== null && base.exitCode !== 0
|
|
11722
|
+
? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
|
|
11723
|
+
: undefined,
|
|
11724
|
+
);
|
|
10530
11725
|
if (!baseline.ok) {
|
|
10531
|
-
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode:
|
|
11726
|
+
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
|
|
10532
11727
|
write(renderMutationReport([], baseline, pkgDir));
|
|
10533
|
-
return
|
|
11728
|
+
return 1;
|
|
10534
11729
|
}
|
|
10535
11730
|
|
|
10536
11731
|
for (const entry of entries) {
|
|
@@ -10565,8 +11760,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10565
11760
|
return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
|
|
10566
11761
|
}
|
|
10567
11762
|
if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
|
|
10568
|
-
let run:
|
|
11763
|
+
let run: SuiteRun | null = null;
|
|
10569
11764
|
let parseError: string | undefined;
|
|
11765
|
+
let parseInternalFailureReason: string | undefined;
|
|
11766
|
+
let parseInternalAttemptLog: string | undefined;
|
|
10570
11767
|
try {
|
|
10571
11768
|
writeFileSync(filePath, applied.text);
|
|
10572
11769
|
// Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
|
|
@@ -10576,10 +11773,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10576
11773
|
warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
|
|
10577
11774
|
if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
|
|
10578
11775
|
}
|
|
11776
|
+
if (check.internalAttempts !== undefined) {
|
|
11777
|
+
internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
|
|
11778
|
+
parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
|
|
11779
|
+
if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
|
|
11780
|
+
}
|
|
11781
|
+
parseInternalFailureReason = check.internalFailureReason;
|
|
10579
11782
|
if (check.error !== undefined) {
|
|
10580
11783
|
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
10581
|
-
} else {
|
|
10582
|
-
run = runSuite();
|
|
11784
|
+
} else if (parseInternalFailureReason === undefined) {
|
|
11785
|
+
run = runSuite('mutation', entry.id);
|
|
10583
11786
|
}
|
|
10584
11787
|
} finally {
|
|
10585
11788
|
writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
|
|
@@ -10613,13 +11816,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10613
11816
|
// those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
10614
11817
|
let rebaselineExitCode: number | null | undefined;
|
|
10615
11818
|
let rebaselineFailureReason: string | undefined;
|
|
11819
|
+
let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
|
|
11820
|
+
let rebaselineInternalAttemptLog: string | undefined;
|
|
10616
11821
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
10617
11822
|
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
10618
11823
|
if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
|
|
10619
|
-
const rebaselineRun = runSuite();
|
|
11824
|
+
const rebaselineRun = runSuite('rebaseline', entry.id);
|
|
10620
11825
|
rebaselineExitCode = rebaselineRun.exitCode;
|
|
10621
11826
|
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
11827
|
+
rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
|
|
11828
|
+
if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
|
|
11829
|
+
rebaselineAttribution = attributeBaselineRedness(
|
|
11830
|
+
rebaselineRun.output,
|
|
11831
|
+
entries.map((candidate) => candidate.file),
|
|
11832
|
+
);
|
|
11833
|
+
}
|
|
10622
11834
|
}
|
|
11835
|
+
const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
|
|
11836
|
+
const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
|
|
11837
|
+
.filter((log): log is string => log !== undefined)
|
|
11838
|
+
.join('; ');
|
|
10623
11839
|
const obs: MutationObservation = {
|
|
10624
11840
|
entry,
|
|
10625
11841
|
occurrences: 1,
|
|
@@ -10629,9 +11845,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10629
11845
|
...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
|
|
10630
11846
|
...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
|
|
10631
11847
|
...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
|
|
10632
|
-
...(
|
|
11848
|
+
...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
|
|
11849
|
+
...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
|
|
10633
11850
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
10634
11851
|
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
11852
|
+
...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
|
|
10635
11853
|
};
|
|
10636
11854
|
observations.push(obs);
|
|
10637
11855
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -10644,7 +11862,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10644
11862
|
// MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
|
|
10645
11863
|
if (rebaselineMode === 'final') {
|
|
10646
11864
|
if (!json) write('mutation-gate: final re-baseline of the restored tree …');
|
|
10647
|
-
const finalRun = runSuite();
|
|
11865
|
+
const finalRun = runSuite('final-rebaseline');
|
|
10648
11866
|
const finalExit = finalRun.exitCode;
|
|
10649
11867
|
if (finalExit !== 0) {
|
|
10650
11868
|
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
@@ -10654,6 +11872,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10654
11872
|
...obs,
|
|
10655
11873
|
rebaselineExitCode: finalExit,
|
|
10656
11874
|
...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
|
|
11875
|
+
...(finalRun.internalAttemptLog !== undefined
|
|
11876
|
+
? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
|
|
11877
|
+
: {}),
|
|
11878
|
+
...(finalExit !== null && finalExit !== 0
|
|
11879
|
+
? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
|
|
11880
|
+
: {}),
|
|
10657
11881
|
}));
|
|
10658
11882
|
results.length = 0;
|
|
10659
11883
|
results.push(...reclassified);
|
|
@@ -10669,7 +11893,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10669
11893
|
|
|
10670
11894
|
const exitCode = mutationGateExitCode(results, baseline.ok);
|
|
10671
11895
|
if (json) {
|
|
10672
|
-
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
|
|
11896
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
|
|
10673
11897
|
return exitCode;
|
|
10674
11898
|
}
|
|
10675
11899
|
write(renderMutationReport(results, baseline, pkgDir));
|
|
@@ -11846,9 +13070,24 @@ function nameCheckScan(repoRoot: string): NameFacts {
|
|
|
11846
13070
|
// Command names come from the dispatcher AND from the help block: a name that dispatches but
|
|
11847
13071
|
// is undocumented is still taken, and so is the reverse.
|
|
11848
13072
|
if (f.name === 'cli.ts') {
|
|
11849
|
-
|
|
11850
|
-
|
|
11851
|
-
|
|
13073
|
+
// ONE enumeration, every consumer derives (ADR-001, feature command-count-triad). This
|
|
13074
|
+
// scan's question is "is the name TAKEN?", so taken = dispatched ∪ documented, which is
|
|
13075
|
+
// legitimately LARGER than the canonical command count — but it must be the SAME parse
|
|
13076
|
+
// the layer-1 parity test uses, not a second private regex that agrees by coincidence.
|
|
13077
|
+
// The any-indent `dispatchedCommandsIn` fallback stays for a cli.ts WITHOUT a main
|
|
13078
|
+
// `switch (command)` (none in this workspace today): a partial sweep would answer "free"
|
|
13079
|
+
// about a taken name, and that is the one answer this command may never give.
|
|
13080
|
+
let mainSwitchParsed = false;
|
|
13081
|
+
try {
|
|
13082
|
+
for (const c of dispatchedCommands(text)) commands.add(c);
|
|
13083
|
+
for (const c of documentedCommands(text)) commands.add(c);
|
|
13084
|
+
mainSwitchParsed = true;
|
|
13085
|
+
} catch { /* no main switch here — fall back to the broad regexes below */ }
|
|
13086
|
+
if (!mainSwitchParsed) {
|
|
13087
|
+
for (const c of dispatchedCommandsIn(text)) commands.add(c);
|
|
13088
|
+
const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
|
|
13089
|
+
for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
|
|
13090
|
+
}
|
|
11852
13091
|
}
|
|
11853
13092
|
}
|
|
11854
13093
|
}
|
|
@@ -11862,6 +13101,71 @@ function nameCheckScan(repoRoot: string): NameFacts {
|
|
|
11862
13101
|
return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
|
|
11863
13102
|
}
|
|
11864
13103
|
|
|
13104
|
+
/**
|
|
13105
|
+
* `dz brief-check <файл>` — проверить бриф роя на контракт вывода (ADR-001 swarm-brief-output-contract).
|
|
13106
|
+
*
|
|
13107
|
+
* Разбирает объявления брифа как ДАННЫЕ и отказывает поимённо: «бриф неверен» не говорит автору,
|
|
13108
|
+
* что чинить, поэтому каждое нарушение называет ключ и причину.
|
|
13109
|
+
*
|
|
13110
|
+
* ЧЕСТНЫЙ ПРЕДЕЛ печатается ВМЕСТЕ С ЗЕЛЁНЫМ ответом: проверено, что бриф ОБЪЯВИЛ каталог и
|
|
13111
|
+
* единицы, а не что агент им последует. Зелёная проверка, читаемая как гарантия поведения, хуже
|
|
13112
|
+
* её отсутствия.
|
|
13113
|
+
*/
|
|
13114
|
+
function cmdBriefCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
13115
|
+
const json = flags.has('json');
|
|
13116
|
+
/**
|
|
13117
|
+
* ВЕТКА «ПРОВЕРИТЬ НЕ УДАЛОСЬ» ТОЖЕ ОБЯЗАНА ОТВЕТИТЬ JSON-ом (находка 11).
|
|
13118
|
+
*
|
|
13119
|
+
* `--json` печатал человеческую строку на несуществующем файле, и потребитель, читающий вывод как
|
|
13120
|
+
* JSON, получал ошибку разбора вместо структурного «не проверено». Признак `checked` — тот же
|
|
13121
|
+
* трихотомический вердикт, что и коды выхода, только для машины: не «бриф плох», а «мы про него
|
|
13122
|
+
* ничего не установили».
|
|
13123
|
+
*/
|
|
13124
|
+
const unchecked = (reason: string, detail: string): number => {
|
|
13125
|
+
if (json) write(JSON.stringify({ ok: false, checked: false, reason, detail }));
|
|
13126
|
+
else write(detail);
|
|
13127
|
+
return 2;
|
|
13128
|
+
};
|
|
13129
|
+
// Только позиционный аргумент: `--file` был необъявленным псевдонимом, и страж дрейфа флагов
|
|
13130
|
+
// справедливо на него указал — лишняя поверхность, которой нет в справке.
|
|
13131
|
+
const file = options.get('_positional_0');
|
|
13132
|
+
if (file === undefined || file.trim() === '') {
|
|
13133
|
+
const code = unchecked('no-file', 'dz brief-check: name the brief file — dz brief-check <file> [--json]');
|
|
13134
|
+
if (!json) write(' A brief must declare: ' + SWARM_BRIEF_CONTRACT.map((c: { key: string }) => c.key).join(', '));
|
|
13135
|
+
return code;
|
|
13136
|
+
}
|
|
13137
|
+
let text: string;
|
|
13138
|
+
try {
|
|
13139
|
+
text = readFileSync(resolve(cwd, file), 'utf-8');
|
|
13140
|
+
} catch {
|
|
13141
|
+
// Нечитаемый файл — НЕ «бриф плох»: мы про него ничего не установили. Отдельный код выхода,
|
|
13142
|
+
// чтобы отказ прибора не смешивался с отказом брифа.
|
|
13143
|
+
return unchecked('unreadable', `dz brief-check: cannot read ${visibleText(file)}`);
|
|
13144
|
+
}
|
|
13145
|
+
const result = checkSwarmBrief(text);
|
|
13146
|
+
if (json) {
|
|
13147
|
+
// `checked: true` — вторая половина того же различителя: потребитель отличает «проверено и
|
|
13148
|
+
// отвергнуто» от «проверить не удалось» полем, а не отсутствием поля.
|
|
13149
|
+
write(JSON.stringify({ ...result, checked: true }));
|
|
13150
|
+
return result.ok ? 0 : 1;
|
|
13151
|
+
}
|
|
13152
|
+
if (result.ok) {
|
|
13153
|
+
// ЗНАЧЕНИЯ ИЗ БРИФА ОБЕЗВРЕЖИВАЮТСЯ И В ЗЕЛЁНОЙ СТРОКЕ (находка 10). Отказ их уже обезвредил,
|
|
13154
|
+
// но подделывается ровно эта строка: управляющая последовательность в имени каталога стирает
|
|
13155
|
+
// предыдущий вывод и печатает поверх него подделку.
|
|
13156
|
+
write(`dz brief-check: OK — dir ${visibleText(result.outputDir ?? '')}, ${result.units.length} unit(s), assembly "${visibleText(result.assemblyUnit ?? '')}"`);
|
|
13157
|
+
write(' LIMIT: this verifies the brief DECLARED the contract, not that the agent will follow it —');
|
|
13158
|
+
write(' only comparing the directory against the unit list on an orchestrator tick can show that.');
|
|
13159
|
+
write(' A filled-in template and an unedited one both pass: the parse cannot tell them apart.');
|
|
13160
|
+
return 0;
|
|
13161
|
+
}
|
|
13162
|
+
write(`dz brief-check: REFUSED — ${result.violations.length} violation(s)`);
|
|
13163
|
+
// Ядро уже обезвредило значения в причинах; повтор на слое печати — не суеверие, а граница:
|
|
13164
|
+
// печатающий слой не обязан знать, кто именно из его источников уже почистил текст.
|
|
13165
|
+
for (const v of result.violations) write(` ${v.rule}: ${visibleText(v.detail)}`);
|
|
13166
|
+
return 1;
|
|
13167
|
+
}
|
|
13168
|
+
|
|
11865
13169
|
function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
11866
13170
|
const repoRoot = resolve(options.get('project') ?? cwd);
|
|
11867
13171
|
const json = flags.has('json');
|
|
@@ -12382,6 +13686,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
|
|
|
12382
13686
|
targetExists: existsSync(target),
|
|
12383
13687
|
targetHasPair: flags.has('once') && existsSync(target),
|
|
12384
13688
|
timestamp: new Date().toISOString(),
|
|
13689
|
+
// WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
|
|
13690
|
+
// supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
|
|
13691
|
+
// seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
|
|
13692
|
+
// ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
|
|
13693
|
+
runnerId: (options.get('runner') ?? '').trim() !== ''
|
|
13694
|
+
? (options.get('runner') ?? '').trim()
|
|
13695
|
+
: (() => { try { return hostname(); } catch { return null; } })(),
|
|
12385
13696
|
});
|
|
12386
13697
|
if (decision.line === null) return emit(decision);
|
|
12387
13698
|
|
|
@@ -12738,12 +14049,24 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
12738
14049
|
// Paths in an amendment row are repo-relative, so they resolve against the repo root — not
|
|
12739
14050
|
// against the feature directory, and not against wherever the caller happened to stand.
|
|
12740
14051
|
const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
|
|
14052
|
+
// A document that opens `## Amendments` twice cannot have its first heading answer for the
|
|
14053
|
+
// rest (Codex round 6, P2). Counted per document and taken at its worst — one contradictory
|
|
14054
|
+
// input is enough to make the run inconclusive.
|
|
14055
|
+
const sectionCount = Math.max(
|
|
14056
|
+
ideation === null ? 0 : amendmentSectionCount(ideation),
|
|
14057
|
+
plan === null ? 0 : amendmentSectionCount(plan),
|
|
14058
|
+
);
|
|
12741
14059
|
const decision = decideAmendmentOutcome({
|
|
12742
14060
|
sectionPresent,
|
|
12743
14061
|
rows,
|
|
12744
14062
|
resolutions,
|
|
12745
14063
|
planSaysNone: plan !== null && planSaysNoAmendments(plan),
|
|
12746
14064
|
missingFromPlan,
|
|
14065
|
+
sectionCount,
|
|
14066
|
+
// Fail-closed: measured on the document the rows would have come from (the plan when it has
|
|
14067
|
+
// a section, otherwise the ideation report).
|
|
14068
|
+
ambiguity: (plan !== null ? amendmentDeclarationAmbiguity(plan) : null)
|
|
14069
|
+
?? (ideation !== null ? amendmentDeclarationAmbiguity(ideation) : null),
|
|
12747
14070
|
});
|
|
12748
14071
|
return { slug, decision, resolutions };
|
|
12749
14072
|
};
|
|
@@ -13777,6 +15100,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
13777
15100
|
}
|
|
13778
15101
|
|
|
13779
15102
|
|
|
15103
|
+
interface ScoreReceiptFile {
|
|
15104
|
+
readonly path: string;
|
|
15105
|
+
readonly displayPath: string;
|
|
15106
|
+
readonly qeHash: string;
|
|
15107
|
+
}
|
|
15108
|
+
|
|
15109
|
+
function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
|
|
15110
|
+
const featuresDir = join(root, 'features');
|
|
15111
|
+
let features: Dirent[];
|
|
15112
|
+
try {
|
|
15113
|
+
features = readdirSync(featuresDir, { withFileTypes: true });
|
|
15114
|
+
} catch {
|
|
15115
|
+
return [];
|
|
15116
|
+
}
|
|
15117
|
+
const receipts: ScoreReceiptFile[] = [];
|
|
15118
|
+
for (const feature of features) {
|
|
15119
|
+
if (!feature.isDirectory()) continue;
|
|
15120
|
+
const stateDir = join(featuresDir, feature.name, '.fa-state');
|
|
15121
|
+
let entries: Dirent[];
|
|
15122
|
+
try {
|
|
15123
|
+
if (lstatSync(stateDir).isSymbolicLink()) continue;
|
|
15124
|
+
entries = readdirSync(stateDir, { withFileTypes: true });
|
|
15125
|
+
} catch {
|
|
15126
|
+
continue;
|
|
15127
|
+
}
|
|
15128
|
+
for (const entry of entries) {
|
|
15129
|
+
if (!entry.isFile()) continue;
|
|
15130
|
+
const match = /^score-(.+)\.json$/.exec(entry.name);
|
|
15131
|
+
if (match === null || match[1] === undefined || match[1] === '') continue;
|
|
15132
|
+
const path = join(stateDir, entry.name);
|
|
15133
|
+
receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
|
|
15134
|
+
}
|
|
15135
|
+
}
|
|
15136
|
+
return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
|
|
15137
|
+
}
|
|
15138
|
+
|
|
15139
|
+
function scoreAggregateChainLine(text: string): {
|
|
15140
|
+
readonly line: string;
|
|
15141
|
+
readonly verification: ReturnType<typeof verifyEventChainText> | null;
|
|
15142
|
+
readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
|
|
15143
|
+
} {
|
|
15144
|
+
if (text === '') {
|
|
15145
|
+
return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
|
|
15146
|
+
}
|
|
15147
|
+
const verification = verifyEventChainText(text);
|
|
15148
|
+
const defectAges = classifyChainDefects(verification, verification.lines);
|
|
15149
|
+
const kinds = new Map<string, number>();
|
|
15150
|
+
for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
|
|
15151
|
+
const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
|
|
15152
|
+
const line =
|
|
15153
|
+
`chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
|
|
15154
|
+
`${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
|
|
15155
|
+
`in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
|
|
15156
|
+
(kindText === '' ? '' : ` · ${kindText}`) +
|
|
15157
|
+
` — ${verification.scope}`;
|
|
15158
|
+
return { line, verification, defectAges };
|
|
15159
|
+
}
|
|
15160
|
+
|
|
15161
|
+
function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
15162
|
+
const json = flags.has('json');
|
|
15163
|
+
if (options.has('slug')) {
|
|
15164
|
+
write(json
|
|
15165
|
+
? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
|
|
15166
|
+
: 'dz score: --all and --slug are mutually exclusive');
|
|
15167
|
+
return 1;
|
|
15168
|
+
}
|
|
15169
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
15170
|
+
const receiptFiles = scoreReceiptFiles(root);
|
|
15171
|
+
if (receiptFiles.length === 0) {
|
|
15172
|
+
const report = buildScoreAggregateReport([], [], 0);
|
|
15173
|
+
const chain = scoreAggregateChainLine('');
|
|
15174
|
+
if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
|
|
15175
|
+
else {
|
|
15176
|
+
write(renderScoreAggregateReport(report));
|
|
15177
|
+
write(chain.line);
|
|
15178
|
+
}
|
|
15179
|
+
return 0;
|
|
15180
|
+
}
|
|
15181
|
+
|
|
15182
|
+
const ts = new Date().toISOString();
|
|
15183
|
+
const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
|
|
15184
|
+
const unreadableReceipts: string[] = [];
|
|
15185
|
+
for (const receipt of receiptFiles) {
|
|
15186
|
+
try {
|
|
15187
|
+
rows.push(scoreReceiptToAggregateRow({
|
|
15188
|
+
content: readFileSync(receipt.path, 'utf8'),
|
|
15189
|
+
qeHash: receipt.qeHash,
|
|
15190
|
+
ts,
|
|
15191
|
+
}));
|
|
15192
|
+
} catch {
|
|
15193
|
+
unreadableReceipts.push(receipt.displayPath);
|
|
15194
|
+
}
|
|
15195
|
+
}
|
|
15196
|
+
|
|
15197
|
+
const storeDir = join(root, '.dz', 'feature-adr');
|
|
15198
|
+
const aggregatePath = join(storeDir, 'scorecards.jsonl');
|
|
15199
|
+
let finalText = '';
|
|
15200
|
+
let finalRows = rows;
|
|
15201
|
+
let appended = 0;
|
|
15202
|
+
let storeError: string | null = null;
|
|
15203
|
+
try {
|
|
15204
|
+
const result = withNamedLockSync(storeDir, 'scorecards', () => {
|
|
15205
|
+
let existingText = '';
|
|
15206
|
+
try {
|
|
15207
|
+
existingText = readFileSync(aggregatePath, 'utf8');
|
|
15208
|
+
} catch (error) {
|
|
15209
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
15210
|
+
}
|
|
15211
|
+
const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
|
|
15212
|
+
const appendText = appendChainedLines(fresh, readTailInfo(existingText));
|
|
15213
|
+
if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
|
|
15214
|
+
const settledText = existingText + appendText;
|
|
15215
|
+
return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
|
|
15216
|
+
});
|
|
15217
|
+
finalText = result.text;
|
|
15218
|
+
finalRows = result.rows;
|
|
15219
|
+
appended = result.appended;
|
|
15220
|
+
} catch (error) {
|
|
15221
|
+
storeError = error instanceof Error ? error.message : String(error);
|
|
15222
|
+
}
|
|
15223
|
+
|
|
15224
|
+
const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
|
|
15225
|
+
const chain = scoreAggregateChainLine(finalText);
|
|
15226
|
+
if (json) {
|
|
15227
|
+
write(JSON.stringify({
|
|
15228
|
+
...report,
|
|
15229
|
+
aggregatePath: '.dz/feature-adr/scorecards.jsonl',
|
|
15230
|
+
chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
|
|
15231
|
+
storeError,
|
|
15232
|
+
exitCode: 0,
|
|
15233
|
+
}, null, 2));
|
|
15234
|
+
} else {
|
|
15235
|
+
write(renderScoreAggregateReport(report));
|
|
15236
|
+
write(chain.line);
|
|
15237
|
+
if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
|
|
15238
|
+
}
|
|
15239
|
+
return 0;
|
|
15240
|
+
}
|
|
15241
|
+
|
|
13780
15242
|
function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
13781
15243
|
const json = flags.has('json');
|
|
13782
15244
|
if (flags.has('help')) {
|
|
@@ -13784,13 +15246,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
13784
15246
|
if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
|
|
13785
15247
|
else {
|
|
13786
15248
|
write(usage);
|
|
15249
|
+
write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
|
|
13787
15250
|
write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
|
|
13788
15251
|
write(' descriptive-only, never a gate: a low score exits 0');
|
|
13789
15252
|
}
|
|
13790
15253
|
return 0;
|
|
13791
15254
|
}
|
|
13792
15255
|
for (const flag of flags) {
|
|
13793
|
-
if (!new Set(['json', 'help']).has(flag)) {
|
|
15256
|
+
if (!new Set(['json', 'help', 'all']).has(flag)) {
|
|
13794
15257
|
write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
|
|
13795
15258
|
return 1;
|
|
13796
15259
|
}
|
|
@@ -13802,6 +15265,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
13802
15265
|
return 1;
|
|
13803
15266
|
}
|
|
13804
15267
|
}
|
|
15268
|
+
if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
|
|
13805
15269
|
const slug = options.get('slug') ?? '';
|
|
13806
15270
|
// The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
|
|
13807
15271
|
// silently aggregated the entire features/ tree as one "run" (Codex QE #2).
|
|
@@ -14915,7 +16379,9 @@ function cmdDeliveryCheck(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
14915
16379
|
/* ------------------------------------------------------------------ */
|
|
14916
16380
|
|
|
14917
16381
|
/** Thin dispatcher — ALL logic lives in harness-core/src/backlog.ts (05 architecture: handlers stay dumb). */
|
|
14918
|
-
async function cmdBacklog(
|
|
16382
|
+
async function cmdBacklog(
|
|
16383
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
|
|
16384
|
+
): Promise<number> {
|
|
14919
16385
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
14920
16386
|
const json = flags.has('json');
|
|
14921
16387
|
const sub = options.get('_positional_0');
|
|
@@ -14933,6 +16399,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
14933
16399
|
const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
|
|
14934
16400
|
if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
|
|
14935
16401
|
const dryRun = flags.has('dry-run');
|
|
16402
|
+
if (!dryRun && !allowLearningStoreWrite(projectRoot, flags, writeErr, 'dz backlog add')) return 1;
|
|
14936
16403
|
// Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
|
|
14937
16404
|
// bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
|
|
14938
16405
|
// (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
|
|
@@ -14961,6 +16428,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
14961
16428
|
const ideas = readIdeas(projectRoot);
|
|
14962
16429
|
const match = ideas.find((i) => i.id === verdict.matchedId);
|
|
14963
16430
|
let absorbErr: string | undefined;
|
|
16431
|
+
let didWrite = false;
|
|
14964
16432
|
if (!dryRun && match !== undefined) {
|
|
14965
16433
|
const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
|
|
14966
16434
|
if (snap.error !== undefined) return emitErr(snap.error);
|
|
@@ -14976,7 +16444,9 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
14976
16444
|
}).error;
|
|
14977
16445
|
match.uses += 1;
|
|
14978
16446
|
writeIdeas(projectRoot, ideas);
|
|
16447
|
+
didWrite = true;
|
|
14979
16448
|
}
|
|
16449
|
+
if (didWrite) refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
|
|
14980
16450
|
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));
|
|
14981
16451
|
else {
|
|
14982
16452
|
const via = verdict.subsetMatch === true
|
|
@@ -15032,6 +16502,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15032
16502
|
ideas.push(rec);
|
|
15033
16503
|
writeIdeas(projectRoot, ideas);
|
|
15034
16504
|
const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
|
|
16505
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
|
|
15035
16506
|
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));
|
|
15036
16507
|
else {
|
|
15037
16508
|
write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
|
|
@@ -15198,8 +16669,18 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15198
16669
|
if (commitId !== undefined) {
|
|
15199
16670
|
// Validated above (safe id + known id) BEFORE any early return.
|
|
15200
16671
|
const idx = ideas.findIndex((i) => i.id === commitId);
|
|
16672
|
+
// ОТМЕТКА ВРЕМЕНИ И ЖУРНАЛ ставятся ЗДЕСЬ, а не «где-нибудь потом».
|
|
16673
|
+
// Измерено 2026-09-02: `grep -c statusTs cli.ts` давал НОЛЬ — оба пути смены статуса в этом
|
|
16674
|
+
// файле меняли поле и не отмечали, когда. Отсюда 22 терминальные записи без отметки и
|
|
16675
|
+
// невычислимое «сколько идея пробыла в работе».
|
|
16676
|
+
const spinFrom = ideas[idx]!.status;
|
|
16677
|
+
const spinTs = new Date().toISOString();
|
|
15201
16678
|
ideas[idx]!.status = 'in-progress';
|
|
16679
|
+
ideas[idx]!.statusTs = spinTs;
|
|
15202
16680
|
writeIdeas(projectRoot, ideas);
|
|
16681
|
+
if (!appendTransition(projectRoot, { id: commitId, from: spinFrom, to: 'in-progress', ts: spinTs, by: 'backlog roulette --commit' })) {
|
|
16682
|
+
write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
|
|
16683
|
+
}
|
|
15203
16684
|
pick = ideas[idx]!;
|
|
15204
16685
|
committed = true;
|
|
15205
16686
|
}
|
|
@@ -15263,6 +16744,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15263
16744
|
if (mirror.mirrored > 0 && mirror.error === undefined && clearEmbedStale(projectRoot, report.id)) embed = 'ok';
|
|
15264
16745
|
else embed = 'stale';
|
|
15265
16746
|
} else embed = 'stale';
|
|
16747
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog edit');
|
|
15266
16748
|
}
|
|
15267
16749
|
if (json) {
|
|
15268
16750
|
write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
|
|
@@ -15290,9 +16772,15 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15290
16772
|
const goalMap = readGoalMap(projectRoot);
|
|
15291
16773
|
const related = ideas.filter((i) => rec.relatedIds.includes(i.id));
|
|
15292
16774
|
const staging = stageEnrichment(projectRoot, rec, related, goalMap);
|
|
16775
|
+
const enrichFrom = rec.status;
|
|
16776
|
+
const enrichTs = new Date().toISOString();
|
|
15293
16777
|
rec.status = 'enriched';
|
|
16778
|
+
rec.statusTs = enrichTs;
|
|
15294
16779
|
rec.enrichedPath = `features/${staging.slug}`;
|
|
15295
16780
|
writeIdeas(projectRoot, ideas);
|
|
16781
|
+
if (!appendTransition(projectRoot, { id: rec.id, from: enrichFrom, to: 'enriched', ts: enrichTs, by: 'backlog enrich' })) {
|
|
16782
|
+
write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
|
|
16783
|
+
}
|
|
15296
16784
|
if (json) write(JSON.stringify({ slug: staging.slug, scaffoldPath: staging.scaffoldPath, handoff: 'idea2prd-manual', exitCode: 0 }, null, 2));
|
|
15297
16785
|
else {
|
|
15298
16786
|
write(`dz backlog enrich: staged ${rec.id} → ${staging.scaffoldPath}`);
|
|
@@ -15332,6 +16820,10 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
15332
16820
|
if (form.action === 'migrated' && !json) write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
|
|
15333
16821
|
else if (form.action === 'deferred' && !json) write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
|
|
15334
16822
|
const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
|
|
16823
|
+
if (apply) {
|
|
16824
|
+
refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog harmonize --apply');
|
|
16825
|
+
storeGuardResetReminder(projectRoot, writeErr, 'dz backlog harmonize --apply');
|
|
16826
|
+
}
|
|
15335
16827
|
if (json) {
|
|
15336
16828
|
write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
|
|
15337
16829
|
return 0;
|
|
@@ -15582,17 +17074,19 @@ function cmdStats(cwd: string, write: Write): number {
|
|
|
15582
17074
|
}
|
|
15583
17075
|
const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
15584
17076
|
const packages = dirs.length;
|
|
15585
|
-
|
|
15586
|
-
|
|
15587
|
-
|
|
15588
|
-
|
|
15589
|
-
|
|
15590
|
-
|
|
15591
|
-
|
|
15592
|
-
|
|
15593
|
-
|
|
15594
|
-
|
|
15595
|
-
|
|
17077
|
+
// Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
|
|
17078
|
+
// it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
|
|
17079
|
+
// keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
|
|
17080
|
+
// layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
|
|
17081
|
+
// quantity, each unable to refute the other because neither knew the other existed.
|
|
17082
|
+
//
|
|
17083
|
+
// The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
|
|
17084
|
+
// Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
|
|
17085
|
+
// The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
|
|
17086
|
+
// third implementation of the same count, which is the very defect being fixed.
|
|
17087
|
+
const registry = buildRegistry(cwd);
|
|
17088
|
+
const totalSkills = registry.totalSkills;
|
|
17089
|
+
const skillPacks = registry.totalPacks;
|
|
15596
17090
|
const targets = TARGET_NAMES.length;
|
|
15597
17091
|
const presets = PRESET_NAMES.length;
|
|
15598
17092
|
write(`dz stats — DZ Harness Hub`);
|
|
@@ -16065,7 +17559,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16065
17559
|
return version === 'unknown' ? 1 : 0;
|
|
16066
17560
|
}
|
|
16067
17561
|
|
|
16068
|
-
|
|
17562
|
+
// `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
|
|
17563
|
+
// landed it fell through to the switch and still printed usage; afterwards it would have died
|
|
17564
|
+
// with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
|
|
17565
|
+
// `-v` above — an argv-level flag, resolved before command dispatch.
|
|
17566
|
+
if (argv[0] === '-h') {
|
|
17567
|
+
write(USAGE);
|
|
17568
|
+
return 0;
|
|
17569
|
+
}
|
|
17570
|
+
// A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
|
|
17571
|
+
// FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
|
|
17572
|
+
// with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
|
|
17573
|
+
// warn-don't-refuse decision above is measured and still stands: with a command present, an
|
|
17574
|
+
// unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
|
|
17575
|
+
// invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
|
|
17576
|
+
if (command === '') {
|
|
17577
|
+
const strayNames = unknownFlagNotice(
|
|
17578
|
+
[...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
|
|
17579
|
+
KNOWN_CLI_FLAGS,
|
|
17580
|
+
).map((n) => n.name);
|
|
17581
|
+
if (strayNames.length > 0) {
|
|
17582
|
+
writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
|
|
17583
|
+
return 2;
|
|
17584
|
+
}
|
|
17585
|
+
}
|
|
17586
|
+
if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
|
|
16069
17587
|
write(USAGE);
|
|
16070
17588
|
return 0;
|
|
16071
17589
|
}
|
|
@@ -16123,17 +17641,29 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16123
17641
|
io.teachReinforceRunner ?? runTeachGuardReinforcement,
|
|
16124
17642
|
);
|
|
16125
17643
|
case 'consolidate':
|
|
16126
|
-
return await cmdConsolidate(options, flags, cwd, write);
|
|
17644
|
+
return await cmdConsolidate(options, flags, cwd, write, writeErr);
|
|
16127
17645
|
case 'recall':
|
|
16128
17646
|
return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
|
|
16129
17647
|
case 'vector':
|
|
16130
|
-
return await cmdVector(options, flags, cwd, write);
|
|
17648
|
+
return await cmdVector(options, flags, cwd, write, writeErr);
|
|
16131
17649
|
case 'brain':
|
|
16132
17650
|
return await cmdBrain(options, flags, cwd, write, readStdin);
|
|
16133
17651
|
case 'statusline':
|
|
16134
|
-
return cmdStatusline(options, flags, cwd, write, readStdin);
|
|
17652
|
+
return cmdStatusline(options, flags, cwd, write, readStdin, writeErr);
|
|
17653
|
+
case 'store-guard':
|
|
17654
|
+
return await cmdStoreGuard(
|
|
17655
|
+
options,
|
|
17656
|
+
flags,
|
|
17657
|
+
cwd,
|
|
17658
|
+
write,
|
|
17659
|
+
writeErr,
|
|
17660
|
+
io.stdin,
|
|
17661
|
+
io.interactive ?? process.stdin.isTTY === true,
|
|
17662
|
+
);
|
|
16135
17663
|
case 'usage':
|
|
16136
17664
|
return cmdUsage(options, optionLists, flags, cwd, write);
|
|
17665
|
+
case 'chain':
|
|
17666
|
+
return cmdChain(options, flags, cwd, write);
|
|
16137
17667
|
case 'claim-check':
|
|
16138
17668
|
return cmdClaimCheck(options, optionLists, flags, cwd, write);
|
|
16139
17669
|
case 'lint':
|
|
@@ -16155,7 +17685,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16155
17685
|
case 'diff':
|
|
16156
17686
|
return cmdDiff(options, cwd, write);
|
|
16157
17687
|
case 'recommend':
|
|
16158
|
-
return cmdRecommend(options, cwd, write);
|
|
17688
|
+
return cmdRecommend(options, flags, cwd, write);
|
|
16159
17689
|
case 'upgrade':
|
|
16160
17690
|
return cmdUpgrade(options, flags, cwd, write, writeErr);
|
|
16161
17691
|
case 'auto-canonicalize':
|
|
@@ -16197,15 +17727,27 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16197
17727
|
case 'mr-rakes':
|
|
16198
17728
|
return await cmdMrRakes(options, flags, cwd, write);
|
|
16199
17729
|
case 'retro':
|
|
16200
|
-
return await cmdRetro(options, flags, cwd, write);
|
|
17730
|
+
return await cmdRetro(options, flags, cwd, write, readStdin);
|
|
16201
17731
|
case 'feature-adr-setup':
|
|
16202
17732
|
return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
|
|
16203
17733
|
case 'challenge':
|
|
16204
17734
|
return cmdChallenge(options, flags, cwd, write);
|
|
16205
17735
|
case 'discrimination-check':
|
|
16206
17736
|
return cmdDiscriminationCheck(options, flags, cwd, write);
|
|
16207
|
-
case 'mutation-gate':
|
|
16208
|
-
|
|
17737
|
+
case 'mutation-gate': {
|
|
17738
|
+
try {
|
|
17739
|
+
return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
|
|
17740
|
+
} catch (error) {
|
|
17741
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
17742
|
+
const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
|
|
17743
|
+
if (flags.has('json')) {
|
|
17744
|
+
write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
|
|
17745
|
+
} else {
|
|
17746
|
+
write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
|
|
17747
|
+
}
|
|
17748
|
+
return 1;
|
|
17749
|
+
}
|
|
17750
|
+
}
|
|
16209
17751
|
case 'delivery-check':
|
|
16210
17752
|
return cmdDeliveryCheck(options, flags, cwd, write);
|
|
16211
17753
|
case 'skills-verify':
|
|
@@ -16230,6 +17772,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16230
17772
|
return cmdTgPost(options, flags, cwd, write);
|
|
16231
17773
|
case 'name-check':
|
|
16232
17774
|
return cmdNameCheck(options, flags, cwd, write);
|
|
17775
|
+
case 'brief-check':
|
|
17776
|
+
return cmdBriefCheck(options, flags, cwd, write);
|
|
16233
17777
|
case 'provenance-check':
|
|
16234
17778
|
return cmdProvenanceCheck(options, flags, cwd, write);
|
|
16235
17779
|
case 'feature-adr-record':
|
|
@@ -16247,7 +17791,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16247
17791
|
case 'qe-bridge':
|
|
16248
17792
|
return await cmdQeBridge(options, flags, cwd, write);
|
|
16249
17793
|
case 'backlog':
|
|
16250
|
-
return await cmdBacklog(options, flags, cwd, write);
|
|
17794
|
+
return await cmdBacklog(options, flags, cwd, write, writeErr);
|
|
16251
17795
|
case 'routing':
|
|
16252
17796
|
return cmdRouting(options, flags, cwd, write);
|
|
16253
17797
|
case 'bto-optimize':
|
|
@@ -16259,9 +17803,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16259
17803
|
case 'import-ecc':
|
|
16260
17804
|
return await cmdImportEcc(options, flags, cwd, write);
|
|
16261
17805
|
default:
|
|
16262
|
-
|
|
16263
|
-
|
|
16264
|
-
return 1;
|
|
17806
|
+
writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
|
|
17807
|
+
return 2;
|
|
16265
17808
|
}
|
|
16266
17809
|
} catch (error) {
|
|
16267
17810
|
// stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
|