@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/.dz-manifest.json +16 -16
- package/README.md +134 -13
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +4 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +516 -42
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +14 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +10 -10
- package/sbom.json +15 -15
- package/src/boolean-flags.ts +4 -0
- package/src/cli.ts +448 -42
- package/src/known-flags.ts +14 -0
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>"
|
|
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}
|
|
2962
|
-
? ` · ${breakdown.quarantined}
|
|
2963
|
-
: ''})${breakdown.tierDelta !== undefined ? ` ⚠
|
|
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 += ` ⛔
|
|
2983
|
+
line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
|
|
2966
2984
|
} else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
|
|
2967
|
-
line += ` ⛔
|
|
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 += ` ⛔
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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
|
|
11727
|
-
write(renderMutationReport(
|
|
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
|
-
|
|
13912
|
-
const adrDir = adrDirectory.realPath;
|
|
14190
|
+
const informationalDiagnostics: ContractDiagnostic[] = [];
|
|
13913
14191
|
let adrNames: string[];
|
|
13914
|
-
|
|
13915
|
-
|
|
13916
|
-
|
|
13917
|
-
|
|
13918
|
-
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
13927
|
-
|
|
13928
|
-
|
|
13929
|
-
|
|
13930
|
-
|
|
13931
|
-
|
|
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
|
|
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
|
-
: `${
|
|
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':
|