@dzhechkov/harness-cli 0.8.6 → 0.8.10
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 +25 -25
- package/README.md +130 -12
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +1 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +28 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1657 -206
- package/dist/cli.js.map +1 -1
- package/dist/core-compat.d.ts +1 -1
- package/dist/core-compat.js +4 -4
- package/dist/core-compat.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +10 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +10 -10
- package/sbom.json +24 -24
- package/src/boolean-flags.ts +1 -0
- package/src/cli.ts +1758 -162
- package/src/core-compat.ts +4 -4
- package/src/index.ts +1 -1
- package/src/known-flags.ts +10 -0
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isBooleanFlag } from './boolean-flags.js';
|
|
|
13
13
|
import { resolveInstallSpec } from './install-spec.js';
|
|
14
14
|
import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
15
15
|
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
-
import { homedir, tmpdir } from 'node:os';
|
|
16
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
19
|
|
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
TARGET_NAMES_SORTED,
|
|
30
30
|
runDoctor,
|
|
31
31
|
runInit,
|
|
32
|
+
discoverSkillIds,
|
|
33
|
+
resolveSelection,
|
|
34
|
+
formatSelectRefusal,
|
|
35
|
+
runIntegrationsVerify,
|
|
32
36
|
resolvePackageSkillRoots,
|
|
33
37
|
PACKAGE_SKILL_LAYOUTS,
|
|
34
38
|
benchmarkSkill,
|
|
@@ -81,6 +85,7 @@ import {
|
|
|
81
85
|
NamedLockTimeoutError,
|
|
82
86
|
NamedLockCompromisedError,
|
|
83
87
|
type CodexHooksSyncReport,
|
|
88
|
+
type IntegrationManifestSource,
|
|
84
89
|
type ParityCell,
|
|
85
90
|
type ParityFeature,
|
|
86
91
|
type ParityReportCell,
|
|
@@ -116,6 +121,8 @@ import {
|
|
|
116
121
|
renderTimelineHtml,
|
|
117
122
|
importEcc,
|
|
118
123
|
recordPattern,
|
|
124
|
+
recordLessonForms,
|
|
125
|
+
normalizeLessonForms,
|
|
119
126
|
resolveLearningBackend,
|
|
120
127
|
storeStats,
|
|
121
128
|
consolidateSessions,
|
|
@@ -141,6 +148,17 @@ import {
|
|
|
141
148
|
importRvfCheckpoint,
|
|
142
149
|
statuslineData,
|
|
143
150
|
writeFeatureAdrState,
|
|
151
|
+
CHECKPOINT_STAGES,
|
|
152
|
+
estimateEta,
|
|
153
|
+
extractStageSamples,
|
|
154
|
+
formatEta,
|
|
155
|
+
parseCheckpointLines,
|
|
156
|
+
segmentRun,
|
|
157
|
+
type CheckpointStage,
|
|
158
|
+
type EtaEstimate,
|
|
159
|
+
type FeatureAdrState,
|
|
160
|
+
type RunSegment,
|
|
161
|
+
type StageSample,
|
|
144
162
|
computeUsage,
|
|
145
163
|
deriveCostLedger,
|
|
146
164
|
planLedgerBackfill,
|
|
@@ -167,6 +185,8 @@ import {
|
|
|
167
185
|
queryBookKnowledge,
|
|
168
186
|
loadStorePatternsSync,
|
|
169
187
|
patternRecordId,
|
|
188
|
+
patternIdentityOf,
|
|
189
|
+
mergeLessonMatchedForms,
|
|
170
190
|
loadStoreRecords,
|
|
171
191
|
recordToPattern,
|
|
172
192
|
bundleSkills,
|
|
@@ -192,6 +212,8 @@ import {
|
|
|
192
212
|
readTailInfo,
|
|
193
213
|
appendChainedLines,
|
|
194
214
|
verifyEventChainText,
|
|
215
|
+
classifyChainDefects,
|
|
216
|
+
CHAINED_JOURNALS,
|
|
195
217
|
buildManifest,
|
|
196
218
|
buildSbom,
|
|
197
219
|
resolveTrustRoot,
|
|
@@ -204,12 +226,22 @@ import {
|
|
|
204
226
|
DEFAULT_RULES,
|
|
205
227
|
parsePnpmLockImporters,
|
|
206
228
|
scannableStubPath,
|
|
229
|
+
type FeatureArtifactFact,
|
|
230
|
+
type FeatureTier,
|
|
231
|
+
type FeatureVolumeFact,
|
|
232
|
+
type GuardObservation,
|
|
233
|
+
type TemplateVolumeFileFact,
|
|
234
|
+
type TemplateVolumeTargetFact,
|
|
235
|
+
type VolumeShadowInput,
|
|
207
236
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
208
237
|
assembleCandidates,
|
|
209
238
|
renderPromotionReport,
|
|
210
239
|
renderPromotionAdr,
|
|
211
240
|
normalizePromotionState,
|
|
212
241
|
nextPromotionState,
|
|
242
|
+
recordPromotionRunEvidence,
|
|
243
|
+
isLessonRuleContentAnchor,
|
|
244
|
+
isOffsetIsoTimestamp,
|
|
213
245
|
globMatch,
|
|
214
246
|
promotionAdrRelPath,
|
|
215
247
|
DEFAULT_WINDOW_DAYS,
|
|
@@ -219,6 +251,10 @@ import {
|
|
|
219
251
|
type ChangeSet,
|
|
220
252
|
type ExistingRuleView,
|
|
221
253
|
type PromotionReport,
|
|
254
|
+
type PromotionAcceptanceEvidence,
|
|
255
|
+
type PromotionRunEvidence,
|
|
256
|
+
type FunnelEvidenceSource,
|
|
257
|
+
type GuardEvent,
|
|
222
258
|
decideProvenance,
|
|
223
259
|
isInsideTree,
|
|
224
260
|
signManifest,
|
|
@@ -339,6 +375,11 @@ import {
|
|
|
339
375
|
type EpochOutcome,
|
|
340
376
|
scoreRun,
|
|
341
377
|
readQeGrade,
|
|
378
|
+
scoreReceiptToAggregateRow,
|
|
379
|
+
readScoreAggregateRows,
|
|
380
|
+
dedupeScoreAggregateRows,
|
|
381
|
+
buildScoreAggregateReport,
|
|
382
|
+
renderScoreAggregateReport,
|
|
342
383
|
recapWindow,
|
|
343
384
|
decideHorizon,
|
|
344
385
|
withinWindow,
|
|
@@ -446,6 +487,7 @@ import {
|
|
|
446
487
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
447
488
|
parseMutationRegistry,
|
|
448
489
|
applyMutationToText,
|
|
490
|
+
attributeBaselineRedness,
|
|
449
491
|
countFailingTests,
|
|
450
492
|
detectSuiteCompletionReceipt,
|
|
451
493
|
detectSuiteReceiptMismatch,
|
|
@@ -455,6 +497,7 @@ import {
|
|
|
455
497
|
mutationGateExitCode,
|
|
456
498
|
summarizeMutationResults,
|
|
457
499
|
renderMutationReport,
|
|
500
|
+
runWithOneInternalRetry,
|
|
458
501
|
TRACE_BUNDLE_LEDGER_PATH,
|
|
459
502
|
TRACE_BUNDLE_SCHEMA,
|
|
460
503
|
TRACE_BUNDLE_RUN_META_FILE,
|
|
@@ -522,7 +565,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
|
|
|
522
565
|
import type { LogTail } from '@dzhechkov/harness-core';
|
|
523
566
|
import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
|
|
524
567
|
import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
|
|
525
|
-
import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
|
|
568
|
+
import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
|
|
526
569
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
527
570
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
528
571
|
|
|
@@ -534,20 +577,20 @@ export const DZ_COMMANDS: readonly string[] = [
|
|
|
534
577
|
'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
|
|
535
578
|
'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
|
|
536
579
|
'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
|
|
537
|
-
'sync-upstream', 'drift-check', 'hooks-sync', 'agents-sync', 'sync-canonical',
|
|
580
|
+
'sync-upstream', 'drift-check', 'hooks-sync', 'integrations-verify', 'agents-sync', 'sync-canonical',
|
|
538
581
|
'plugin', 'downloads', 'stats', 'architecture', 'project-skills', 'mr-rakes',
|
|
539
582
|
'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
|
|
540
583
|
'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
|
|
541
584
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
542
585
|
'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
|
|
543
586
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
544
|
-
'bto-optimize', 'dashboard', 'roam', 'import-ecc',
|
|
587
|
+
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
545
588
|
];
|
|
546
589
|
|
|
547
590
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
548
591
|
|
|
549
592
|
Usage:
|
|
550
|
-
dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--no-hooks] [--no-verify] (
|
|
593
|
+
dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--allow-integrations <sha256:digest>] [--no-integrations] [--no-hooks] [--no-verify] (integration manifests require exact digest consent; --no-integrations = explicit skills-only)
|
|
551
594
|
dz verify [--skills-dir <dir>] [--target <name>]
|
|
552
595
|
dz sync [--canonical <dir>] [--project <dir>] [--dry-run] [--force]
|
|
553
596
|
dz update (alias of sync)
|
|
@@ -576,13 +619,14 @@ Usage:
|
|
|
576
619
|
dz delivery-check --slug <slug> [--context-only] [--findings <f.json>] [--strict] [--author <model>] [--json] (portable Step-10 Delivery Gate: prints the 4-plane review brief + artifact probes; --findings classifies a fed-back review into a fail-closed ready|blocked hand-off and writes features/<slug>/10_delivery_review.md; --strict exits 1 on blocked)
|
|
577
620
|
dz challenge --plan <plan.md> [--author <model>] (the deterministic cartridge behind the challenge-panel adversarial plan-gate (R6): assembles the wide brief — plan + architecture/vision.md + testing.md + map.json + degradations.md — and prints the C1-C8 adversary prompt naming the cross-family reviewer to dispatch. exit 0 brief printed / 1 plan missing or empty)
|
|
578
621
|
dz skills-verify [--dir <project>] [--expect a,b] [--static] [--strict] [--json] (does .claude/skills/ actually REGISTER? --static = instant layout scan for CI; default reads the authoritative system/init listing from a real session. exit 0 pass / 1 fail / 2 inconclusive — never a false pass)
|
|
579
|
-
dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool
|
|
622
|
+
dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool/replay/guard instrumentation + monthly eligible→attempted→accepted→executions; unavailable is NOT MEASURED, and only a named empty stage after a non-empty predecessor for 3 measured months is a funnel finding)
|
|
580
623
|
dz deadwood [--weeks <n>] [--json] (advisory zero-usage candidates for human deprecation review; safety-excluded surfaces carry reasons; never deletes or deprecates anything; shallow history says INSUFFICIENT_DATA)
|
|
581
624
|
dz epoch-replay --mock [--n <N>] [--effect <-1..1>] [--tie-rate <0..1>] [--seed <N>] [--slice <name>] [--json] ($0 synthetic run — exercises the verdict math, NOT evidence)
|
|
582
625
|
dz epoch-replay --emit [--project <dir>] [--limit <N>] [--seed <N>] [--out <file>] (cold-vs-warm work order: instances + PRE-REGISTERED blind A/B assignment; the runner never calls a model)
|
|
583
626
|
dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
|
|
584
627
|
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)
|
|
585
628
|
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)
|
|
629
|
+
dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
|
|
586
630
|
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)
|
|
587
631
|
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)
|
|
588
632
|
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")
|
|
@@ -601,7 +645,7 @@ Usage:
|
|
|
601
645
|
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)
|
|
602
646
|
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)
|
|
603
647
|
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)
|
|
604
|
-
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
|
|
648
|
+
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)
|
|
605
649
|
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)
|
|
606
650
|
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
|
|
607
651
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
@@ -617,7 +661,7 @@ Usage:
|
|
|
617
661
|
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)
|
|
618
662
|
dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
|
|
619
663
|
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)
|
|
620
|
-
dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz
|
|
664
|
+
dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
|
|
621
665
|
dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
|
|
622
666
|
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)
|
|
623
667
|
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)
|
|
@@ -640,6 +684,7 @@ Usage:
|
|
|
640
684
|
dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
|
|
641
685
|
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)
|
|
642
686
|
dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
|
|
687
|
+
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)
|
|
643
688
|
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)
|
|
644
689
|
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)
|
|
645
690
|
dz pretrain [--project <dir>]
|
|
@@ -656,6 +701,7 @@ Usage:
|
|
|
656
701
|
dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
|
|
657
702
|
dz agents-sync [--project <dir>] [--check] [--json] (sync/verify the always-on policy fence in root AGENTS.md; exit 0 synced/written, 1 drift, 3 inconclusive)
|
|
658
703
|
dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] (install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json; exit 0 armed+trusted, 1 not armed/drift, 3 inconclusive)
|
|
704
|
+
dz integrations-verify --target <name> --component <mcp|hooks> [--project <dir>] [--json] (non-executing exact-version registration probe; exit 0 observed / 1 refused)
|
|
659
705
|
dz sync-canonical <skill> [--check] [--from <dir>] [--auto] [--project <dir>] (heal every copy from skills-meta/<skill> or --from; no canonical + --check = compare copies to each other (exit 1 on drift); no canonical + write = refuse unless --auto (LOUD, picks most-complete copy); --check writes nothing)
|
|
660
706
|
dz plugin [--version <ver>]
|
|
661
707
|
dz downloads
|
|
@@ -684,6 +730,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
|
|
|
684
730
|
Targets: ${TARGET_NAMES.join(', ')}
|
|
685
731
|
Presets: ${PRESET_NAMES.join(', ')}`;
|
|
686
732
|
|
|
733
|
+
export interface MutationGateRunnerObservation {
|
|
734
|
+
readonly exitCode: number | null;
|
|
735
|
+
readonly output: string;
|
|
736
|
+
readonly failureReason?: string;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export type MutationGateRunner = (
|
|
740
|
+
command: string,
|
|
741
|
+
options: { readonly cwd: string; readonly timeoutMs: number },
|
|
742
|
+
) => MutationGateRunnerObservation;
|
|
743
|
+
|
|
687
744
|
/** Output sink + working directory — injectable so the CLI is testable. */
|
|
688
745
|
export interface CliIo {
|
|
689
746
|
readonly cwd?: string;
|
|
@@ -706,6 +763,14 @@ export interface CliIo {
|
|
|
706
763
|
* command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
|
|
707
764
|
*/
|
|
708
765
|
readonly stdin?: string;
|
|
766
|
+
/** Human-terminal rendering seam; production defaults to stdout TTY detection. */
|
|
767
|
+
readonly interactive?: boolean;
|
|
768
|
+
/** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
|
|
769
|
+
readonly classMatcher?: RecallPatternsOptions['classMatcher'];
|
|
770
|
+
/** Guard decision seam; production always uses the real vector-backed teach guard. */
|
|
771
|
+
readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
|
|
772
|
+
/** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
|
|
773
|
+
readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }>;
|
|
709
774
|
/**
|
|
710
775
|
* Test seam for `dz release`: overrides subprocess execution for gate steps and the
|
|
711
776
|
* gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
|
|
@@ -720,6 +785,8 @@ export interface CliIo {
|
|
|
720
785
|
* offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
|
|
721
786
|
*/
|
|
722
787
|
readonly installRunner?: (command: string, cwd: string) => void;
|
|
788
|
+
/** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
|
|
789
|
+
readonly mutationGateRunner?: MutationGateRunner;
|
|
723
790
|
}
|
|
724
791
|
|
|
725
792
|
/** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
|
|
@@ -809,6 +876,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
|
|
|
809
876
|
|
|
810
877
|
/** Outcome of {@link installSkills}. */
|
|
811
878
|
interface InstallSkillsResult {
|
|
879
|
+
/**
|
|
880
|
+
* Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
|
|
881
|
+
* caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
|
|
882
|
+
* set nothing has been written yet — the refusal is decided before the first byte.
|
|
883
|
+
*/
|
|
884
|
+
readonly selectRefusal?: string;
|
|
812
885
|
readonly results: { id: string; written: number; skipped: number }[];
|
|
813
886
|
readonly dirsSearched: number;
|
|
814
887
|
readonly written: number;
|
|
@@ -823,6 +896,8 @@ interface InstallSkillsResult {
|
|
|
823
896
|
* the source `SKILL.md`.
|
|
824
897
|
*/
|
|
825
898
|
readonly applyFailures: SkillApplyFailure[];
|
|
899
|
+
readonly integrations: readonly IntegrationOutcome[];
|
|
900
|
+
readonly integrationDigest?: string;
|
|
826
901
|
}
|
|
827
902
|
|
|
828
903
|
/**
|
|
@@ -838,11 +913,48 @@ async function installSkills(opts: {
|
|
|
838
913
|
select?: readonly string[] | undefined;
|
|
839
914
|
force: boolean;
|
|
840
915
|
enrich: boolean;
|
|
916
|
+
noHooks?: boolean;
|
|
917
|
+
noIntegrations?: boolean;
|
|
918
|
+
noVerify?: boolean;
|
|
919
|
+
allowIntegrations?: string;
|
|
920
|
+
writeErr?: (line: string) => void;
|
|
841
921
|
}): Promise<InstallSkillsResult> {
|
|
842
922
|
const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
|
|
843
923
|
|
|
844
924
|
const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
|
|
845
925
|
|
|
926
|
+
// PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
|
|
927
|
+
//
|
|
928
|
+
// Two defects lived in asking each root independently instead of resolving the request: a skill
|
|
929
|
+
// present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
|
|
930
|
+
// one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
|
|
931
|
+
// `0 skill(s)` reading as success. Both are gone once the decision happens here.
|
|
932
|
+
//
|
|
933
|
+
// Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
|
|
934
|
+
// half-configured project, which is worse than either clean outcome. This runs before the loop
|
|
935
|
+
// below and before every target adapter.
|
|
936
|
+
//
|
|
937
|
+
// Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
|
|
938
|
+
// count and the exit contract, and gives that work a base it can trust.
|
|
939
|
+
if (select !== undefined) {
|
|
940
|
+
const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
941
|
+
const resolution = resolveSelection(select, roots);
|
|
942
|
+
for (const shadow of resolution.shadowed) {
|
|
943
|
+
opts.writeErr?.(
|
|
944
|
+
`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
|
|
945
|
+
`installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
949
|
+
if (refusal !== null) {
|
|
950
|
+
return {
|
|
951
|
+
selectRefusal: refusal,
|
|
952
|
+
results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
|
|
953
|
+
missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
846
958
|
// agents-md and gemini are FLATTENING single-file targets: each must aggregate
|
|
847
959
|
// every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
|
|
848
960
|
// GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
|
|
@@ -850,8 +962,8 @@ async function installSkills(opts: {
|
|
|
850
962
|
// route them through one aggregation.
|
|
851
963
|
if (target === 'agents-md' || target === 'gemini') {
|
|
852
964
|
const report = target === 'gemini'
|
|
853
|
-
? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) })
|
|
854
|
-
: runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) });
|
|
965
|
+
? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) })
|
|
966
|
+
: runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) });
|
|
855
967
|
const results = report.skills.map((s) => ({
|
|
856
968
|
id: s.id,
|
|
857
969
|
written: s.written.length,
|
|
@@ -860,13 +972,21 @@ async function installSkills(opts: {
|
|
|
860
972
|
let written = 0;
|
|
861
973
|
let skipped = 0;
|
|
862
974
|
for (const s of results) { written += s.written; skipped += s.skipped; }
|
|
863
|
-
return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
|
|
975
|
+
return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures], integrations: [...report.integrations], ...(report.integrationDigest !== undefined ? { integrationDigest: report.integrationDigest } : {}) };
|
|
864
976
|
}
|
|
865
977
|
|
|
866
978
|
const results: { id: string; written: number; skipped: number }[] = [];
|
|
867
979
|
const failures: SkillLoadFailure[] = [];
|
|
868
980
|
const applyFailures: SkillApplyFailure[] = [];
|
|
869
|
-
|
|
981
|
+
let integrations: readonly IntegrationOutcome[] = [];
|
|
982
|
+
let integrationDigest: string | undefined;
|
|
983
|
+
const integrationManifestSources: readonly IntegrationManifestSource[] = skillsDirs.flatMap((skillsDir) => {
|
|
984
|
+
const discovered = discoverSkillIds(skillsDir);
|
|
985
|
+
return discovered
|
|
986
|
+
.filter((id) => select === undefined || select.includes(id))
|
|
987
|
+
.map((skillId) => ({ skillId, skillDir: skillsDir }));
|
|
988
|
+
});
|
|
989
|
+
for (const [dirIndex, skillsDir] of skillsDirs.entries()) {
|
|
870
990
|
const r = await runInit({
|
|
871
991
|
target,
|
|
872
992
|
skillsDir,
|
|
@@ -874,12 +994,24 @@ async function installSkills(opts: {
|
|
|
874
994
|
force,
|
|
875
995
|
enrich,
|
|
876
996
|
...(select !== undefined ? { select } : {}),
|
|
997
|
+
...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}),
|
|
998
|
+
...(dirIndex === 0
|
|
999
|
+
? {
|
|
1000
|
+
integrationManifestSources,
|
|
1001
|
+
...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}),
|
|
1002
|
+
}
|
|
1003
|
+
: { noIntegrations: true }),
|
|
1004
|
+
...(opts.noVerify !== undefined ? { noVerify: opts.noVerify } : {}),
|
|
1005
|
+
...(opts.allowIntegrations !== undefined ? { allowIntegrations: opts.allowIntegrations } : {}),
|
|
877
1006
|
});
|
|
878
1007
|
for (const skill of r.skills) {
|
|
879
1008
|
results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
|
|
880
1009
|
}
|
|
881
1010
|
failures.push(...r.failures);
|
|
882
1011
|
applyFailures.push(...r.applyFailures);
|
|
1012
|
+
if (r.integrations.some((row) => row.status !== 'not-requested')) integrations = [...r.integrations];
|
|
1013
|
+
else if (integrations.length === 0) integrations = [...r.integrations];
|
|
1014
|
+
if (r.integrationDigest !== undefined) integrationDigest = r.integrationDigest;
|
|
883
1015
|
}
|
|
884
1016
|
|
|
885
1017
|
let written = 0;
|
|
@@ -887,7 +1019,7 @@ async function installSkills(opts: {
|
|
|
887
1019
|
for (const s of results) { written += s.written; skipped += s.skipped; }
|
|
888
1020
|
const installed = new Set(results.map((s) => s.id));
|
|
889
1021
|
const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
|
|
890
|
-
return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
|
|
1022
|
+
return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures, integrations, ...(integrationDigest !== undefined ? { integrationDigest } : {}) };
|
|
891
1023
|
}
|
|
892
1024
|
|
|
893
1025
|
/** Warn about preset/select ids that weren't found in any installed pack. */
|
|
@@ -947,28 +1079,56 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
947
1079
|
cwd,
|
|
948
1080
|
explicitSkillsDir,
|
|
949
1081
|
select,
|
|
1082
|
+
writeErr,
|
|
950
1083
|
force: flags.has('force'),
|
|
951
1084
|
enrich: flags.has('enrich'),
|
|
1085
|
+
noHooks: flags.has('no-hooks'),
|
|
1086
|
+
noIntegrations: flags.has('no-integrations'),
|
|
1087
|
+
noVerify: flags.has('no-verify'),
|
|
1088
|
+
...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
|
|
952
1089
|
});
|
|
953
1090
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1091
|
+
// PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
|
|
1092
|
+
// Printed and returned here, before any target adapter runs — nothing has been written yet.
|
|
1093
|
+
if (r.selectRefusal !== undefined) {
|
|
1094
|
+
writeErr(r.selectRefusal);
|
|
1095
|
+
return 1;
|
|
957
1096
|
}
|
|
958
|
-
writeMissingSkillsHint(write, r.missing, presetName);
|
|
959
1097
|
|
|
960
|
-
// Codex
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
|
|
964
|
-
// must be able to say so — and every test that is about skills says it.
|
|
1098
|
+
// Codex keeps its established user-registry writer, but its result is normalized into the same
|
|
1099
|
+
// two-outcome contract before JSON/human rendering. A write without a live ready observation is
|
|
1100
|
+
// a refusal with applied=true, never a second success channel.
|
|
1101
|
+
let integrationOutcomes: readonly IntegrationOutcome[] = r.integrations;
|
|
965
1102
|
let codexHooksOk = true;
|
|
966
1103
|
if (target === 'codex' && !flags.has('no-hooks')) {
|
|
967
1104
|
const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz init');
|
|
968
1105
|
codexHooksOk = delivery.ok;
|
|
969
1106
|
for (const line of delivery.stdout) write(line);
|
|
970
1107
|
for (const line of delivery.stderr) writeErr(line);
|
|
1108
|
+
const hookIndex = integrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
|
|
1109
|
+
if (hookIndex !== -1) {
|
|
1110
|
+
const hook = normalizeCodexHookOutcome(integrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
|
|
1111
|
+
integrationOutcomes = integrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (flags.has('json')) {
|
|
1116
|
+
write(JSON.stringify({ target, skills: { count: r.results.length, written: r.written, skipped: r.skipped }, integrations: integrationOutcomes, integrationDigest: r.integrationDigest ?? null }));
|
|
1117
|
+
} else {
|
|
1118
|
+
write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
|
|
1119
|
+
if (r.dirsSearched > 1) write(` (searched ${r.dirsSearched} skill directories)`);
|
|
1120
|
+
for (const outcome of integrationOutcomes) {
|
|
1121
|
+
const label = outcome.component === 'mcp' ? 'MCP' : 'Hooks';
|
|
1122
|
+
const detail = outcome.status === 'refused'
|
|
1123
|
+
? `${outcome.reasonCode ?? 'LIVE_PROBE_FAILED'}${outcome.remediation ? ` — ${outcome.remediation}` : ''}`
|
|
1124
|
+
: outcome.status === 'emitted'
|
|
1125
|
+
? `${outcome.carrier?.path ?? 'registered'}${outcome.registrations.some((row) => row.approval === 'pending') ? '; Pending approval; ready=false' : ''}`
|
|
1126
|
+
: 'explicitly not requested';
|
|
1127
|
+
write(`${label}: ${outcome.status.toUpperCase()} (${detail})`);
|
|
1128
|
+
}
|
|
971
1129
|
}
|
|
1130
|
+
writeMissingSkillsHint(write, r.missing, presetName);
|
|
1131
|
+
|
|
972
1132
|
// Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
|
|
973
1133
|
// named on stderr and the command exits 1 (it exited 1 before too — by throwing).
|
|
974
1134
|
if (r.failures.length > 0 || r.applyFailures.length > 0) {
|
|
@@ -987,7 +1147,35 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
987
1147
|
for (const line of formatSkillApplyFailures(r.applyFailures)) writeErr(line);
|
|
988
1148
|
return 1;
|
|
989
1149
|
}
|
|
990
|
-
|
|
1150
|
+
const integrationOk = !integrationOutcomes.some((row) => row.status === 'refused');
|
|
1151
|
+
return codexHooksOk && integrationOk ? 0 : 1;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function cmdIntegrationsVerify(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): number {
|
|
1155
|
+
const targetInput = options.get('target');
|
|
1156
|
+
const component = options.get('component');
|
|
1157
|
+
if (targetInput === undefined || (component !== 'mcp' && component !== 'hooks')) {
|
|
1158
|
+
writeErr('dz integrations-verify: requires --target <name> --component <mcp|hooks>');
|
|
1159
|
+
return 1;
|
|
1160
|
+
}
|
|
1161
|
+
const resolution = resolveTargetName(targetInput);
|
|
1162
|
+
if (resolution.kind === 'unknown') {
|
|
1163
|
+
for (const line of formatTargetProblem('dz integrations-verify', resolution)) writeErr(line);
|
|
1164
|
+
return 1;
|
|
1165
|
+
}
|
|
1166
|
+
if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz integrations-verify', targetInput, resolution.target));
|
|
1167
|
+
const result = runIntegrationsVerify({
|
|
1168
|
+
target: resolution.target,
|
|
1169
|
+
component,
|
|
1170
|
+
projectRoot: resolve(cwd, options.get('project') ?? '.'),
|
|
1171
|
+
});
|
|
1172
|
+
if (flags.has('json')) write(JSON.stringify(result));
|
|
1173
|
+
else if (result.ok) {
|
|
1174
|
+
write(`dz integrations-verify: ${resolution.target}/${component} registered (runtime ${result.runtimeVersion ?? 'unknown'}; ready=${result.registrations.every((row) => row.ready === true)})`);
|
|
1175
|
+
} else {
|
|
1176
|
+
writeErr(`dz integrations-verify: ${resolution.target}/${component} REFUSED (${result.reasonCode ?? 'LIVE_PROBE_FAILED'}) — ${result.remediation ?? 'no qualifying receipt'}`);
|
|
1177
|
+
}
|
|
1178
|
+
return result.ok ? 0 : 1;
|
|
991
1179
|
}
|
|
992
1180
|
|
|
993
1181
|
async function cmdVerify(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
|
|
@@ -2423,6 +2611,146 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
|
|
|
2423
2611
|
return 0;
|
|
2424
2612
|
}
|
|
2425
2613
|
|
|
2614
|
+
interface EtaCorpusRead {
|
|
2615
|
+
readonly history: StageSample[];
|
|
2616
|
+
readonly currentRunSamples: StageSample[];
|
|
2617
|
+
readonly currentTier: string | undefined;
|
|
2618
|
+
readonly activeStages: CheckpointStage[];
|
|
2619
|
+
readonly lastCheckpointTsMs: number | undefined;
|
|
2620
|
+
readonly hasCurrentCheckpoints: boolean;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
function statuslineEtaStage(step: string): CheckpointStage | undefined {
|
|
2624
|
+
const match = step.match(/\bStep\s*-?\s*(0|[1-9](?:\.5)?)(?:\b|\s)/i);
|
|
2625
|
+
if (match === null) return undefined;
|
|
2626
|
+
const value = Number(match[1]);
|
|
2627
|
+
if (value === 0) return 'router';
|
|
2628
|
+
if (value > 0 && value < 6) return 'design';
|
|
2629
|
+
if (value === 6) return 'plan';
|
|
2630
|
+
if (value === 7) return 'code';
|
|
2631
|
+
if (value === 8) return 'qe';
|
|
2632
|
+
if (value === 9) return 'fleet';
|
|
2633
|
+
return undefined;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
function routerMetadata(router: RunSegment['router']): { tier?: string; activeSteps?: number[] } {
|
|
2637
|
+
const result = router?.result;
|
|
2638
|
+
if (result === null || typeof result !== 'object') return {};
|
|
2639
|
+
const record = result as Record<string, unknown>;
|
|
2640
|
+
const tier = typeof record['tier'] === 'string' && record['tier'].length > 0 ? record['tier'] : undefined;
|
|
2641
|
+
const activeSteps = Array.isArray(record['activeSteps'])
|
|
2642
|
+
? record['activeSteps'].filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
|
2643
|
+
: undefined;
|
|
2644
|
+
return {
|
|
2645
|
+
...(tier !== undefined ? { tier } : {}),
|
|
2646
|
+
...(activeSteps !== undefined ? { activeSteps } : {}),
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function activeCheckpointStages(tier: string | undefined, activeSteps: readonly number[] | undefined): CheckpointStage[] {
|
|
2651
|
+
const stages = new Set<CheckpointStage>();
|
|
2652
|
+
if (activeSteps !== undefined) {
|
|
2653
|
+
for (const step of activeSteps) {
|
|
2654
|
+
if (step === 0) stages.add('router');
|
|
2655
|
+
else if (step > 0 && step < 6) stages.add('design');
|
|
2656
|
+
else if (step === 6) stages.add('plan');
|
|
2657
|
+
else if (step === 7) stages.add('code');
|
|
2658
|
+
else if (step === 8) stages.add('qe');
|
|
2659
|
+
else if (step === 9) stages.add('fleet');
|
|
2660
|
+
}
|
|
2661
|
+
} else if (tier !== undefined) {
|
|
2662
|
+
for (const stage of CHECKPOINT_STAGES) {
|
|
2663
|
+
if (stage !== 'fleet' || tier === 'L' || tier === 'XL') stages.add(stage);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
return CHECKPOINT_STAGES.filter((stage) => stages.has(stage));
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/** All checkpoint I/O for ETA lives in this CLI-only wrapper and degrades per file. */
|
|
2670
|
+
function readEtaCorpus(projectRoot: string, currentSlug: string): EtaCorpusRead {
|
|
2671
|
+
const trace = process.env['DZ_ETA_TRACE'];
|
|
2672
|
+
if (trace !== undefined && trace.length > 0) {
|
|
2673
|
+
try {
|
|
2674
|
+
appendFileSync(trace, `${JSON.stringify({ projectRoot, currentSlug, ts: new Date().toISOString() })}\n`);
|
|
2675
|
+
} catch { /* a test/debug receipt can never take down the statusline */ }
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
const samples: StageSample[] = [];
|
|
2679
|
+
let currentSegments: RunSegment[] = [];
|
|
2680
|
+
let hasCurrentCheckpoints = false;
|
|
2681
|
+
let entries: Dirent[] = [];
|
|
2682
|
+
try {
|
|
2683
|
+
entries = readdirSync(join(projectRoot, 'features'), { withFileTypes: true });
|
|
2684
|
+
} catch {
|
|
2685
|
+
return {
|
|
2686
|
+
history: [], currentRunSamples: [], currentTier: undefined, activeStages: [],
|
|
2687
|
+
lastCheckpointTsMs: undefined, hasCurrentCheckpoints: false,
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
for (const entry of entries) {
|
|
2692
|
+
if (!entry.isDirectory()) continue;
|
|
2693
|
+
const checkpointPath = join(projectRoot, 'features', entry.name, '.fa-state', 'checkpoints.jsonl');
|
|
2694
|
+
try {
|
|
2695
|
+
const text = readFileSync(checkpointPath, 'utf8');
|
|
2696
|
+
const observations = parseCheckpointLines(text, entry.name);
|
|
2697
|
+
const segments = segmentRun(observations);
|
|
2698
|
+
samples.push(...extractStageSamples(segments));
|
|
2699
|
+
if (entry.name === currentSlug) {
|
|
2700
|
+
hasCurrentCheckpoints = observations.length > 0;
|
|
2701
|
+
currentSegments = segments;
|
|
2702
|
+
}
|
|
2703
|
+
} catch {
|
|
2704
|
+
// Missing, unreadable, or racing append: this run contributes no evidence.
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const lastSegment = currentSegments.at(-1);
|
|
2709
|
+
const currentRouter = lastSegment?.router;
|
|
2710
|
+
const invocationSegments = currentRouter === undefined
|
|
2711
|
+
? []
|
|
2712
|
+
: currentSegments.filter((segment) => segment.router === currentRouter);
|
|
2713
|
+
const invocationRunIds = new Set(invocationSegments.map((segment) => segment.runId));
|
|
2714
|
+
const currentRunSamples = extractStageSamples(invocationSegments);
|
|
2715
|
+
const metadata = routerMetadata(currentRouter);
|
|
2716
|
+
const currentTimestamps = invocationSegments
|
|
2717
|
+
.flatMap((segment) => segment.observations)
|
|
2718
|
+
.flatMap((observation) => observation.tsMs === undefined ? [] : [observation.tsMs]);
|
|
2719
|
+
|
|
2720
|
+
return {
|
|
2721
|
+
history: samples.filter((sample) => !invocationRunIds.has(sample.runId)),
|
|
2722
|
+
currentRunSamples,
|
|
2723
|
+
currentTier: metadata.tier,
|
|
2724
|
+
activeStages: activeCheckpointStages(metadata.tier, metadata.activeSteps),
|
|
2725
|
+
lastCheckpointTsMs: currentTimestamps.length > 0 ? Math.max(...currentTimestamps) : undefined,
|
|
2726
|
+
hasCurrentCheckpoints,
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: number): EtaEstimate | undefined {
|
|
2731
|
+
const currentStage = statuslineEtaStage(state.step);
|
|
2732
|
+
if (currentStage === undefined) return undefined;
|
|
2733
|
+
const corpus = readEtaCorpus(projectRoot, state.slug);
|
|
2734
|
+
const currentIndex = CHECKPOINT_STAGES.indexOf(currentStage);
|
|
2735
|
+
const remainingStages = corpus.activeStages.filter((stage) => CHECKPOINT_STAGES.indexOf(stage) >= currentIndex && stage !== 'router');
|
|
2736
|
+
// Preserve honest absence as a machine-readable union member. The text formatter intentionally
|
|
2737
|
+
// omits both variants, but `--json` must still distinguish no file from a file with no tier.
|
|
2738
|
+
const stagesForEstimate = remainingStages.length > 0
|
|
2739
|
+
? remainingStages
|
|
2740
|
+
: (currentStage === 'router' ? [] : [currentStage]);
|
|
2741
|
+
if (stagesForEstimate.length === 0 && corpus.hasCurrentCheckpoints && corpus.currentTier !== undefined) return undefined;
|
|
2742
|
+
return estimateEta({
|
|
2743
|
+
samples: corpus.history,
|
|
2744
|
+
currentTier: corpus.currentTier,
|
|
2745
|
+
currentStage,
|
|
2746
|
+
remainingStages: stagesForEstimate,
|
|
2747
|
+
currentRunSamples: corpus.currentRunSamples,
|
|
2748
|
+
nowMs,
|
|
2749
|
+
...(corpus.lastCheckpointTsMs !== undefined ? { lastCheckpointTsMs: corpus.lastCheckpointTsMs } : {}),
|
|
2750
|
+
hasCurrentCheckpoints: corpus.hasCurrentCheckpoints,
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2426
2754
|
/**
|
|
2427
2755
|
* `dz statusline` — render dz's OWN self-learning counts as one compact, emoji-tagged line for
|
|
2428
2756
|
* Claude Code's status bar (modeled on agentic-qe's "🎓 12 patterns"). Claude Code pipes a JSON
|
|
@@ -2447,9 +2775,22 @@ function cmdStatusline(
|
|
|
2447
2775
|
try {
|
|
2448
2776
|
const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
|
|
2449
2777
|
const data = statuslineData(projectRoot);
|
|
2778
|
+
const fa = data.featureAdr;
|
|
2779
|
+
let eta: EtaEstimate | undefined;
|
|
2780
|
+
let etaFragment: string | undefined;
|
|
2781
|
+
if (fa !== undefined && fa.kind !== 'loop') {
|
|
2782
|
+
try {
|
|
2783
|
+
eta = statuslineEta(projectRoot, fa, Date.now());
|
|
2784
|
+
if (eta !== undefined) etaFragment = formatEta(eta);
|
|
2785
|
+
} catch {
|
|
2786
|
+
// ETA is advisory: any corpus/resource failure omits only this fragment.
|
|
2787
|
+
eta = undefined;
|
|
2788
|
+
etaFragment = undefined;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2450
2791
|
|
|
2451
2792
|
if (flags.has('json')) {
|
|
2452
|
-
write(JSON.stringify(data));
|
|
2793
|
+
write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
|
|
2453
2794
|
return 0;
|
|
2454
2795
|
}
|
|
2455
2796
|
|
|
@@ -2459,13 +2800,12 @@ function cmdStatusline(
|
|
|
2459
2800
|
if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
|
|
2460
2801
|
|
|
2461
2802
|
// Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
|
|
2462
|
-
const fa = data.featureAdr;
|
|
2463
2803
|
if (fa !== undefined) {
|
|
2464
2804
|
// The producer marker reached data and arbitration in a previous round but not this label, so the bar asserted a pipeline that was not running.
|
|
2465
2805
|
if (fa.kind === 'loop') {
|
|
2466
2806
|
line = `🔁 loop ${fa.step} · ${line}`;
|
|
2467
2807
|
} else {
|
|
2468
|
-
line = `📐 feature-adr ${fa.step} · 🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
|
|
2808
|
+
line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
|
|
2469
2809
|
}
|
|
2470
2810
|
}
|
|
2471
2811
|
|
|
@@ -3092,7 +3432,22 @@ function learningStoreLine(
|
|
|
3092
3432
|
) + (reason ? ' [' + reason + ']' : '');
|
|
3093
3433
|
}
|
|
3094
3434
|
|
|
3095
|
-
async function
|
|
3435
|
+
async function runTeachGuardReinforcement(
|
|
3436
|
+
projectRoot: string,
|
|
3437
|
+
dzId: string,
|
|
3438
|
+
reward: number,
|
|
3439
|
+
): Promise<{ readonly flushed: number }> {
|
|
3440
|
+
const backend = resolveLearningBackend(projectRoot);
|
|
3441
|
+
backend.addSample({ dzId, kind: 'reinforce', reward, ts: new Date().toISOString() });
|
|
3442
|
+
return backend.train();
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
async function cmdTeach(
|
|
3446
|
+
options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
|
|
3447
|
+
writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
|
|
3448
|
+
guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
|
|
3449
|
+
reinforceRunner: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
|
|
3450
|
+
): Promise<number> {
|
|
3096
3451
|
// WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
|
|
3097
3452
|
// `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
|
|
3098
3453
|
// per-session choice; for a CLI every invocation is a fresh process, so the only honest session
|
|
@@ -3176,26 +3531,39 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
3176
3531
|
write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
|
|
3177
3532
|
return 1;
|
|
3178
3533
|
}
|
|
3179
|
-
const
|
|
3534
|
+
const importedKey = (p: PatternRecord): string => p.lessonForm !== undefined && p.lessonPairId !== undefined
|
|
3535
|
+
? `${p.pattern}\u0000${p.lessonForm}\u0000${p.lessonPairId}`
|
|
3536
|
+
: `legacy\u0000${p.pattern}`;
|
|
3537
|
+
const existing = new Set(loadStorePatternsSync(storeRoot).map(importedKey));
|
|
3180
3538
|
let imported = 0;
|
|
3181
3539
|
let skipped = 0;
|
|
3182
3540
|
const importedRecs: PatternRecord[] = [];
|
|
3183
3541
|
for (const item of parsed) {
|
|
3184
3542
|
const p = item as Partial<PatternRecord>;
|
|
3185
|
-
if (!p || typeof p
|
|
3543
|
+
if (!p || typeof p !== 'object') {
|
|
3186
3544
|
skipped += 1;
|
|
3187
3545
|
continue;
|
|
3188
3546
|
}
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3547
|
+
const pair = (p.lessonForm === 'specific' || p.lessonForm === 'class')
|
|
3548
|
+
&& typeof p.lessonPairId === 'string' && p.lessonPairId !== ''
|
|
3549
|
+
? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
|
|
3550
|
+
: {};
|
|
3551
|
+
const candidate = {
|
|
3552
|
+
pattern: typeof p.pattern === 'string' ? p.pattern : '',
|
|
3191
3553
|
type: (typeof p.type === 'string' ? p.type : 'lesson-learned') as PatternRecord['type'],
|
|
3192
3554
|
reward: typeof p.reward === 'number' ? Math.max(0, Math.min(1, p.reward)) : 0.8,
|
|
3193
3555
|
domain: typeof p.domain === 'string' ? p.domain : 'general',
|
|
3194
3556
|
ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
|
|
3195
3557
|
source: 'dz-teach-import',
|
|
3196
|
-
|
|
3558
|
+
...pair,
|
|
3559
|
+
} satisfies PatternRecord;
|
|
3560
|
+
if (candidate.pattern.trim() === '' || existing.has(importedKey(candidate))) {
|
|
3561
|
+
skipped += 1;
|
|
3562
|
+
continue;
|
|
3563
|
+
}
|
|
3564
|
+
const rec: PatternRecord = candidate;
|
|
3197
3565
|
await recordPattern(storeRoot, rec);
|
|
3198
|
-
existing.add(
|
|
3566
|
+
existing.add(importedKey(rec));
|
|
3199
3567
|
importedRecs.push(rec);
|
|
3200
3568
|
imported += 1;
|
|
3201
3569
|
}
|
|
@@ -3279,23 +3647,30 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
3279
3647
|
|
|
3280
3648
|
const reward = parseFloat(options.get('reward') ?? '0.8');
|
|
3281
3649
|
const domain = options.get('domain') ?? 'general';
|
|
3650
|
+
const classWasRequested = options.has('class-form') || flags.has('class-form');
|
|
3651
|
+
const lessonForms = normalizeLessonForms(pattern, options.get('class-form'));
|
|
3652
|
+
let guardedExisting: { readonly dzId: string; readonly cosine: number; readonly pattern: PatternRecord } | undefined;
|
|
3282
3653
|
|
|
3283
3654
|
if (flags.has('guard')) {
|
|
3284
|
-
const verdict = await
|
|
3655
|
+
const verdict = await guardRunner(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
|
|
3285
3656
|
if (verdict.action === 'reinforce') {
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
const
|
|
3295
|
-
if (
|
|
3296
|
-
|
|
3657
|
+
if (lessonForms.classForm !== undefined) {
|
|
3658
|
+
const existing = loadStoreRecords(storeRoot).find((record) => record.id === verdict.dzId);
|
|
3659
|
+
if (existing !== undefined) {
|
|
3660
|
+
guardedExisting = { dzId: verdict.dzId, cosine: verdict.cosine, pattern: recordToPattern(existing) };
|
|
3661
|
+
} else {
|
|
3662
|
+
write(`dz teach --guard: matched pattern ${verdict.dzId} was not found in the lexical store — teaching the lesson normally`);
|
|
3663
|
+
}
|
|
3664
|
+
} else {
|
|
3665
|
+
const trained = await reinforceRunner(storeRoot, verdict.dzId, Math.max(0, Math.min(1, reward)));
|
|
3666
|
+
if (trained.flushed > 0) {
|
|
3667
|
+
write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
|
|
3668
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
|
|
3669
|
+
if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
|
|
3670
|
+
return 0;
|
|
3671
|
+
}
|
|
3672
|
+
write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
|
|
3297
3673
|
}
|
|
3298
|
-
write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
|
|
3299
3674
|
}
|
|
3300
3675
|
}
|
|
3301
3676
|
|
|
@@ -3310,24 +3685,63 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
3310
3685
|
// Typed through the shared schema (harness-core owns PatternRecord) so the
|
|
3311
3686
|
// write side and the read side (recommend's loadPatterns) can never drift —
|
|
3312
3687
|
// a field rename here is a compile error, not a silently re-muted loop (audit #2).
|
|
3313
|
-
const entry: PatternRecord = {
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3688
|
+
const entry: PatternRecord = guardedExisting?.pattern ?? {
|
|
3689
|
+
pattern: lessonForms.specific,
|
|
3690
|
+
type: type as PatternRecord['type'],
|
|
3691
|
+
reward: Math.max(0, Math.min(1, reward)),
|
|
3692
|
+
domain,
|
|
3693
|
+
ts: new Date().toISOString(),
|
|
3694
|
+
source: 'dz-teach',
|
|
3695
|
+
};
|
|
3321
3696
|
|
|
3322
3697
|
// Tier-2 (ADR-005): persist through the unified @dzhechkov/memory store. recordPattern
|
|
3323
3698
|
// folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
|
|
3324
3699
|
// total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
|
|
3325
3700
|
// lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
|
|
3326
3701
|
const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
|
|
3327
|
-
const
|
|
3702
|
+
const stored = await recordLessonForms(
|
|
3703
|
+
storeRoot,
|
|
3704
|
+
entry,
|
|
3705
|
+
lessonForms.classForm,
|
|
3706
|
+
quarantineOn ? { quarantine: true } : {},
|
|
3707
|
+
);
|
|
3708
|
+
let recordsToMirror = stored.records;
|
|
3709
|
+
let commandFailed = stored.class === 'failed';
|
|
3710
|
+
let reinforced = false;
|
|
3711
|
+
if (guardedExisting !== undefined) {
|
|
3712
|
+
let reinforceId = guardedExisting.dzId;
|
|
3713
|
+
if (stored.class === 'stored') {
|
|
3714
|
+
const specificRow = stored.records.find((row) => row.lessonForm === 'specific');
|
|
3715
|
+
if (specificRow !== undefined) reinforceId = patternRecordId(specificRow);
|
|
3716
|
+
if (reinforceId !== guardedExisting.dzId) {
|
|
3717
|
+
const removed = removePatternsByIds(storeRoot, new Set([guardedExisting.dzId]));
|
|
3718
|
+
if (removed.error !== undefined || removed.removed === 0) {
|
|
3719
|
+
commandFailed = true;
|
|
3720
|
+
writeErr(`dz teach --guard: class pair stored, but old pattern cleanup failed${removed.error === undefined ? '' : ` — ${removed.error}`}`);
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
} else if (stored.class === 'failed') {
|
|
3724
|
+
const partialSpecific = stored.records.find((row) => row.lessonForm === 'specific');
|
|
3725
|
+
if (partialSpecific !== undefined && patternRecordId(partialSpecific) !== guardedExisting.dzId) {
|
|
3726
|
+
const rolledBack = removePatternsByIds(storeRoot, new Set([patternRecordId(partialSpecific)]));
|
|
3727
|
+
if (rolledBack.error !== undefined) writeErr(`dz teach --guard: partial enrichment rollback failed — ${rolledBack.error}`);
|
|
3728
|
+
}
|
|
3729
|
+
recordsToMirror = [];
|
|
3730
|
+
reinforceId = guardedExisting.dzId;
|
|
3731
|
+
}
|
|
3732
|
+
if (!commandFailed) {
|
|
3733
|
+
const trained = await reinforceRunner(storeRoot, reinforceId, Math.max(0, Math.min(1, reward)));
|
|
3734
|
+
reinforced = trained.flushed > 0;
|
|
3735
|
+
if (reinforced) {
|
|
3736
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforceId]);
|
|
3737
|
+
if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
const count = guardedExisting === undefined ? stored.count : loadStorePatternsSync(storeRoot).length;
|
|
3328
3742
|
|
|
3329
|
-
write(
|
|
3330
|
-
write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
|
|
3743
|
+
write(`${guardedExisting !== undefined && stored.class === 'stored' ? 'Enriched' : 'Learned'}: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
|
|
3744
|
+
write(` Domain: ${entry.domain} Reward: ${entry.reward} Backend: memory (@dzhechkov/memory)`);
|
|
3331
3745
|
write(` Total patterns: ${count}`);
|
|
3332
3746
|
// WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
|
|
3333
3747
|
// domain, the reward and the backend — and not one word about the path, so a store written to
|
|
@@ -3337,6 +3751,20 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
3337
3751
|
// sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
|
|
3338
3752
|
// nothing, so the line stays byte-identical for everyone who set nothing.
|
|
3339
3753
|
write(storeLine('written'));
|
|
3754
|
+
if (stored.class === 'stored') write(' Class form: stored separately and linked to the specific lesson');
|
|
3755
|
+
if (classWasRequested && stored.class === 'absent') write(' Class form: skipped; the specific lesson was saved');
|
|
3756
|
+
if (stored.class === 'rejected') writeErr(`dz teach: class form rejected — ${stored.reason ?? 'invalid class form'}; the specific lesson was saved`);
|
|
3757
|
+
if (stored.class === 'failed') writeErr(`dz teach: specific stored; class failed — ${stored.reason ?? 'unknown storage failure'}`);
|
|
3758
|
+
if (guardedExisting !== undefined && stored.class === 'stored') {
|
|
3759
|
+
write(commandFailed
|
|
3760
|
+
? ' Guard: class enrichment stored; reinforcement skipped because old-pattern cleanup failed'
|
|
3761
|
+
: reinforced
|
|
3762
|
+
? ` Guard: enriched existing pattern (cos=${guardedExisting.cosine.toFixed(2)}), then reinforced the linked specific form`
|
|
3763
|
+
: ' Guard: class enrichment stored; reinforcement did not flush');
|
|
3764
|
+
}
|
|
3765
|
+
if (!classWasRequested && interactive) {
|
|
3766
|
+
write(' Rule of one place or of a class? Optional: add --class-form when a future reader needs the why for structure, interfaces, or maintainability; skip when scope, risk, time, and cost are low or standards, policy, or documentation already cover it.');
|
|
3767
|
+
}
|
|
3340
3768
|
// ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
|
|
3341
3769
|
// directories and this binary; refusing would be defending a user against themselves,
|
|
3342
3770
|
// which this design does not attempt. Making the choice INFORMED is the part that is
|
|
@@ -3351,8 +3779,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
3351
3779
|
write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
|
|
3352
3780
|
}
|
|
3353
3781
|
// The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
|
|
3354
|
-
await emitMirrorQ(storeRoot,
|
|
3355
|
-
return 0;
|
|
3782
|
+
await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
|
|
3783
|
+
return commandFailed ? 1 : 0;
|
|
3356
3784
|
}
|
|
3357
3785
|
|
|
3358
3786
|
async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
|
|
@@ -3704,7 +4132,14 @@ async function cmdRecallPromote(
|
|
|
3704
4132
|
return 0;
|
|
3705
4133
|
}
|
|
3706
4134
|
|
|
3707
|
-
async function cmdRecall(
|
|
4135
|
+
async function cmdRecall(
|
|
4136
|
+
options: Map<string, string>,
|
|
4137
|
+
flags: Set<string>,
|
|
4138
|
+
cwd: string,
|
|
4139
|
+
write: Write,
|
|
4140
|
+
writeErr: WriteErr,
|
|
4141
|
+
classMatcher?: RecallPatternsOptions['classMatcher'],
|
|
4142
|
+
): Promise<number> {
|
|
3708
4143
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3709
4144
|
const asJson = flags.has('json');
|
|
3710
4145
|
const all = flags.has('all');
|
|
@@ -3723,7 +4158,17 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3723
4158
|
// review could not make correct: a tag set by the writer is decidable, prose is not.
|
|
3724
4159
|
const allPatterns = loadStorePatternsSync(projectRoot);
|
|
3725
4160
|
const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
|
|
3726
|
-
const
|
|
4161
|
+
const classByPair = new Map(
|
|
4162
|
+
holdout.exported
|
|
4163
|
+
.filter((row) => row.lessonForm === 'class' && row.lessonPairId !== undefined)
|
|
4164
|
+
.map((row) => [row.lessonPairId as string, row.pattern]),
|
|
4165
|
+
);
|
|
4166
|
+
const patterns = holdout.exported.map((row) => {
|
|
4167
|
+
const classForm = row.lessonForm === 'specific' && row.lessonPairId !== undefined
|
|
4168
|
+
? classByPair.get(row.lessonPairId)
|
|
4169
|
+
: undefined;
|
|
4170
|
+
return classForm === undefined ? row : { ...row, classForm };
|
|
4171
|
+
});
|
|
3727
4172
|
const holdoutNote = renderHoldoutNote(holdout);
|
|
3728
4173
|
// The opt-in is honoured without argument — and named out loud. A flag that silently
|
|
3729
4174
|
// includes medical lessons in a portable export is a flag whose consequence the user
|
|
@@ -3757,7 +4202,15 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3757
4202
|
const perDomain = Object.fromEntries(
|
|
3758
4203
|
Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))),
|
|
3759
4204
|
);
|
|
3760
|
-
const
|
|
4205
|
+
const generalizedPairs = new Set(patterns.flatMap((p) => p.lessonPairId === undefined ? [] : [p.lessonPairId]));
|
|
4206
|
+
const unpairedLessons = patterns.filter((p) => p.lessonPairId === undefined).length;
|
|
4207
|
+
const stats = {
|
|
4208
|
+
...rawStats,
|
|
4209
|
+
topUses: topHoldout.exported,
|
|
4210
|
+
perDomain,
|
|
4211
|
+
generalized: generalizedPairs.size,
|
|
4212
|
+
logicalLessons: generalizedPairs.size + unpairedLessons,
|
|
4213
|
+
};
|
|
3761
4214
|
const backendStats = resolveLearningBackend(projectRoot).getStats();
|
|
3762
4215
|
if (asJson) {
|
|
3763
4216
|
write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
|
|
@@ -3769,6 +4222,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3769
4222
|
write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
|
|
3770
4223
|
write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
|
|
3771
4224
|
write(` exact-dup groups: ${stats.exactDupGroups}`);
|
|
4225
|
+
write(` generalized: ${stats.generalized} of ${stats.logicalLessons} lessons`);
|
|
3772
4226
|
write(` re-teach trend: ${stats.teachEvents} teach event(s), ${stats.reinforceEvents} reinforce event(s)`);
|
|
3773
4227
|
write(' top uses:');
|
|
3774
4228
|
for (const row of stats.topUses) write(` ${row.uses}× [${row.reward.toFixed(2)}] (${row.domain}) ${row.pattern.slice(0, 80)}`);
|
|
@@ -3889,6 +4343,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3889
4343
|
? ('lexical' as const)
|
|
3890
4344
|
: ('hybrid' as const);
|
|
3891
4345
|
const wantedDomain = options.get('domain');
|
|
4346
|
+
const classRecallOptions = {
|
|
4347
|
+
onClassDegraded: writeErr,
|
|
4348
|
+
...(classMatcher === undefined ? {} : { classMatcher }),
|
|
4349
|
+
};
|
|
3892
4350
|
// OVER-FETCH before boosting (Codex QE #5): the boost used to run on hits ALREADY
|
|
3893
4351
|
// truncated to `limit`, so an exact-domain lesson sitting at rank limit+1 could
|
|
3894
4352
|
// never receive its promised lift — the feature was weakest in exactly the case it
|
|
@@ -3911,8 +4369,8 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3911
4369
|
// second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
|
|
3912
4370
|
// own output is written after it returns.
|
|
3913
4371
|
const result = asJson
|
|
3914
|
-
? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
3915
|
-
: await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
4372
|
+
? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
4373
|
+
: await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
3916
4374
|
|
|
3917
4375
|
if (mode === 'semantic' && result.vectorEngine === 'none') {
|
|
3918
4376
|
// --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
|
|
@@ -3948,13 +4406,26 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3948
4406
|
let globalHits: typeof result.hits = [];
|
|
3949
4407
|
if (readGlobal) {
|
|
3950
4408
|
const g = asJson
|
|
3951
|
-
? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
3952
|
-
: await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
4409
|
+
? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
4410
|
+
: await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
3953
4411
|
globalHits = g.hits;
|
|
3954
4412
|
}
|
|
4413
|
+
const projectHits = boost ? boost.hits : result.hits;
|
|
4414
|
+
// TWO different keys on purpose, and they must not be unified. The cross-store MERGE key is the
|
|
4415
|
+
// lesson TEXT: the same lesson taught into the project store and the global store is one lesson
|
|
4416
|
+
// shown once, labelled `both`, and those two records legitimately differ in timestamp, reward and
|
|
4417
|
+
// domain. The matchedForm LOOKUP key may be narrower. Folding the merge key into
|
|
4418
|
+
// patternIdentityOf (which includes ts) made two `dz teach` calls of identical text stop
|
|
4419
|
+
// collapsing — it reddened the pre-existing P3 case in recall-reads-both-stores.test.ts.
|
|
4420
|
+
const mergeKey = (hit: (typeof projectHits)[number]): string => hit.pattern.pattern;
|
|
4421
|
+
const globalByIdentity = new Map(globalHits.map((hit) => [mergeKey(hit), hit]));
|
|
3955
4422
|
const merged = readGlobal
|
|
3956
|
-
? mergeStoreHits(
|
|
3957
|
-
|
|
4423
|
+
? mergeStoreHits(projectHits, globalHits, mergeKey).map((hit) => {
|
|
4424
|
+
if (hit.origin !== 'both') return hit;
|
|
4425
|
+
const matchedForm = mergeLessonMatchedForms(hit.matchedForm, globalByIdentity.get(mergeKey(hit))?.matchedForm);
|
|
4426
|
+
return matchedForm === undefined ? hit : { ...hit, matchedForm };
|
|
4427
|
+
})
|
|
4428
|
+
: projectHits;
|
|
3958
4429
|
const hits = merged.slice(0, limit);
|
|
3959
4430
|
// Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
|
|
3960
4431
|
// paths that return earlier — `--json` and the zero-hits branch — still reported success. That
|
|
@@ -4002,6 +4473,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4002
4473
|
// version emitted the number while its own comment promised null (found by independent review).
|
|
4003
4474
|
write(JSON.stringify(hits.map((h) => ({
|
|
4004
4475
|
...h.pattern,
|
|
4476
|
+
...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
|
|
4005
4477
|
relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
|
|
4006
4478
|
// The TRUE cosine, as a second companion key — the design named it and the first ship missed
|
|
4007
4479
|
// it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
|
|
@@ -4066,6 +4538,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4066
4538
|
// guessing which second one answered.
|
|
4067
4539
|
write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
|
|
4068
4540
|
}
|
|
4541
|
+
const classMatches = hits.filter((h) => h.matchedForm === 'class' || h.matchedForm === 'both').length;
|
|
4542
|
+
if (hits.some((h) => h.pattern.classForm !== undefined)) {
|
|
4543
|
+
write(` class-form matches: ${classMatches} of ${hits.length}`);
|
|
4544
|
+
}
|
|
4069
4545
|
let sawQuarantined = false;
|
|
4070
4546
|
for (const h of hits) {
|
|
4071
4547
|
const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
|
|
@@ -4090,9 +4566,21 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
4090
4566
|
// 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
|
|
4091
4567
|
// hidden remainder, and the cosine then appears to describe the visible fragment rather than
|
|
4092
4568
|
// the whole lesson. `--full` prints it all, still on one line.
|
|
4093
|
-
const
|
|
4094
|
-
const
|
|
4095
|
-
|
|
4569
|
+
const specificText = oneLine(h.pattern.pattern);
|
|
4570
|
+
const classText = h.pattern.classForm === undefined ? undefined : oneLine(h.pattern.classForm);
|
|
4571
|
+
let shown = specificText;
|
|
4572
|
+
if (classText !== undefined) {
|
|
4573
|
+
const suffix = ` [match: ${h.matchedForm ?? 'specific'}]`;
|
|
4574
|
+
if (flags.has('full')) {
|
|
4575
|
+
shown = `specific: ${specificText} · class: ${classText}${suffix}`;
|
|
4576
|
+
} else {
|
|
4577
|
+
const contentWidth = Math.max(2, 160 - 'specific: '.length - ' · class: '.length - suffix.length);
|
|
4578
|
+
const specificWidth = Math.floor(contentWidth / 2);
|
|
4579
|
+
shown = `specific: ${specificText.slice(0, specificWidth)} · class: ${classText.slice(0, contentWidth - specificWidth)}${suffix}`;
|
|
4580
|
+
}
|
|
4581
|
+
} else if (!flags.has('full')) {
|
|
4582
|
+
shown = shown.slice(0, 160);
|
|
4583
|
+
}
|
|
4096
4584
|
// WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
|
|
4097
4585
|
// blindness the store-location line just removed, one level down: the reader would see more
|
|
4098
4586
|
// results and have no way to tell whether the global store is even connected.
|
|
@@ -5056,6 +5544,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5056
5544
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
5057
5545
|
const presetName = options.get('preset');
|
|
5058
5546
|
|
|
5547
|
+
// PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
|
|
5548
|
+
// environment and step 4 installs skills, so refusing at step 4 would leave a project that has
|
|
5549
|
+
// memory and hooks but not the skills the operator asked for — a half-configured state worse than
|
|
5550
|
+
// either clean outcome. The request is therefore resolved HERE, before the banner's first step.
|
|
5551
|
+
//
|
|
5552
|
+
// Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
|
|
5553
|
+
// there is our packaging defect, not the operator's typo, and it is reported by the existing
|
|
5554
|
+
// missing-list rather than by refusing the whole run.
|
|
5555
|
+
const setupSelectRaw = options.get('select');
|
|
5556
|
+
if (setupSelectRaw !== undefined) {
|
|
5557
|
+
const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
|
|
5558
|
+
const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
5559
|
+
const resolution = resolveSelection(requested, roots);
|
|
5560
|
+
for (const shadow of resolution.shadowed) {
|
|
5561
|
+
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(', ')}`);
|
|
5562
|
+
}
|
|
5563
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
5564
|
+
if (refusal !== null) { writeErr(refusal); return 1; }
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5059
5567
|
write(`\n╔══════════════════════════════════════════════════════╗`);
|
|
5060
5568
|
write(`║ DZ SETUP — Full Environment ║`);
|
|
5061
5569
|
write(`╠══════════════════════════════════════════════════════╣`);
|
|
@@ -5096,7 +5604,13 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5096
5604
|
? selectArg.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
5097
5605
|
: getPreset(preset)?.skills;
|
|
5098
5606
|
const install = select !== undefined && select.length > 0
|
|
5099
|
-
? await installSkills({
|
|
5607
|
+
? await installSkills({
|
|
5608
|
+
target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select,
|
|
5609
|
+
force: flags.has('force'), enrich: flags.has('enrich'), noHooks: flags.has('no-hooks'),
|
|
5610
|
+
noIntegrations: flags.has('no-integrations'),
|
|
5611
|
+
noVerify: flags.has('no-verify'),
|
|
5612
|
+
...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
|
|
5613
|
+
})
|
|
5100
5614
|
: undefined;
|
|
5101
5615
|
if (install) {
|
|
5102
5616
|
write(`║ ${String(install.results.length).padStart(2)} skill(s), ${String(install.written).padStart(3)} file(s) written${' '.repeat(15)}║`);
|
|
@@ -5106,6 +5620,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5106
5620
|
|
|
5107
5621
|
// Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
|
|
5108
5622
|
// setup has already run and the summary still prints; only the exit code carries the failure.
|
|
5623
|
+
let setupIntegrationOutcomes: readonly IntegrationOutcome[] = install?.integrations ?? [];
|
|
5109
5624
|
let codexHooksOk = true;
|
|
5110
5625
|
if (target === 'codex' && !flags.has('no-hooks')) {
|
|
5111
5626
|
write(`║ 5. Delivering codex hooks (live verify)... ║`);
|
|
@@ -5113,6 +5628,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5113
5628
|
codexHooksOk = delivery.ok;
|
|
5114
5629
|
for (const line of delivery.stdout) write(line);
|
|
5115
5630
|
for (const line of delivery.stderr) writeErr(line);
|
|
5631
|
+
const hookIndex = setupIntegrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
|
|
5632
|
+
if (hookIndex !== -1) {
|
|
5633
|
+
const hook = normalizeCodexHookOutcome(setupIntegrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
|
|
5634
|
+
setupIntegrationOutcomes = setupIntegrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
|
|
5635
|
+
}
|
|
5116
5636
|
}
|
|
5117
5637
|
|
|
5118
5638
|
write(`╠══════════════════════════════════════════════════════╣`);
|
|
@@ -5132,6 +5652,9 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5132
5652
|
if (install) {
|
|
5133
5653
|
write(`dz setup: ${install.results.length} skill(s), ${install.written} file(s) written, ${install.skipped} skipped`
|
|
5134
5654
|
+ (install.dirsSearched > 1 ? ` (searched ${install.dirsSearched} skill dirs)` : ''));
|
|
5655
|
+
for (const outcome of setupIntegrationOutcomes) {
|
|
5656
|
+
write(`dz setup integration ${outcome.component}: ${outcome.status.toUpperCase()}${outcome.reasonCode ? ` (${outcome.reasonCode})` : ''}`);
|
|
5657
|
+
}
|
|
5135
5658
|
writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
|
|
5136
5659
|
}
|
|
5137
5660
|
// A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
|
|
@@ -5146,7 +5669,8 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5146
5669
|
if (erroredSteps.length > 0) {
|
|
5147
5670
|
write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
|
|
5148
5671
|
}
|
|
5149
|
-
|
|
5672
|
+
const integrationsOk = !setupIntegrationOutcomes.some((row) => row.status === 'refused');
|
|
5673
|
+
return codexHooksOk && erroredSteps.length === 0 && integrationsOk ? 0 : 1;
|
|
5150
5674
|
}
|
|
5151
5675
|
|
|
5152
5676
|
function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): number {
|
|
@@ -5191,7 +5715,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
|
|
|
5191
5715
|
return 0;
|
|
5192
5716
|
}
|
|
5193
5717
|
|
|
5194
|
-
function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
|
|
5718
|
+
function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
5195
5719
|
const task = options.get('_positional_0');
|
|
5196
5720
|
if (!task) {
|
|
5197
5721
|
write('dz recommend: task description required');
|
|
@@ -5201,17 +5725,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5201
5725
|
|
|
5202
5726
|
const registry = buildRegistry(cwd);
|
|
5203
5727
|
const report = recommend(task, registry, cwd);
|
|
5728
|
+
if (flags.has('json')) {
|
|
5729
|
+
write(JSON.stringify(report, null, 2));
|
|
5730
|
+
return 0;
|
|
5731
|
+
}
|
|
5204
5732
|
|
|
5205
5733
|
write(`\n╔══════════════════════════════════════════════════════════════╗`);
|
|
5206
5734
|
write(`║ DZ RECOMMEND — Task Advisor ║`);
|
|
5207
5735
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5208
5736
|
write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
|
|
5209
|
-
|
|
5210
|
-
|
|
5737
|
+
if (report.topicSource === 'task') {
|
|
5738
|
+
write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
|
|
5739
|
+
} else if (report.topicSource === 'project-stack') {
|
|
5740
|
+
write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
|
|
5741
|
+
} else {
|
|
5742
|
+
write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
|
|
5743
|
+
}
|
|
5211
5744
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5212
5745
|
|
|
5746
|
+
if (report.topicSource === 'project-stack') {
|
|
5747
|
+
write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
|
|
5748
|
+
write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
|
|
5749
|
+
write(`PROJECT-STACK SUGGESTIONS`);
|
|
5750
|
+
} else if (report.topicSource === 'none') {
|
|
5751
|
+
write(`Тема запроса не распознана; рекомендаций нет.`);
|
|
5752
|
+
write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
|
|
5753
|
+
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
5754
|
+
return 0;
|
|
5755
|
+
}
|
|
5756
|
+
|
|
5757
|
+
const stackDerived = report.topicSource === 'project-stack';
|
|
5758
|
+
|
|
5213
5759
|
if (report.presets.length > 0) {
|
|
5214
|
-
write(
|
|
5760
|
+
write(stackDerived
|
|
5761
|
+
? `║ PROJECT-STACK PRESETS ║`
|
|
5762
|
+
: `║ RECOMMENDED PRESETS ║`);
|
|
5215
5763
|
for (const p of report.presets) {
|
|
5216
5764
|
const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
|
|
5217
5765
|
write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
|
|
@@ -5220,7 +5768,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5220
5768
|
}
|
|
5221
5769
|
|
|
5222
5770
|
if (report.skills.length > 0) {
|
|
5223
|
-
write(
|
|
5771
|
+
write(stackDerived
|
|
5772
|
+
? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
|
|
5773
|
+
: `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
|
|
5224
5774
|
for (const s of report.skills.slice(0, 8)) {
|
|
5225
5775
|
const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
|
|
5226
5776
|
write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
|
|
@@ -5229,7 +5779,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5229
5779
|
}
|
|
5230
5780
|
|
|
5231
5781
|
if (report.toolkits.length > 0) {
|
|
5232
|
-
write(
|
|
5782
|
+
write(stackDerived
|
|
5783
|
+
? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
|
|
5784
|
+
: `║ FULL PIPELINE (npx toolkits) ║`);
|
|
5233
5785
|
for (const tk of report.toolkits) {
|
|
5234
5786
|
const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
|
|
5235
5787
|
write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
|
|
@@ -5240,13 +5792,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5240
5792
|
}
|
|
5241
5793
|
|
|
5242
5794
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5243
|
-
write(
|
|
5795
|
+
write(stackDerived
|
|
5796
|
+
? `║ PROJECT-STACK PLAN ║`
|
|
5797
|
+
: `║ STEP-BY-STEP PLAN ║`);
|
|
5244
5798
|
for (const step of report.plan) {
|
|
5245
5799
|
const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
|
|
5246
5800
|
write(`║ ${line.padEnd(58)}║`);
|
|
5247
5801
|
}
|
|
5248
5802
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5249
|
-
write(
|
|
5803
|
+
write(stackDerived
|
|
5804
|
+
? `║ PROJECT-STACK QUICK INSTALL ║`
|
|
5805
|
+
: `║ QUICK INSTALL ║`);
|
|
5250
5806
|
const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
|
|
5251
5807
|
write(`║ ${cmd.padEnd(58)}║`);
|
|
5252
5808
|
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
@@ -5804,6 +6360,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
5804
6360
|
if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
|
|
5805
6361
|
else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
|
|
5806
6362
|
else write('dz publish: ✓ guard pre-flight passed');
|
|
6363
|
+
for (const observation of guardResult.observations ?? []) {
|
|
6364
|
+
write(`dz publish: ℹ guard observation — ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`);
|
|
6365
|
+
}
|
|
5807
6366
|
for (const n of guardResult.notes ?? []) write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
|
|
5808
6367
|
}
|
|
5809
6368
|
|
|
@@ -6992,6 +7551,84 @@ function looksBinaryText(text: string): boolean {
|
|
|
6992
7551
|
* per-file findings (each enriched with its `file`), and applies the exit-code contract.
|
|
6993
7552
|
* `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
|
|
6994
7553
|
*/
|
|
7554
|
+
/**
|
|
7555
|
+
* `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
|
|
7556
|
+
*
|
|
7557
|
+
* The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
|
|
7558
|
+
* verification lived inside two consumers, each carrying its own hardcoded list of which files are
|
|
7559
|
+
* chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
|
|
7560
|
+
* DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
|
|
7561
|
+
* report by construction.
|
|
7562
|
+
*
|
|
7563
|
+
* An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
|
|
7564
|
+
* indistinguishable in a report, and that indistinguishability is how the original blind spot
|
|
7565
|
+
* survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
|
|
7566
|
+
* reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
|
|
7567
|
+
*
|
|
7568
|
+
* A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
|
|
7569
|
+
* predate the chain) and therefore does not fail the command. Calling it a defect would train the
|
|
7570
|
+
* reader to ignore the output — the failure mode already measured once on the doctor's own line.
|
|
7571
|
+
*/
|
|
7572
|
+
function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
7573
|
+
const root = options.get('project') ?? cwd;
|
|
7574
|
+
const journals = CHAINED_JOURNALS.map((journal) => {
|
|
7575
|
+
const path = join(root, journal.rel);
|
|
7576
|
+
if (!existsSync(path)) {
|
|
7577
|
+
return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
|
|
7578
|
+
}
|
|
7579
|
+
let text = '';
|
|
7580
|
+
try {
|
|
7581
|
+
text = readFileSync(path, 'utf-8');
|
|
7582
|
+
} catch {
|
|
7583
|
+
// Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
|
|
7584
|
+
// "nothing to report": we did not look, so we know nothing.
|
|
7585
|
+
return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
|
|
7586
|
+
}
|
|
7587
|
+
const v = verifyEventChainText(text);
|
|
7588
|
+
if (v.chained === 0) {
|
|
7589
|
+
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)' };
|
|
7590
|
+
}
|
|
7591
|
+
const total = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
7592
|
+
const age = classifyChainDefects(v, total);
|
|
7593
|
+
if (v.ok) {
|
|
7594
|
+
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)` };
|
|
7595
|
+
}
|
|
7596
|
+
// A break the current unbroken run has already outlived does not make TODAY's records unsound.
|
|
7597
|
+
// Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
|
|
7598
|
+
const historical = age.inRun.length === 0 && age.runRecords > 0;
|
|
7599
|
+
return {
|
|
7600
|
+
rel: journal.rel,
|
|
7601
|
+
decides: journal.decides,
|
|
7602
|
+
status: historical ? ('healed' as const) : ('broken' as const),
|
|
7603
|
+
chained: v.chained,
|
|
7604
|
+
defects: v.defects.length,
|
|
7605
|
+
detail: historical
|
|
7606
|
+
? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
|
|
7607
|
+
: `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
|
|
7608
|
+
};
|
|
7609
|
+
});
|
|
7610
|
+
|
|
7611
|
+
const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
|
|
7612
|
+
const ok = failed.length === 0;
|
|
7613
|
+
|
|
7614
|
+
if (flags.has('json')) {
|
|
7615
|
+
write(JSON.stringify({ ok, root, journals }, null, 2));
|
|
7616
|
+
return ok ? 0 : 1;
|
|
7617
|
+
}
|
|
7618
|
+
|
|
7619
|
+
write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
|
|
7620
|
+
write('');
|
|
7621
|
+
const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
|
|
7622
|
+
for (const j of journals) {
|
|
7623
|
+
write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
|
|
7624
|
+
write(` ${j.detail}`);
|
|
7625
|
+
write(` decides: ${j.decides}`);
|
|
7626
|
+
}
|
|
7627
|
+
write('');
|
|
7628
|
+
write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
|
|
7629
|
+
return ok ? 0 : 1;
|
|
7630
|
+
}
|
|
7631
|
+
|
|
6995
7632
|
function cmdClaimCheck(
|
|
6996
7633
|
options: Map<string, string>,
|
|
6997
7634
|
_optionLists: Map<string, string[]>,
|
|
@@ -7503,6 +8140,31 @@ export interface CodexHooksSummary {
|
|
|
7503
8140
|
readonly stderr: readonly string[];
|
|
7504
8141
|
}
|
|
7505
8142
|
|
|
8143
|
+
/** Map the retained Codex hook writer's one live verdict into the common integration contract. */
|
|
8144
|
+
export function normalizeCodexHookOutcome(
|
|
8145
|
+
base: IntegrationOutcome,
|
|
8146
|
+
delivery: CodexHooksSummary & { readonly report: CodexHooksSyncReport },
|
|
8147
|
+
noVerify = false,
|
|
8148
|
+
): IntegrationOutcome {
|
|
8149
|
+
if (delivery.report.ready) {
|
|
8150
|
+
return {
|
|
8151
|
+
target: 'codex', component: 'hooks', status: 'emitted',
|
|
8152
|
+
registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: true }],
|
|
8153
|
+
carrier: { scope: 'user', path: '$CODEX_HOME/hooks.json' },
|
|
8154
|
+
...(delivery.report.codexVersion !== null ? { runtimeVersion: delivery.report.codexVersion } : {}),
|
|
8155
|
+
evidenceVersion: 'codex-hooks-live-v1',
|
|
8156
|
+
};
|
|
8157
|
+
}
|
|
8158
|
+
return {
|
|
8159
|
+
...base,
|
|
8160
|
+
target: 'codex', component: 'hooks', status: 'refused',
|
|
8161
|
+
registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: false }],
|
|
8162
|
+
reasonCode: 'CURRENT_LIVE_CHECK_FAILED',
|
|
8163
|
+
remediation: noVerify ? '--no-verify cannot establish ready; rerun with live verification' : 'approve the managed hooks and rerun the live verification',
|
|
8164
|
+
applied: delivery.report.written || delivery.report.installed,
|
|
8165
|
+
};
|
|
8166
|
+
}
|
|
8167
|
+
|
|
7506
8168
|
/**
|
|
7507
8169
|
* What the user is told about a sync report — the ONE place the success word can be printed.
|
|
7508
8170
|
*
|
|
@@ -7831,6 +8493,392 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
|
|
|
7831
8493
|
return pairs;
|
|
7832
8494
|
}
|
|
7833
8495
|
|
|
8496
|
+
const MAX_VOLUME_FILES_PER_SCOPE = 512;
|
|
8497
|
+
const MAX_VOLUME_BYTES_PER_SCOPE = 32 * 1024 * 1024;
|
|
8498
|
+
const CYRILLIC_CHARACTER = /\p{Script=Cyrillic}/u;
|
|
8499
|
+
|
|
8500
|
+
function volumePathInside(root: string, candidate: string): boolean {
|
|
8501
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
8502
|
+
}
|
|
8503
|
+
|
|
8504
|
+
function cyrillicUtf8Bytes(buffer: Buffer): number {
|
|
8505
|
+
let bytes = 0;
|
|
8506
|
+
for (const character of buffer.toString('utf8')) {
|
|
8507
|
+
if (CYRILLIC_CHARACTER.test(character)) bytes += Buffer.byteLength(character, 'utf8');
|
|
8508
|
+
}
|
|
8509
|
+
return bytes;
|
|
8510
|
+
}
|
|
8511
|
+
|
|
8512
|
+
function gatherTemplateVolumeTarget(
|
|
8513
|
+
packageDir: string,
|
|
8514
|
+
target: string,
|
|
8515
|
+
): TemplateVolumeTargetFact | undefined {
|
|
8516
|
+
const templateRoot = join(packageDir, 'templates', '.claude');
|
|
8517
|
+
if (!existsSync(templateRoot)) return undefined;
|
|
8518
|
+
const files: TemplateVolumeFileFact[] = [];
|
|
8519
|
+
const realFiles = new Set<string>();
|
|
8520
|
+
let totalBytes = 0;
|
|
8521
|
+
let failure: { reason: string; detail: string } | undefined;
|
|
8522
|
+
let packageReal: string;
|
|
8523
|
+
let templateReal: string;
|
|
8524
|
+
try {
|
|
8525
|
+
packageReal = realpathSync(packageDir);
|
|
8526
|
+
templateReal = realpathSync(templateRoot);
|
|
8527
|
+
if (!volumePathInside(packageReal, templateReal)) {
|
|
8528
|
+
failure = { reason: 'template-root-escape', detail: templateRoot };
|
|
8529
|
+
}
|
|
8530
|
+
} catch (error) {
|
|
8531
|
+
return {
|
|
8532
|
+
target,
|
|
8533
|
+
files,
|
|
8534
|
+
collection: { complete: false, reason: 'template-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
|
|
8535
|
+
};
|
|
8536
|
+
}
|
|
8537
|
+
|
|
8538
|
+
const fail = (reason: string, detail: string): void => {
|
|
8539
|
+
failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
|
|
8540
|
+
};
|
|
8541
|
+
const addFile = (absolute: string, kind: 'rules' | 'commands' | 'skills'): void => {
|
|
8542
|
+
if (failure !== undefined) return;
|
|
8543
|
+
if (files.length >= MAX_VOLUME_FILES_PER_SCOPE) {
|
|
8544
|
+
fail('template-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
|
|
8545
|
+
return;
|
|
8546
|
+
}
|
|
8547
|
+
try {
|
|
8548
|
+
const stat = lstatSync(absolute);
|
|
8549
|
+
if (!stat.isFile()) { fail('template-input-not-regular', absolute); return; }
|
|
8550
|
+
const real = realpathSync(absolute);
|
|
8551
|
+
if (!volumePathInside(templateReal, real)) { fail('template-input-escape', absolute); return; }
|
|
8552
|
+
if (realFiles.has(real)) { fail('template-input-duplicate', absolute); return; }
|
|
8553
|
+
if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
|
|
8554
|
+
fail(stat.size <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
|
|
8555
|
+
return;
|
|
8556
|
+
}
|
|
8557
|
+
const content = readFileSync(absolute);
|
|
8558
|
+
if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
|
|
8559
|
+
fail(content.byteLength <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
|
|
8560
|
+
return;
|
|
8561
|
+
}
|
|
8562
|
+
realFiles.add(real);
|
|
8563
|
+
totalBytes += content.byteLength;
|
|
8564
|
+
files.push({
|
|
8565
|
+
path: relative(packageDir, absolute).split(sep).join('/'),
|
|
8566
|
+
kind,
|
|
8567
|
+
bytes: content.byteLength,
|
|
8568
|
+
cyrillicUtf8Bytes: cyrillicUtf8Bytes(content),
|
|
8569
|
+
});
|
|
8570
|
+
} catch (error) {
|
|
8571
|
+
fail('template-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8572
|
+
}
|
|
8573
|
+
};
|
|
8574
|
+
const scanFlat = (name: 'rules' | 'commands'): void => {
|
|
8575
|
+
const dir = join(templateRoot, name);
|
|
8576
|
+
if (!existsSync(dir) || failure !== undefined) return;
|
|
8577
|
+
try {
|
|
8578
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
8579
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
8580
|
+
addFile(join(dir, entry.name), name);
|
|
8581
|
+
}
|
|
8582
|
+
} catch (error) {
|
|
8583
|
+
fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8584
|
+
}
|
|
8585
|
+
};
|
|
8586
|
+
const scanSkills = (dir: string): void => {
|
|
8587
|
+
if (!existsSync(dir) || failure !== undefined) return;
|
|
8588
|
+
let entries: Dirent[];
|
|
8589
|
+
try {
|
|
8590
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
8591
|
+
} catch (error) {
|
|
8592
|
+
fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8593
|
+
return;
|
|
8594
|
+
}
|
|
8595
|
+
for (const entry of entries) {
|
|
8596
|
+
if (failure !== undefined) return;
|
|
8597
|
+
const absolute = join(dir, entry.name);
|
|
8598
|
+
if (entry.isSymbolicLink()) { fail('template-input-not-regular', absolute); return; }
|
|
8599
|
+
if (entry.isDirectory()) scanSkills(absolute);
|
|
8600
|
+
else if (entry.name === 'SKILL.md') addFile(absolute, 'skills');
|
|
8601
|
+
}
|
|
8602
|
+
};
|
|
8603
|
+
|
|
8604
|
+
scanFlat('rules');
|
|
8605
|
+
scanFlat('commands');
|
|
8606
|
+
scanSkills(join(templateRoot, 'skills'));
|
|
8607
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
8608
|
+
return {
|
|
8609
|
+
target,
|
|
8610
|
+
files,
|
|
8611
|
+
collection: failure === undefined
|
|
8612
|
+
? { complete: true }
|
|
8613
|
+
: { complete: false, reason: failure.reason, detail: failure.detail },
|
|
8614
|
+
};
|
|
8615
|
+
}
|
|
8616
|
+
|
|
8617
|
+
function volumeGitText(root: string, args: readonly string[], allowedStatuses: readonly number[] = [0]): string {
|
|
8618
|
+
const result = spawnSync('git', [...args], {
|
|
8619
|
+
cwd: root,
|
|
8620
|
+
encoding: 'utf8',
|
|
8621
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
8622
|
+
});
|
|
8623
|
+
if (result.status === null || !allowedStatuses.includes(result.status)) {
|
|
8624
|
+
throw result.error ?? new Error(`git ${args[0] ?? ''} exited ${String(result.status)}`);
|
|
8625
|
+
}
|
|
8626
|
+
return result.stdout ?? '';
|
|
8627
|
+
}
|
|
8628
|
+
|
|
8629
|
+
function parseGuardStatusPaths(root: string): { readonly code: string; readonly path: string }[] {
|
|
8630
|
+
const text = volumeGitText(root, ['status', '--porcelain', '-uall']);
|
|
8631
|
+
return text.split('\n').flatMap((line) => {
|
|
8632
|
+
if (line.length < 4) return [];
|
|
8633
|
+
const code = line.slice(0, 2);
|
|
8634
|
+
const raw = line.slice(3).trim();
|
|
8635
|
+
const path = (raw.includes(' -> ') ? raw.split(' -> ')[1] : raw)?.trim();
|
|
8636
|
+
return path ? [{ code, path }] : [];
|
|
8637
|
+
});
|
|
8638
|
+
}
|
|
8639
|
+
|
|
8640
|
+
function parseFeatureTier(text: string): FeatureTier | undefined {
|
|
8641
|
+
const match = text.match(/^##\s+Tier:\s*(S|M|L|XL)\s*$/m);
|
|
8642
|
+
return match?.[1] as FeatureTier | undefined;
|
|
8643
|
+
}
|
|
8644
|
+
|
|
8645
|
+
function parseFeatureActiveSteps(text: string): (number | string)[] | undefined {
|
|
8646
|
+
const section = text.match(/^## Active steps[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/mi)?.[1];
|
|
8647
|
+
if (section === undefined) return undefined;
|
|
8648
|
+
const steps = [...section.matchAll(/\b(?:10|[0-9](?:\.5)?)\b/g)].map((match) => match[0]!);
|
|
8649
|
+
return [...new Set(steps)];
|
|
8650
|
+
}
|
|
8651
|
+
|
|
8652
|
+
function featureLifecycle(root: string, slug: string, assessment: string | undefined): FeatureVolumeFact['lifecycle'] {
|
|
8653
|
+
if (assessment !== undefined && /^Lifecycle:\s*complete\s*$/mi.test(assessment)) return { phase: 'complete' };
|
|
8654
|
+
try {
|
|
8655
|
+
const state = JSON.parse(readFileSync(join(root, '.dz', 'feature-adr', 'learning-state', `${slug}.json`), 'utf8')) as { step?: unknown };
|
|
8656
|
+
const match = typeof state.step === 'string' ? state.step.match(/\bStep\s+(\d+(?:\.5)?)/i) : null;
|
|
8657
|
+
if (!match?.[1]) return undefined;
|
|
8658
|
+
const current = Number(match[1]);
|
|
8659
|
+
if (!Number.isFinite(current)) return undefined;
|
|
8660
|
+
return { phase: 'in-progress', completedThroughStep: current <= 0 ? 0 : Math.ceil(current) - 1 };
|
|
8661
|
+
} catch {
|
|
8662
|
+
return undefined;
|
|
8663
|
+
}
|
|
8664
|
+
}
|
|
8665
|
+
|
|
8666
|
+
interface GatheredFeatureVolume {
|
|
8667
|
+
readonly fact: FeatureVolumeFact;
|
|
8668
|
+
readonly manifestText?: string;
|
|
8669
|
+
}
|
|
8670
|
+
|
|
8671
|
+
function gatherFeatureVolumeFact(root: string, slug: string): GatheredFeatureVolume {
|
|
8672
|
+
const featureDir = join(root, 'features', slug);
|
|
8673
|
+
const artifacts: FeatureArtifactFact[] = [];
|
|
8674
|
+
const contents = new Map<string, Buffer>();
|
|
8675
|
+
let totalBytes = 0;
|
|
8676
|
+
let failure: { reason: string; detail: string } | undefined;
|
|
8677
|
+
let rootReal: string;
|
|
8678
|
+
let featureReal: string;
|
|
8679
|
+
try {
|
|
8680
|
+
rootReal = realpathSync(root);
|
|
8681
|
+
featureReal = realpathSync(featureDir);
|
|
8682
|
+
if (!volumePathInside(rootReal, featureReal)) failure = { reason: 'feature-root-escape', detail: featureDir };
|
|
8683
|
+
} catch (error) {
|
|
8684
|
+
return { fact: {
|
|
8685
|
+
slug,
|
|
8686
|
+
artifacts,
|
|
8687
|
+
collection: { complete: false, reason: 'feature-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
|
|
8688
|
+
} };
|
|
8689
|
+
}
|
|
8690
|
+
const fail = (reason: string, detail: string): void => {
|
|
8691
|
+
failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
|
|
8692
|
+
};
|
|
8693
|
+
const addArtifact = (absolute: string): void => {
|
|
8694
|
+
if (failure !== undefined) return;
|
|
8695
|
+
if (artifacts.length >= MAX_VOLUME_FILES_PER_SCOPE) {
|
|
8696
|
+
fail('feature-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
|
|
8697
|
+
return;
|
|
8698
|
+
}
|
|
8699
|
+
try {
|
|
8700
|
+
const stat = lstatSync(absolute);
|
|
8701
|
+
if (!stat.isFile()) { fail('feature-input-not-regular', absolute); return; }
|
|
8702
|
+
const real = realpathSync(absolute);
|
|
8703
|
+
if (!volumePathInside(featureReal, real)) { fail('feature-input-escape', absolute); return; }
|
|
8704
|
+
if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
|
|
8705
|
+
fail(stat.size <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
|
|
8706
|
+
return;
|
|
8707
|
+
}
|
|
8708
|
+
const content = readFileSync(absolute);
|
|
8709
|
+
if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
|
|
8710
|
+
fail(content.byteLength <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
|
|
8711
|
+
return;
|
|
8712
|
+
}
|
|
8713
|
+
const path = relative(featureDir, absolute).split(sep).join('/');
|
|
8714
|
+
totalBytes += content.byteLength;
|
|
8715
|
+
contents.set(path, content);
|
|
8716
|
+
artifacts.push({ path, bytes: content.byteLength });
|
|
8717
|
+
} catch (error) {
|
|
8718
|
+
fail('feature-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8719
|
+
}
|
|
8720
|
+
};
|
|
8721
|
+
const scanNested = (dir: string): void => {
|
|
8722
|
+
if (!existsSync(dir) || failure !== undefined) return;
|
|
8723
|
+
let entries: Dirent[];
|
|
8724
|
+
try {
|
|
8725
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
8726
|
+
} catch (error) {
|
|
8727
|
+
fail('feature-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8728
|
+
return;
|
|
8729
|
+
}
|
|
8730
|
+
for (const entry of entries) {
|
|
8731
|
+
if (failure !== undefined) return;
|
|
8732
|
+
const absolute = join(dir, entry.name);
|
|
8733
|
+
if (entry.isSymbolicLink()) { fail('feature-input-not-regular', absolute); return; }
|
|
8734
|
+
if (entry.isDirectory()) scanNested(absolute);
|
|
8735
|
+
else addArtifact(absolute);
|
|
8736
|
+
}
|
|
8737
|
+
};
|
|
8738
|
+
try {
|
|
8739
|
+
const entries = readdirSync(featureDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
8740
|
+
for (const entry of entries) {
|
|
8741
|
+
if (failure !== undefined) break;
|
|
8742
|
+
const absolute = join(featureDir, entry.name);
|
|
8743
|
+
if (entry.name === '03_adr' || entry.name === '07_code_changes') {
|
|
8744
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) fail('feature-input-not-regular', absolute);
|
|
8745
|
+
else scanNested(absolute);
|
|
8746
|
+
} else if (entry.name === 'README.md' || /^0[0-9](?:[_.-][^/]*)?\.md$/.test(entry.name)) {
|
|
8747
|
+
addArtifact(absolute);
|
|
8748
|
+
}
|
|
8749
|
+
}
|
|
8750
|
+
} catch (error) {
|
|
8751
|
+
fail('feature-directory-unreadable', `${featureDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8752
|
+
}
|
|
8753
|
+
artifacts.sort((a, b) => a.path.localeCompare(b.path));
|
|
8754
|
+
const assessment = contents.get('00_complexity_assessment.md')?.toString('utf8');
|
|
8755
|
+
const tier = assessment !== undefined ? parseFeatureTier(assessment) : undefined;
|
|
8756
|
+
const activeSteps = assessment !== undefined ? parseFeatureActiveSteps(assessment) : undefined;
|
|
8757
|
+
const lifecycle = featureLifecycle(root, slug, assessment);
|
|
8758
|
+
const fact: FeatureVolumeFact = {
|
|
8759
|
+
slug,
|
|
8760
|
+
...(tier !== undefined ? { tier } : {}),
|
|
8761
|
+
...(activeSteps !== undefined ? { activeSteps } : {}),
|
|
8762
|
+
namedConsumers: [],
|
|
8763
|
+
...(lifecycle !== undefined ? { lifecycle } : {}),
|
|
8764
|
+
artifacts,
|
|
8765
|
+
collection: failure === undefined
|
|
8766
|
+
? { complete: true }
|
|
8767
|
+
: { complete: false, reason: failure.reason, detail: failure.detail },
|
|
8768
|
+
};
|
|
8769
|
+
const manifestText = contents.get('07_code_changes/change_manifest.md')?.toString('utf8');
|
|
8770
|
+
return { fact, ...(manifestText !== undefined ? { manifestText } : {}) };
|
|
8771
|
+
}
|
|
8772
|
+
|
|
8773
|
+
function manifestPaths(text: string, slug: string): string[] {
|
|
8774
|
+
const paths = new Set<string>();
|
|
8775
|
+
for (const line of text.split('\n')) {
|
|
8776
|
+
const match = line.match(/^\s*-\s+`?([^`]+?)`?(?:\s+[—-]\s+.*)?$/);
|
|
8777
|
+
if (!match?.[1]) continue;
|
|
8778
|
+
const path = match[1].trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
8779
|
+
if (path === '' || path.startsWith('/') || path.split('/').some((part) => part === '..')) continue;
|
|
8780
|
+
if (path.startsWith(`features/${slug}/`)) continue;
|
|
8781
|
+
paths.add(path);
|
|
8782
|
+
}
|
|
8783
|
+
return [...paths].sort();
|
|
8784
|
+
}
|
|
8785
|
+
|
|
8786
|
+
function attributableDiff(
|
|
8787
|
+
root: string,
|
|
8788
|
+
slug: string,
|
|
8789
|
+
feature: GatheredFeatureVolume,
|
|
8790
|
+
status: readonly { readonly code: string; readonly path: string }[],
|
|
8791
|
+
changedSlugs: readonly string[],
|
|
8792
|
+
): NonNullable<FeatureVolumeFact['diff']> {
|
|
8793
|
+
if (changedSlugs.length !== 1 || changedSlugs[0] !== slug) {
|
|
8794
|
+
return { attributable: false, reason: changedSlugs.length > 1 ? 'ambiguous-feature-attribution' : 'feature-not-attributable' };
|
|
8795
|
+
}
|
|
8796
|
+
if (feature.manifestText === undefined) return { attributable: false, reason: 'change-manifest-unavailable' };
|
|
8797
|
+
const listed = manifestPaths(feature.manifestText, slug);
|
|
8798
|
+
const changedByPath = new Map(status.map((item) => [item.path, item.code]));
|
|
8799
|
+
const changed = listed.filter((path) => changedByPath.has(path));
|
|
8800
|
+
if (changed.length === 0) return { attributable: true, bytes: 0, base: 'HEAD', head: 'working-tree', method: 'git-unified-diff-bytes/v1', excludedFeaturePath: `features/${slug}/**` };
|
|
8801
|
+
const tracked: string[] = [];
|
|
8802
|
+
const untracked: string[] = [];
|
|
8803
|
+
for (const path of changed) {
|
|
8804
|
+
const absolute = resolve(root, path);
|
|
8805
|
+
if (!volumePathInside(root, absolute)) return { attributable: false, reason: 'manifest-path-escape' };
|
|
8806
|
+
if (changedByPath.get(path) === '??') {
|
|
8807
|
+
try {
|
|
8808
|
+
const stat = lstatSync(absolute);
|
|
8809
|
+
const real = realpathSync(absolute);
|
|
8810
|
+
if (!stat.isFile() || !volumePathInside(root, real) || stat.size > MAX_VOLUME_BYTES_PER_SCOPE) {
|
|
8811
|
+
return { attributable: false, reason: 'untracked-diff-input-refused' };
|
|
8812
|
+
}
|
|
8813
|
+
} catch { return { attributable: false, reason: 'untracked-diff-input-unreadable' }; }
|
|
8814
|
+
untracked.push(path);
|
|
8815
|
+
} else {
|
|
8816
|
+
tracked.push(path);
|
|
8817
|
+
}
|
|
8818
|
+
}
|
|
8819
|
+
let bytes = 0;
|
|
8820
|
+
try {
|
|
8821
|
+
if (tracked.length > 0) {
|
|
8822
|
+
const diff = volumeGitText(root, ['diff', '--binary', '--no-ext-diff', 'HEAD', '--', ...tracked]);
|
|
8823
|
+
bytes += Buffer.byteLength(diff, 'utf8');
|
|
8824
|
+
}
|
|
8825
|
+
for (const path of untracked) {
|
|
8826
|
+
const diff = volumeGitText(root, ['diff', '--no-index', '--binary', '--', '/dev/null', resolve(root, path)], [0, 1]);
|
|
8827
|
+
bytes += Buffer.byteLength(diff, 'utf8');
|
|
8828
|
+
if (bytes > MAX_VOLUME_BYTES_PER_SCOPE) return { attributable: false, reason: 'diff-byte-cap-exceeded' };
|
|
8829
|
+
}
|
|
8830
|
+
} catch {
|
|
8831
|
+
return { attributable: false, reason: 'git-diff-failed' };
|
|
8832
|
+
}
|
|
8833
|
+
return {
|
|
8834
|
+
attributable: true,
|
|
8835
|
+
bytes,
|
|
8836
|
+
base: 'HEAD',
|
|
8837
|
+
head: 'working-tree',
|
|
8838
|
+
method: 'git-unified-diff-bytes/v1',
|
|
8839
|
+
excludedFeaturePath: `features/${slug}/**`,
|
|
8840
|
+
};
|
|
8841
|
+
}
|
|
8842
|
+
|
|
8843
|
+
function gatherVolumeShadowFacts(
|
|
8844
|
+
root: string,
|
|
8845
|
+
packages: readonly { readonly dir: string; readonly name: string; readonly privateFlag: boolean }[],
|
|
8846
|
+
): VolumeShadowInput {
|
|
8847
|
+
const templates = packages
|
|
8848
|
+
.filter((item) => !item.privateFlag)
|
|
8849
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.dir.localeCompare(b.dir))
|
|
8850
|
+
.flatMap((item) => {
|
|
8851
|
+
const fact = gatherTemplateVolumeTarget(join(root, item.dir), item.name);
|
|
8852
|
+
return fact === undefined ? [] : [fact];
|
|
8853
|
+
});
|
|
8854
|
+
let status: { code: string; path: string }[];
|
|
8855
|
+
try { status = parseGuardStatusPaths(root); }
|
|
8856
|
+
catch { return templates.length > 0 ? { templates } : {}; }
|
|
8857
|
+
const slugs = [...new Set(status.flatMap((item) => {
|
|
8858
|
+
const match = item.path.match(/^features\/([^/]+)\//);
|
|
8859
|
+
return match?.[1] ? [match[1]] : [];
|
|
8860
|
+
}))].sort();
|
|
8861
|
+
const gathered = slugs.slice(0, 32).map((slug) => gatherFeatureVolumeFact(root, slug));
|
|
8862
|
+
const measuredFeatures = gathered.map((feature) => ({
|
|
8863
|
+
...feature.fact,
|
|
8864
|
+
diff: attributableDiff(root, feature.fact.slug, feature, status, slugs),
|
|
8865
|
+
}));
|
|
8866
|
+
const cappedFeatures: FeatureVolumeFact[] = slugs.slice(32).map((slug) => ({
|
|
8867
|
+
slug,
|
|
8868
|
+
artifacts: [],
|
|
8869
|
+
collection: {
|
|
8870
|
+
complete: false,
|
|
8871
|
+
reason: 'feature-scope-cap-exceeded',
|
|
8872
|
+
detail: 'only the first 32 lexicographically sorted changed feature scopes were traversed',
|
|
8873
|
+
},
|
|
8874
|
+
}));
|
|
8875
|
+
const features = [...measuredFeatures, ...cappedFeatures];
|
|
8876
|
+
return {
|
|
8877
|
+
...(templates.length > 0 ? { templates } : {}),
|
|
8878
|
+
...(features.length > 0 ? { features } : {}),
|
|
8879
|
+
};
|
|
8880
|
+
}
|
|
8881
|
+
|
|
7834
8882
|
/** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
|
|
7835
8883
|
function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
|
|
7836
8884
|
const facts: Record<string, unknown> = { op };
|
|
@@ -7954,7 +9002,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
7954
9002
|
const manifests: Manifest[] = [];
|
|
7955
9003
|
const located: { dir: string; m: Manifest }[] = [];
|
|
7956
9004
|
try {
|
|
7957
|
-
const out =
|
|
9005
|
+
const out = volumeGitText(root, ['ls-files', 'packages/@dzhechkov/*/package.json']);
|
|
7958
9006
|
for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
7959
9007
|
try {
|
|
7960
9008
|
const m = JSON.parse(readFileSync(join(root, rel), 'utf8')) as Manifest;
|
|
@@ -7978,6 +9026,11 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
7978
9026
|
packages.push({ name: m.name ?? '(unnamed)', deps });
|
|
7979
9027
|
}
|
|
7980
9028
|
facts['packages'] = packages;
|
|
9029
|
+
facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
|
|
9030
|
+
dir,
|
|
9031
|
+
name: m.name ?? dir,
|
|
9032
|
+
privateFlag: m.private === true,
|
|
9033
|
+
})));
|
|
7981
9034
|
|
|
7982
9035
|
// licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
|
|
7983
9036
|
// `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
|
|
@@ -8202,6 +9255,11 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
|
|
|
8202
9255
|
return result;
|
|
8203
9256
|
}
|
|
8204
9257
|
|
|
9258
|
+
function renderGuardObservation(observation: GuardObservation): string {
|
|
9259
|
+
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
9260
|
+
return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
|
|
9261
|
+
}
|
|
9262
|
+
|
|
8205
9263
|
/**
|
|
8206
9264
|
* The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
|
|
8207
9265
|
* lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
|
|
@@ -8542,7 +9600,8 @@ function cmdGuardPromote(options: Map<string, string>, flags: Set<string>, root:
|
|
|
8542
9600
|
...next,
|
|
8543
9601
|
entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
|
|
8544
9602
|
};
|
|
8545
|
-
|
|
9603
|
+
const withEvidence = recordPromotionRunEvidence(withApplied, report, nowTs);
|
|
9604
|
+
try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withEvidence); } catch { /* best-effort */ }
|
|
8546
9605
|
}
|
|
8547
9606
|
|
|
8548
9607
|
// A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
|
|
@@ -8634,6 +9693,7 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
8634
9693
|
|
|
8635
9694
|
const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
|
|
8636
9695
|
write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
|
|
9696
|
+
for (const observation of result.observations ?? []) write(renderGuardObservation(observation));
|
|
8637
9697
|
for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
|
|
8638
9698
|
for (const n of result.notes ?? []) write(` [note] ${n}`); // information, never a verdict input (FN-7)
|
|
8639
9699
|
if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
|
|
@@ -9241,9 +10301,74 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
9241
10301
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
9242
10302
|
const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
9243
10303
|
|
|
9244
|
-
|
|
10304
|
+
// Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
|
|
10305
|
+
// package's own scripts.test, never from a global default. The package dir is the nearest
|
|
10306
|
+
// ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
|
|
10307
|
+
// because the pure half deliberately takes the script text as data and never touches the fs.
|
|
10308
|
+
let packageTestScript: string | null = null;
|
|
10309
|
+
let packageDevDependencies: string[] = [];
|
|
10310
|
+
let packageDir = repoRoot;
|
|
10311
|
+
{
|
|
10312
|
+
const firstTest = propertyTests[0]?.file;
|
|
10313
|
+
// QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
|
|
10314
|
+
// engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
|
|
10315
|
+
// repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
|
|
10316
|
+
// marker file). Containment first: a start point outside the repo root never gets walked,
|
|
10317
|
+
// the script stays null, and the engine's own path sanitation then refuses the test path.
|
|
10318
|
+
const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
|
|
10319
|
+
if (firstTest !== undefined && walkStart !== undefined
|
|
10320
|
+
&& (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
|
|
10321
|
+
let probe = walkStart;
|
|
10322
|
+
// walk up to the repo root looking for package.json (bounded by the fs root either way)
|
|
10323
|
+
for (;;) {
|
|
10324
|
+
if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
|
|
10325
|
+
const parent = dirname(probe);
|
|
10326
|
+
if (parent === probe || probe === repoRoot) break;
|
|
10327
|
+
probe = parent;
|
|
10328
|
+
}
|
|
10329
|
+
try {
|
|
10330
|
+
const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
|
|
10331
|
+
scripts?: Record<string, string>; devDependencies?: Record<string, string>;
|
|
10332
|
+
};
|
|
10333
|
+
packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
|
|
10334
|
+
packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
|
|
10335
|
+
} catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
|
|
10336
|
+
}
|
|
10337
|
+
}
|
|
10338
|
+
// The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
|
|
10339
|
+
// absolute one — an absolute path is refused as unsafe-package-dir by design.
|
|
10340
|
+
const packageDirRel = relative(repoRoot, packageDir) || '.';
|
|
10341
|
+
const planInput = runnerOpt !== undefined
|
|
10342
|
+
? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
|
|
10343
|
+
: { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
|
|
10344
|
+
const plan = planDiscriminationCheck(planInput);
|
|
9245
10345
|
|
|
9246
10346
|
if (!plan.runnable) {
|
|
10347
|
+
// QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
|
|
10348
|
+
// "no property test to check"/map-a-test framing — the operator-facing surface re-created the
|
|
10349
|
+
// exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
|
|
10350
|
+
// backlog entries existed. The plan's own named reason is the verdict; the generic classify
|
|
10351
|
+
// stays only for the genuinely-empty-target case.
|
|
10352
|
+
const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
|
|
10353
|
+
if (runnerRefusal) {
|
|
10354
|
+
const refusal = {
|
|
10355
|
+
aggregate: 'CANNOT_ISOLATE',
|
|
10356
|
+
measurementValid: false,
|
|
10357
|
+
primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
|
|
10358
|
+
finding: {
|
|
10359
|
+
severity: 'high',
|
|
10360
|
+
verdict: 'CANNOT_ISOLATE',
|
|
10361
|
+
files: plan.targets.map((t) => t.file),
|
|
10362
|
+
detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
|
|
10363
|
+
+ `declare scripts.test in the target package (or pass --runner) and re-run. `
|
|
10364
|
+
+ `This is NOT a statement about the tests.`,
|
|
10365
|
+
},
|
|
10366
|
+
};
|
|
10367
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
|
|
10368
|
+
write(`discrimination-check: REFUSED (${plan.reason})`);
|
|
10369
|
+
write(` → ${refusal.finding.detail}`);
|
|
10370
|
+
return 0;
|
|
10371
|
+
}
|
|
9247
10372
|
// No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
|
|
9248
10373
|
const result = classifyDiscrimination({ propertyTests: [], results: [] });
|
|
9249
10374
|
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
|
|
@@ -9332,10 +10457,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
9332
10457
|
|
|
9333
10458
|
// t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
|
|
9334
10459
|
// still quote + `--` so a path can never be read as a runner option or split a word.
|
|
10460
|
+
// Runner honesty (ADR-001): the run executes FROM the target package dir with a
|
|
10461
|
+
// package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
|
|
10462
|
+
// (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
|
|
10463
|
+
// feature removes. The plan's own commands encode the same cd; this body mirrors it.
|
|
10464
|
+
const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
|
|
10465
|
+
const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
|
|
10466
|
+
const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
|
|
10467
|
+
const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
|
|
9335
10468
|
const nameArg = t.name ? ` -t '${t.name}'` : '';
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
10469
|
+
// NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
|
|
10470
|
+
// filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
|
|
10471
|
+
// this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
|
|
10472
|
+
// leading dash, no metacharacters), so it can never be read as an option.
|
|
10473
|
+
const cmd = `${runner}${nameArg} '${fileInPkg}'`;
|
|
10474
|
+
const base = runCapturedTest(cmd, execDirBase, timeoutMs);
|
|
10475
|
+
// The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
|
|
10476
|
+
// paths, so it must be probed with the same form, or every hit reads as target-unseen.
|
|
10477
|
+
const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
|
|
9339
10478
|
const outcome = discriminationOutcomeOf(base.exitCode, evidence);
|
|
9340
10479
|
const row: Record<string, unknown> = t.name !== undefined
|
|
9341
10480
|
? { file: t.file, name: t.name, outcome, evidence }
|
|
@@ -9347,8 +10486,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
9347
10486
|
// base rows per the matrix; running it is cheap and only ever on an already-broken path.
|
|
9348
10487
|
// Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
|
|
9349
10488
|
if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
|
|
9350
|
-
const tip = runCapturedTest(cmd,
|
|
9351
|
-
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode,
|
|
10489
|
+
const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
|
|
10490
|
+
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
|
|
9352
10491
|
row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
|
|
9353
10492
|
row['tipEvidence'] = tipEvidence;
|
|
9354
10493
|
// R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
|
|
@@ -9472,9 +10611,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
9472
10611
|
*
|
|
9473
10612
|
* Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
|
|
9474
10613
|
* below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
|
|
9475
|
-
* error (missing registry
|
|
9476
|
-
* read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
|
|
10614
|
+
* error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
|
|
9477
10615
|
* symlink escape is refused before anything is written, SPEC rule 3).
|
|
10616
|
+
* A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
|
|
9478
10617
|
*/
|
|
9479
10618
|
/**
|
|
9480
10619
|
* Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
|
|
@@ -9483,7 +10622,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
9483
10622
|
* redness says nothing about the named protection. Returns `{error}` when a parser ran and the
|
|
9484
10623
|
* text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
|
|
9485
10624
|
*/
|
|
9486
|
-
|
|
10625
|
+
interface MutationParseCheckResult {
|
|
10626
|
+
readonly error?: string;
|
|
10627
|
+
readonly skipped?: string;
|
|
10628
|
+
readonly internalFailureReason?: string;
|
|
10629
|
+
readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
10630
|
+
}
|
|
10631
|
+
|
|
10632
|
+
function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
|
|
9487
10633
|
interface TsLike {
|
|
9488
10634
|
transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
|
|
9489
10635
|
flattenDiagnosticMessageText(m: unknown, s: string): string;
|
|
@@ -9507,17 +10653,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
9507
10653
|
try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
|
|
9508
10654
|
}
|
|
9509
10655
|
if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
|
|
9510
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
10656
|
+
const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
|
|
10657
|
+
try {
|
|
10658
|
+
// `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
|
|
10659
|
+
execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
|
|
10660
|
+
return {};
|
|
10661
|
+
} catch (e) {
|
|
10662
|
+
const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
|
|
10663
|
+
// A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
|
|
10664
|
+
// launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
|
|
10665
|
+
// and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
|
|
10666
|
+
if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
|
|
10667
|
+
const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
10668
|
+
const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
|
|
10669
|
+
?? stderrLines.at(-1)
|
|
10670
|
+
?? err.message
|
|
10671
|
+
?? 'node --check failed';
|
|
10672
|
+
return { error: msg.slice(0, 200) };
|
|
10673
|
+
}
|
|
10674
|
+
});
|
|
10675
|
+
if (checked.value === null) {
|
|
10676
|
+
return {
|
|
10677
|
+
internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
10678
|
+
internalAttempts: checked.attempts,
|
|
10679
|
+
};
|
|
9520
10680
|
}
|
|
10681
|
+
return checked.internalRetries === 1
|
|
10682
|
+
? { ...checked.value, internalAttempts: checked.attempts }
|
|
10683
|
+
: checked.value;
|
|
9521
10684
|
}
|
|
9522
10685
|
return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
|
|
9523
10686
|
} catch (e) {
|
|
@@ -9525,7 +10688,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
9525
10688
|
}
|
|
9526
10689
|
}
|
|
9527
10690
|
|
|
9528
|
-
function cmdMutationGate(
|
|
10691
|
+
function cmdMutationGate(
|
|
10692
|
+
options: Map<string, string>,
|
|
10693
|
+
flags: Set<string>,
|
|
10694
|
+
cwd: string,
|
|
10695
|
+
write: Write,
|
|
10696
|
+
injectedRunner?: MutationGateRunner,
|
|
10697
|
+
): number {
|
|
9529
10698
|
const json = flags.has('json');
|
|
9530
10699
|
const fail = (what: string): number => {
|
|
9531
10700
|
write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
|
|
@@ -9596,6 +10765,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9596
10765
|
const results: MutationEntryResult[] = [];
|
|
9597
10766
|
const observations: MutationObservation[] = [];
|
|
9598
10767
|
const warnings: string[] = [];
|
|
10768
|
+
const internalRetries: {
|
|
10769
|
+
readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
|
|
10770
|
+
readonly entryId?: string;
|
|
10771
|
+
readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
10772
|
+
}[] = [];
|
|
9599
10773
|
let baseline: ReturnType<typeof classifyBaseline>;
|
|
9600
10774
|
try {
|
|
9601
10775
|
if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
|
|
@@ -9635,7 +10809,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9635
10809
|
const realScratchRoot = realpathSync(copyDir);
|
|
9636
10810
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
9637
10811
|
|
|
9638
|
-
|
|
10812
|
+
type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
|
|
10813
|
+
const invokeSuite = (): MutationGateRunnerObservation => {
|
|
10814
|
+
if (injectedRunner !== undefined) {
|
|
10815
|
+
return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
|
|
10816
|
+
}
|
|
9639
10817
|
const run = spawnSync(testCmd, {
|
|
9640
10818
|
cwd: copyDir,
|
|
9641
10819
|
shell: true,
|
|
@@ -9647,6 +10825,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9647
10825
|
const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
|
|
9648
10826
|
? run.error.code
|
|
9649
10827
|
: undefined;
|
|
10828
|
+
// Node may populate both `error` and a numeric `status` for an internal spawn failure. The
|
|
10829
|
+
// error wins except for the two already-named resource observations: a status alongside
|
|
10830
|
+
// EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
|
|
10831
|
+
if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
|
|
10832
|
+
throw run.error;
|
|
10833
|
+
}
|
|
9650
10834
|
const signal = typeof run.signal === 'string' ? run.signal : undefined;
|
|
9651
10835
|
let failureReason: string | undefined;
|
|
9652
10836
|
if (typeof run.status !== 'number') {
|
|
@@ -9658,22 +10842,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9658
10842
|
}
|
|
9659
10843
|
return {
|
|
9660
10844
|
exitCode: typeof run.status === 'number' ? run.status : null,
|
|
9661
|
-
// Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
|
|
9662
|
-
// collection would silently lose a green-run marker.
|
|
9663
10845
|
output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
|
|
9664
10846
|
...(failureReason !== undefined ? { failureReason } : {}),
|
|
9665
10847
|
};
|
|
9666
10848
|
};
|
|
9667
10849
|
|
|
10850
|
+
const runSuite = (
|
|
10851
|
+
phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
|
|
10852
|
+
entryId?: string,
|
|
10853
|
+
): SuiteRun => {
|
|
10854
|
+
const retried = runWithOneInternalRetry(invokeSuite);
|
|
10855
|
+
const loggedAttempts = retried.attempts.map((attempt) => {
|
|
10856
|
+
if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
|
|
10857
|
+
const outcome = retried.value.exitCode === null
|
|
10858
|
+
? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
|
|
10859
|
+
: `exit ${retried.value.exitCode}`;
|
|
10860
|
+
return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
|
|
10861
|
+
});
|
|
10862
|
+
if (retried.internalRetries === 1) {
|
|
10863
|
+
const record = entryId === undefined
|
|
10864
|
+
? { phase, attempts: loggedAttempts }
|
|
10865
|
+
: { phase, entryId, attempts: loggedAttempts };
|
|
10866
|
+
internalRetries.push(record);
|
|
10867
|
+
if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
|
|
10868
|
+
}
|
|
10869
|
+
const internalAttemptLog = retried.internalRetries === 1
|
|
10870
|
+
? loggedAttempts.map((attempt) => attempt.detail).join('; ')
|
|
10871
|
+
: undefined;
|
|
10872
|
+
if (retried.value !== null) {
|
|
10873
|
+
return {
|
|
10874
|
+
...retried.value,
|
|
10875
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
10876
|
+
};
|
|
10877
|
+
}
|
|
10878
|
+
return {
|
|
10879
|
+
exitCode: null,
|
|
10880
|
+
output: '',
|
|
10881
|
+
failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
10882
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
10883
|
+
};
|
|
10884
|
+
};
|
|
10885
|
+
|
|
9668
10886
|
// Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
|
|
9669
10887
|
// result would be this gate shipping the defect class it exists to catch.
|
|
9670
10888
|
if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
9671
|
-
const base = runSuite();
|
|
9672
|
-
baseline = classifyBaseline(
|
|
10889
|
+
const base = runSuite('baseline');
|
|
10890
|
+
baseline = classifyBaseline(
|
|
10891
|
+
base.exitCode,
|
|
10892
|
+
base.failureReason,
|
|
10893
|
+
base.exitCode !== null && base.exitCode !== 0
|
|
10894
|
+
? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
|
|
10895
|
+
: undefined,
|
|
10896
|
+
);
|
|
9673
10897
|
if (!baseline.ok) {
|
|
9674
|
-
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode:
|
|
10898
|
+
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
|
|
9675
10899
|
write(renderMutationReport([], baseline, pkgDir));
|
|
9676
|
-
return
|
|
10900
|
+
return 1;
|
|
9677
10901
|
}
|
|
9678
10902
|
|
|
9679
10903
|
for (const entry of entries) {
|
|
@@ -9708,8 +10932,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9708
10932
|
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.`);
|
|
9709
10933
|
}
|
|
9710
10934
|
if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
|
|
9711
|
-
let run:
|
|
10935
|
+
let run: SuiteRun | null = null;
|
|
9712
10936
|
let parseError: string | undefined;
|
|
10937
|
+
let parseInternalFailureReason: string | undefined;
|
|
10938
|
+
let parseInternalAttemptLog: string | undefined;
|
|
9713
10939
|
try {
|
|
9714
10940
|
writeFileSync(filePath, applied.text);
|
|
9715
10941
|
// Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
|
|
@@ -9719,10 +10945,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9719
10945
|
warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
|
|
9720
10946
|
if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
|
|
9721
10947
|
}
|
|
10948
|
+
if (check.internalAttempts !== undefined) {
|
|
10949
|
+
internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
|
|
10950
|
+
parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
|
|
10951
|
+
if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
|
|
10952
|
+
}
|
|
10953
|
+
parseInternalFailureReason = check.internalFailureReason;
|
|
9722
10954
|
if (check.error !== undefined) {
|
|
9723
10955
|
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
9724
|
-
} else {
|
|
9725
|
-
run = runSuite();
|
|
10956
|
+
} else if (parseInternalFailureReason === undefined) {
|
|
10957
|
+
run = runSuite('mutation', entry.id);
|
|
9726
10958
|
}
|
|
9727
10959
|
} finally {
|
|
9728
10960
|
writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
|
|
@@ -9756,13 +10988,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9756
10988
|
// those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
9757
10989
|
let rebaselineExitCode: number | null | undefined;
|
|
9758
10990
|
let rebaselineFailureReason: string | undefined;
|
|
10991
|
+
let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
|
|
10992
|
+
let rebaselineInternalAttemptLog: string | undefined;
|
|
9759
10993
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
9760
10994
|
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
9761
10995
|
if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
|
|
9762
|
-
const rebaselineRun = runSuite();
|
|
10996
|
+
const rebaselineRun = runSuite('rebaseline', entry.id);
|
|
9763
10997
|
rebaselineExitCode = rebaselineRun.exitCode;
|
|
9764
10998
|
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
10999
|
+
rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
|
|
11000
|
+
if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
|
|
11001
|
+
rebaselineAttribution = attributeBaselineRedness(
|
|
11002
|
+
rebaselineRun.output,
|
|
11003
|
+
entries.map((candidate) => candidate.file),
|
|
11004
|
+
);
|
|
11005
|
+
}
|
|
9765
11006
|
}
|
|
11007
|
+
const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
|
|
11008
|
+
const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
|
|
11009
|
+
.filter((log): log is string => log !== undefined)
|
|
11010
|
+
.join('; ');
|
|
9766
11011
|
const obs: MutationObservation = {
|
|
9767
11012
|
entry,
|
|
9768
11013
|
occurrences: 1,
|
|
@@ -9772,9 +11017,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9772
11017
|
...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
|
|
9773
11018
|
...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
|
|
9774
11019
|
...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
|
|
9775
|
-
...(
|
|
11020
|
+
...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
|
|
11021
|
+
...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
|
|
9776
11022
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
9777
11023
|
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
11024
|
+
...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
|
|
9778
11025
|
};
|
|
9779
11026
|
observations.push(obs);
|
|
9780
11027
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -9787,7 +11034,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9787
11034
|
// MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
|
|
9788
11035
|
if (rebaselineMode === 'final') {
|
|
9789
11036
|
if (!json) write('mutation-gate: final re-baseline of the restored tree …');
|
|
9790
|
-
const finalRun = runSuite();
|
|
11037
|
+
const finalRun = runSuite('final-rebaseline');
|
|
9791
11038
|
const finalExit = finalRun.exitCode;
|
|
9792
11039
|
if (finalExit !== 0) {
|
|
9793
11040
|
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
@@ -9797,6 +11044,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9797
11044
|
...obs,
|
|
9798
11045
|
rebaselineExitCode: finalExit,
|
|
9799
11046
|
...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
|
|
11047
|
+
...(finalRun.internalAttemptLog !== undefined
|
|
11048
|
+
? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
|
|
11049
|
+
: {}),
|
|
11050
|
+
...(finalExit !== null && finalExit !== 0
|
|
11051
|
+
? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
|
|
11052
|
+
: {}),
|
|
9800
11053
|
}));
|
|
9801
11054
|
results.length = 0;
|
|
9802
11055
|
results.push(...reclassified);
|
|
@@ -9812,7 +11065,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
9812
11065
|
|
|
9813
11066
|
const exitCode = mutationGateExitCode(results, baseline.ok);
|
|
9814
11067
|
if (json) {
|
|
9815
|
-
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
|
|
11068
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
|
|
9816
11069
|
return exitCode;
|
|
9817
11070
|
}
|
|
9818
11071
|
write(renderMutationReport(results, baseline, pkgDir));
|
|
@@ -11525,6 +12778,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
|
|
|
11525
12778
|
targetExists: existsSync(target),
|
|
11526
12779
|
targetHasPair: flags.has('once') && existsSync(target),
|
|
11527
12780
|
timestamp: new Date().toISOString(),
|
|
12781
|
+
// WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
|
|
12782
|
+
// supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
|
|
12783
|
+
// seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
|
|
12784
|
+
// ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
|
|
12785
|
+
runnerId: (options.get('runner') ?? '').trim() !== ''
|
|
12786
|
+
? (options.get('runner') ?? '').trim()
|
|
12787
|
+
: (() => { try { return hostname(); } catch { return null; } })(),
|
|
11528
12788
|
});
|
|
11529
12789
|
if (decision.line === null) return emit(decision);
|
|
11530
12790
|
|
|
@@ -12920,6 +14180,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
12920
14180
|
}
|
|
12921
14181
|
|
|
12922
14182
|
|
|
14183
|
+
interface ScoreReceiptFile {
|
|
14184
|
+
readonly path: string;
|
|
14185
|
+
readonly displayPath: string;
|
|
14186
|
+
readonly qeHash: string;
|
|
14187
|
+
}
|
|
14188
|
+
|
|
14189
|
+
function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
|
|
14190
|
+
const featuresDir = join(root, 'features');
|
|
14191
|
+
let features: Dirent[];
|
|
14192
|
+
try {
|
|
14193
|
+
features = readdirSync(featuresDir, { withFileTypes: true });
|
|
14194
|
+
} catch {
|
|
14195
|
+
return [];
|
|
14196
|
+
}
|
|
14197
|
+
const receipts: ScoreReceiptFile[] = [];
|
|
14198
|
+
for (const feature of features) {
|
|
14199
|
+
if (!feature.isDirectory()) continue;
|
|
14200
|
+
const stateDir = join(featuresDir, feature.name, '.fa-state');
|
|
14201
|
+
let entries: Dirent[];
|
|
14202
|
+
try {
|
|
14203
|
+
if (lstatSync(stateDir).isSymbolicLink()) continue;
|
|
14204
|
+
entries = readdirSync(stateDir, { withFileTypes: true });
|
|
14205
|
+
} catch {
|
|
14206
|
+
continue;
|
|
14207
|
+
}
|
|
14208
|
+
for (const entry of entries) {
|
|
14209
|
+
if (!entry.isFile()) continue;
|
|
14210
|
+
const match = /^score-(.+)\.json$/.exec(entry.name);
|
|
14211
|
+
if (match === null || match[1] === undefined || match[1] === '') continue;
|
|
14212
|
+
const path = join(stateDir, entry.name);
|
|
14213
|
+
receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
|
|
14214
|
+
}
|
|
14215
|
+
}
|
|
14216
|
+
return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
|
|
14217
|
+
}
|
|
14218
|
+
|
|
14219
|
+
function scoreAggregateChainLine(text: string): {
|
|
14220
|
+
readonly line: string;
|
|
14221
|
+
readonly verification: ReturnType<typeof verifyEventChainText> | null;
|
|
14222
|
+
readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
|
|
14223
|
+
} {
|
|
14224
|
+
if (text === '') {
|
|
14225
|
+
return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
|
|
14226
|
+
}
|
|
14227
|
+
const verification = verifyEventChainText(text);
|
|
14228
|
+
const defectAges = classifyChainDefects(verification, verification.lines);
|
|
14229
|
+
const kinds = new Map<string, number>();
|
|
14230
|
+
for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
|
|
14231
|
+
const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
|
|
14232
|
+
const line =
|
|
14233
|
+
`chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
|
|
14234
|
+
`${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
|
|
14235
|
+
`in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
|
|
14236
|
+
(kindText === '' ? '' : ` · ${kindText}`) +
|
|
14237
|
+
` — ${verification.scope}`;
|
|
14238
|
+
return { line, verification, defectAges };
|
|
14239
|
+
}
|
|
14240
|
+
|
|
14241
|
+
function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
14242
|
+
const json = flags.has('json');
|
|
14243
|
+
if (options.has('slug')) {
|
|
14244
|
+
write(json
|
|
14245
|
+
? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
|
|
14246
|
+
: 'dz score: --all and --slug are mutually exclusive');
|
|
14247
|
+
return 1;
|
|
14248
|
+
}
|
|
14249
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
14250
|
+
const receiptFiles = scoreReceiptFiles(root);
|
|
14251
|
+
if (receiptFiles.length === 0) {
|
|
14252
|
+
const report = buildScoreAggregateReport([], [], 0);
|
|
14253
|
+
const chain = scoreAggregateChainLine('');
|
|
14254
|
+
if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
|
|
14255
|
+
else {
|
|
14256
|
+
write(renderScoreAggregateReport(report));
|
|
14257
|
+
write(chain.line);
|
|
14258
|
+
}
|
|
14259
|
+
return 0;
|
|
14260
|
+
}
|
|
14261
|
+
|
|
14262
|
+
const ts = new Date().toISOString();
|
|
14263
|
+
const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
|
|
14264
|
+
const unreadableReceipts: string[] = [];
|
|
14265
|
+
for (const receipt of receiptFiles) {
|
|
14266
|
+
try {
|
|
14267
|
+
rows.push(scoreReceiptToAggregateRow({
|
|
14268
|
+
content: readFileSync(receipt.path, 'utf8'),
|
|
14269
|
+
qeHash: receipt.qeHash,
|
|
14270
|
+
ts,
|
|
14271
|
+
}));
|
|
14272
|
+
} catch {
|
|
14273
|
+
unreadableReceipts.push(receipt.displayPath);
|
|
14274
|
+
}
|
|
14275
|
+
}
|
|
14276
|
+
|
|
14277
|
+
const storeDir = join(root, '.dz', 'feature-adr');
|
|
14278
|
+
const aggregatePath = join(storeDir, 'scorecards.jsonl');
|
|
14279
|
+
let finalText = '';
|
|
14280
|
+
let finalRows = rows;
|
|
14281
|
+
let appended = 0;
|
|
14282
|
+
let storeError: string | null = null;
|
|
14283
|
+
try {
|
|
14284
|
+
const result = withNamedLockSync(storeDir, 'scorecards', () => {
|
|
14285
|
+
let existingText = '';
|
|
14286
|
+
try {
|
|
14287
|
+
existingText = readFileSync(aggregatePath, 'utf8');
|
|
14288
|
+
} catch (error) {
|
|
14289
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
14290
|
+
}
|
|
14291
|
+
const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
|
|
14292
|
+
const appendText = appendChainedLines(fresh, readTailInfo(existingText));
|
|
14293
|
+
if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
|
|
14294
|
+
const settledText = existingText + appendText;
|
|
14295
|
+
return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
|
|
14296
|
+
});
|
|
14297
|
+
finalText = result.text;
|
|
14298
|
+
finalRows = result.rows;
|
|
14299
|
+
appended = result.appended;
|
|
14300
|
+
} catch (error) {
|
|
14301
|
+
storeError = error instanceof Error ? error.message : String(error);
|
|
14302
|
+
}
|
|
14303
|
+
|
|
14304
|
+
const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
|
|
14305
|
+
const chain = scoreAggregateChainLine(finalText);
|
|
14306
|
+
if (json) {
|
|
14307
|
+
write(JSON.stringify({
|
|
14308
|
+
...report,
|
|
14309
|
+
aggregatePath: '.dz/feature-adr/scorecards.jsonl',
|
|
14310
|
+
chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
|
|
14311
|
+
storeError,
|
|
14312
|
+
exitCode: 0,
|
|
14313
|
+
}, null, 2));
|
|
14314
|
+
} else {
|
|
14315
|
+
write(renderScoreAggregateReport(report));
|
|
14316
|
+
write(chain.line);
|
|
14317
|
+
if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
|
|
14318
|
+
}
|
|
14319
|
+
return 0;
|
|
14320
|
+
}
|
|
14321
|
+
|
|
12923
14322
|
function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
12924
14323
|
const json = flags.has('json');
|
|
12925
14324
|
if (flags.has('help')) {
|
|
@@ -12927,13 +14326,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
12927
14326
|
if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
|
|
12928
14327
|
else {
|
|
12929
14328
|
write(usage);
|
|
14329
|
+
write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
|
|
12930
14330
|
write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
|
|
12931
14331
|
write(' descriptive-only, never a gate: a low score exits 0');
|
|
12932
14332
|
}
|
|
12933
14333
|
return 0;
|
|
12934
14334
|
}
|
|
12935
14335
|
for (const flag of flags) {
|
|
12936
|
-
if (!new Set(['json', 'help']).has(flag)) {
|
|
14336
|
+
if (!new Set(['json', 'help', 'all']).has(flag)) {
|
|
12937
14337
|
write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
|
|
12938
14338
|
return 1;
|
|
12939
14339
|
}
|
|
@@ -12945,6 +14345,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
12945
14345
|
return 1;
|
|
12946
14346
|
}
|
|
12947
14347
|
}
|
|
14348
|
+
if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
|
|
12948
14349
|
const slug = options.get('slug') ?? '';
|
|
12949
14350
|
// The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
|
|
12950
14351
|
// silently aggregated the entire features/ tree as one "run" (Codex QE #2).
|
|
@@ -13058,6 +14459,160 @@ function readOptionalText(path: string): string {
|
|
|
13058
14459
|
}
|
|
13059
14460
|
}
|
|
13060
14461
|
|
|
14462
|
+
function localEvidenceReadReason(resource: 'promotion-journal' | 'guard-audit', error: unknown): string {
|
|
14463
|
+
const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : '';
|
|
14464
|
+
return code === 'ENOENT' ? `${resource}-missing` : `${resource}-unreadable`;
|
|
14465
|
+
}
|
|
14466
|
+
|
|
14467
|
+
interface PromotionEvidenceRead {
|
|
14468
|
+
readonly source: FunnelEvidenceSource<PromotionRunEvidence>;
|
|
14469
|
+
readonly acceptances: readonly PromotionAcceptanceEvidence[];
|
|
14470
|
+
readonly truncatedPeriods: readonly string[];
|
|
14471
|
+
readonly acceptanceHistoryComplete: boolean;
|
|
14472
|
+
}
|
|
14473
|
+
|
|
14474
|
+
function unavailablePromotionEvidence(reason: string): PromotionEvidenceRead {
|
|
14475
|
+
return { source: { status: 'not-measured', reason }, acceptances: [], truncatedPeriods: [], acceptanceHistoryComplete: false };
|
|
14476
|
+
}
|
|
14477
|
+
|
|
14478
|
+
function readPromotionRunEvidence(root: string): PromotionEvidenceRead {
|
|
14479
|
+
let raw: unknown;
|
|
14480
|
+
try {
|
|
14481
|
+
raw = JSON.parse(readFileSync(join(root, PROMOTION_STATE_FILE), 'utf-8')) as unknown;
|
|
14482
|
+
} catch (error) {
|
|
14483
|
+
const reason = error instanceof SyntaxError
|
|
14484
|
+
? 'promotion-journal-malformed'
|
|
14485
|
+
: localEvidenceReadReason('promotion-journal', error);
|
|
14486
|
+
return unavailablePromotionEvidence(reason);
|
|
14487
|
+
}
|
|
14488
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
14489
|
+
return unavailablePromotionEvidence('promotion-journal-malformed');
|
|
14490
|
+
}
|
|
14491
|
+
const record = raw as Record<string, unknown>;
|
|
14492
|
+
if (record['version'] !== 1 || !Object.hasOwn(record, 'runs')) {
|
|
14493
|
+
return unavailablePromotionEvidence(
|
|
14494
|
+
record['version'] === 1 ? 'promotion-history-not-recorded' : 'promotion-journal-malformed',
|
|
14495
|
+
);
|
|
14496
|
+
}
|
|
14497
|
+
if (!Array.isArray(record['runs'])) {
|
|
14498
|
+
return unavailablePromotionEvidence('promotion-history-malformed');
|
|
14499
|
+
}
|
|
14500
|
+
const state = normalizePromotionState(raw);
|
|
14501
|
+
if (state.runs === undefined || state.runs.length !== record['runs'].length) {
|
|
14502
|
+
return unavailablePromotionEvidence('promotion-history-malformed');
|
|
14503
|
+
}
|
|
14504
|
+
if (
|
|
14505
|
+
(Object.hasOwn(record, 'acceptances') && (
|
|
14506
|
+
!Array.isArray(record['acceptances']) || state.acceptances?.length !== record['acceptances'].length
|
|
14507
|
+
)) ||
|
|
14508
|
+
(Object.hasOwn(record, 'truncatedRunPeriods') && (
|
|
14509
|
+
!Array.isArray(record['truncatedRunPeriods']) || state.truncatedRunPeriods?.length !== record['truncatedRunPeriods'].length
|
|
14510
|
+
)) ||
|
|
14511
|
+
(Object.hasOwn(record, 'acceptanceHistoryComplete') && typeof record['acceptanceHistoryComplete'] !== 'boolean')
|
|
14512
|
+
) {
|
|
14513
|
+
return unavailablePromotionEvidence('promotion-history-malformed');
|
|
14514
|
+
}
|
|
14515
|
+
const derivedAcceptances = state.runs.flatMap((run) => run.candidates
|
|
14516
|
+
.filter((candidate) => candidate.verdict === 'promote' && candidate.ruleContentAnchor !== null)
|
|
14517
|
+
.map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: run.ts })));
|
|
14518
|
+
const claimedAcceptanceComplete = state.acceptanceHistoryComplete ??
|
|
14519
|
+
((state.truncatedRunPeriods?.length ?? 0) === 0 && state.runs.every((run) => run.complete === true));
|
|
14520
|
+
if (claimedAcceptanceComplete && state.acceptances !== undefined && derivedAcceptances.some((derived) =>
|
|
14521
|
+
!state.acceptances!.some((stored) =>
|
|
14522
|
+
stored.ruleContentAnchor === derived.ruleContentAnchor &&
|
|
14523
|
+
Date.parse(stored.acceptedTs) <= Date.parse(derived.acceptedTs),
|
|
14524
|
+
),
|
|
14525
|
+
)) {
|
|
14526
|
+
return unavailablePromotionEvidence('promotion-history-malformed');
|
|
14527
|
+
}
|
|
14528
|
+
const acceptancesByAnchor = new Map<string, PromotionAcceptanceEvidence>();
|
|
14529
|
+
for (const acceptance of [...(state.acceptances ?? []), ...derivedAcceptances]) {
|
|
14530
|
+
const prior = acceptancesByAnchor.get(acceptance.ruleContentAnchor);
|
|
14531
|
+
if (prior === undefined || Date.parse(acceptance.acceptedTs) < Date.parse(prior.acceptedTs)) {
|
|
14532
|
+
acceptancesByAnchor.set(acceptance.ruleContentAnchor, acceptance);
|
|
14533
|
+
}
|
|
14534
|
+
}
|
|
14535
|
+
return {
|
|
14536
|
+
source: { status: 'measured', rows: state.runs },
|
|
14537
|
+
acceptances: [...acceptancesByAnchor.values()],
|
|
14538
|
+
truncatedPeriods: state.truncatedRunPeriods ?? [],
|
|
14539
|
+
acceptanceHistoryComplete: claimedAcceptanceComplete,
|
|
14540
|
+
};
|
|
14541
|
+
}
|
|
14542
|
+
|
|
14543
|
+
interface GuardAuditEvidenceRead {
|
|
14544
|
+
readonly source: FunnelEvidenceSource<GuardEvent>;
|
|
14545
|
+
readonly rows: readonly GuardEvent[];
|
|
14546
|
+
readonly text: string | null;
|
|
14547
|
+
}
|
|
14548
|
+
|
|
14549
|
+
function readGuardAuditEvidence(root: string): GuardAuditEvidenceRead {
|
|
14550
|
+
let text: string;
|
|
14551
|
+
try {
|
|
14552
|
+
text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
|
|
14553
|
+
} catch (error) {
|
|
14554
|
+
return {
|
|
14555
|
+
source: { status: 'not-measured', reason: localEvidenceReadReason('guard-audit', error) },
|
|
14556
|
+
rows: [],
|
|
14557
|
+
text: null,
|
|
14558
|
+
};
|
|
14559
|
+
}
|
|
14560
|
+
const rows: GuardEvent[] = [];
|
|
14561
|
+
let malformed = false;
|
|
14562
|
+
for (const line of text.split('\n')) {
|
|
14563
|
+
if (line.trim() === '') continue;
|
|
14564
|
+
try {
|
|
14565
|
+
const raw = JSON.parse(line) as Record<string, unknown>;
|
|
14566
|
+
if (
|
|
14567
|
+
!isOffsetIsoTimestamp(raw['ts']) ||
|
|
14568
|
+
!['publish', 'teach', 'consolidate', 'reindex'].includes(String(raw['op'])) ||
|
|
14569
|
+
!['pass', 'warn', 'block'].includes(String(raw['verdict']))
|
|
14570
|
+
) {
|
|
14571
|
+
malformed = true;
|
|
14572
|
+
continue;
|
|
14573
|
+
}
|
|
14574
|
+
const violations: { rule: string; contentAnchor?: string }[] = [];
|
|
14575
|
+
if (!Array.isArray(raw['violations'])) {
|
|
14576
|
+
malformed = true;
|
|
14577
|
+
continue;
|
|
14578
|
+
}
|
|
14579
|
+
for (const item of raw['violations']) {
|
|
14580
|
+
const rule = item && typeof item === 'object' ? (item as { rule?: unknown }).rule : undefined;
|
|
14581
|
+
if (typeof rule !== 'string' || rule === '' || rule.length > 200) {
|
|
14582
|
+
malformed = true;
|
|
14583
|
+
continue;
|
|
14584
|
+
}
|
|
14585
|
+
const anchor = (item as { contentAnchor?: unknown }).contentAnchor;
|
|
14586
|
+
if (anchor !== undefined && !isLessonRuleContentAnchor(anchor)) {
|
|
14587
|
+
malformed = true;
|
|
14588
|
+
continue;
|
|
14589
|
+
}
|
|
14590
|
+
violations.push({ rule, ...(typeof anchor === 'string' ? { contentAnchor: anchor } : {}) });
|
|
14591
|
+
}
|
|
14592
|
+
const verdict = raw['verdict'] as 'pass' | 'warn' | 'block';
|
|
14593
|
+
if ((verdict === 'pass') !== (violations.length === 0)) malformed = true;
|
|
14594
|
+
rows.push({
|
|
14595
|
+
ts: raw['ts'],
|
|
14596
|
+
op: raw['op'] as 'publish' | 'teach' | 'consolidate' | 'reindex',
|
|
14597
|
+
verdict,
|
|
14598
|
+
rules: violations.map((item) => item.rule),
|
|
14599
|
+
violations,
|
|
14600
|
+
});
|
|
14601
|
+
} catch {
|
|
14602
|
+
malformed = true;
|
|
14603
|
+
}
|
|
14604
|
+
}
|
|
14605
|
+
return {
|
|
14606
|
+
source: malformed
|
|
14607
|
+
? { status: 'not-measured', reason: 'guard-audit-malformed' }
|
|
14608
|
+
: !verifyEventChainText(text).ok
|
|
14609
|
+
? { status: 'not-measured', reason: 'guard-audit-chain-corrupt' }
|
|
14610
|
+
: { status: 'measured', rows },
|
|
14611
|
+
rows,
|
|
14612
|
+
text,
|
|
14613
|
+
};
|
|
14614
|
+
}
|
|
14615
|
+
|
|
13061
14616
|
function deadwoodAllowlistText(): string {
|
|
13062
14617
|
const require = createRequire(import.meta.url);
|
|
13063
14618
|
const corePackage = require.resolve('@dzhechkov/harness-core/package.json');
|
|
@@ -13143,7 +14698,7 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
13143
14698
|
const json = flags.has('json');
|
|
13144
14699
|
if (flags.has('help')) {
|
|
13145
14700
|
write('dz compounding [--project <dir>] [--json] — honest learning-loop payoff report');
|
|
13146
|
-
write(' pool payoff
|
|
14701
|
+
write(' pool payoff · guard trajectory · replay readiness · instrumentation · monthly eligible→attempted→accepted→executions funnel');
|
|
13147
14702
|
return 0;
|
|
13148
14703
|
}
|
|
13149
14704
|
for (const flag of flags) {
|
|
@@ -13175,40 +14730,24 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
13175
14730
|
// apply-leg usage events (read records only; aggregate rows carry no query by construction)
|
|
13176
14731
|
const usage = readRecallUsageEvents(root);
|
|
13177
14732
|
|
|
13178
|
-
|
|
13179
|
-
const
|
|
13180
|
-
|
|
13181
|
-
const text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
|
|
13182
|
-
for (const line of text.split('\n')) {
|
|
13183
|
-
if (line.trim() === '') continue;
|
|
13184
|
-
try {
|
|
13185
|
-
const o = JSON.parse(line) as { ts?: unknown; verdict?: unknown; violations?: { rule?: unknown }[] };
|
|
13186
|
-
if (typeof o.ts === 'string' && typeof o.verdict === 'string') {
|
|
13187
|
-
guard.push({
|
|
13188
|
-
ts: o.ts,
|
|
13189
|
-
verdict: o.verdict,
|
|
13190
|
-
rules: Array.isArray(o.violations) ? o.violations.map((v) => (typeof v?.rule === 'string' ? v.rule : '')).filter(Boolean) : [],
|
|
13191
|
-
});
|
|
13192
|
-
}
|
|
13193
|
-
} catch {
|
|
13194
|
-
/* skip */
|
|
13195
|
-
}
|
|
13196
|
-
}
|
|
13197
|
-
} catch {
|
|
13198
|
-
/* no audit yet */
|
|
13199
|
-
}
|
|
14733
|
+
const promotionEvidence = readPromotionRunEvidence(root);
|
|
14734
|
+
const guardEvidence = readGuardAuditEvidence(root);
|
|
14735
|
+
const guard = [...guardEvidence.rows];
|
|
13200
14736
|
|
|
13201
14737
|
// The evidence logs themselves, verbatim: the report verifies their hash chains (feature
|
|
13202
14738
|
// event-chain). Handing over the TEXT rather than a pre-computed verdict keeps one definition of
|
|
13203
14739
|
// "the chain is intact" — a second copy here is how a gate and its report start disagreeing.
|
|
13204
14740
|
const evidenceLogs: { log: string; text: string }[] = [];
|
|
13205
|
-
for (const rel of ['.dz/recall-usage.jsonl'
|
|
14741
|
+
for (const rel of ['.dz/recall-usage.jsonl']) {
|
|
13206
14742
|
try {
|
|
13207
14743
|
evidenceLogs.push({ log: rel, text: readFileSync(join(root, ...rel.split('/')), 'utf-8') });
|
|
13208
14744
|
} catch {
|
|
13209
14745
|
/* absent log — reported by its own gate above, not invented here */
|
|
13210
14746
|
}
|
|
13211
14747
|
}
|
|
14748
|
+
if (guardEvidence.text !== null) {
|
|
14749
|
+
evidenceLogs.push({ log: '.dz/guard-audit.jsonl', text: guardEvidence.text });
|
|
14750
|
+
}
|
|
13212
14751
|
|
|
13213
14752
|
const now = new Date();
|
|
13214
14753
|
const report = assembleCompoundingReport({
|
|
@@ -13218,6 +14757,13 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
13218
14757
|
nowTs: now.toISOString(),
|
|
13219
14758
|
evidenceLogs,
|
|
13220
14759
|
cmdUsageDepthDays: cmdUsageDepthDays(root, now),
|
|
14760
|
+
lessonToRule: {
|
|
14761
|
+
promotionRuns: promotionEvidence.source,
|
|
14762
|
+
guardAudits: guardEvidence.source,
|
|
14763
|
+
promotionAcceptances: promotionEvidence.acceptances,
|
|
14764
|
+
truncatedPromotionPeriods: promotionEvidence.truncatedPeriods,
|
|
14765
|
+
acceptanceHistoryComplete: promotionEvidence.acceptanceHistoryComplete,
|
|
14766
|
+
},
|
|
13221
14767
|
});
|
|
13222
14768
|
// lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
|
|
13223
14769
|
// dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
|
|
@@ -14580,17 +16126,19 @@ function cmdStats(cwd: string, write: Write): number {
|
|
|
14580
16126
|
}
|
|
14581
16127
|
const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
14582
16128
|
const packages = dirs.length;
|
|
14583
|
-
|
|
14584
|
-
|
|
14585
|
-
|
|
14586
|
-
|
|
14587
|
-
|
|
14588
|
-
|
|
14589
|
-
|
|
14590
|
-
|
|
14591
|
-
|
|
14592
|
-
|
|
14593
|
-
|
|
16129
|
+
// Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
|
|
16130
|
+
// it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
|
|
16131
|
+
// keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
|
|
16132
|
+
// layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
|
|
16133
|
+
// quantity, each unable to refute the other because neither knew the other existed.
|
|
16134
|
+
//
|
|
16135
|
+
// The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
|
|
16136
|
+
// Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
|
|
16137
|
+
// The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
|
|
16138
|
+
// third implementation of the same count, which is the very defect being fixed.
|
|
16139
|
+
const registry = buildRegistry(cwd);
|
|
16140
|
+
const totalSkills = registry.totalSkills;
|
|
16141
|
+
const skillPacks = registry.totalPacks;
|
|
14594
16142
|
const targets = TARGET_NAMES.length;
|
|
14595
16143
|
const presets = PRESET_NAMES.length;
|
|
14596
16144
|
write(`dz stats — DZ Harness Hub`);
|
|
@@ -15063,7 +16611,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15063
16611
|
return version === 'unknown' ? 1 : 0;
|
|
15064
16612
|
}
|
|
15065
16613
|
|
|
15066
|
-
|
|
16614
|
+
// `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
|
|
16615
|
+
// landed it fell through to the switch and still printed usage; afterwards it would have died
|
|
16616
|
+
// with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
|
|
16617
|
+
// `-v` above — an argv-level flag, resolved before command dispatch.
|
|
16618
|
+
if (argv[0] === '-h') {
|
|
16619
|
+
write(USAGE);
|
|
16620
|
+
return 0;
|
|
16621
|
+
}
|
|
16622
|
+
// A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
|
|
16623
|
+
// FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
|
|
16624
|
+
// with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
|
|
16625
|
+
// warn-don't-refuse decision above is measured and still stands: with a command present, an
|
|
16626
|
+
// unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
|
|
16627
|
+
// invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
|
|
16628
|
+
if (command === '') {
|
|
16629
|
+
const strayNames = unknownFlagNotice(
|
|
16630
|
+
[...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
|
|
16631
|
+
KNOWN_CLI_FLAGS,
|
|
16632
|
+
).map((n) => n.name);
|
|
16633
|
+
if (strayNames.length > 0) {
|
|
16634
|
+
writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
|
|
16635
|
+
return 2;
|
|
16636
|
+
}
|
|
16637
|
+
}
|
|
16638
|
+
if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
|
|
15067
16639
|
write(USAGE);
|
|
15068
16640
|
return 0;
|
|
15069
16641
|
}
|
|
@@ -15110,11 +16682,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15110
16682
|
case 'bundle':
|
|
15111
16683
|
return cmdBundle(options, flags, cwd, write);
|
|
15112
16684
|
case 'teach':
|
|
15113
|
-
return await cmdTeach(
|
|
16685
|
+
return await cmdTeach(
|
|
16686
|
+
options,
|
|
16687
|
+
flags,
|
|
16688
|
+
cwd,
|
|
16689
|
+
write,
|
|
16690
|
+
writeErr,
|
|
16691
|
+
io.interactive ?? process.stdout.isTTY === true,
|
|
16692
|
+
io.teachGuardRunner ?? teachGuard,
|
|
16693
|
+
io.teachReinforceRunner ?? runTeachGuardReinforcement,
|
|
16694
|
+
);
|
|
15114
16695
|
case 'consolidate':
|
|
15115
16696
|
return await cmdConsolidate(options, flags, cwd, write);
|
|
15116
16697
|
case 'recall':
|
|
15117
|
-
return await cmdRecall(options, flags, cwd, write);
|
|
16698
|
+
return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
|
|
15118
16699
|
case 'vector':
|
|
15119
16700
|
return await cmdVector(options, flags, cwd, write);
|
|
15120
16701
|
case 'brain':
|
|
@@ -15123,6 +16704,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15123
16704
|
return cmdStatusline(options, flags, cwd, write, readStdin);
|
|
15124
16705
|
case 'usage':
|
|
15125
16706
|
return cmdUsage(options, optionLists, flags, cwd, write);
|
|
16707
|
+
case 'chain':
|
|
16708
|
+
return cmdChain(options, flags, cwd, write);
|
|
15126
16709
|
case 'claim-check':
|
|
15127
16710
|
return cmdClaimCheck(options, optionLists, flags, cwd, write);
|
|
15128
16711
|
case 'lint':
|
|
@@ -15144,7 +16727,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15144
16727
|
case 'diff':
|
|
15145
16728
|
return cmdDiff(options, cwd, write);
|
|
15146
16729
|
case 'recommend':
|
|
15147
|
-
return cmdRecommend(options, cwd, write);
|
|
16730
|
+
return cmdRecommend(options, flags, cwd, write);
|
|
15148
16731
|
case 'upgrade':
|
|
15149
16732
|
return cmdUpgrade(options, flags, cwd, write, writeErr);
|
|
15150
16733
|
case 'auto-canonicalize':
|
|
@@ -15167,6 +16750,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15167
16750
|
return cmdDriftCheck(options, flags, cwd, write);
|
|
15168
16751
|
case 'hooks-sync':
|
|
15169
16752
|
return cmdHooksSync(options, flags, cwd, write, writeErr);
|
|
16753
|
+
case 'integrations-verify':
|
|
16754
|
+
return cmdIntegrationsVerify(options, flags, cwd, write, writeErr);
|
|
15170
16755
|
case 'agents-sync':
|
|
15171
16756
|
return cmdAgentsSync(options, flags, cwd, write, writeErr);
|
|
15172
16757
|
case 'sync-canonical':
|
|
@@ -15191,8 +16776,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15191
16776
|
return cmdChallenge(options, flags, cwd, write);
|
|
15192
16777
|
case 'discrimination-check':
|
|
15193
16778
|
return cmdDiscriminationCheck(options, flags, cwd, write);
|
|
15194
|
-
case 'mutation-gate':
|
|
15195
|
-
|
|
16779
|
+
case 'mutation-gate': {
|
|
16780
|
+
try {
|
|
16781
|
+
return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
|
|
16782
|
+
} catch (error) {
|
|
16783
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
16784
|
+
const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
|
|
16785
|
+
if (flags.has('json')) {
|
|
16786
|
+
write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
|
|
16787
|
+
} else {
|
|
16788
|
+
write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
|
|
16789
|
+
}
|
|
16790
|
+
return 1;
|
|
16791
|
+
}
|
|
16792
|
+
}
|
|
15196
16793
|
case 'delivery-check':
|
|
15197
16794
|
return cmdDeliveryCheck(options, flags, cwd, write);
|
|
15198
16795
|
case 'skills-verify':
|
|
@@ -15246,9 +16843,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
15246
16843
|
case 'import-ecc':
|
|
15247
16844
|
return await cmdImportEcc(options, flags, cwd, write);
|
|
15248
16845
|
default:
|
|
15249
|
-
|
|
15250
|
-
|
|
15251
|
-
return 1;
|
|
16846
|
+
writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
|
|
16847
|
+
return 2;
|
|
15252
16848
|
}
|
|
15253
16849
|
} catch (error) {
|
|
15254
16850
|
// stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
|