@dzhechkov/harness-core 0.8.39 → 0.8.41
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 +235 -135
- package/README.md +91 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +56 -8
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +109 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +172 -9
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +39 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +117 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/mutation-gate.d.ts +30 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +45 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +112 -2
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -25
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +18 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +30 -0
- package/dist/release.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +29 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +8 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +384 -134
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +59 -8
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +244 -10
- package/src/discrimination-gate.ts +2 -5
- package/src/doctor-instrument.ts +153 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +128 -17
- package/src/index.ts +28 -7
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/mutation-gate.ts +54 -1
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +109 -3
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +114 -18
- package/src/release.ts +36 -0
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round-exec.ts +13 -2
- package/src/round.ts +38 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- package/src/workflow-run.ts +7 -8
package/src/operations.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
13
13
|
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -51,6 +51,8 @@ import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
|
|
|
51
51
|
import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
|
|
52
52
|
import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
|
|
53
53
|
import { computeRiskScore } from './risk-scoring.js';
|
|
54
|
+
import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
|
|
55
|
+
import { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
|
|
54
56
|
|
|
55
57
|
import { applyEmitResult } from './apply.js';
|
|
56
58
|
import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
@@ -1039,6 +1041,12 @@ export function runMigrate(options: { projectRoot: string }): MigrateReport {
|
|
|
1039
1041
|
export interface DoctorCheck {
|
|
1040
1042
|
readonly name: string;
|
|
1041
1043
|
readonly ok: boolean;
|
|
1044
|
+
/**
|
|
1045
|
+
* Rendering severity for a check that is NOT a failure. `warn` = measured and worth saying out
|
|
1046
|
+
* loud; `unknown` = the evidence could not be gathered, which is never rendered as a pass. Neither
|
|
1047
|
+
* value participates in `DoctorReport.ok`, so neither can change any caller's exit code.
|
|
1048
|
+
*/
|
|
1049
|
+
readonly level?: 'warn' | 'unknown';
|
|
1042
1050
|
readonly detail: string;
|
|
1043
1051
|
}
|
|
1044
1052
|
|
|
@@ -1050,7 +1058,13 @@ export interface DoctorReport {
|
|
|
1050
1058
|
}
|
|
1051
1059
|
|
|
1052
1060
|
/** Report environment diagnostics for the harness. */
|
|
1053
|
-
|
|
1061
|
+
/**
|
|
1062
|
+
* `instrumentPath` is the executable that is answering — normally the CLI's own `process.argv[1]`.
|
|
1063
|
+
* It is an INPUT, not something this module reads for itself: `core-boundary` rule A forbids core
|
|
1064
|
+
* from touching process globals, because core is a library and the process belongs to whoever hosts
|
|
1065
|
+
* it. Omitted ⇒ the instrument-freshness check honestly reports that it could not tell.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function runDoctor(options: { projectRoot: string; instrumentPath?: string | null }): Promise<DoctorReport> {
|
|
1054
1068
|
const checks: DoctorCheck[] = [];
|
|
1055
1069
|
const root = options.projectRoot;
|
|
1056
1070
|
|
|
@@ -1058,6 +1072,29 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1058
1072
|
const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
|
|
1059
1073
|
checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
|
|
1060
1074
|
|
|
1075
|
+
// Свежесть копии — отдельная ось: ok:false изменил бы код выхода doctor и заблокировал
|
|
1076
|
+
// автоматику публикации. Поэтому отказ копии — WARN, отсутствие свидетельства — UNKNOWN.
|
|
1077
|
+
const backupLogPath = process.env.DZ_BACKLOG_BACKUP_LOG ?? '/var/log/backlog-backup.log';
|
|
1078
|
+
try {
|
|
1079
|
+
const verdict = decideBackupFreshness({
|
|
1080
|
+
lines: readFileSync(backupLogPath, 'utf8').split(/\r?\n/),
|
|
1081
|
+
now: new Date(),
|
|
1082
|
+
maxAgeHours: 36,
|
|
1083
|
+
});
|
|
1084
|
+
checks.push({
|
|
1085
|
+
name: 'backlog backup',
|
|
1086
|
+
ok: true,
|
|
1087
|
+
...(verdict.state === 'fresh' ? {} : { level: verdict.state === 'stale' ? 'warn' as const : 'unknown' as const }),
|
|
1088
|
+
detail: renderBackupFreshness(verdict),
|
|
1089
|
+
});
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN';
|
|
1092
|
+
const detail = code === 'ENOENT'
|
|
1093
|
+
? `журнал копии не найден: ${backupLogPath}`
|
|
1094
|
+
: `журнал копии нечитаем: ${backupLogPath} (${code})`;
|
|
1095
|
+
checks.push({ name: 'backlog backup', ok: true, level: 'unknown', detail });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1061
1098
|
// 2/3. MONOREPO-ONLY checks, gated on PROJECT KIND (backlog fcf29728: in a consumer project
|
|
1062
1099
|
// skills-meta and the 10 adapters are not there and MUST not be — both checks were red forever
|
|
1063
1100
|
// and dz doctor could never exit 0 outside this repository, a standing false BLOCK for any
|
|
@@ -1065,6 +1102,74 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1065
1102
|
// monorepo (or a fork of it) and owes itself these checks; anything else is a consumer project
|
|
1066
1103
|
// and gets a NAMED skip — a skip, never a silent pass and never a fail.
|
|
1067
1104
|
const isMonorepo = existsSync(join(root, 'packages', '@dzhechkov'));
|
|
1105
|
+
|
|
1106
|
+
// The executable is part of the measurement. Resolve the CALLER-SUPPLIED path (including its
|
|
1107
|
+
// symlinks), then attribute it to the nearest package.json. Every read is independent and
|
|
1108
|
+
// fail-quiet so a broken install becomes an explicit UNKNOWN diagnostic instead of throwing.
|
|
1109
|
+
let binPath: string | null = null;
|
|
1110
|
+
try {
|
|
1111
|
+
const given = options.instrumentPath;
|
|
1112
|
+
binPath = given === undefined || given === null ? null : realpathSync(given);
|
|
1113
|
+
} catch { /* unresolved is reported by the pure decider */ }
|
|
1114
|
+
let binVersion: string | null = null;
|
|
1115
|
+
if (binPath !== null) {
|
|
1116
|
+
try {
|
|
1117
|
+
let cursor = dirname(binPath);
|
|
1118
|
+
while (true) {
|
|
1119
|
+
const packagePath = join(cursor, 'package.json');
|
|
1120
|
+
if (existsSync(packagePath)) {
|
|
1121
|
+
const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { version?: unknown };
|
|
1122
|
+
binVersion = typeof parsed.version === 'string' ? parsed.version : null;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
const parent = dirname(cursor);
|
|
1126
|
+
if (parent === cursor) break;
|
|
1127
|
+
cursor = parent;
|
|
1128
|
+
}
|
|
1129
|
+
} catch { binVersion = null; }
|
|
1130
|
+
}
|
|
1131
|
+
let treeVersion: string | null = null;
|
|
1132
|
+
if (isMonorepo) {
|
|
1133
|
+
try {
|
|
1134
|
+
const parsed = JSON.parse(readFileSync(join(root, 'packages', '@dzhechkov', 'harness-cli', 'package.json'), 'utf8')) as { version?: unknown };
|
|
1135
|
+
treeVersion = typeof parsed.version === 'string' ? parsed.version : null;
|
|
1136
|
+
} catch { treeVersion = null; }
|
|
1137
|
+
}
|
|
1138
|
+
let realRoot = resolvePath(root);
|
|
1139
|
+
let realRootResolved = true;
|
|
1140
|
+
// A root that cannot be resolved through its symlinks makes the containment question undecidable
|
|
1141
|
+
// rather than false — the decider is told so explicitly instead of silently comparing two path
|
|
1142
|
+
// forms that need not agree.
|
|
1143
|
+
try { realRoot = realpathSync(root); } catch { realRootResolved = false; }
|
|
1144
|
+
const instrument = checkInstrumentFreshness({ binPath, binVersion, treeVersion, projectRoot: realRoot, projectRootRealpathed: realRootResolved, isMonorepo });
|
|
1145
|
+
checks.push({
|
|
1146
|
+
name: 'doctor instrument freshness',
|
|
1147
|
+
// The pure decider owns the verdict; this wiring only carries it. Never re-derive `ok` here.
|
|
1148
|
+
ok: true,
|
|
1149
|
+
...(instrument.level === 'ok' ? {} : { level: instrument.level }),
|
|
1150
|
+
detail: instrument.detail,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const rankingStatePath = join(realRoot, '.dz', 'lesson-bandit', 'state.json');
|
|
1154
|
+
let rankingFlagOn = false;
|
|
1155
|
+
try {
|
|
1156
|
+
const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as {
|
|
1157
|
+
memory?: { learning?: { banditRerank?: unknown } };
|
|
1158
|
+
};
|
|
1159
|
+
rankingFlagOn = parsed.memory?.learning?.banditRerank === true;
|
|
1160
|
+
} catch { /* malformed config is owned by the config diagnostic; ranking defaults off */ }
|
|
1161
|
+
const ranking = checkRankingState({
|
|
1162
|
+
flagOn: rankingFlagOn,
|
|
1163
|
+
statePath: rankingStatePath,
|
|
1164
|
+
stateExists: existsSync(rankingStatePath),
|
|
1165
|
+
binPath,
|
|
1166
|
+
});
|
|
1167
|
+
checks.push({
|
|
1168
|
+
name: 'bandit ranking state',
|
|
1169
|
+
ok: true,
|
|
1170
|
+
...(ranking.level === 'warn' ? { level: 'warn' as const } : {}),
|
|
1171
|
+
detail: ranking.detail,
|
|
1172
|
+
});
|
|
1068
1173
|
checks.push({
|
|
1069
1174
|
name: '.claude/skills present',
|
|
1070
1175
|
ok: existsSync(join(root, '.claude', 'skills')),
|
|
@@ -1648,7 +1753,8 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1648
1753
|
const text = readFileSync(p, 'utf-8');
|
|
1649
1754
|
const v = verifyEventChainText(text);
|
|
1650
1755
|
if (v.chained === 0 || v.ok) continue;
|
|
1651
|
-
|
|
1756
|
+
// The verifier already counted non-empty lines; re-deriving it drifts (see cli.ts note).
|
|
1757
|
+
const total = v.lines;
|
|
1652
1758
|
const age = classifyChainDefects(v, total);
|
|
1653
1759
|
const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
|
|
1654
1760
|
// A break that an unbroken run has already outlived is not a reason to distrust today's
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { scanSecretsChunked } from './guard.js';
|
|
6
|
+
import { NamedLockTimeoutError, withDirLockSync } from './named-lock.js';
|
|
7
|
+
import { listPackFiles, listSignablePackFiles } from './sign.js';
|
|
8
|
+
|
|
9
|
+
export type PackerId = 'npm' | 'pnpm';
|
|
10
|
+
export type InventorySource = 'tarball' | 'npm-dry-run' | 'cache' | 'fallback-walk';
|
|
11
|
+
export type PackExec = (cmd: string, opts: { readonly cwd: string; readonly timeoutMs: number }) => {
|
|
12
|
+
readonly stdout: string;
|
|
13
|
+
readonly stderr: string;
|
|
14
|
+
};
|
|
15
|
+
export type ChunkReader = (absolutePath: string, chunkBytes: number) => Iterable<Buffer>;
|
|
16
|
+
|
|
17
|
+
export interface ListPublishInventoryOptions {
|
|
18
|
+
readonly packer?: PackerId;
|
|
19
|
+
readonly tgzPath?: string;
|
|
20
|
+
readonly exec?: PackExec;
|
|
21
|
+
readonly cacheDir?: string;
|
|
22
|
+
readonly timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PublishInventory {
|
|
26
|
+
readonly files: readonly string[];
|
|
27
|
+
readonly source: InventorySource;
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
readonly packer: PackerId;
|
|
30
|
+
readonly packerVersion: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PublishSecretSeams {
|
|
34
|
+
readonly listInventory?: typeof listPublishInventory;
|
|
35
|
+
readonly reader?: ChunkReader;
|
|
36
|
+
readonly tgzPathFor?: (dir: string) => string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Scan the publish inventory as streams, retaining every coverage gap by name. */
|
|
40
|
+
export function gatherPublishSecretFacts(root: string, packageDirs: readonly string[], seams: PublishSecretSeams = {}):
|
|
41
|
+
{ readonly secretFindings: readonly { label: string; name: string }[];
|
|
42
|
+
readonly secretScan: { skipped: number; skippedPaths: string[]; scanned: number; inventory: string } } {
|
|
43
|
+
const listInventory = seams.listInventory ?? listPublishInventory;
|
|
44
|
+
const reader = seams.reader ?? readFileChunks;
|
|
45
|
+
const secretFindings: { label: string; name: string }[] = [];
|
|
46
|
+
const skippedPaths: string[] = [];
|
|
47
|
+
let scanned = 0;
|
|
48
|
+
const counts: Record<InventorySource, number> = { tarball: 0, cache: 0, 'npm-dry-run': 0, 'fallback-walk': 0 };
|
|
49
|
+
const fallbacks: string[] = [];
|
|
50
|
+
const skip = (label: string, reason: string): void => { skippedPaths.push(`${label} (${reason})`); };
|
|
51
|
+
|
|
52
|
+
for (const dir of packageDirs) {
|
|
53
|
+
const packageRoot = join(root, dir);
|
|
54
|
+
let inventory: PublishInventory;
|
|
55
|
+
try {
|
|
56
|
+
// Lead edit (tsc exactOptionalPropertyTypes): an absent tgzPath must be ABSENT, not `undefined`.
|
|
57
|
+
const tgz = seams.tgzPathFor?.(dir);
|
|
58
|
+
inventory = listInventory(packageRoot, {
|
|
59
|
+
cacheDir: join(root, '.dz', 'cache'),
|
|
60
|
+
...(tgz !== undefined ? { tgzPath: tgz } : {}),
|
|
61
|
+
});
|
|
62
|
+
} catch { skip(dir, 'inventory unreadable'); continue; }
|
|
63
|
+
counts[inventory.source]++;
|
|
64
|
+
if (inventory.source === 'fallback-walk') fallbacks.push(`${dir} (${inventory.reason ?? 'unknown reason'})`);
|
|
65
|
+
|
|
66
|
+
for (const rel of inventory.files) {
|
|
67
|
+
const absolute = join(packageRoot, rel);
|
|
68
|
+
const label = relative(root, absolute).split(sep).join('/');
|
|
69
|
+
try {
|
|
70
|
+
if (!lstatSync(absolute).isFile()) { skip(label, 'not a regular file'); continue; }
|
|
71
|
+
let binary = false;
|
|
72
|
+
let unreadable = false;
|
|
73
|
+
function* textChunks(): Iterable<Buffer> {
|
|
74
|
+
let first = true;
|
|
75
|
+
try {
|
|
76
|
+
for (const chunk of reader(absolute, 1024 * 1024)) {
|
|
77
|
+
if (first && chunk.subarray(0, 8 * 1024).includes(0)) { binary = true; return; }
|
|
78
|
+
first = false;
|
|
79
|
+
yield chunk;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// End the iterable normally so the scanner can return hits already found.
|
|
83
|
+
unreadable = true;
|
|
84
|
+
skip(label, first ? 'unreadable' : 'unreadable (partial)');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const findings = scanSecretsChunked(textChunks());
|
|
88
|
+
if (binary) { skip(label, 'binary'); continue; }
|
|
89
|
+
for (const { name } of findings) secretFindings.push({ label, name });
|
|
90
|
+
if (!unreadable) scanned++;
|
|
91
|
+
} catch { skip(label, 'unreadable'); }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sources: InventorySource[] = ['tarball', 'cache', 'npm-dry-run', 'fallback-walk'];
|
|
96
|
+
const summary = sources.filter((source) => counts[source] > 0).map((source) => `${source}×${counts[source]}`).join(', ');
|
|
97
|
+
return { secretFindings, secretScan: {
|
|
98
|
+
skipped: skippedPaths.length, skippedPaths, scanned,
|
|
99
|
+
inventory: summary + (fallbacks.length > 0 ? `: ${fallbacks.join(', ')}` : ''),
|
|
100
|
+
} };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reject malformed or empty listings so they cannot suppress scanning. */
|
|
104
|
+
export function parseNpmPackListing(stdout: string): readonly string[] | null {
|
|
105
|
+
let listing: unknown;
|
|
106
|
+
try {
|
|
107
|
+
listing = JSON.parse(stdout);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(listing) || listing.length === 0) return null;
|
|
112
|
+
const paths = new Set<string>();
|
|
113
|
+
for (const entry of listing) {
|
|
114
|
+
if (entry === null || typeof entry !== 'object' || !Array.isArray(entry.files) || entry.files.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
for (const file of entry.files) {
|
|
118
|
+
if (file === null || typeof file !== 'object' || typeof file.path !== 'string' || file.path.length === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
paths.add(file.path);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...paths].sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Versioned key over packer identity, package metadata bytes, and the signable file walk. */
|
|
128
|
+
export function computeInventoryCacheKey(packageRoot: string, packer: PackerId, packerVersion: string): string {
|
|
129
|
+
const hash = createHash('sha256');
|
|
130
|
+
hash.update(`pack-inventory/1\n${packer}@${packerVersion}\n`);
|
|
131
|
+
hash.update(readFileSync(join(packageRoot, 'package.json')));
|
|
132
|
+
hash.update('\n');
|
|
133
|
+
try {
|
|
134
|
+
hash.update(readFileSync(join(packageRoot, '.npmignore')));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
137
|
+
hash.update('<absent>');
|
|
138
|
+
}
|
|
139
|
+
hash.update('\n');
|
|
140
|
+
for (const relpath of listSignablePackFiles(packageRoot).sort()) {
|
|
141
|
+
const { size, mtimeMs } = statSync(join(packageRoot, relpath));
|
|
142
|
+
hash.update(`${relpath}\0${size}\0${mtimeMs}\n`);
|
|
143
|
+
}
|
|
144
|
+
return hash.digest('hex');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reuse the read buffer, yielding copies that remain valid after subsequent reads. */
|
|
148
|
+
export function* readFileChunks(absolutePath: string, chunkBytes = 1024 * 1024): Iterable<Buffer> {
|
|
149
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) {
|
|
150
|
+
throw new RangeError('chunkBytes must be a positive safe integer');
|
|
151
|
+
}
|
|
152
|
+
const buffer = Buffer.allocUnsafe(chunkBytes);
|
|
153
|
+
const fd = openSync(absolutePath, 'r');
|
|
154
|
+
try {
|
|
155
|
+
let bytesRead: number;
|
|
156
|
+
while ((bytesRead = readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
157
|
+
yield Buffer.from(buffer.subarray(0, bytesRead));
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const defaultExec: PackExec = (cmd, { cwd, timeoutMs }) => ({
|
|
165
|
+
stdout: execSync(cmd, { cwd, timeout: timeoutMs, encoding: 'utf8', stdio: 'pipe' }),
|
|
166
|
+
stderr: '',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Scope memoization to the executor as well as the packer so injected runtimes stay independent.
|
|
170
|
+
const versions = new WeakMap<PackExec, Map<PackerId, string | null>>();
|
|
171
|
+
|
|
172
|
+
function packerVersion(exec: PackExec, packer: PackerId, cwd: string, timeoutMs: number): string | null {
|
|
173
|
+
let memo = versions.get(exec);
|
|
174
|
+
if (!memo) { memo = new Map(); versions.set(exec, memo); }
|
|
175
|
+
if (memo.has(packer)) return memo.get(packer)!;
|
|
176
|
+
let version: string | null = null;
|
|
177
|
+
try {
|
|
178
|
+
const started = Date.now();
|
|
179
|
+
const stdout = exec(`${packer} --version`, { cwd, timeoutMs }).stdout.trim();
|
|
180
|
+
if (Date.now() - started <= timeoutMs && stdout.length > 0) version = stdout;
|
|
181
|
+
} catch { /* Unknown versions must never serve or populate the cache. */ }
|
|
182
|
+
memo.set(packer, version);
|
|
183
|
+
return version;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function repositoryRoot(packageRoot: string): string {
|
|
187
|
+
for (let dir = packageRoot; ; dir = dirname(dir)) {
|
|
188
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml')) || existsSync(join(dir, '.git', 'HEAD'))) return dir;
|
|
189
|
+
if (dirname(dir) === dir) return packageRoot; // standalone package
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface CacheEntry {
|
|
194
|
+
readonly key: string;
|
|
195
|
+
readonly files: readonly string[];
|
|
196
|
+
readonly packer: PackerId;
|
|
197
|
+
readonly packerVersion: string;
|
|
198
|
+
readonly writtenAt: string;
|
|
199
|
+
}
|
|
200
|
+
interface InventoryCache { schema: 1; entries: Record<string, CacheEntry> }
|
|
201
|
+
|
|
202
|
+
function readCache(path: string): InventoryCache {
|
|
203
|
+
try {
|
|
204
|
+
const cache = JSON.parse(readFileSync(path, 'utf8')) as InventoryCache;
|
|
205
|
+
if (cache?.schema === 1 && cache.entries && typeof cache.entries === 'object' && !Array.isArray(cache.entries)) {
|
|
206
|
+
return cache;
|
|
207
|
+
}
|
|
208
|
+
} catch { /* Missing or unparsable cache is a miss. */ }
|
|
209
|
+
return { schema: 1, entries: {} };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Listings must stay relative to the package and cannot represent an empty successful scan. */
|
|
213
|
+
function inventoryPaths(paths: readonly string[]): string[] | null {
|
|
214
|
+
const out = new Set<string>();
|
|
215
|
+
for (const path of paths) {
|
|
216
|
+
if (typeof path !== 'string') return null;
|
|
217
|
+
const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
218
|
+
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)
|
|
219
|
+
|| normalized.split('/').some((part) => part === '..' || part === '' || part === '.')) return null;
|
|
220
|
+
out.add(normalized);
|
|
221
|
+
}
|
|
222
|
+
return out.size > 0 ? [...out].sort() : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function firstErrorLine(error: unknown): string {
|
|
226
|
+
const stderr = (error as { stderr?: unknown } | null)?.stderr;
|
|
227
|
+
const text = typeof stderr === 'string' || Buffer.isBuffer(stderr) ? stderr.toString().trim() : '';
|
|
228
|
+
return (text || (error instanceof Error ? error.message : String(error))).split(/\r?\n/)[0]!;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Prefer the supplied artifact, then a tree/version cache, then stdout from the packer. */
|
|
232
|
+
export function listPublishInventory(packageRoot: string, opts: ListPublishInventoryOptions = {}): PublishInventory {
|
|
233
|
+
packageRoot = resolve(packageRoot);
|
|
234
|
+
const packer = opts.packer ?? 'npm';
|
|
235
|
+
const exec = opts.exec ?? defaultExec;
|
|
236
|
+
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
237
|
+
let version: string | null = null;
|
|
238
|
+
const fallback = (reason: string): PublishInventory => {
|
|
239
|
+
const files = listPackFiles(packageRoot);
|
|
240
|
+
if (files.length === 0) throw new Error('inventory unreadable');
|
|
241
|
+
return { files, source: 'fallback-walk', reason, packer, packerVersion: version };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (opts.tgzPath !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
const path = resolve(packageRoot, opts.tgzPath).replace(/'/g, "'\\''");
|
|
247
|
+
const stdout = exec(`tar -tzf '${path}'`, { cwd: packageRoot, timeoutMs }).stdout;
|
|
248
|
+
const entries = stdout.split(/\r?\n/).filter((entry) => entry.length > 0 && !entry.endsWith('/'));
|
|
249
|
+
if (entries.some((entry) => !entry.startsWith('package/'))) throw new Error('unparsable listing');
|
|
250
|
+
const files = inventoryPaths(entries.map((entry) => entry.slice('package/'.length)));
|
|
251
|
+
if (!files) throw new Error('unparsable listing');
|
|
252
|
+
return { files, source: 'tarball', packer, packerVersion: null };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return fallback(`tarball unreadable: ${firstErrorLine(error)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
version = packerVersion(exec, packer, packageRoot, timeoutMs);
|
|
259
|
+
const root = opts.cacheDir !== undefined ? dirname(dirname(resolve(opts.cacheDir))) : repositoryRoot(packageRoot);
|
|
260
|
+
const cacheDir = opts.cacheDir ?? join(root, '.dz', 'cache');
|
|
261
|
+
const cachePath = join(cacheDir, 'pack-inventory.json');
|
|
262
|
+
const packageDir = relative(root, packageRoot).split(sep).join('/') || '.';
|
|
263
|
+
let key: string | undefined;
|
|
264
|
+
if (version !== null) {
|
|
265
|
+
try { key = computeInventoryCacheKey(packageRoot, packer, version); }
|
|
266
|
+
catch { /* A tree that cannot be keyed must still be listed/scanned. */ }
|
|
267
|
+
}
|
|
268
|
+
if (key !== undefined) {
|
|
269
|
+
try {
|
|
270
|
+
const cached = withDirLockSync(dirname(cacheDir), 'pack-inventory', () => readCache(cachePath).entries[packageDir]);
|
|
271
|
+
const files = cached && Array.isArray(cached.files) ? inventoryPaths(cached.files) : null;
|
|
272
|
+
if (cached?.key === key && cached.packer === packer && cached.packerVersion === version && files) {
|
|
273
|
+
return { files, source: 'cache', packer, packerVersion: version };
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!(error instanceof NamedLockTimeoutError)) throw error;
|
|
277
|
+
key = undefined; // A lock timeout is a miss, with no later write in this call.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let stdout: string;
|
|
282
|
+
const started = Date.now();
|
|
283
|
+
try {
|
|
284
|
+
stdout = exec(`${packer} pack --dry-run --json`, { cwd: packageRoot, timeoutMs }).stdout;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const timedOut = (error as NodeJS.ErrnoException | null)?.code === 'ETIMEDOUT' || Date.now() - started > timeoutMs;
|
|
287
|
+
return fallback(timedOut ? `packer timeout ${timeoutMs} ms` : `packer failed: ${firstErrorLine(error)}`);
|
|
288
|
+
}
|
|
289
|
+
if (Date.now() - started > timeoutMs) return fallback(`packer timeout ${timeoutMs} ms`);
|
|
290
|
+
const parsed = parseNpmPackListing(stdout);
|
|
291
|
+
const files = parsed && inventoryPaths(parsed);
|
|
292
|
+
if (!files) return fallback('unparsable listing');
|
|
293
|
+
|
|
294
|
+
if (key !== undefined && version !== null) {
|
|
295
|
+
const entry: CacheEntry = { key, files, packer, packerVersion: version, writtenAt: new Date().toISOString() };
|
|
296
|
+
try {
|
|
297
|
+
withDirLockSync(dirname(cacheDir), 'pack-inventory', () => {
|
|
298
|
+
// Re-read INSIDE the write transaction, preserving entries another package just added.
|
|
299
|
+
const cache = readCache(cachePath);
|
|
300
|
+
cache.entries[packageDir] = entry;
|
|
301
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
302
|
+
const temp = `${cachePath}.${randomUUID()}.tmp`;
|
|
303
|
+
try {
|
|
304
|
+
writeFileSync(temp, JSON.stringify(cache));
|
|
305
|
+
renameSync(temp, cachePath);
|
|
306
|
+
} finally { rmSync(temp, { force: true }); }
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (!(error instanceof NamedLockTimeoutError)) throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { files, source: 'npm-dry-run', packer, packerVersion: version };
|
|
313
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Package skill-layout resolution — where an installed npm package keeps its skills.
|
|
3
3
|
*
|
|
4
|
-
* `dz install <pkg>`
|
|
4
|
+
* `dz install <pkg>` and `dz registry`
|
|
5
5
|
* must find `SKILL.md` skill dirs inside an npm package. Packages in the wild ship three
|
|
6
6
|
* on-disk arrangements; before this module existed, `cmdInstall` hard-coded exactly one
|
|
7
7
|
* (`flat`), so every npx-init pack (`templates/.claude/skills/…`) and bare-`skills/`
|
|
@@ -51,7 +51,8 @@ export interface PackageSkillRoot {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
54
|
+
* Shared ordered layout probe list. The resolver selects the first non-empty root;
|
|
55
|
+
* the registry collects all roots, with the first occurrence of each skill id winning.
|
|
55
56
|
*
|
|
56
57
|
* `flat` is probed first so a classic pack can never be re-interpreted; `npx-template`
|
|
57
58
|
* before `skills-dir` matches the measured census (13 vs 2 packages). Extending support
|
package/src/patterns.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface PatternRecord {
|
|
|
49
49
|
readonly type: 'rule' | 'success-pattern' | 'lesson-learned';
|
|
50
50
|
/** Reward signal in [0, 1]. */
|
|
51
51
|
readonly reward: number;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the reward was explicit or defaulted: measured rewards had only 7 distinct
|
|
54
|
+
* values, a median of 0.8, and covered 5.5% of impressions (338 / 6140).
|
|
55
|
+
* This field does NOT change the default reward value; absent means unknown for legacy records.
|
|
56
|
+
* Set at record creation and unchanged by subsequent reinforce/guard-reinforce calls,
|
|
57
|
+
* even with an explicit --reward, it describes the first invocation, not the latest.
|
|
58
|
+
*/
|
|
59
|
+
readonly rewardSource?: 'explicit' | 'default';
|
|
52
60
|
/** Domain tag (e.g. "performance", "api", "general"). */
|
|
53
61
|
readonly domain: string;
|
|
54
62
|
/** ISO-8601 timestamp the record was written. */
|
|
@@ -510,12 +518,16 @@ export function patternToRecord(p: PatternRecord): MemoryRecord {
|
|
|
510
518
|
// the learn-loop write path.
|
|
511
519
|
outcome: PATTERN_TYPES.has(p.type) ? p.type : 'lesson-learned',
|
|
512
520
|
timestamp: p.ts,
|
|
513
|
-
metadata: {
|
|
521
|
+
metadata: {
|
|
522
|
+
domain: p.domain ?? 'general', source: p.source ?? 'dz-teach', ...pairMetadata,
|
|
523
|
+
...(p.rewardSource !== undefined ? { rewardSource: p.rewardSource } : {}),
|
|
524
|
+
},
|
|
514
525
|
};
|
|
515
526
|
}
|
|
516
527
|
|
|
517
528
|
/** Anti-corruption mapping: canonical `MemoryRecord` → harness `PatternRecord`. */
|
|
518
529
|
export function recordToPattern(r: MemoryRecord): PatternRecord {
|
|
530
|
+
const rewardSource = r.metadata?.['rewardSource'];
|
|
519
531
|
const lessonForm = r.metadata?.['lessonForm'];
|
|
520
532
|
const lessonPairId = r.metadata?.['lessonPairId'];
|
|
521
533
|
const pair: { lessonForm?: LessonForm; lessonPairId?: string } = (lessonForm === 'specific' || lessonForm === 'class')
|
|
@@ -535,6 +547,7 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
|
|
|
535
547
|
pattern: r.text,
|
|
536
548
|
type: PATTERN_TYPES.has(r.outcome) ? (r.outcome as PatternRecord['type']) : 'lesson-learned',
|
|
537
549
|
reward: r.score,
|
|
550
|
+
...(rewardSource === 'explicit' || rewardSource === 'default' ? { rewardSource } : {}),
|
|
538
551
|
domain: r.metadata?.['domain'] ?? 'general',
|
|
539
552
|
ts: r.timestamp,
|
|
540
553
|
source: r.metadata?.['source'] ?? 'dz-teach',
|
|
@@ -1130,6 +1143,10 @@ function migrationRecords(projectRoot: string): MemoryRecord[] {
|
|
|
1130
1143
|
* JSON file is never deleted. Returns the total record count after the write.
|
|
1131
1144
|
*/
|
|
1132
1145
|
export async function recordPattern(projectRoot: string, p: PatternRecord, opts: { quarantine?: boolean } = {}): Promise<number> {
|
|
1146
|
+
// The WRITER creates the store, not the lock (lock-never-seeds-store, ADR-001 item 6). Guarded by
|
|
1147
|
+
// existsSync so a fixture that plants a FILE at .dz/memory still reaches the real open error
|
|
1148
|
+
// below instead of EEXIST here (patterns.test.ts, forced-sqlite cause).
|
|
1149
|
+
if (!existsSync(join(projectRoot, '.dz', 'memory'))) mkdirSync(join(projectRoot, '.dz', 'memory'), { recursive: true });
|
|
1133
1150
|
if (p.lessonForm === 'class') ensureClassFormMarker(projectRoot);
|
|
1134
1151
|
const { sqliteBackend } = readLearningConfig(projectRoot);
|
|
1135
1152
|
// lesson-quarantine: a fresh lesson is a HYPOTHESIS — mark it when the feature is on. Folded
|