@dzhechkov/harness-cli 0.8.15 → 0.8.17

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/src/cli.ts CHANGED
@@ -17,6 +17,8 @@ import { createHash, randomBytes } from 'node:crypto';
17
17
  import { homedir, hostname, tmpdir } from 'node:os';
18
18
  import { createRequire } from 'node:module';
19
19
  import { isDeepStrictEqual } from 'node:util';
20
+ import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed, type JournalKind, type JournalIo } from '@dzhechkov/harness-core';
21
+ import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply, type WorktreeFact, type RunEvent } from '@dzhechkov/harness-core';
20
22
 
21
23
  import {
22
24
  createSkill,
@@ -204,6 +206,7 @@ import {
204
206
  checkSwarmBrief,
205
207
  visibleText,
206
208
  loadStoreRecords,
209
+ findExactLesson,
207
210
  recordToPattern,
208
211
  bundleSkills,
209
212
  brainHome,
@@ -537,6 +540,7 @@ import {
537
540
  amendmentsMissingFromPlan,
538
541
  AMENDMENT_VACUITY_NOTE,
539
542
  extractContractChecklist,
543
+ readFeatureTier,
540
544
  parseContractVerdictReport,
541
545
  verifyContractVerdicts,
542
546
  decideSignableSet,
@@ -629,12 +633,15 @@ export const DZ_COMMANDS: readonly string[] = [
629
633
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
630
634
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
631
635
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
632
- 'name-check', 'brief-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
636
+ 'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
633
637
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
634
638
  'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
635
639
  ];
636
640
 
637
641
  const USAGE = `dz - DZ cross-platform harness CLI
642
+ dz runs [--settle] [--stall-minutes N] [--json] [--project <dir>] [--probe-pid <pid>] (run registry: live, stalled, orphaned, inconclusive or finished; PID probe prints true|false|unknown)
643
+ dz runs-record --event started|heartbeat|finished [--run-id <id>] [--kind <kind>] [--slug <slug>] [--pid <pid|host>] [--parent-run-id <id>] [--outcome <outcome>] [--reason <text>] [--project <dir>] [--json] (append run event; started can allocate a run ID; host resolves the Claude ancestor PID)
644
+ dz runs-clean [--apply] [--retention-days N] [--project <dir>] [--json] (plan cleanup of old clean merged worktrees and completed/dead registry events; apply explicitly)
638
645
 
639
646
  Usage:
640
647
  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)
@@ -689,6 +696,7 @@ Usage:
689
696
  dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
690
697
  dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N / AM-CP-N row must resolve to a test found INSIDE the file the row names (the challenge-panel prefix is part of the id: AM-CP-1 is never AM-1); the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass, UNLESS the plan explicitly declares \"None\"/\"нет\", which is an answer and reports skip. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
691
698
  dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
699
+ dz journal add --kind decision|verdict|run|error|block "<text>" [--ref <trace>] [--at <ISO>] [--quote <file>] [--commit-quote]; dz journal show [--day|--week] [--at <date>] [--kind <kind>] [--json] (UTC day files, witnessed append; quotes stay local unless explicitly staged)
692
700
  dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
693
701
  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)
694
702
  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)
@@ -730,7 +738,7 @@ Usage:
730
738
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
731
739
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
732
740
  dz store-guard [--status|--reset] [--yes] [--project <dir>] (show the monotonic external high-water mark; --reset is the only lowering path and requires confirmation or --yes)
733
- dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; a plain "Step <n>" label that goes BACKWARDS against a slot younger than 90 min is absorbed as a stale duplicate — prefix the label with ⛔ or ⏸ to record a legitimate regression)
741
+ dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--run-id <id>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; the monotone guard absorbs a backwards plain "Step <n>" only within the same non-empty run id, while an absent/empty id retains legacy fresh-slot behavior — prefix the label with ⛔ or ⏸ to record a legitimate regression)
734
742
  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)
735
743
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
736
744
  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)
@@ -840,6 +848,8 @@ export interface CliIo {
840
848
  readonly installRunner?: (command: string, cwd: string) => void;
841
849
  /** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
842
850
  readonly mutationGateRunner?: MutationGateRunner;
851
+ /** Read-back fault seam; production uses the real filesystem. */
852
+ readonly journalIo?: JournalIo;
843
853
  }
844
854
 
845
855
  /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
@@ -2730,12 +2740,14 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2730
2740
  }
2731
2741
 
2732
2742
  const mode = options.get('mode');
2743
+ const runId = options.get('run-id')?.trim();
2733
2744
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2734
2745
  const outcome = writeFeatureAdrStateDetailed(projectRoot, {
2735
2746
  kind: kindRaw, slug, step, recalled, stored,
2736
2747
  ...(reinforced > 0 ? { reinforced } : {}),
2737
2748
  ...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
2738
2749
  ...(tier !== undefined ? { tier } : {}),
2750
+ ...(runId !== undefined && runId !== '' ? { runId } : {}),
2739
2751
  });
2740
2752
  const state = outcome.state;
2741
2753
 
@@ -2955,23 +2967,32 @@ function cmdStatusline(
2955
2967
  return 0;
2956
2968
  }
2957
2969
 
2970
+ // ТЕКСТ ПАНЕЛИ — ПО-АНГЛИЙСКИ по просьбе владельца 2026-09-09: строка статуса узкая,
2971
+ // английские слова в ней короче русских при той же ясности. Комментарии остаются русскими.
2958
2972
  const breakdown = data.patternBreakdown;
2959
2973
  let line = breakdown === undefined
2960
2974
  ? `🎓 dz: ${data.patterns} patterns`
2961
- : `🎓 dz: ${data.patterns} (${breakdown.active} актив${breakdown.quarantined > 0
2962
- ? ` · ${breakdown.quarantined} карантин${breakdown.attention ? ' ⚠' : ''}`
2963
- : ''})${breakdown.tierDelta !== undefined ? ` ⚠ тиры Δ${breakdown.tierDelta}` : ''}`;
2975
+ : `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
2976
+ ? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
2977
+ : ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
2978
+ // Показатель зеркала печатается и здесь: `dz statusline` — та же панель, и показатель,
2979
+ // живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
2980
+ if (data.patternMirror?.state === 'unavailable') line += ' (mirror unreadable ⚠)';
2981
+ else if (data.patternMirror?.state === 'different') line += ` (mirror ${data.patternMirror.vector} ⚠)`;
2964
2982
  if (data.storeHealth?.verdict === 'collapsed') {
2965
- line += ` ⛔ ОБВАЛ: было ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2983
+ line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2966
2984
  } else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
2967
- line += ` ⛔ СТОР ПУСТ: было ${data.storeHealth.previousMax ?? '?'} · восстановить из снимков ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
2985
+ line += ` ⛔ STORE EMPTY: was ${data.storeHealth.previousMax ?? '?'} · restore from snapshots ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
2968
2986
  } else if (data.storeHealth?.verdict === 'unreadable') {
2969
- line += ` ⛔ СТОР НЕЧИТАЕМ${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2987
+ line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2970
2988
  ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
2971
2989
  } else if (data.storeHealth?.verdict === 'source-changed') {
2972
- line += ' ⚠ смена источника хранения';
2990
+ line += ' ⚠ store source changed';
2973
2991
  }
2974
- line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2992
+ // Рядом с числом источников объём каждого через «/» (просьба владельца 2026-09-09):
2993
+ // четыре источника по 300 единиц и четыре по три — разные корпуса, а число одно и то же.
2994
+ const ku = data.brainKuCounts.length > 0 ? ` (${data.brainKuCounts.join('/')})` : '';
2995
+ line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources${ku}`;
2975
2996
  const branch = statuslineGitBranch(projectRoot);
2976
2997
  if (branch !== undefined) line += ` · ⎇ ${branch}`;
2977
2998
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
@@ -3893,9 +3914,15 @@ async function runTeachGuardReinforcement(
3893
3914
  projectRoot: string,
3894
3915
  dzId: string,
3895
3916
  reward: number,
3917
+ preserveQuarantine = false,
3896
3918
  ): Promise<{ readonly flushed: number }> {
3897
3919
  const backend = resolveLearningBackend(projectRoot);
3898
- backend.addSample({ dzId, kind: 'reinforce', reward, ts: new Date().toISOString() });
3920
+ backend.addSample({
3921
+ dzId,
3922
+ kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
3923
+ reward,
3924
+ ts: new Date().toISOString(),
3925
+ });
3899
3926
  return backend.train();
3900
3927
  }
3901
3928
 
@@ -3903,7 +3930,7 @@ async function cmdTeach(
3903
3930
  options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3904
3931
  writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3905
3932
  guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3906
- reinforceRunner: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3933
+ reinforceRunner: (projectRoot: string, dzId: string, reward: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3907
3934
  ): Promise<number> {
3908
3935
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3909
3936
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
@@ -4141,6 +4168,16 @@ async function cmdTeach(
4141
4168
  }
4142
4169
  }
4143
4170
 
4171
+ if (!flags.has('guard')) {
4172
+ const exact = findExactLesson(loadStoreRecords(storeRoot), pattern, domain);
4173
+ if (exact !== null) {
4174
+ await reinforceRunner(storeRoot, exact.id, Math.max(0, Math.min(1, reward)), exact.quarantined);
4175
+ write(`Reinforced existing: ${exact.id} (exact text)${exact.quarantined ? ' [quarantined]' : ''}`);
4176
+ write(` Total patterns: ${loadStorePatternsSync(storeRoot).length}`);
4177
+ return 0;
4178
+ }
4179
+ }
4180
+
4144
4181
  // Distill pattern into actionable rule (claude-smart inspired)
4145
4182
  // Convert "what happened" into "what to do next time"
4146
4183
  const isRule = pattern.toLowerCase().startsWith('always') || pattern.toLowerCase().startsWith('never') ||
@@ -7090,10 +7127,18 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7090
7127
  // A one-line preview inline; on error the FULL captured npm error prints below (truncating a publish
7091
7128
  // failure to 60 chars is how a release stays undiagnosable — the reason lives past char 60).
7092
7129
  const detail = pkg.error && pkg.status !== 'error' ? ` (${pkg.error.slice(0, 60)})` : '';
7093
- write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} ${pkg.newVersion} ${pkg.status}${detail}`);
7130
+ const receipt = pkg.status === 'published' && pkg.registryProbes !== undefined
7131
+ ? ` (confirmed by registry after ${pkg.registryProbes} probes)`
7132
+ : '';
7133
+ write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
7094
7134
  if (pkg.status === 'error' && pkg.error) {
7095
7135
  for (const line of pkg.error.split('\n')) write(` ${line}`);
7096
7136
  }
7137
+ // The rollback branch cannot tell "publish failed" from "publish landed, registry never answered":
7138
+ // both restore package.json. The operator can — one registry query — so say what to check.
7139
+ if (pkg.status === 'error' && pkg.error?.startsWith('registry did not confirm')) {
7140
+ write(` ↳ disk bump rolled back; if \`npm view ${pkg.name}@${pkg.newVersion} version\` answers, the publish DID land — re-apply the bump by hand`);
7141
+ }
7097
7142
  // Surface warn-mode findings that did not block the publish.
7098
7143
  if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
7099
7144
  write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
@@ -11548,13 +11593,33 @@ function cmdMutationGate(
11548
11593
  }
11549
11594
 
11550
11595
  let entries: readonly MutationRegistryEntry[] = parsed.registry.entries;
11596
+ let entryResults: readonly MutationEntryResult[] = parsed.entryResults;
11551
11597
  const only = options.get('only');
11552
11598
  if (only !== undefined) {
11553
11599
  const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
11554
- const known = new Set(entries.map((e) => e.id));
11600
+ const known = new Set([
11601
+ ...entries.map((entry) => entry.id),
11602
+ ...entryResults.map((result) => result.id),
11603
+ ]);
11555
11604
  const unknown = ids.filter((id) => !known.has(id));
11556
11605
  if (unknown.length > 0) return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
11557
11606
  entries = entries.filter((e) => ids.includes(e.id));
11607
+ entryResults = entryResults.filter((result) => ids.includes(result.id));
11608
+ }
11609
+
11610
+ if (entries.length === 0) {
11611
+ const scope = only === undefined ? 'registry' : 'selected registry entries';
11612
+ const error = `${scope} has no runnable entries after validation — nothing can be run; the registry is unusable`;
11613
+ const summary = summarizeMutationResults(entryResults);
11614
+ if (json) {
11615
+ write(JSON.stringify({ error, registryPath, results: entryResults, summary, exitCode: 2 }, null, 2));
11616
+ } else {
11617
+ write(`dz mutation-gate: ${error}`);
11618
+ for (const result of entryResults) {
11619
+ write(` - ${result.id}: ${result.verdict} — ${result.detail}`);
11620
+ }
11621
+ }
11622
+ return 2;
11558
11623
  }
11559
11624
 
11560
11625
  const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
@@ -11590,7 +11655,7 @@ function cmdMutationGate(
11590
11655
  let gitTop: string | null = null;
11591
11656
  try { gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null; } catch { /* not in a git repo */ }
11592
11657
  let copyDir = join(scratchParent, 'pkg');
11593
- const results: MutationEntryResult[] = [];
11658
+ const results: MutationEntryResult[] = [...entryResults];
11594
11659
  const observations: MutationObservation[] = [];
11595
11660
  const warnings: string[] = [];
11596
11661
  const internalRetries: {
@@ -11723,8 +11788,8 @@ function cmdMutationGate(
11723
11788
  : undefined,
11724
11789
  );
11725
11790
  if (!baseline.ok) {
11726
- if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
11727
- write(renderMutationReport([], baseline, pkgDir));
11791
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
11792
+ write(renderMutationReport(results, baseline, pkgDir));
11728
11793
  return 1;
11729
11794
  }
11730
11795
 
@@ -13639,6 +13704,206 @@ function cmdLedgerBackfill(options: Map<string, string>, flags: Set<string>, cwd
13639
13704
  return 0;
13640
13705
  }
13641
13706
 
13707
+ const runRegistryIO = {
13708
+ append: (path: string, line: string): void => withNamedLockSync(dirname(path), 'run-registry-archive', () => appendFileSync(path, line, 'utf8')),
13709
+ read: (path: string): string => readFileSync(path, 'utf8'),
13710
+ mkdir: (dir: string): void => { mkdirSync(dir, { recursive: true }); },
13711
+ };
13712
+
13713
+ function cmdRuns(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13714
+ if (options.has('probe-pid')) {
13715
+ const result = probePid(Number(options.get('probe-pid')));
13716
+ write(result === null ? 'unknown' : String(result));
13717
+ return 0;
13718
+ }
13719
+ const root = resolve(options.get('project') ?? cwd);
13720
+ const minutes = Number(options.get('stall-minutes') ?? 120);
13721
+ if (!Number.isFinite(minutes) || minutes < 0) {
13722
+ write(flags.has('json') ? JSON.stringify({ status: 'refused', reason: 'stall-minutes must be a finite non-negative number' }) : 'stall-minutes must be a finite non-negative number');
13723
+ return 2;
13724
+ }
13725
+ const opts = { stallMs: minutes * 60_000 };
13726
+ let settled: RunEvent[] = [];
13727
+ if (flags.has('settle')) {
13728
+ settled = withNamedLockSync(join(root, '.dz', 'runs'), 'run-registry-archive', () => {
13729
+ const events = settleDeadRuns(readRunRegistry(root, runRegistryIO), Date.now(), probePid);
13730
+ // Already holding the shared archive/append lock across read-plan-append.
13731
+ const unlockedIO = { ...runRegistryIO, append: (path: string, line: string): void => appendFileSync(path, line, 'utf8') };
13732
+ for (const event of events) appendRunEvent(root, event, unlockedIO);
13733
+ return events;
13734
+ });
13735
+ if (!flags.has('json')) {
13736
+ for (const event of settled) write(`settled ${event.runId} — died (${event.reason})`);
13737
+ if (!settled.length) write('nothing to settle');
13738
+ }
13739
+ }
13740
+ const registry = readRunRegistry(root, runRegistryIO);
13741
+ if (registry.status === 'missing') {
13742
+ write(flags.has('json') ? JSON.stringify(registry) : 'нет реестра: .dz/runs/registry.jsonl ещё не создан (ни одного прогона)');
13743
+ return 0;
13744
+ }
13745
+ const now = Date.now();
13746
+ const parents = liveParents(registry, now, probePid, opts);
13747
+ const rows = registry.runs.map((run, i) => {
13748
+ const parent = parents[i]!.liveness;
13749
+ const own = liveness(run, now, probePid, opts);
13750
+ const decision = registry.status !== 'readable' ? parent :
13751
+ run.finished ? { state: 'finished', reason: run.outcome } :
13752
+ parent.state !== 'live' && parent.state !== 'stalled' ? parent : own;
13753
+ return { ...run, state: decision.state, reason: decision.reason,
13754
+ parentLiveness: parent, heartbeatAgeMs: run.heartbeat ? now - Date.parse(run.heartbeat) : null };
13755
+ });
13756
+ if (flags.has('json')) write(JSON.stringify({ ...registry, runs: rows, ...(flags.has('settle') ? { settled } : {}) }));
13757
+ else {
13758
+ if (registry.status !== 'readable') write('inconclusive — ' + registry.reason);
13759
+ write('runId | kind | slug | pid | parent | liveness | heartbeat age');
13760
+ for (const row of rows) write([row.runId, row.kind, row.slug, row.pid, row.parentRunId ?? '—',
13761
+ row.state, row.heartbeatAgeMs === null ? '—' : `${row.heartbeatAgeMs}ms`].join(' | ') + ' — ' + row.reason);
13762
+ }
13763
+ return registry.status === 'readable' ? 0 : 1;
13764
+ }
13765
+
13766
+ /** Git effects are injected; the core owns eligibility and the explicit apply boundary. */
13767
+ export function cmdRunsClean(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
13768
+ exec: (command: string, options: { cwd: string; encoding: 'utf8'; stdio: ['ignore', 'pipe', 'pipe'] }) => string | Buffer = execSync): number {
13769
+ const root = resolve(options.get('project') ?? cwd);
13770
+ const json = flags.has('json');
13771
+ const messages: string[] = [];
13772
+ const say = (line: string): void => { messages.push(line); if (!json) write(line); };
13773
+ try {
13774
+ const days = Number(options.get('retention-days') ?? 2);
13775
+ if (!Number.isFinite(days) || days < 0) throw new Error('retention-days must be a finite non-negative number');
13776
+ const now = Date.now(), retentionMs = days * 86400000;
13777
+ const git = (command: string, dir = root): string => String(exec(command, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
13778
+ // Git C-quotes paths containing controls/non-ASCII bytes. Decode octal UTF-8 as well.
13779
+ const unquote = (value: string): string => {
13780
+ if (!value.startsWith('"')) return value;
13781
+ const bytes: number[] = [];
13782
+ const body = value.slice(1, -1);
13783
+ const escapes: Record<string, string> = { a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', '"': '"', '\\': '\\' };
13784
+ for (let i = 0; i < body.length; i++) {
13785
+ if (body[i] === '\\') {
13786
+ const octal = /^[0-7]{1,3}/.exec(body.slice(i + 1));
13787
+ if (octal) { bytes.push(parseInt(octal[0], 8)); i += octal[0].length; }
13788
+ else { i++; bytes.push(...Buffer.from(escapes[body[i]!] ?? body[i]!)); }
13789
+ } else {
13790
+ const cp = String.fromCodePoint(body.codePointAt(i)!);
13791
+ bytes.push(...Buffer.from(cp)); i += cp.length - 1;
13792
+ }
13793
+ }
13794
+ return Buffer.from(bytes).toString('utf8');
13795
+ };
13796
+ const list = (): Array<{ path: string; branch: string | null; detached: boolean; sha: string }> => {
13797
+ const text = git('git worktree list --porcelain');
13798
+ const trees = text.trimEnd().split(/\n\n+/).filter(Boolean).map(block => {
13799
+ const lines = block.split('\n');
13800
+ const path = lines.find(line => line.startsWith('worktree '))?.slice(9);
13801
+ const sha = lines.find(line => line.startsWith('HEAD '))?.slice(5);
13802
+ if (!path || !sha) throw new Error('unreadable worktree entry: ' + block);
13803
+ return { path: unquote(path), sha,
13804
+ branch: lines.find(line => line.startsWith('branch '))?.slice(7) ?? null,
13805
+ detached: lines.includes('detached') };
13806
+ });
13807
+ if (!trees.length) throw new Error('empty git worktree list');
13808
+ return trees;
13809
+ };
13810
+ const trees = list();
13811
+ const facts: WorktreeFact[] = trees.map((tree, index) => {
13812
+ // Any unreadable status aborts before removal; unknown cleanliness is never clean.
13813
+ const dirtyFiles = git('git status --short --untracked-files=all', tree.path).split('\n').filter(Boolean)
13814
+ .map(line => unquote(line.slice(3)));
13815
+ let merged: boolean | null = null;
13816
+ try { git(`git merge-base --is-ancestor ${shellQuote(tree.branch ?? tree.sha)} main`); merged = true; }
13817
+ catch (error) { if ((error as { status?: number }).status === 1) merged = false; }
13818
+ let lastCommitTs: number | null = null;
13819
+ try {
13820
+ const raw = git('git log -1 --format=%ct', tree.path).trim();
13821
+ if (/^\d+$/.test(raw) && Number.isFinite(Number(raw) * 1000)) lastCommitTs = Number(raw) * 1000;
13822
+ } catch { /* unknown age keeps the worktree */ }
13823
+ return { path: tree.path, branch: tree.branch?.replace(/^refs\/heads\//, '') ?? null,
13824
+ detached: tree.detached, isMain: index === 0 || tree.branch === 'refs/heads/main', merged, dirtyFiles, lastCommitTs };
13825
+ });
13826
+ const plan = planWorktreeCleanup(facts, { now, retentionMs });
13827
+ for (const line of renderCleanupPlan(plan)) say(line);
13828
+ let code = 0;
13829
+ for (const fact of worktreeRemovalsToApply(plan, flags.has('apply'))) {
13830
+ let failure: string | undefined;
13831
+ try { git(`git worktree remove ${shellQuote(fact.path)}`); }
13832
+ catch (error) { failure = String(error); }
13833
+ try {
13834
+ if (!list().some(tree => tree.path === fact.path)) say(`removed ${fact.path} (confirmed)`);
13835
+ else { say(`NOT removed ${fact.path}` + (failure ? ` — ${failure}` : '')); code = 1; }
13836
+ } catch (error) { say(`NOT removed ${fact.path} — confirmation unavailable: ${String(error)}`); code = 1; }
13837
+ }
13838
+ const archiveRegistry = (): { archive: RunEvent[]; keep: RunEvent[] } => {
13839
+ const registry = readRunRegistry(root, runRegistryIO);
13840
+ if (registry.status === 'inconclusive') throw new Error(registry.reason);
13841
+ const archivePlan = planRegistryArchive(registry, { now, retentionMs, probe: probePid });
13842
+ if (flags.has('apply') && archivePlan.archive.length > 0) {
13843
+ const dir = join(root, '.dz', 'runs');
13844
+ const encode = (events: RunEvent[]): string => events.map(ev => JSON.stringify(ev) + '\n').join('');
13845
+ const temp = join(dir, `registry.${randomBytes(8).toString('hex')}.tmp`);
13846
+ try {
13847
+ writeFileSync(temp, encode(archivePlan.keep), { flag: 'wx', mode: 0o600 });
13848
+ appendFileSync(join(dir, 'registry.archive.jsonl'), encode(archivePlan.archive));
13849
+ renameSync(temp, join(dir, 'registry.jsonl'));
13850
+ } finally { if (existsSync(temp)) unlinkSync(temp); }
13851
+ }
13852
+ return archivePlan;
13853
+ };
13854
+ const archive = flags.has('apply')
13855
+ ? withNamedLockSync(join(root, '.dz', 'runs'), 'run-registry-archive', archiveRegistry)
13856
+ : archiveRegistry();
13857
+ say(`${flags.has('apply') ? 'archived' : 'archive'} ${archive.archive.length} registry event(s)`);
13858
+ if (json) write(JSON.stringify({ ...plan, applied: flags.has('apply'), archive, messages, ok: code === 0 }));
13859
+ return code;
13860
+ } catch (error) {
13861
+ const reason = String(error);
13862
+ if (json) write(JSON.stringify({ ok: false, reason, messages }));
13863
+ else write('runs-clean refused: ' + reason);
13864
+ return 1;
13865
+ }
13866
+ }
13867
+
13868
+ function cmdRunsRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13869
+ try {
13870
+ const event = options.get('event') as RunEvent['event'];
13871
+ const runId = options.get('run-id') ?? (event === 'started' ? randomBytes(16).toString('hex') : '');
13872
+ const ev: RunEvent = { event, runId, ts: new Date().toISOString() };
13873
+ if (event === 'started') {
13874
+ const kind = options.get('kind');
13875
+ const slug = options.get('slug');
13876
+ Object.assign(ev, { ...(kind === undefined ? {} : { kind }), ...(slug === undefined ? {} : { slug }) });
13877
+ const rawPid = options.get('pid');
13878
+ if (rawPid === 'host') {
13879
+ // The shell agent is transient. Walk to the nearest Claude host, never guess from age.
13880
+ let candidate = process.ppid;
13881
+ const seen = new Set<number>();
13882
+ while (candidate > 1 && !seen.has(candidate)) {
13883
+ seen.add(candidate);
13884
+ const row = execFileSync('ps', ['-p', String(candidate), '-o', 'ppid=', '-o', 'comm='], { encoding: 'utf8' }).trim();
13885
+ const match = /^(\d+)\s+(.+)$/.exec(row);
13886
+ if (!match) break;
13887
+ if (/^claude(?:$|[- ])/.test(basename(match[2]!))) { ev.pid = candidate; break; }
13888
+ candidate = Number(match[1]);
13889
+ }
13890
+ if (ev.pid === undefined) throw new Error('Claude host PID unavailable; pass explicit --pid (Workflow args.runPid)');
13891
+ } else ev.pid = Number(rawPid);
13892
+ const parentRunId = options.get('parent-run-id');
13893
+ Object.assign(ev, { ...(parentRunId === undefined ? {} : { parentRunId }) });
13894
+ }
13895
+ const outcome = event === 'finished' ? options.get('outcome') : undefined;
13896
+ const reason = options.get('reason');
13897
+ Object.assign(ev, { ...(outcome === undefined ? {} : { outcome }), ...(reason === undefined ? {} : { reason }) });
13898
+ appendRunEvent(resolve(options.get('project') ?? cwd), ev, runRegistryIO);
13899
+ write(flags.has('json') ? JSON.stringify({ status: 'written', ...ev }) : `written ${event} ${runId}`);
13900
+ return 0;
13901
+ } catch (error) {
13902
+ write(flags.has('json') ? JSON.stringify({ status: 'refused', reason: String(error) }) : `refused: ${String(error)}`);
13903
+ return 2;
13904
+ }
13905
+ }
13906
+
13642
13907
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13643
13908
  const json = flags.has('json');
13644
13909
  // `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
@@ -13765,7 +14030,7 @@ type ContractDiskRead =
13765
14030
 
13766
14031
  type ContractDirectoryRead =
13767
14032
  | { readonly ok: true; readonly realPath: string }
13768
- | { readonly ok: false; readonly diagnostic: ContractDiagnostic };
14033
+ | { readonly ok: false; readonly reason: 'missing' | 'unreadable'; readonly diagnostic: ContractDiagnostic };
13769
14034
 
13770
14035
  function contractRepoRoot(cwd: string): string {
13771
14036
  let root = cwd;
@@ -13824,14 +14089,26 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13824
14089
  } catch {
13825
14090
  return {
13826
14091
  ok: false,
14092
+ reason: 'unreadable',
13827
14093
  diagnostic: { code: 'repository-unreadable', message: 'repository root cannot be resolved', artifact: '.' },
13828
14094
  };
13829
14095
  }
14096
+ try {
14097
+ lstatSync(absolute);
14098
+ } catch (error) {
14099
+ const missing = (error as NodeJS.ErrnoException).code === 'ENOENT';
14100
+ return {
14101
+ ok: false,
14102
+ reason: missing ? 'missing' : 'unreadable',
14103
+ diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory cannot be resolved: ${artifact}`, artifact },
14104
+ };
14105
+ }
13830
14106
  try {
13831
14107
  targetReal = realpathSync(absolute);
13832
14108
  } catch {
13833
14109
  return {
13834
14110
  ok: false,
14111
+ reason: 'unreadable',
13835
14112
  diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory cannot be resolved: ${artifact}`, artifact },
13836
14113
  };
13837
14114
  }
@@ -13839,6 +14116,7 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13839
14116
  if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) {
13840
14117
  return {
13841
14118
  ok: false,
14119
+ reason: 'unreadable',
13842
14120
  diagnostic: { code: 'artifact-outside-repository', message: `artifact resolves outside the repository: ${artifact}`, artifact },
13843
14121
  };
13844
14122
  }
@@ -13848,6 +14126,7 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13848
14126
  } catch {
13849
14127
  return {
13850
14128
  ok: false,
14129
+ reason: 'unreadable',
13851
14130
  diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory is not readable: ${artifact}`, artifact },
13852
14131
  };
13853
14132
  }
@@ -13908,34 +14187,64 @@ function cmdContractCheck(
13908
14187
 
13909
14188
  const adrDirRel = `features/${slug}/03_adr`;
13910
14189
  const adrDirectory = contractDirectoryConfined(repoRoot, join(featureDir, '03_adr'), adrDirRel);
13911
- if (!adrDirectory.ok) return emitEarly('not-established', 2, [adrDirectory.diagnostic]);
13912
- const adrDir = adrDirectory.realPath;
14190
+ const informationalDiagnostics: ContractDiagnostic[] = [];
13913
14191
  let adrNames: string[];
13914
- try {
13915
- adrNames = readdirSync(adrDir, { withFileTypes: true })
13916
- .filter((entry) => entry.name.endsWith('.md') && (entry.isFile() || entry.isSymbolicLink()))
13917
- .map((entry) => entry.name)
13918
- .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
13919
- } catch {
13920
- return emitEarly('not-established', 2, [{
13921
- code: 'adr-directory-unreadable',
13922
- message: `required ADR directory cannot be read: ${adrDirRel}`,
13923
- artifact: adrDirRel,
13924
- }]);
13925
- }
13926
- if (adrNames.length === 0) {
13927
- return emitEarly('not-established', 2, [{
13928
- code: 'adr-artifacts-missing',
13929
- message: `no direct ADR Markdown artifacts exist under ${adrDirRel}`,
13930
- artifact: adrDirRel,
13931
- observed: 0,
13932
- }]);
14192
+ let adrDir: string | null = null;
14193
+ let adrsOptional: true | undefined;
14194
+ if (!adrDirectory.ok) {
14195
+ if (adrDirectory.reason !== 'missing') return emitEarly('not-established', 2, [adrDirectory.diagnostic]);
14196
+ const tier = readFeatureTier((rel) => {
14197
+ const assessment = contractReadConfined(repoRoot, join(repoRoot, rel), rel);
14198
+ return assessment.ok ? assessment.text : null;
14199
+ }, slug);
14200
+ if (tier === 'S') {
14201
+ adrNames = [];
14202
+ adrsOptional = true;
14203
+ informationalDiagnostics.push({
14204
+ code: 'adr-not-required-for-tier',
14205
+ message: 'ADR artifacts are not required for an S-tier feature',
14206
+ artifact: adrDirRel,
14207
+ });
14208
+ } else if (tier !== null) {
14209
+ return emitEarly('not-established', 2, [{
14210
+ code: 'adr-directory-missing',
14211
+ message: `required ADR directory is missing for ${tier}-tier feature: ${adrDirRel}`,
14212
+ artifact: adrDirRel,
14213
+ }]);
14214
+ } else {
14215
+ return emitEarly('not-established', 2, [{
14216
+ ...adrDirectory.diagnostic,
14217
+ message: `${adrDirectory.diagnostic.message}; feature tier is unreadable`,
14218
+ }]);
14219
+ }
14220
+ } else {
14221
+ adrDir = adrDirectory.realPath;
14222
+ try {
14223
+ adrNames = readdirSync(adrDir, { withFileTypes: true })
14224
+ .filter((entry) => entry.name.endsWith('.md') && (entry.isFile() || entry.isSymbolicLink()))
14225
+ .map((entry) => entry.name)
14226
+ .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
14227
+ } catch {
14228
+ return emitEarly('not-established', 2, [{
14229
+ code: 'adr-directory-unreadable',
14230
+ message: `required ADR directory cannot be read: ${adrDirRel}`,
14231
+ artifact: adrDirRel,
14232
+ }]);
14233
+ }
14234
+ if (adrNames.length === 0) {
14235
+ return emitEarly('not-established', 2, [{
14236
+ code: 'adr-artifacts-missing',
14237
+ message: `no direct ADR Markdown artifacts exist under ${adrDirRel}`,
14238
+ artifact: adrDirRel,
14239
+ observed: 0,
14240
+ }]);
14241
+ }
13933
14242
  }
13934
14243
  const adrs: Array<{ path: string; text: string }> = [];
13935
14244
  const adrReadErrors: ContractDiagnostic[] = [];
13936
14245
  for (const name of adrNames) {
13937
14246
  const rel = `features/${slug}/03_adr/${name}`;
13938
- const read = contractReadConfined(repoRoot, join(adrDir, name), rel);
14247
+ const read = contractReadConfined(repoRoot, join(adrDir!, name), rel);
13939
14248
  if (read.ok) adrs.push({ path: rel, text: read.text });
13940
14249
  else adrReadErrors.push(read.diagnostic);
13941
14250
  }
@@ -13944,6 +14253,7 @@ function cmdContractCheck(
13944
14253
  const extracted = extractContractChecklist({
13945
14254
  requirements: { path: requirementsRel, text: requirementsRead.text },
13946
14255
  adrs,
14256
+ ...(adrsOptional === true ? { adrsOptional: true } : {}),
13947
14257
  });
13948
14258
  if (!extracted.ok) return emitEarly('fail', 1, extracted.diagnostics);
13949
14259
 
@@ -13985,7 +14295,7 @@ function cmdContractCheck(
13985
14295
  });
13986
14296
  const verification = {
13987
14297
  ...rawVerification,
13988
- diagnostics: rawVerification.diagnostics.map(withReportArtifact),
14298
+ diagnostics: [...informationalDiagnostics, ...rawVerification.diagnostics.map(withReportArtifact)],
13989
14299
  items: rawVerification.items.map((item) => ({
13990
14300
  ...item,
13991
14301
  diagnostics: item.diagnostics.map(withReportArtifact),
@@ -14013,7 +14323,7 @@ function cmdContractCheck(
14013
14323
  for (const entry of verification.diagnostics) writeErr(contractDiagnosticLine(entry));
14014
14324
  const summary = verification.outcome === 'pass'
14015
14325
  ? `${verification.counts.met} contract item(s) met`
14016
- : `${verification.diagnostics.length} contract or evidence violation(s)`;
14326
+ : `${rawVerification.diagnostics.length} contract or evidence violation(s)`;
14017
14327
  write(`contract-check: ${verification.outcome === 'pass' ? 'PASS' : 'FAIL'} — ${summary}`);
14018
14328
  return verification.exitCode;
14019
14329
  }
@@ -17508,6 +17818,94 @@ async function cmdProfile(options: Map<string, string>, flags: Set<string>, writ
17508
17818
  return fail(2, `dz profile: unknown subcommand ${JSON.stringify(sub)} — accepted: init | show | set | sync`);
17509
17819
  }
17510
17820
 
17821
+ function cmdJournal(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, injectedIo?: JournalIo): number {
17822
+ try {
17823
+ const verb = options.get('_positional_0');
17824
+ if (verb !== 'add' && verb !== 'show') throw new Error('Ожидается journal add или journal show');
17825
+ const kindRaw = options.get('kind');
17826
+ if ((verb === 'add' || kindRaw !== undefined) && !JOURNAL_KINDS.includes(kindRaw as JournalKind)) {
17827
+ write(`Категория должна быть одной из: ${JOURNAL_KINDS.join(', ')}`);
17828
+ return 1;
17829
+ }
17830
+ if (flags.has('day') && flags.has('week')) throw new Error('Выберите --day или --week');
17831
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
17832
+ const dir = join(root, 'docs', 'journal');
17833
+ // A journal symlink must never redirect an append or a quote outside this repository.
17834
+ for (const path of [join(root, 'docs'), dir, join(dir, 'quotes')]) {
17835
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`Символическая ссылка запрещена: ${path}`);
17836
+ }
17837
+ const at = options.get('at') ?? (verb === 'add' ? new Date().toISOString() : new Date().toISOString().slice(0, 10));
17838
+ let date = at;
17839
+ let time = '';
17840
+ if (verb === 'add') {
17841
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(at)) throw new Error('--at ожидает ISO с часовым поясом');
17842
+ selectWindow([], at.slice(0, 10), false);
17843
+ const stamp = new Date(at).toISOString();
17844
+ date = stamp.slice(0, 10); time = stamp.slice(11, 16);
17845
+ }
17846
+ selectWindow([], date, false);
17847
+ if (verb === 'show') {
17848
+ const days = existsSync(dir) ? readdirSync(dir).filter(n => /^\d{4}-\d{2}-\d{2}\.md$/.test(n)).map(n => n.slice(0, 10)) : [];
17849
+ const rows = selectWindow(days, date, flags.has('week')).flatMap(day => {
17850
+ return readFileSync(join(dir, `${day}.md`), 'utf8').split('\n').filter(line => line.startsWith('- '))
17851
+ .map(raw => ({ day, ...parseLine(raw) }));
17852
+ }).filter(row => row.status === 'unparsed' || kindRaw === undefined || row.kind === kindRaw);
17853
+ if (flags.has('json')) write(JSON.stringify(rows));
17854
+ else for (const row of rows) write(row.status === 'unparsed' ? `[unparsed] ${row.raw}` : row.raw);
17855
+ return 0;
17856
+ }
17857
+ const text = options.get('_positional_1') ?? '';
17858
+ let ref = options.get('ref') ?? '';
17859
+ const event = { time, kind: kindRaw as JournalKind, text, ref };
17860
+ formatLine(event); // validate before any journal/quote write
17861
+ if (options.has('_positional_2')) throw new Error('Текст должен быть одним аргументом');
17862
+ if (flags.has('commit-quote') && !options.has('quote')) throw new Error('--commit-quote требует --quote');
17863
+ return withNamedLockSync(root, 'journal', () => {
17864
+ const path = join(dir, `${date}.md`);
17865
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`Символическая ссылка запрещена: ${path}`);
17866
+ const previous = existsSync(path) ? readFileSync(path, 'utf8') : '';
17867
+ if (previous.split('\n').map(parseLine).some(row => row.status === 'parsed' && row.time === time && row.text === text.trim())) {
17868
+ write('уже записано'); return 0;
17869
+ }
17870
+ const quote = options.get('quote');
17871
+ if (quote !== undefined) {
17872
+ const content = readFileSync(resolve(cwd, quote), 'utf8');
17873
+ const quotes = join(dir, 'quotes');
17874
+ const exclude = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: root, encoding: 'utf8' }).trim();
17875
+ const excludePath = resolve(root, exclude);
17876
+ const ignored = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
17877
+ if (!ignored.split('\n').includes('/docs/journal/quotes/')) {
17878
+ mkdirSync(dirname(excludePath), { recursive: true });
17879
+ appendFileSync(excludePath, `${ignored.endsWith('\n') || !ignored ? '' : '\n'}/docs/journal/quotes/\n`);
17880
+ }
17881
+ mkdirSync(quotes, { recursive: true });
17882
+ const tracked = new Set(execFileSync('git', ['ls-files', '-z', '--', 'docs/journal/quotes/'], { cwd: root, encoding: 'utf8' }).split('\0'));
17883
+ let n = 1;
17884
+ while (existsSync(join(quotes, `${date}-${n}.md`)) || tracked.has(`docs/journal/quotes/${date}-${n}.md`)) n++;
17885
+ const quoteRef = `docs/journal/quotes/${date}-${n}.md`;
17886
+ const quotePath = join(root, quoteRef);
17887
+ writeFileSync(quotePath, content, { flag: 'wx' });
17888
+ if (readFileSync(quotePath, 'utf8') !== content) throw new Error('Цитата не засвидетельствована');
17889
+ if (flags.has('commit-quote')) {
17890
+ execFileSync('git', ['add', '-f', '--', quoteRef], { cwd: root, stdio: 'pipe' });
17891
+ }
17892
+ ref = ref ? `${ref}; ${quoteRef}` : quoteRef;
17893
+ }
17894
+ mkdirSync(dir, { recursive: true });
17895
+ if (!existsSync(path)) writeFileSync(path, `# Журнал действий — ${date}\n\n## Решения · вердикты · запуски и падения · ошибки · блокировки (UTC)\n\n`, { flag: 'wx' });
17896
+ else if (previous && !previous.endsWith('\n')) appendFileSync(path, '\n');
17897
+ const fs: JournalIo = injectedIo ?? { read: p => readFileSync(p, 'utf8'), append: (p, s) => appendFileSync(p, s) };
17898
+ const line = formatLine({ ...event, ref });
17899
+ appendWitnessed(fs, path, line);
17900
+ write(`записано и засвидетельствовано: ${line}`);
17901
+ return 0;
17902
+ });
17903
+ } catch (error) {
17904
+ write(`dz journal: ${error instanceof Error ? error.message : String(error)}`);
17905
+ return 1;
17906
+ }
17907
+ }
17908
+
17511
17909
  export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
17512
17910
  const cwd = io.cwd ?? process.cwd();
17513
17911
  const write: Write = io.write ?? ((line) => { console.log(line); });
@@ -17776,8 +18174,16 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
17776
18174
  return cmdBriefCheck(options, flags, cwd, write);
17777
18175
  case 'provenance-check':
17778
18176
  return cmdProvenanceCheck(options, flags, cwd, write);
18177
+ case 'journal':
18178
+ return cmdJournal(options, flags, cwd, write, io.journalIo);
17779
18179
  case 'feature-adr-record':
17780
18180
  return cmdFeatureAdrRecord(options, flags, cwd, write);
18181
+ case 'runs':
18182
+ return cmdRuns(options, flags, cwd, write);
18183
+ case 'runs-clean':
18184
+ return cmdRunsClean(options, flags, cwd, write);
18185
+ case 'runs-record':
18186
+ return cmdRunsRecord(options, flags, cwd, write);
17781
18187
  case 'amendment-check':
17782
18188
  return cmdAmendmentCheck(options, flags, cwd, write);
17783
18189
  case 'contract-check':