@dzhechkov/harness-cli 0.8.16 → 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)
@@ -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}). */
@@ -3904,9 +3914,15 @@ async function runTeachGuardReinforcement(
3904
3914
  projectRoot: string,
3905
3915
  dzId: string,
3906
3916
  reward: number,
3917
+ preserveQuarantine = false,
3907
3918
  ): Promise<{ readonly flushed: number }> {
3908
3919
  const backend = resolveLearningBackend(projectRoot);
3909
- 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
+ });
3910
3926
  return backend.train();
3911
3927
  }
3912
3928
 
@@ -3914,7 +3930,7 @@ async function cmdTeach(
3914
3930
  options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3915
3931
  writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3916
3932
  guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3917
- 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,
3918
3934
  ): Promise<number> {
3919
3935
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3920
3936
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
@@ -4152,6 +4168,16 @@ async function cmdTeach(
4152
4168
  }
4153
4169
  }
4154
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
+
4155
4181
  // Distill pattern into actionable rule (claude-smart inspired)
4156
4182
  // Convert "what happened" into "what to do next time"
4157
4183
  const isRule = pattern.toLowerCase().startsWith('always') || pattern.toLowerCase().startsWith('never') ||
@@ -7101,10 +7127,18 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7101
7127
  // A one-line preview inline; on error the FULL captured npm error prints below (truncating a publish
7102
7128
  // failure to 60 chars is how a release stays undiagnosable — the reason lives past char 60).
7103
7129
  const detail = pkg.error && pkg.status !== 'error' ? ` (${pkg.error.slice(0, 60)})` : '';
7104
- 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}`);
7105
7134
  if (pkg.status === 'error' && pkg.error) {
7106
7135
  for (const line of pkg.error.split('\n')) write(` ${line}`);
7107
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
+ }
7108
7142
  // Surface warn-mode findings that did not block the publish.
7109
7143
  if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
7110
7144
  write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
@@ -13670,6 +13704,206 @@ function cmdLedgerBackfill(options: Map<string, string>, flags: Set<string>, cwd
13670
13704
  return 0;
13671
13705
  }
13672
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
+
13673
13907
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13674
13908
  const json = flags.has('json');
13675
13909
  // `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
@@ -13796,7 +14030,7 @@ type ContractDiskRead =
13796
14030
 
13797
14031
  type ContractDirectoryRead =
13798
14032
  | { readonly ok: true; readonly realPath: string }
13799
- | { readonly ok: false; readonly diagnostic: ContractDiagnostic };
14033
+ | { readonly ok: false; readonly reason: 'missing' | 'unreadable'; readonly diagnostic: ContractDiagnostic };
13800
14034
 
13801
14035
  function contractRepoRoot(cwd: string): string {
13802
14036
  let root = cwd;
@@ -13855,14 +14089,26 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13855
14089
  } catch {
13856
14090
  return {
13857
14091
  ok: false,
14092
+ reason: 'unreadable',
13858
14093
  diagnostic: { code: 'repository-unreadable', message: 'repository root cannot be resolved', artifact: '.' },
13859
14094
  };
13860
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
+ }
13861
14106
  try {
13862
14107
  targetReal = realpathSync(absolute);
13863
14108
  } catch {
13864
14109
  return {
13865
14110
  ok: false,
14111
+ reason: 'unreadable',
13866
14112
  diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory cannot be resolved: ${artifact}`, artifact },
13867
14113
  };
13868
14114
  }
@@ -13870,6 +14116,7 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13870
14116
  if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) {
13871
14117
  return {
13872
14118
  ok: false,
14119
+ reason: 'unreadable',
13873
14120
  diagnostic: { code: 'artifact-outside-repository', message: `artifact resolves outside the repository: ${artifact}`, artifact },
13874
14121
  };
13875
14122
  }
@@ -13879,6 +14126,7 @@ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact:
13879
14126
  } catch {
13880
14127
  return {
13881
14128
  ok: false,
14129
+ reason: 'unreadable',
13882
14130
  diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory is not readable: ${artifact}`, artifact },
13883
14131
  };
13884
14132
  }
@@ -13939,34 +14187,64 @@ function cmdContractCheck(
13939
14187
 
13940
14188
  const adrDirRel = `features/${slug}/03_adr`;
13941
14189
  const adrDirectory = contractDirectoryConfined(repoRoot, join(featureDir, '03_adr'), adrDirRel);
13942
- if (!adrDirectory.ok) return emitEarly('not-established', 2, [adrDirectory.diagnostic]);
13943
- const adrDir = adrDirectory.realPath;
14190
+ const informationalDiagnostics: ContractDiagnostic[] = [];
13944
14191
  let adrNames: string[];
13945
- try {
13946
- adrNames = readdirSync(adrDir, { withFileTypes: true })
13947
- .filter((entry) => entry.name.endsWith('.md') && (entry.isFile() || entry.isSymbolicLink()))
13948
- .map((entry) => entry.name)
13949
- .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
13950
- } catch {
13951
- return emitEarly('not-established', 2, [{
13952
- code: 'adr-directory-unreadable',
13953
- message: `required ADR directory cannot be read: ${adrDirRel}`,
13954
- artifact: adrDirRel,
13955
- }]);
13956
- }
13957
- if (adrNames.length === 0) {
13958
- return emitEarly('not-established', 2, [{
13959
- code: 'adr-artifacts-missing',
13960
- message: `no direct ADR Markdown artifacts exist under ${adrDirRel}`,
13961
- artifact: adrDirRel,
13962
- observed: 0,
13963
- }]);
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
+ }
13964
14242
  }
13965
14243
  const adrs: Array<{ path: string; text: string }> = [];
13966
14244
  const adrReadErrors: ContractDiagnostic[] = [];
13967
14245
  for (const name of adrNames) {
13968
14246
  const rel = `features/${slug}/03_adr/${name}`;
13969
- const read = contractReadConfined(repoRoot, join(adrDir, name), rel);
14247
+ const read = contractReadConfined(repoRoot, join(adrDir!, name), rel);
13970
14248
  if (read.ok) adrs.push({ path: rel, text: read.text });
13971
14249
  else adrReadErrors.push(read.diagnostic);
13972
14250
  }
@@ -13975,6 +14253,7 @@ function cmdContractCheck(
13975
14253
  const extracted = extractContractChecklist({
13976
14254
  requirements: { path: requirementsRel, text: requirementsRead.text },
13977
14255
  adrs,
14256
+ ...(adrsOptional === true ? { adrsOptional: true } : {}),
13978
14257
  });
13979
14258
  if (!extracted.ok) return emitEarly('fail', 1, extracted.diagnostics);
13980
14259
 
@@ -14016,7 +14295,7 @@ function cmdContractCheck(
14016
14295
  });
14017
14296
  const verification = {
14018
14297
  ...rawVerification,
14019
- diagnostics: rawVerification.diagnostics.map(withReportArtifact),
14298
+ diagnostics: [...informationalDiagnostics, ...rawVerification.diagnostics.map(withReportArtifact)],
14020
14299
  items: rawVerification.items.map((item) => ({
14021
14300
  ...item,
14022
14301
  diagnostics: item.diagnostics.map(withReportArtifact),
@@ -14044,7 +14323,7 @@ function cmdContractCheck(
14044
14323
  for (const entry of verification.diagnostics) writeErr(contractDiagnosticLine(entry));
14045
14324
  const summary = verification.outcome === 'pass'
14046
14325
  ? `${verification.counts.met} contract item(s) met`
14047
- : `${verification.diagnostics.length} contract or evidence violation(s)`;
14326
+ : `${rawVerification.diagnostics.length} contract or evidence violation(s)`;
14048
14327
  write(`contract-check: ${verification.outcome === 'pass' ? 'PASS' : 'FAIL'} — ${summary}`);
14049
14328
  return verification.exitCode;
14050
14329
  }
@@ -17539,6 +17818,94 @@ async function cmdProfile(options: Map<string, string>, flags: Set<string>, writ
17539
17818
  return fail(2, `dz profile: unknown subcommand ${JSON.stringify(sub)} — accepted: init | show | set | sync`);
17540
17819
  }
17541
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
+
17542
17909
  export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
17543
17910
  const cwd = io.cwd ?? process.cwd();
17544
17911
  const write: Write = io.write ?? ((line) => { console.log(line); });
@@ -17807,8 +18174,16 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
17807
18174
  return cmdBriefCheck(options, flags, cwd, write);
17808
18175
  case 'provenance-check':
17809
18176
  return cmdProvenanceCheck(options, flags, cwd, write);
18177
+ case 'journal':
18178
+ return cmdJournal(options, flags, cwd, write, io.journalIo);
17810
18179
  case 'feature-adr-record':
17811
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);
17812
18187
  case 'amendment-check':
17813
18188
  return cmdAmendmentCheck(options, flags, cwd, write);
17814
18189
  case 'contract-check':