@dzhechkov/harness-core 0.8.45 → 0.8.47
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 +206 -174
- package/README.md +136 -8
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +32 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-snapshot.d.ts +14 -1
- package/dist/agentdb-snapshot.d.ts.map +1 -1
- package/dist/agentdb-snapshot.js +100 -4
- package/dist/agentdb-snapshot.js.map +1 -1
- package/dist/apply-leg.d.ts +9 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +47 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/architecture.d.ts +0 -5
- package/dist/architecture.d.ts.map +1 -1
- package/dist/architecture.js +25 -2
- package/dist/architecture.js.map +1 -1
- package/dist/codex-rollouts.d.ts +35 -7
- package/dist/codex-rollouts.d.ts.map +1 -1
- package/dist/codex-rollouts.js +201 -113
- package/dist/codex-rollouts.js.map +1 -1
- package/dist/cost-ledger.d.ts +42 -3
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +478 -15
- package/dist/cost-ledger.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +16 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +21 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +48 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-plan.d.ts +4 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +4 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/mutation-gate.d.ts +36 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +109 -19
- package/dist/mutation-gate.js.map +1 -1
- package/dist/npm-homepage.d.ts +64 -0
- package/dist/npm-homepage.d.ts.map +1 -0
- package/dist/npm-homepage.js +109 -0
- package/dist/npm-homepage.js.map +1 -0
- package/dist/operations.d.ts +6 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +150 -24
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +13 -0
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +28 -2
- package/dist/pack-inventory.js.map +1 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +4 -1
- package/dist/parity.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +43 -1
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +269 -13
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/publish.d.ts +5 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +18 -1
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +63 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +82 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release-package-audit.d.ts +86 -0
- package/dist/release-package-audit.d.ts.map +1 -0
- package/dist/release-package-audit.js +272 -0
- package/dist/release-package-audit.js.map +1 -0
- package/dist/release.d.ts +8 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +58 -32
- package/dist/release.js.map +1 -1
- package/dist/round-exec-claim.d.ts +36 -0
- package/dist/round-exec-claim.d.ts.map +1 -0
- package/dist/round-exec-claim.js +17 -0
- package/dist/round-exec-claim.js.map +1 -0
- package/dist/round.d.ts +10 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +5 -1
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +11 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +124 -20
- package/dist/run-records.js.map +1 -1
- package/dist/session-retro.d.ts +33 -0
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +170 -34
- package/dist/session-retro.js.map +1 -1
- package/dist/sign.d.ts +44 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +126 -1
- package/dist/sign.js.map +1 -1
- package/dist/stage-usage.d.ts +148 -0
- package/dist/stage-usage.d.ts.map +1 -0
- package/dist/stage-usage.js +261 -0
- package/dist/stage-usage.js.map +1 -0
- package/dist/statusline.d.ts +23 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +167 -1
- package/dist/statusline.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +21 -19
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +206 -95
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +12 -3
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +29 -2
- package/dist/workflow-run.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +309 -229
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +47 -10
- package/src/architecture.ts +15 -2
- package/src/codex-rollouts.ts +155 -147
- package/src/cost-ledger.ts +308 -19
- package/src/feature-adr-routing.ts +22 -0
- package/src/guard.ts +52 -0
- package/src/index.ts +19 -3
- package/src/loop-plan.ts +8 -0
- package/src/mutation-gate.ts +153 -19
- package/src/npm-homepage.ts +142 -0
- package/src/operations.ts +177 -22
- package/src/pack-inventory.ts +27 -2
- package/src/parity.ts +4 -1
- package/src/publish-sibling-drift.ts +214 -11
- package/src/publish.ts +20 -1
- package/src/qe-bridge.ts +86 -1
- package/src/release-package-audit.ts +264 -0
- package/src/release.ts +55 -22
- package/src/round-exec-claim.ts +43 -0
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/session-retro.ts +153 -31
- package/src/sign.ts +136 -1
- package/src/stage-usage.ts +191 -0
- package/src/statusline.ts +149 -1
- package/src/workflow-run-dispatch.ts +148 -89
- package/src/workflow-run.ts +40 -4
- package/dist/ledger-cost-fill.d.ts +0 -58
- package/dist/ledger-cost-fill.d.ts.map +0 -1
- package/dist/ledger-cost-fill.js +0 -78
- package/dist/ledger-cost-fill.js.map +0 -1
- package/dist/retro.d.ts +0 -131
- package/dist/retro.d.ts.map +0 -1
- package/dist/retro.js +0 -207
- package/dist/retro.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -120
- package/dist/sbom.js.map +0 -1
package/src/operations.ts
CHANGED
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
buildCodexHookManifest,
|
|
19
19
|
codexHooksPaths,
|
|
20
20
|
diffCodexHooks,
|
|
21
|
+
looksLikeDzEntry,
|
|
21
22
|
parseCodexHookManifest,
|
|
22
23
|
planCodexHooks,
|
|
23
24
|
removeCodexHooks,
|
|
24
25
|
selectOwnHookMetadata,
|
|
25
26
|
upsertTrustBlock,
|
|
26
27
|
type CodexHooksPaths,
|
|
28
|
+
type CodexRegistry,
|
|
27
29
|
type HooksListHookMetadata,
|
|
28
30
|
type ManagedEntry,
|
|
29
31
|
} from './codex-hooks.js';
|
|
@@ -1922,6 +1924,12 @@ export interface CodexHooksSyncOptions {
|
|
|
1922
1924
|
readonly probeModel?: string;
|
|
1923
1925
|
/** Test seam: replace the live probe. Production runs {@link runCodexVetoProbe}. */
|
|
1924
1926
|
readonly probe?: (options: CodexVetoProbeOptions) => CodexVetoProbeRun;
|
|
1927
|
+
/**
|
|
1928
|
+
* Test seam (codex-hooks-remove-never-orphans, review r1 item 1): how a helper body reaches disk.
|
|
1929
|
+
* Production writes atomically; a test injects a failing writer (ENOSPC) to prove the section-2
|
|
1930
|
+
* restore fails CLOSED. Nothing else in the operation goes through it.
|
|
1931
|
+
*/
|
|
1932
|
+
readonly writeHelperFile?: (path: string, body: string) => void;
|
|
1925
1933
|
}
|
|
1926
1934
|
|
|
1927
1935
|
export interface CodexHooksSyncReport {
|
|
@@ -2204,24 +2212,44 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2204
2212
|
// 2 deleted them after the lock was released, so a concurrent installer could observe (and
|
|
2205
2213
|
// rebuild against) a registry that had already been emptied — or leave a manifest describing
|
|
2206
2214
|
// entries that no longer exist. One decision, one critical section.
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2215
|
+
//
|
|
2216
|
+
// codex-hooks-remove-never-orphans (ADR-001): the helpers go ONLY when nothing left in the
|
|
2217
|
+
// registry still points at them. Keyed on the OBSERVED fact — dz-like entries surviving this
|
|
2218
|
+
// plan — not on a flag: an installer between its two sections (no manifest yet), or a manifest
|
|
2219
|
+
// deleted by hand, leaves entries we cannot attribute; deleting their helpers anyway turned the
|
|
2220
|
+
// entry into `exit 127`, which Codex reads as ALLOW. A dead reference is worse than a stray file.
|
|
2221
|
+
//
|
|
2222
|
+
// Review r1 item 3: "nothing left points at them" is measured on the registry AS IT WILL BE
|
|
2223
|
+
// WRITTEN — every surviving handler, attributed-and-salvaged or not. `unattributable` counts
|
|
2224
|
+
// whole unattributed entries only; a handler salvaged from an attributed matcher group (another
|
|
2225
|
+
// interpreter sharing these helpers) would slip past it and lose its helper.
|
|
2226
|
+
if (!planned.ok) return { planned, references: 0 };
|
|
2227
|
+
const references = dzHelperReferences(planned.result.registry);
|
|
2228
|
+
const doomed = references > 0 ? [paths.manifest] : [paths.vetoHelper, paths.recallHelper, paths.manifest];
|
|
2229
|
+
for (const p of doomed) rmSync(p, { force: true });
|
|
2230
|
+
return { planned, references };
|
|
2211
2231
|
});
|
|
2212
|
-
|
|
2232
|
+
const planned = removal.planned;
|
|
2233
|
+
if (!planned.ok) return { ...base, codexVersion, exitCode: 1, warnings, errors: [planned.error] };
|
|
2234
|
+
// A remove that leaves dz references in the registry did not remove dz — it must not say it did.
|
|
2235
|
+
const kept = removal.references;
|
|
2213
2236
|
return {
|
|
2214
2237
|
...base,
|
|
2215
2238
|
codexVersion,
|
|
2216
|
-
written:
|
|
2217
|
-
removed:
|
|
2218
|
-
unattributable:
|
|
2219
|
-
exitCode: 0,
|
|
2220
|
-
warnings
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2239
|
+
written: planned.result.changed,
|
|
2240
|
+
removed: planned.result.removed,
|
|
2241
|
+
unattributable: planned.result.unattributable,
|
|
2242
|
+
exitCode: kept > 0 ? 1 : 0,
|
|
2243
|
+
warnings,
|
|
2244
|
+
errors:
|
|
2245
|
+
kept > 0
|
|
2246
|
+
? [
|
|
2247
|
+
`remove-unattributed-entries-kept: ${kept} handler(s) in ${paths.registry} still reference the dz helpers but are ` +
|
|
2248
|
+
'not manifest-attributed, so they were KEPT — and the helper files they reference were KEPT with them ' +
|
|
2249
|
+
'(deleting a referenced helper makes the hook exit 127, which Codex treats as ALLOW). An install may be ' +
|
|
2250
|
+
'in flight: let it finish and re-run `--remove`; otherwise delete those entries by hand, then re-run.',
|
|
2251
|
+
]
|
|
2252
|
+
: errors,
|
|
2225
2253
|
writes,
|
|
2226
2254
|
};
|
|
2227
2255
|
}
|
|
@@ -2305,8 +2333,8 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2305
2333
|
if (!plan.ok) return { plan, wrote: false as const, freshText };
|
|
2306
2334
|
mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
|
|
2307
2335
|
const helpers = generateCodexHelpers();
|
|
2308
|
-
writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes);
|
|
2309
|
-
writeHelperIfChanged(paths.recallHelper, helpers.recall, writes);
|
|
2336
|
+
writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes, options.writeHelperFile);
|
|
2337
|
+
writeHelperIfChanged(paths.recallHelper, helpers.recall, writes, options.writeHelperFile);
|
|
2310
2338
|
if (plan.plan.changed) {
|
|
2311
2339
|
backupRegistry(paths, freshText, now, writes);
|
|
2312
2340
|
atomicWrite(paths.registry, plan.plan.text);
|
|
@@ -2368,13 +2396,58 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2368
2396
|
const nowText = existsSync(paths.registry) ? readFileSync(paths.registry, 'utf8') : undefined;
|
|
2369
2397
|
const nowDrift = diffCodexHooks(nowText, entries, undefined);
|
|
2370
2398
|
if (!nowDrift.installed) {
|
|
2371
|
-
return { wrote: false, drift: nowDrift };
|
|
2399
|
+
return { wrote: false, drift: nowDrift, restored: false, restoreFailure: null };
|
|
2372
2400
|
}
|
|
2401
|
+
// codex-hooks-remove-never-orphans (ADR-001): the entries are ours and present — so are their
|
|
2402
|
+
// helpers, or the registry points into nothing (exit 127 = ALLOW). Something that ignores the lock
|
|
2403
|
+
// (an older dz's `--remove`, a hand) may have deleted or altered them since section one. Re-assert
|
|
2404
|
+
// them HERE, under the lock, before the manifest calls this install complete. Two small writes,
|
|
2405
|
+
// no probe: the section stays a short transaction.
|
|
2406
|
+
const before = writes.length;
|
|
2407
|
+
try {
|
|
2408
|
+
mkdirSync(paths.helperDir, { recursive: true, mode: 0o700 });
|
|
2409
|
+
const helpers = generateCodexHelpers();
|
|
2410
|
+
writeHelperIfChanged(paths.vetoHelper, helpers.veto, writes, options.writeHelperFile);
|
|
2411
|
+
writeHelperIfChanged(paths.recallHelper, helpers.recall, writes, options.writeHelperFile);
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
// Review r1 item 1: the restore FAILED (ENOSPC, a file where the helper dir should be, …). The
|
|
2414
|
+
// entries this install wrote now point at helpers that may not exist — fail CLOSED on the
|
|
2415
|
+
// invariant: take OUR entries back out of the registry (same lock, same transaction), write no
|
|
2416
|
+
// manifest, and let the report say why. Foreign entries are untouched by construction:
|
|
2417
|
+
// `removeCodexHooks` attributes by the hashes of the commands this very run emitted.
|
|
2418
|
+
return {
|
|
2419
|
+
wrote: false,
|
|
2420
|
+
drift: nowDrift,
|
|
2421
|
+
restored: false,
|
|
2422
|
+
restoreFailure: rollBackOwnEntries(paths, entries, nowText, manifestText, `${now}-rollback`, writes, String((err as Error)?.message ?? err)),
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
const restored = writes.length > before;
|
|
2373
2426
|
atomicWrite(paths.manifest, manifestText);
|
|
2374
2427
|
writes.push(paths.manifest);
|
|
2375
|
-
return { wrote: true, drift: nowDrift };
|
|
2428
|
+
return { wrote: true, drift: nowDrift, restored, restoreFailure: null };
|
|
2376
2429
|
});
|
|
2377
|
-
if (
|
|
2430
|
+
if (manifestOutcome.restoreFailure !== null) {
|
|
2431
|
+
const f = manifestOutcome.restoreFailure;
|
|
2432
|
+
errors.push(
|
|
2433
|
+
`helpers-restore-failed: the helper files were missing or altered after the registry transaction and could not be ` +
|
|
2434
|
+
`rewritten (${f.cause}). ` +
|
|
2435
|
+
(f.rolledBack
|
|
2436
|
+
? 'This install\u2019s own entries were REMOVED from the registry again, so none of THEM references a missing helper (exit 127 = ALLOW); no manifest was written.'
|
|
2437
|
+
: `Rolling this install\u2019s entries back ALSO failed (${f.rollbackError ?? 'unknown'}) — ${paths.registry} may reference a missing helper: fix the disk and re-run, or run \`--remove\`.`) +
|
|
2438
|
+
(f.missing.length > 0
|
|
2439
|
+
? ` Missing helper path(s): ${f.missing.join(', ')}. Handlers not written by this run that reference these paths were left untouched and will exit 127 (Codex reads that as ALLOW) — run \`dz hooks-sync --target codex --check\` and repair.`
|
|
2440
|
+
: ''),
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
if (manifestOutcome.restored) {
|
|
2444
|
+
warnings.push(
|
|
2445
|
+
'helpers-restored: the helper files were missing or altered between the registry transaction and the manifest ' +
|
|
2446
|
+
'write (an older dz `--remove`, or a process that ignores the codex-hooks lock) — they were rewritten under the lock so the ' +
|
|
2447
|
+
'registry never references a helper that does not exist.',
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
if (!manifestOutcome.wrote && manifestOutcome.restoreFailure === null) {
|
|
2378
2451
|
warnings.push(
|
|
2379
2452
|
'the registry no longer carries this install\u2019s entries — a concurrent remover won the window between the ' +
|
|
2380
2453
|
'registry transaction and the manifest write, so NO manifest was written (a manifest describing entries that are ' +
|
|
@@ -2382,7 +2455,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2382
2455
|
);
|
|
2383
2456
|
}
|
|
2384
2457
|
|
|
2385
|
-
const drift = manifestOutcome.drift;
|
|
2458
|
+
const drift = manifestOutcome.restoreFailure !== null ? manifestOutcome.restoreFailure.drift : manifestOutcome.drift;
|
|
2386
2459
|
const installed = drift.installed;
|
|
2387
2460
|
const armedState = installed && executable && trustResult.trust === 'trusted';
|
|
2388
2461
|
// The install is not finished when the file is written — it is finished when the guard has been
|
|
@@ -2615,13 +2688,95 @@ function atomicWrite(path: string, text: string): void {
|
|
|
2615
2688
|
}
|
|
2616
2689
|
|
|
2617
2690
|
/** 0600: the helpers are invoked as `node <path>`, never executed directly (AM-35c). */
|
|
2618
|
-
function writeHelperIfChanged(
|
|
2691
|
+
function writeHelperIfChanged(
|
|
2692
|
+
path: string,
|
|
2693
|
+
body: string,
|
|
2694
|
+
writes: string[],
|
|
2695
|
+
write: (path: string, body: string) => void = atomicWrite,
|
|
2696
|
+
): void {
|
|
2619
2697
|
const current = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
2620
2698
|
if (current === body) return; // byte-idempotence: no change ⇒ no write ⇒ trust survives (FR-2)
|
|
2621
|
-
|
|
2699
|
+
write(path, body);
|
|
2622
2700
|
writes.push(path);
|
|
2623
2701
|
}
|
|
2624
2702
|
|
|
2703
|
+
/**
|
|
2704
|
+
* Handlers in a registry that reference the dz helper files (codex-hooks-remove-never-orphans).
|
|
2705
|
+
* Counted per HANDLER, over every surviving entry — so a foreign-looking handler salvaged from an
|
|
2706
|
+
* attributed matcher group still counts as a reference.
|
|
2707
|
+
*/
|
|
2708
|
+
function dzHelperReferences(registry: CodexRegistry): number {
|
|
2709
|
+
let n = 0;
|
|
2710
|
+
for (const list of Object.values(registry.hooks)) {
|
|
2711
|
+
for (const entry of list) {
|
|
2712
|
+
const e = entry as { hooks?: unknown };
|
|
2713
|
+
const handlers = Array.isArray(e?.hooks) ? (e.hooks as unknown[]) : [entry];
|
|
2714
|
+
for (const h of handlers) if (looksLikeDzEntry(h)) n += 1;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
return n;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
interface HelperRestoreFailure {
|
|
2721
|
+
readonly cause: string;
|
|
2722
|
+
readonly rolledBack: boolean;
|
|
2723
|
+
readonly rollbackError: string | null;
|
|
2724
|
+
/** Helper paths of this home that are absent on disk after the failed restore. */
|
|
2725
|
+
readonly missing: readonly string[];
|
|
2726
|
+
readonly drift: ReturnType<typeof diffCodexHooks>;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Section-2 fail-closed path: remove exactly the entries THIS run emitted (attributed by the hashes
|
|
2731
|
+
* in the manifest it was about to write) and any stale manifest, under the caller's lock.
|
|
2732
|
+
*/
|
|
2733
|
+
function rollBackOwnEntries(
|
|
2734
|
+
paths: CodexHooksPaths,
|
|
2735
|
+
entries: readonly ManagedEntry[],
|
|
2736
|
+
registryText: string | undefined,
|
|
2737
|
+
manifestText: string,
|
|
2738
|
+
backupStamp: string,
|
|
2739
|
+
writes: string[],
|
|
2740
|
+
cause: string,
|
|
2741
|
+
): HelperRestoreFailure {
|
|
2742
|
+
const ours = parseCodexHookManifest(manifestText);
|
|
2743
|
+
const planned = removeCodexHooks(registryText, ours);
|
|
2744
|
+
let rolledBack = false;
|
|
2745
|
+
let rollbackError: string | null = null;
|
|
2746
|
+
let finalText = registryText;
|
|
2747
|
+
if (!planned.ok) {
|
|
2748
|
+
rollbackError = planned.error;
|
|
2749
|
+
} else {
|
|
2750
|
+
try {
|
|
2751
|
+
if (planned.result.changed) {
|
|
2752
|
+
backupRegistry(paths, registryText, backupStamp, writes);
|
|
2753
|
+
atomicWrite(paths.registry, planned.result.text);
|
|
2754
|
+
writes.push(paths.registry);
|
|
2755
|
+
}
|
|
2756
|
+
finalText = planned.result.text;
|
|
2757
|
+
rolledBack = true;
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
rollbackError = String((err as Error)?.message ?? err);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
// A stale manifest from an earlier install would now describe entries that are gone.
|
|
2763
|
+
try {
|
|
2764
|
+
rmSync(paths.manifest, { force: true });
|
|
2765
|
+
} catch { /* the helper dir may itself be the obstacle (a file) — nothing to describe there */ }
|
|
2766
|
+
// Named, never pruned (AM-6, review r3): a handler this run did not write is left untouched even if
|
|
2767
|
+
// it references a missing helper. Deciding "does this shell command execute our helper?" needs a
|
|
2768
|
+
// shell parser; a substring match misfired BOTH ways (removed a valid foreign guard that merely
|
|
2769
|
+
// mentioned the path; missed an escaped path). Detection is `--check`'s job.
|
|
2770
|
+
const missing = [paths.vetoHelper, paths.recallHelper].filter((p) => !existsSync(p));
|
|
2771
|
+
return {
|
|
2772
|
+
cause,
|
|
2773
|
+
rolledBack,
|
|
2774
|
+
rollbackError,
|
|
2775
|
+
missing,
|
|
2776
|
+
drift: diffCodexHooks(finalText, entries, undefined),
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2625
2780
|
function backupRegistry(paths: CodexHooksPaths, currentText: string | undefined, now: string, writes: string[]): void {
|
|
2626
2781
|
if (currentText === undefined) return;
|
|
2627
2782
|
const backup = `${paths.registry}.bak-${now.replace(/[:.]/g, '-')}`;
|
package/src/pack-inventory.ts
CHANGED
|
@@ -36,6 +36,26 @@ export interface PublishSecretSeams {
|
|
|
36
36
|
readonly tgzPathFor?: (dir: string) => string | undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Share of NUL bytes above which a sample is binary. MEASURED 2026-09-21 (backlog d3841a3b): one NUL in
|
|
41
|
+
* 19 628 bytes of TypeScript made the old `includes(0)` sniff skip a packed TEXT file from the no-secrets scan.
|
|
42
|
+
*/
|
|
43
|
+
export const NUL_RATIO = 0.01;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* PURE binary sniff over the first bytes of a file: `true` iff NUL bytes exceed `NUL_RATIO` of the sample length.
|
|
47
|
+
* Empty ⇒ text. ONLY NULs count (fix round 1, F1): the rule this replaces skipped on a NUL and nothing else, so a
|
|
48
|
+
* criterion over other control bytes or UTF-8 validity would SKIP files the old rule SCANNED — a latin-1 source with
|
|
49
|
+
* a secret, a text with \x01 separators — and that is a fail-open regression. One NUL in 8 KiB is text and gets
|
|
50
|
+
* scanned; UTF-16 (about half NULs) is binary as before; a PNG with few NULs is scanned — harmless, the scan is read-only.
|
|
51
|
+
*/
|
|
52
|
+
export function looksBinarySample(sample: Buffer): boolean {
|
|
53
|
+
if (sample.length === 0) return false;
|
|
54
|
+
let nuls = 0;
|
|
55
|
+
for (const byte of sample) if (byte === 0) nuls++;
|
|
56
|
+
return nuls > NUL_RATIO * sample.length;
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/** Scan the publish inventory as streams, retaining every coverage gap by name. */
|
|
40
60
|
export function gatherPublishSecretFacts(root: string, packageDirs: readonly string[], seams: PublishSecretSeams = {}):
|
|
41
61
|
{ readonly secretFindings: readonly { label: string; name: string }[];
|
|
@@ -74,7 +94,7 @@ export function gatherPublishSecretFacts(root: string, packageDirs: readonly str
|
|
|
74
94
|
let first = true;
|
|
75
95
|
try {
|
|
76
96
|
for (const chunk of reader(absolute, 1024 * 1024)) {
|
|
77
|
-
if (first && chunk.subarray(0, 8 * 1024)
|
|
97
|
+
if (first && looksBinarySample(chunk.subarray(0, 8 * 1024))) { binary = true; return; }
|
|
78
98
|
first = false;
|
|
79
99
|
yield chunk;
|
|
80
100
|
}
|
|
@@ -233,7 +253,12 @@ export function listPublishInventory(packageRoot: string, opts: ListPublishInven
|
|
|
233
253
|
packageRoot = resolve(packageRoot);
|
|
234
254
|
const packer = opts.packer ?? 'npm';
|
|
235
255
|
const exec = opts.exec ?? defaultExec;
|
|
236
|
-
|
|
256
|
+
// Default packer ceiling = 3 × the max `npm pack --dry-run --json` measured UNDER LOAD, rounded up to 1 000 ms.
|
|
257
|
+
// MEASURED 2026-09-26 (features/secret-scan-tails/07_code_changes/packer-timing-under-load.md): two sweeps over
|
|
258
|
+
// all 56 non-private packages/@dzhechkov/* while `npx vitest run` of harness-core ran (load1 2.38–7.73, 8 CPUs):
|
|
259
|
+
// max 5 358 ms (@dzhechkov/harness-core), p95 2 692 ms over 112 calls ⇒ 3 × 5 358 = 16 074 > 15 000 ⇒ 17 000.
|
|
260
|
+
// Supersedes the UNLOADED 15 000 ms (max 3 994 ms, parent AM-2). Reproducer: that file's measure script.
|
|
261
|
+
const timeoutMs = opts.timeoutMs ?? 17_000;
|
|
237
262
|
let version: string | null = null;
|
|
238
263
|
const fallback = (reason: string): PublishInventory => {
|
|
239
264
|
const files = listPackFiles(packageRoot);
|
package/src/parity.ts
CHANGED
|
@@ -397,7 +397,10 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
|
|
|
397
397
|
{
|
|
398
398
|
id: 'statusline-gates',
|
|
399
399
|
title: 'Live 🚦 gates status line',
|
|
400
|
-
forms: [
|
|
400
|
+
forms: [
|
|
401
|
+
{ form: 'statusline integration', requires: ['statusline'], level: 'full' },
|
|
402
|
+
{ form: 'dz statusline --watch (adjacent terminal)', requires: ['shell'], level: 'manual' },
|
|
403
|
+
],
|
|
401
404
|
},
|
|
402
405
|
{
|
|
403
406
|
id: 'project-guards',
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
* @packageDocumentation
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from 'node:fs';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import { join, relative, isAbsolute } from 'node:path';
|
|
32
|
+
import { judgeReleaseCohortAudit } from './release-package-audit.js';
|
|
33
|
+
import { rewriteWorkspaceSpecs } from './pack-artifact.js';
|
|
34
|
+
import { hashPackBytes, verifyManifest, isSafeManifestPath, type SignedManifest } from './sign.js';
|
|
32
35
|
|
|
33
36
|
export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
|
|
34
37
|
|
|
@@ -54,7 +57,7 @@ export interface SiblingDriftResult {
|
|
|
54
57
|
readonly changedFiles: readonly string[];
|
|
55
58
|
/** Export names the workspace's dist/index.js declares that the published one lacks (А2, secondary signal). */
|
|
56
59
|
readonly missingExports: readonly string[];
|
|
57
|
-
/**
|
|
60
|
+
/** Unavailable reason or diagnostic for a verified/declined owner-branding comparison. */
|
|
58
61
|
readonly reason?: string;
|
|
59
62
|
/**
|
|
60
63
|
* AM-6: named per-result (not merely per-call) because `detectSiblingDrift` short-circuits to
|
|
@@ -62,6 +65,8 @@ export interface SiblingDriftResult {
|
|
|
62
65
|
* source that WOULD have been used, so a reader never has to guess.
|
|
63
66
|
*/
|
|
64
67
|
readonly inventorySource: InventorySource;
|
|
68
|
+
/** Verified directional policy class; SAME here does not mean byte-identical. */
|
|
69
|
+
readonly classification?: 'owner-branding-only' | 'retained-registered-binding';
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
export interface FetchedPublished {
|
|
@@ -100,18 +105,46 @@ export interface DetectSiblingDriftOptions {
|
|
|
100
105
|
readonly localInventory?: LocalInventory;
|
|
101
106
|
/** Label for the injected provider (default 'npm-pack'); CLI uses 'pack-artifact'. */
|
|
102
107
|
readonly localInventorySource?: InventorySource;
|
|
108
|
+
/** Repository trust root, supplied externally; never read from either artifact. */
|
|
109
|
+
readonly trustedPublicKeyPem?: string;
|
|
110
|
+
/** Structural validation seam only; normal CLI additionally requires its completed invocation-owned object. */
|
|
111
|
+
readonly retainedBindingProof?: {
|
|
112
|
+
readonly version: 1; readonly phase: 'preview' | 'final'; readonly batch: readonly string[];
|
|
113
|
+
readonly head: string; readonly refs: readonly { ref: string; oid: string }[];
|
|
114
|
+
readonly workspaceVersions: readonly (readonly [string, string])[];
|
|
115
|
+
readonly adapters: Readonly<Record<string, {
|
|
116
|
+
sourceSha256: string; localTreeSha256: string; registryTreeSha256: string; registryIntegrity: string;
|
|
117
|
+
version: string; localFloor: string; registeredFloor: string; creation: string;
|
|
118
|
+
history: readonly { oid: string; blob: string | null; literal: string | null; boundary: boolean }[];
|
|
119
|
+
}>>;
|
|
120
|
+
readonly baseline: { roots: Parameters<typeof judgeReleaseCohortAudit>[0]; evidence: unknown };
|
|
121
|
+
readonly candidate: { roots: Parameters<typeof judgeReleaseCohortAudit>[0]; evidence: unknown };
|
|
122
|
+
readonly digest: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
function listFilesRecursive(root: string, dir: string): string[] {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
function listFilesRecursive(root: string, dir: string, strictPacked = false): string[] {
|
|
128
|
+
try {
|
|
129
|
+
if (!existsSync(dir)) { if (strictPacked) throw new InventoryListingError('missing packed tree'); return []; }
|
|
130
|
+
if (strictPacked && !lstatSync(dir).isDirectory()) throw new InventoryListingError('packed directory is a symlink or unsupported entry');
|
|
131
|
+
const out: string[] = []; const seen = new Set<string>();
|
|
132
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
133
|
+
const abs = join(dir, entry); const path = relative(root, abs);
|
|
134
|
+
const st = strictPacked ? lstatSync(abs) : statSync(abs);
|
|
135
|
+
if (strictPacked) {
|
|
136
|
+
if (!isSafeManifestPath(path) || seen.has(path.toLowerCase())) throw new InventoryListingError('noncanonical or aliased packed path: ' + path);
|
|
137
|
+
seen.add(path.toLowerCase());
|
|
138
|
+
if (!st.isDirectory() && !st.isFile()) throw new InventoryListingError('symlink or special packed entry: ' + path);
|
|
139
|
+
}
|
|
140
|
+
if (st.isDirectory()) out.push(...listFilesRecursive(root, abs, strictPacked));
|
|
141
|
+
else out.push(path);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (strictPacked && !(error instanceof InventoryListingError)) throw new InventoryListingError('physical packed inventory unreadable: ' + String(error));
|
|
146
|
+
throw error;
|
|
113
147
|
}
|
|
114
|
-
return out;
|
|
115
148
|
}
|
|
116
149
|
|
|
117
150
|
function sha256(data: string | Buffer): string {
|
|
@@ -251,6 +284,140 @@ function readManifest(dir: string): Record<string, unknown> | null {
|
|
|
251
284
|
}
|
|
252
285
|
}
|
|
253
286
|
|
|
287
|
+
/** Readonly exception for two original, fully verified artifact trees. Every other delta refuses. */
|
|
288
|
+
function classifyOwnerBrandingDelta(
|
|
289
|
+
localDir: string, registryDir: string, name: string, version: string,
|
|
290
|
+
publicKey: string | undefined, declaredPaths: readonly string[] | undefined, retained?: { localFloor: string; registeredFloor: string },
|
|
291
|
+
): { accepted: boolean; coverageVerified: boolean; changedFiles: string[]; reason: string } {
|
|
292
|
+
let coverageVerified = false;
|
|
293
|
+
const decline = (detail: string) => ({ accepted: false, coverageVerified, changedFiles: [], reason: 'owner-branding not proven: ' + detail });
|
|
294
|
+
if (!publicKey) return decline('no trusted external public key');
|
|
295
|
+
try {
|
|
296
|
+
for (const dir of [localDir, registryDir]) {
|
|
297
|
+
for (const path of ['.dz-manifest.json', 'sbom.json', 'package.json', 'README.md']) {
|
|
298
|
+
if (!lstatSync(join(dir, path)).isFile()) return decline('original metadata is not a regular file');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const localSignedBytes = readFileSync(join(localDir, '.dz-manifest.json'), 'utf8');
|
|
302
|
+
const registrySignedBytes = readFileSync(join(registryDir, '.dz-manifest.json'), 'utf8');
|
|
303
|
+
const localSigned = JSON.parse(localSignedBytes) as SignedManifest;
|
|
304
|
+
const registrySigned = JSON.parse(registrySignedBytes) as SignedManifest;
|
|
305
|
+
// No projected/re-signed object reaches the verifier. Both untouched inventories and SBOMs first.
|
|
306
|
+
if (!verifyManifest(localDir, localSigned, publicKey).ok || !verifyManifest(registryDir, registrySigned, publicKey).ok) return decline('original signature, SBOM or full signed inventory failed verification');
|
|
307
|
+
for (const [signed, bytes] of [[localSigned, localSignedBytes], [registrySigned, registrySignedBytes]] as const) {
|
|
308
|
+
const serializations = [JSON.stringify(signed), JSON.stringify(signed, null, 2)];
|
|
309
|
+
if (!serializations.some(text => bytes === text || bytes === text + '\n')) return decline('manifest serialization has unexplained or ambiguous bytes');
|
|
310
|
+
if (Buffer.from(signed.signature, 'base64').toString('base64') !== signed.signature) return decline('signature encoding is not canonical');
|
|
311
|
+
}
|
|
312
|
+
const paths = listFilesRecursive(localDir, localDir, true).sort();
|
|
313
|
+
const registryPaths = listFilesRecursive(registryDir, registryDir, true).sort();
|
|
314
|
+
if (JSON.stringify(paths) !== JSON.stringify(registryPaths)) return decline('artifact file sets differ');
|
|
315
|
+
if (declaredPaths !== undefined && JSON.stringify([...declaredPaths].sort()) !== JSON.stringify(paths)) return decline('declared packed inventory is incomplete or duplicated');
|
|
316
|
+
// Installed/source verifier exclusions are not packed-artifact signing coverage.
|
|
317
|
+
// Every physical regular leaf is authenticated; only the two validated ROOT metadata files self-exempt.
|
|
318
|
+
for (const [signed, physical] of [[localSigned, paths], [registrySigned, registryPaths]] as const) {
|
|
319
|
+
const signedPaths = signed.manifest.files.map(entry => entry.path).sort();
|
|
320
|
+
if (signedPaths.some(path => !isSafeManifestPath(path) || path === '.dz-manifest.json' || path === 'sbom.json')) return decline('invalid signed leaf or generated-root self-reference');
|
|
321
|
+
const expected = physical.filter(path => path !== '.dz-manifest.json' && path !== 'sbom.json');
|
|
322
|
+
if (JSON.stringify(signedPaths) !== JSON.stringify(expected)) return decline('physical packed leaves are not fully covered by the signed inventory');
|
|
323
|
+
}
|
|
324
|
+
coverageVerified = true;
|
|
325
|
+
const localPkgBytes = readFileSync(join(localDir, 'package.json'));
|
|
326
|
+
const registryPkgBytes = readFileSync(join(registryDir, 'package.json'));
|
|
327
|
+
const localPkg = JSON.parse(localPkgBytes.toString('utf8')) as Record<string, unknown>;
|
|
328
|
+
const registryPkg = JSON.parse(registryPkgBytes.toString('utf8')) as Record<string, unknown>;
|
|
329
|
+
if (localPkg['name'] !== name || registryPkg['name'] !== name || localPkg['version'] !== version || registryPkg['version'] !== version) return decline('package identity/version does not bind both artifacts');
|
|
330
|
+
const source = 'https://github.com/djd1m/dz-harness/tree/main/packages/' + name;
|
|
331
|
+
if (localPkg['homepage'] !== 'https://aicoding.space' || !['https://aicoding.space', source + '#readme'].includes(String(registryPkg['homepage']))) return decline('homepage is not the exact directional owner policy');
|
|
332
|
+
const projectedPkg: Record<string, unknown> = { ...localPkg, homepage: registryPkg['homepage'] };
|
|
333
|
+
if (retained !== undefined) {
|
|
334
|
+
const left = localPkg['dependencies'] as Record<string, unknown> | undefined;
|
|
335
|
+
const right = registryPkg['dependencies'] as Record<string, unknown> | undefined;
|
|
336
|
+
if (!left || !right || left['@dzhechkov/core'] !== retained.localFloor || right['@dzhechkov/core'] !== retained.registeredFloor) return decline('retained exact dependency binding contradicts originals');
|
|
337
|
+
projectedPkg['dependencies'] = { ...left, '@dzhechkov/core': right['@dzhechkov/core'] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Existing v3 canonical handling preserves order-sensitive exports/imports; no dependency values omitted.
|
|
341
|
+
if (hashPackBytes('package.json', Buffer.from(JSON.stringify(projectedPkg))) !== hashPackBytes('package.json', registryPkgBytes)) return decline('packed metadata differs beyond homepage (including dependency values)');
|
|
342
|
+
const stamp = 'Site: https://aicoding.space · Source: ' + source;
|
|
343
|
+
const stripTopStamp = (bytes: Buffer, required: boolean): Buffer | undefined => {
|
|
344
|
+
const text = bytes.toString('utf8'); if (!Buffer.from(text).equals(bytes)) return undefined;
|
|
345
|
+
const lines = text.split('\n');
|
|
346
|
+
const stamps = lines.filter(line => line.startsWith('Site: ') || line.includes(' · Source: '));
|
|
347
|
+
if (stamps.length === 0) return required ? undefined : bytes;
|
|
348
|
+
if (stamps.length !== 1 || !/^# [^\r\n]+$/.test(lines[0] ?? '') || lines[1] !== '' || lines[2] !== stamp || lines[3] !== '') return undefined;
|
|
349
|
+
// Remove only the inserted blank/stamp framing; retain every other byte including trailing newlines.
|
|
350
|
+
return Buffer.from(lines[0] + '\n' + lines.slice(3).join('\n'));
|
|
351
|
+
};
|
|
352
|
+
const localReadme = readFileSync(join(localDir, 'README.md'));
|
|
353
|
+
const registryReadme = readFileSync(join(registryDir, 'README.md'));
|
|
354
|
+
const localRest = stripTopStamp(localReadme, true), registryRest = stripTopStamp(registryReadme, false);
|
|
355
|
+
if (!localRest || !registryRest || !localRest.equals(registryRest)) return decline('README differs beyond the one exact top owner stamp');
|
|
356
|
+
const admitted = new Set<string>();
|
|
357
|
+
if (!localReadme.equals(registryReadme)) admitted.add('README.md');
|
|
358
|
+
if (hashPackBytes('package.json', localPkgBytes, localSigned.manifest.version) !== hashPackBytes('package.json', registryPkgBytes, registrySigned.manifest.version)) admitted.add('package.json');
|
|
359
|
+
if (admitted.size === 0) return decline('no admitted branding leaf changed');
|
|
360
|
+
const canonical = (value: unknown): string => Array.isArray(value) ? '[' + value.map(canonical).join(',') + ']' : value !== null && typeof value === 'object' ? '{' + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => JSON.stringify(k) + ':' + canonical(v)).join(',') + '}' : JSON.stringify(value);
|
|
361
|
+
const projectManifest = (signed: SignedManifest) => ({ ...signed, signature: '<verified-original>', manifest: { ...signed.manifest, files: [...signed.manifest.files].sort((a, b) => a.path.localeCompare(b.path)).map(entry => ({ ...entry, sha256: admitted.has(entry.path) ? '<admitted-digest>' : entry.sha256 })) } });
|
|
362
|
+
if (canonical(projectManifest(localSigned)) !== canonical(projectManifest(registrySigned))) return decline('unexplained signed-manifest field or digest change');
|
|
363
|
+
const projectSbom = (dir: string, signed: SignedManifest) => {
|
|
364
|
+
const sbom = JSON.parse(readFileSync(join(dir, 'sbom.json'), 'utf8'));
|
|
365
|
+
for (const component of sbom.components) {
|
|
366
|
+
if (!admitted.has(component.name)) continue;
|
|
367
|
+
const digest = signed.manifest.files.find(entry => entry.path === component.name)?.sha256;
|
|
368
|
+
for (const hash of component.hashes ?? []) if (hash.alg === 'SHA-256' && hash.content === digest) hash.content = '<admitted-digest>';
|
|
369
|
+
for (const prop of component.properties ?? []) if (['dz:canonical-json-sha256-v1', 'dz:canonical-json-sha256-v2'].includes(prop.name) && prop.value === digest) prop.value = '<admitted-digest>';
|
|
370
|
+
}
|
|
371
|
+
return sbom;
|
|
372
|
+
};
|
|
373
|
+
if (canonical(projectSbom(localDir, localSigned)) !== canonical(projectSbom(registryDir, registrySigned))) return decline('unexplained SBOM field or digest change');
|
|
374
|
+
const changedFiles: string[] = [];
|
|
375
|
+
for (const path of paths) {
|
|
376
|
+
const left = readFileSync(join(localDir, path)), right = readFileSync(join(registryDir, path));
|
|
377
|
+
if (left.equals(right)) continue;
|
|
378
|
+
if (!admitted.has(path) && !['.dz-manifest.json', 'sbom.json'].includes(path)) return decline('other shipped bytes changed: ' + path);
|
|
379
|
+
changedFiles.push(path);
|
|
380
|
+
}
|
|
381
|
+
return { accepted: true, coverageVerified, changedFiles, reason: 'owner-branding-only: both original signatures/full inventories verified; exact owner leaves and corresponding manifest/SBOM digests only' };
|
|
382
|
+
} catch { return decline('artifact proof is missing, unreadable or malformed'); }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function classifyRetainedBindingDelta(opts: DetectSiblingDriftOptions, localDir: string, registryDir: string, sourceDir: string, name: string, version: string, paths: readonly string[] | undefined): ReturnType<typeof classifyOwnerBrandingDelta> | undefined {
|
|
386
|
+
const proof = opts.retainedBindingProof;
|
|
387
|
+
if (!proof) return undefined;
|
|
388
|
+
const decline = (reason: string) => ({ accepted: false, coverageVerified: true, changedFiles: [], reason: 'retained registered binding not proven: ' + reason });
|
|
389
|
+
try {
|
|
390
|
+
const roots = ['harness-core', 'harness-cli', 'skills-meta', 'keysarium', 'skills-feature-adr'].map(n => '@dzhechkov/' + n).sort();
|
|
391
|
+
const adapters = ['adapter-agents-md', 'adapter-claude', 'adapter-codex', 'adapter-copilot', 'adapter-cursor', 'adapter-gemini', 'adapter-hermes', 'adapter-openclaude', 'adapter-opencode', 'adapter-windsurf'].map(n => '@dzhechkov/' + n).sort();
|
|
392
|
+
if (proof.version !== 1 || !['preview', 'final'].includes(proof.phase) || JSON.stringify([...opts.batch].sort()) !== JSON.stringify(roots) || JSON.stringify([...proof.batch].sort()) !== JSON.stringify(roots) || !adapters.includes(name) || opts.batch.has(name) || JSON.stringify(Object.keys(proof.adapters).sort()) !== JSON.stringify(adapters)) return decline('batch or literal adapter identity differs');
|
|
393
|
+
const canonical = (v: unknown): string => Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : v !== null && typeof v === 'object' ? '{' + Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, value]) => JSON.stringify(k) + ':' + canonical(value)).join(',') + '}' : JSON.stringify(v);
|
|
394
|
+
const { digest, ...payload } = proof; if (sha256(canonical(payload)) !== digest || !/^[a-f0-9]{40,64}$/.test(proof.head) || proof.refs.length === 0 || proof.refs.some(r => !/^[a-f0-9]{40,64}$/.test(r.oid))) return decline('snapshot/digest incomplete');
|
|
395
|
+
const baselineExecution = proof.baseline.evidence as Record<string, unknown>, candidateExecution = proof.candidate.evidence as Record<string, unknown>;
|
|
396
|
+
if (canonical(baselineExecution.platform) !== canonical(candidateExecution.platform) || baselineExecution.npmVersion !== candidateExecution.npmVersion) return decline('host/npm identity differs across cohorts');
|
|
397
|
+
const baseline = judgeReleaseCohortAudit(proof.baseline.roots, proof.baseline.evidence), candidate = judgeReleaseCohortAudit(proof.candidate.roots, proof.candidate.evidence);
|
|
398
|
+
if (baseline.status !== 'clean' || candidate.status !== 'clean' || !baseline.graph || !candidate.graph) return decline('fresh five-root cohorts are not both clean and complete');
|
|
399
|
+
for (const adapter of adapters) { const fact = proof.adapters[adapter]; if (!fact || ![baseline.graph, candidate.graph].every(graph => graph.some(node => node.name === adapter && !node.selectedRoot && node.installed && node.version === fact.version && node.integrity === fact.registryIntegrity && node.declarations.dependencies?.['@dzhechkov/core'] === fact.registeredFloor && node.edges.some((edge: any) => edge.table === 'dependencies' && edge.name === '@dzhechkov/core' && graph.some(target => target.path === edge.target && !target.selectedRoot && target.version === fact.registeredFloor))))) return decline('all ten registered adapter bindings must occur in both complete graphs'); }
|
|
400
|
+
const project = (graph: readonly Record<string, any>[]) => graph.map(node => {
|
|
401
|
+
if (!node.selectedRoot) return node;
|
|
402
|
+
const expectedPath = 'node_modules/' + node.name; if (node.path !== expectedPath || !roots.includes(node.name)) throw new Error('selected projection is not a root occurrence');
|
|
403
|
+
const declarations = Object.fromEntries(Object.entries(node.declarations).map(([table, values]) => [table, Object.fromEntries(Object.entries(values as Record<string, string>).map(([dep, spec]) => [dep, node.edges.some((edge: any) => edge.table === table && edge.name === dep && edge.target === 'node_modules/' + dep && roots.includes(dep)) ? '<selected-root-step>' : spec]))]));
|
|
404
|
+
return { ...node, version: '<provided-root>', resolved: '<provided-root>', integrity: '<provided-root>', declarations, edges: node.edges.map((edge: any) => ({ ...edge, spec: roots.includes(edge.name) && edge.target === 'node_modules/' + edge.name ? '<selected-root-step>' : edge.spec })) };
|
|
405
|
+
});
|
|
406
|
+
if (canonical(project(baseline.graph)) !== canonical(project(candidate.graph)) || canonical(baseline.optionalAbsences) !== canonical(candidate.optionalAbsences)) return decline('unselected graph, occurrence, edge or omission differs');
|
|
407
|
+
const record = proof.adapters[name]; if (!record) return decline('adapter record missing'); const sourceBytes = readFileSync(join(sourceDir, 'package.json'));
|
|
408
|
+
hashPackBytes('package.json', sourceBytes); const source = JSON.parse(sourceBytes.toString('utf8'));
|
|
409
|
+
if (sha256(sourceBytes) !== record.sourceSha256 || source.name !== name || source.version !== version || source.dependencies?.['@dzhechkov/core'] !== 'workspace:*' || record.version !== version || !/^[a-f0-9]{40,64}$/.test(record.creation) || !record.history.some(row => row.oid === record.creation && !row.boundary) || record.history.length === 0 || record.history.length > 2048 || record.history.some(row => !/^[a-f0-9]{40,64}$/.test(row.oid) || (row.boundary ? row.literal !== null || row.blob !== null : row.literal !== 'workspace:*' || !/^[a-f0-9]{40,64}$/.test(row.blob ?? '')))) return decline('current source or complete declaration history differs');
|
|
410
|
+
if (JSON.stringify([...opts.workspaceVersions].sort()) !== JSON.stringify([...proof.workspaceVersions].sort())) return decline('captured workspace materialization map changed');
|
|
411
|
+
const expected = JSON.parse(rewriteWorkspaceSpecs(sourceBytes.toString('utf8'), new Map(proof.workspaceVersions))); if (expected.scripts) delete expected.scripts.prepublishOnly;
|
|
412
|
+
const actual = readFileSync(join(localDir, 'package.json')); if (hashPackBytes('package.json', Buffer.from(JSON.stringify(expected))) !== hashPackBytes('package.json', actual)) return decline('source-to-packed materialization differs');
|
|
413
|
+
const treeHash = (dir: string) => sha256(canonical(listFilesRecursive(dir, dir, true).sort().map(path => [path, sha256(readFileSync(join(dir, path)))])));
|
|
414
|
+
if (treeHash(localDir) !== record.localTreeSha256 || treeHash(registryDir) !== record.registryTreeSha256 || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(record.registryIntegrity) || !/^\d+\.\d+\.\d+$/.test(record.registeredFloor) || record.localFloor === record.registeredFloor || expected.dependencies?.['@dzhechkov/core'] !== record.localFloor) return decline('artifact/SRI/exact-floor binding differs');
|
|
415
|
+
const verified = classifyOwnerBrandingDelta(localDir, registryDir, name, version, opts.trustedPublicKeyPem, paths, { localFloor: record.localFloor, registeredFloor: record.registeredFloor });
|
|
416
|
+
if (!verified.accepted) return verified;
|
|
417
|
+
return { ...verified, reason: `retained-registered-binding: registered core ${record.registeredFloor} retained; workspace materializes ${record.localFloor}; fresh cohort/source/artifact proof ${proof.digest}; historical release gitHead not established` };
|
|
418
|
+
} catch { return decline('source/artifact/graph proof unreadable or inconsistent'); }
|
|
419
|
+
}
|
|
420
|
+
|
|
254
421
|
/** package.json normalized for comparison: strip fields that legitimately differ (version, gitHead, npm-internal `_*`). */
|
|
255
422
|
function normalizedPackageJsonText(raw: Record<string, unknown>): string {
|
|
256
423
|
// Lead edit after the live dry-run on the hub (2026-09-13 10:40): the packer strips
|
|
@@ -409,6 +576,16 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
409
576
|
// AM-3: a missing/unparseable package.json on EITHER side must not silently drop out of the
|
|
410
577
|
// comparison (the old `hashTree` simply omitted the key, which — with an empty/matching
|
|
411
578
|
// `dist/**` on both sides — could report `same` about an input that was never actually read).
|
|
579
|
+
if (opts.localInventory !== undefined && inventorySource === 'pack-artifact') {
|
|
580
|
+
try {
|
|
581
|
+
listFilesRecursive(fetched.dir, fetched.dir, true);
|
|
582
|
+
if (!lstatSync(workspaceDir).isDirectory()) throw new InventoryListingError('workspace artifact root is a symlink or unsupported entry');
|
|
583
|
+
if (!lstatSync(join(fetched.dir, 'package.json')).isFile() || !lstatSync(join(workspaceDir, 'package.json')).isFile()) throw new InventoryListingError('package metadata is not a regular file');
|
|
584
|
+
} catch (error) {
|
|
585
|
+
results.push({ name: dep, version, status: 'unavailable', changedFiles: [], missingExports: [], reason: 'physical packed admission failed: ' + String(error), inventorySource });
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
412
589
|
const publishedManifest = readManifest(fetched.dir);
|
|
413
590
|
const workspaceManifest = readManifest(workspaceDir);
|
|
414
591
|
if (publishedManifest === null || workspaceManifest === null) {
|
|
@@ -437,6 +614,8 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
437
614
|
// pack`) correctly includes them, reading as a false "only in workspace" drift. WITHOUT a
|
|
438
615
|
// provider, core has no way to ask npm on either side, so it degrades to the SAME approximation
|
|
439
616
|
// on BOTH sides (symmetry preserved, just cruder) — a named approximation, never a subprocess.
|
|
617
|
+
let localPackedDir: string | undefined;
|
|
618
|
+
let localPackedPaths: readonly string[] | undefined;
|
|
440
619
|
let workspaceHashes: Map<string, string>;
|
|
441
620
|
let publishedHashes: Map<string, string>;
|
|
442
621
|
if (opts.localInventory !== undefined) {
|
|
@@ -453,7 +632,12 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
453
632
|
});
|
|
454
633
|
continue;
|
|
455
634
|
}
|
|
635
|
+
if ('packedDir' in localResult) { localPackedDir = localResult.packedDir; localPackedPaths = localResult.paths; }
|
|
456
636
|
try {
|
|
637
|
+
if ('packedDir' in localResult && inventorySource === 'pack-artifact') {
|
|
638
|
+
const physical = listFilesRecursive(localResult.packedDir, localResult.packedDir, true).sort();
|
|
639
|
+
if (localResult.paths !== undefined && (!Array.isArray(localResult.paths) || localResult.paths.some(path => !isSafeManifestPath(path)) || JSON.stringify([...localResult.paths].sort()) !== JSON.stringify(physical))) throw new InventoryListingError('declared packed inventory is not the complete canonical physical leaf set');
|
|
640
|
+
}
|
|
457
641
|
workspaceHashes = 'packedDir' in localResult
|
|
458
642
|
? localResult.paths !== undefined
|
|
459
643
|
? hashTreeFromPaths(localResult.packedDir, localResult.paths, workspaceManifest)
|
|
@@ -485,6 +669,24 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
485
669
|
}
|
|
486
670
|
changed.sort();
|
|
487
671
|
|
|
672
|
+
const branding = (changed.length > 0 || opts.trustedPublicKeyPem !== undefined) && localPackedDir !== undefined && inventorySource === 'pack-artifact'
|
|
673
|
+
? classifyOwnerBrandingDelta(localPackedDir, fetched.dir, dep, version, opts.trustedPublicKeyPem, localPackedPaths)
|
|
674
|
+
: undefined;
|
|
675
|
+
const retained = !branding?.accepted && localPackedDir !== undefined && inventorySource === 'pack-artifact'
|
|
676
|
+
? classifyRetainedBindingDelta(opts, localPackedDir, fetched.dir, workspaceDir, dep, version, localPackedPaths) : undefined;
|
|
677
|
+
if (retained?.accepted) {
|
|
678
|
+
results.push({ name: dep, version, status: 'same', classification: 'retained-registered-binding', changedFiles: retained.changedFiles, missingExports: [], reason: retained.reason, inventorySource });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (opts.trustedPublicKeyPem !== undefined && branding !== undefined && !branding.coverageVerified) {
|
|
682
|
+
results.push({ name: dep, version, status: 'unavailable', changedFiles: changed, missingExports: [], reason: branding.reason, inventorySource });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (branding?.accepted) {
|
|
686
|
+
results.push({ name: dep, version, status: 'same', classification: 'owner-branding-only', changedFiles: branding.changedFiles, missingExports: [], reason: branding.reason, inventorySource });
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
488
690
|
if (changed.length === 0) {
|
|
489
691
|
results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [], inventorySource });
|
|
490
692
|
} else {
|
|
@@ -493,6 +695,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
493
695
|
version,
|
|
494
696
|
status: 'drift',
|
|
495
697
|
changedFiles: changed,
|
|
698
|
+
...(branding !== undefined ? { reason: branding.reason } : {}),
|
|
496
699
|
missingExports: missingExportNames(fetched.dir, workspaceDir),
|
|
497
700
|
inventorySource,
|
|
498
701
|
});
|