@jinn-network/jinn-layer 0.1.0
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/dist/adapters/contribution-adapter.d.ts +18 -0
- package/dist/adapters/contribution-adapter.js +134 -0
- package/dist/adapters/corpus-adapter.d.ts +12 -0
- package/dist/adapters/corpus-adapter.js +212 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/json-map-store.d.ts +4 -0
- package/dist/adapters/json-map-store.js +31 -0
- package/dist/adapters/local-learning-adapter.d.ts +25 -0
- package/dist/adapters/local-learning-adapter.js +49 -0
- package/dist/adapters/skills-adapter.d.ts +8 -0
- package/dist/adapters/skills-adapter.js +55 -0
- package/dist/admission-policy.d.ts +38 -0
- package/dist/admission-policy.js +17 -0
- package/dist/bin/jinn-distill-mcp.d.ts +2 -0
- package/dist/bin/jinn-distill-mcp.js +10 -0
- package/dist/bin/jinn-layer.d.ts +7 -0
- package/dist/bin/jinn-layer.js +15 -0
- package/dist/bridge-fetch-evidence.d.ts +102 -0
- package/dist/bridge-fetch-evidence.js +677 -0
- package/dist/bridge-verdict-source.d.ts +47 -0
- package/dist/bridge-verdict-source.js +216 -0
- package/dist/bridge.d.ts +247 -0
- package/dist/bridge.js +439 -0
- package/dist/capture.d.ts +98 -0
- package/dist/capture.js +617 -0
- package/dist/cli.d.ts +148 -0
- package/dist/cli.js +2221 -0
- package/dist/cluster-selection.d.ts +34 -0
- package/dist/cluster-selection.js +135 -0
- package/dist/cluster.d.ts +89 -0
- package/dist/cluster.js +142 -0
- package/dist/consume.d.ts +139 -0
- package/dist/consume.js +280 -0
- package/dist/corpus-probes.d.ts +61 -0
- package/dist/corpus-probes.js +120 -0
- package/dist/corpus-store.d.ts +16 -0
- package/dist/corpus-store.js +101 -0
- package/dist/distill-captures.d.ts +56 -0
- package/dist/distill-captures.js +283 -0
- package/dist/distill-feedback.d.ts +20 -0
- package/dist/distill-feedback.js +30 -0
- package/dist/distill-llm.d.ts +138 -0
- package/dist/distill-llm.js +438 -0
- package/dist/distill-mcp-server.d.ts +30 -0
- package/dist/distill-mcp-server.js +281 -0
- package/dist/distill-mode.d.ts +62 -0
- package/dist/distill-mode.js +107 -0
- package/dist/distill-progress.d.ts +78 -0
- package/dist/distill-progress.js +69 -0
- package/dist/distill-prompt.d.ts +34 -0
- package/dist/distill-prompt.js +85 -0
- package/dist/distill-render.d.ts +123 -0
- package/dist/distill-render.js +303 -0
- package/dist/distill-runs.d.ts +28 -0
- package/dist/distill-runs.js +39 -0
- package/dist/distill-traces.d.ts +67 -0
- package/dist/distill-traces.js +211 -0
- package/dist/distill.d.ts +180 -0
- package/dist/distill.js +337 -0
- package/dist/distiller.d.ts +118 -0
- package/dist/distiller.js +142 -0
- package/dist/envelope.d.ts +9 -0
- package/dist/envelope.js +9 -0
- package/dist/eval-prep.d.ts +129 -0
- package/dist/eval-prep.js +523 -0
- package/dist/execution-publish.d.ts +62 -0
- package/dist/execution-publish.js +72 -0
- package/dist/gate.d.ts +39 -0
- package/dist/gate.js +106 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +50 -0
- package/dist/ipfs-cid.d.ts +18 -0
- package/dist/ipfs-cid.js +149 -0
- package/dist/layer-default.d.ts +3 -0
- package/dist/layer-default.js +10 -0
- package/dist/ledger.d.ts +62 -0
- package/dist/ledger.js +104 -0
- package/dist/measurement.d.ts +90 -0
- package/dist/measurement.js +134 -0
- package/dist/path-safety.d.ts +19 -0
- package/dist/path-safety.js +303 -0
- package/dist/pipeline.d.ts +94 -0
- package/dist/pipeline.js +124 -0
- package/dist/plugin-wiring.d.ts +4 -0
- package/dist/plugin-wiring.js +54 -0
- package/dist/preview.d.ts +31 -0
- package/dist/preview.js +28 -0
- package/dist/process-contract.d.ts +303 -0
- package/dist/process-contract.js +118 -0
- package/dist/publish-skill.d.ts +86 -0
- package/dist/publish-skill.js +116 -0
- package/dist/publish.d.ts +294 -0
- package/dist/publish.js +1090 -0
- package/dist/scripts/scan-skills-registry.d.ts +24 -0
- package/dist/scripts/scan-skills-registry.js +207 -0
- package/dist/seed-import/curated-batch.d.ts +36 -0
- package/dist/seed-import/curated-batch.js +103 -0
- package/dist/seed-import/episode-execute.d.ts +50 -0
- package/dist/seed-import/episode-execute.js +235 -0
- package/dist/seed-import/episode-fetch.d.ts +119 -0
- package/dist/seed-import/episode-fetch.js +137 -0
- package/dist/seed-import/episode-plan.d.ts +11 -0
- package/dist/seed-import/episode-plan.js +27 -0
- package/dist/seed-import/episode-report.d.ts +39 -0
- package/dist/seed-import/episode-report.js +36 -0
- package/dist/seed-import/execute.d.ts +61 -0
- package/dist/seed-import/execute.js +245 -0
- package/dist/seed-import/fetch.d.ts +62 -0
- package/dist/seed-import/fetch.js +82 -0
- package/dist/seed-import/licence.d.ts +17 -0
- package/dist/seed-import/licence.js +36 -0
- package/dist/seed-import/plan.d.ts +10 -0
- package/dist/seed-import/plan.js +21 -0
- package/dist/seed-import/report.d.ts +34 -0
- package/dist/seed-import/report.js +31 -0
- package/dist/seed-import/state.d.ts +72 -0
- package/dist/seed-import/state.js +120 -0
- package/dist/signal.d.ts +36 -0
- package/dist/signal.js +48 -0
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +21 -0
- package/dist/skill-package.d.ts +70 -0
- package/dist/skill-package.js +141 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +77 -0
- package/dist/snapshot-transcript.d.ts +54 -0
- package/dist/snapshot-transcript.js +117 -0
- package/dist/three-arm-run.d.ts +27 -0
- package/dist/three-arm-run.js +41 -0
- package/package.json +60 -0
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contribution ledger — the operator's local receipt of what left their
|
|
3
|
+
* machine (plan Task 4, issue #1311).
|
|
4
|
+
*
|
|
5
|
+
* Append-only JSONL at `~/.jinn-client/harness-layer/ledger.jsonl` by
|
|
6
|
+
* default. Entries carry only persistence-safe fields (the task summary is
|
|
7
|
+
* already scrubbed by capture; redaction `before` values never reach here).
|
|
8
|
+
* The Task 5 fork renders this; Task 7 aggregates over it.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { VerifiabilityTierSchema } from './envelope.js';
|
|
15
|
+
export const DEFAULT_LEDGER_PATH = join(homedir(), '.jinn-client', 'harness-layer', 'ledger.jsonl');
|
|
16
|
+
export const LedgerEntrySchema = z.strictObject({
|
|
17
|
+
/** ISO-8601 time the entry was recorded. */
|
|
18
|
+
ts: z.iso.datetime(),
|
|
19
|
+
/** Scrubbed one-line task summary (from the envelope). */
|
|
20
|
+
taskSummary: z.string().min(1),
|
|
21
|
+
/** Published envelope ref (manifest CID) — null when vetoed. */
|
|
22
|
+
envelopeRef: z.string().min(1).nullable(),
|
|
23
|
+
/** ERC-8004 anchor tx hash — null when vetoed or the anchor returned none. */
|
|
24
|
+
anchorTx: z.string().min(1).nullable(),
|
|
25
|
+
verifiabilityTier: VerifiabilityTierSchema,
|
|
26
|
+
status: z.enum(['published', 'vetoed (local only)']),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Fork-shaped projection of a ledger entry — the row shape the TUI consumer
|
|
30
|
+
* (`apps/jinn-agent/plugins/jinn/ledger_view.py`, `rows_from_json`) reads.
|
|
31
|
+
* `state` is absent for published rows; the schema also accepts a `failed`
|
|
32
|
+
* state for forward-compat, even though `toLedgerRow` never emits it.
|
|
33
|
+
*/
|
|
34
|
+
export const LedgerRowSchema = z.strictObject({
|
|
35
|
+
time: z.string().min(1),
|
|
36
|
+
task: z.string().min(1),
|
|
37
|
+
env: z.string().min(1).nullable(),
|
|
38
|
+
anchor: z.string().min(1).nullable(),
|
|
39
|
+
tier: VerifiabilityTierSchema,
|
|
40
|
+
state: z.enum(['vetoed', 'failed']).optional(),
|
|
41
|
+
});
|
|
42
|
+
/** Project an internal ledger entry into the fork-consumed row shape. */
|
|
43
|
+
export function toLedgerRow(entry) {
|
|
44
|
+
const base = {
|
|
45
|
+
time: entry.ts,
|
|
46
|
+
task: entry.taskSummary,
|
|
47
|
+
env: entry.envelopeRef,
|
|
48
|
+
anchor: entry.anchorTx,
|
|
49
|
+
tier: entry.verifiabilityTier,
|
|
50
|
+
};
|
|
51
|
+
switch (entry.status) {
|
|
52
|
+
case 'published':
|
|
53
|
+
return base;
|
|
54
|
+
case 'vetoed (local only)':
|
|
55
|
+
return { ...base, state: 'vetoed' };
|
|
56
|
+
default: {
|
|
57
|
+
// Exhaustiveness: a new status forces this branch to fail compilation.
|
|
58
|
+
const _never = entry.status;
|
|
59
|
+
return _never;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** In-memory ledger (tests, embedders that persist elsewhere). */
|
|
64
|
+
export function createMemoryLedger() {
|
|
65
|
+
const entries = [];
|
|
66
|
+
return {
|
|
67
|
+
append(entry) {
|
|
68
|
+
entries.push(LedgerEntrySchema.parse(entry));
|
|
69
|
+
},
|
|
70
|
+
list: () => entries,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function readEntries(path) {
|
|
74
|
+
if (!existsSync(path))
|
|
75
|
+
return [];
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
78
|
+
if (line.trim() === '')
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
entries.push(LedgerEntrySchema.parse(JSON.parse(line)));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A corrupt line must not sink the operator's whole receipt history.
|
|
85
|
+
console.warn(`[harness-layer] skipping malformed ledger line in ${path}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
/** Append-only JSONL file ledger. Creates parent directories on first write. */
|
|
91
|
+
export function createFileLedger(path = DEFAULT_LEDGER_PATH) {
|
|
92
|
+
return {
|
|
93
|
+
append(entry) {
|
|
94
|
+
const parsed = LedgerEntrySchema.parse(entry);
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
appendFileSync(path, JSON.stringify(parsed) + '\n', 'utf-8');
|
|
97
|
+
},
|
|
98
|
+
list: () => readEntries(path),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Read the ledger (plan Task 4's `ledger()` surface). Missing file ⇒ empty. */
|
|
102
|
+
export function ledger(path = DEFAULT_LEDGER_PATH) {
|
|
103
|
+
return readEntries(path);
|
|
104
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-arm measurement — the §11 capability gate for distillation-v1
|
|
3
|
+
* (spec/2026-07-06-distillation-v1.md §11, D9/D11).
|
|
4
|
+
*
|
|
5
|
+
* REUSES the capability-eval rig — `comparePaired` / `mcnemarExact` (exact
|
|
6
|
+
* McNemar) from `client/src/eval/paired.ts`. This module is only the analysis
|
|
7
|
+
* layer that composes those into the three-arm gate; the actual per-arm run
|
|
8
|
+
* (executing the harness against the `cap-v0` slate with each corpus snapshot
|
|
9
|
+
* pre-installed, graded by the swe-rebench-v2 grader) is wired at run time and
|
|
10
|
+
* feeds this its per-instance results.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Wilson intervals (`wilson.ts`) are deliberately NOT used here. This is a
|
|
13
|
+
* matched (paired) design — the same slate under each arm — so exact McNemar on
|
|
14
|
+
* the discordant pairs is the correct test. Marginal Wilson intervals would
|
|
15
|
+
* reintroduce the between-instance variance that the paired design exists to
|
|
16
|
+
* remove (see `paired.ts`), so bolting one on would weaken, not strengthen, the
|
|
17
|
+
* comparison. Do not add it.
|
|
18
|
+
*
|
|
19
|
+
* NOTE (§11.1 — process gate, not code): the IUT composition below — capability
|
|
20
|
+
* = resolve-rate superiority AND cost non-inferiority — inverts cap-v0's
|
|
21
|
+
* quality/cost roles and MUST be confirmed with the capability-eval session
|
|
22
|
+
* before a `ship-distilled` verdict is treated as final. This module encodes the
|
|
23
|
+
* shape; it cannot ratify it.
|
|
24
|
+
*
|
|
25
|
+
* Arms: **seeds-only** (control), **raw-evidence** (the shipped v1 product
|
|
26
|
+
* baseline, D11), **distilled** (the bet). Two comparisons:
|
|
27
|
+
* - distilled vs seeds — the capability claim: **resolve-rate superiority**
|
|
28
|
+
* (trustworthy McNemar, distilled-favored) AND **cost non-inferior** on the
|
|
29
|
+
* both-solve set.
|
|
30
|
+
* - distilled vs raw-evidence — the **Bitter-Lesson test (D9)**: the distiller
|
|
31
|
+
* earns its place only if it beats raw-evidence retrieval; otherwise the
|
|
32
|
+
* retrieval baseline ships (D11).
|
|
33
|
+
*
|
|
34
|
+
* The first run is a **pilot** (§11): if the distilled-vs-seeds discordant count
|
|
35
|
+
* cannot reach significance, the verdict is `inconclusive`, not a false null.
|
|
36
|
+
*/
|
|
37
|
+
import { type PairedComparison } from '@jinn-network/core';
|
|
38
|
+
export interface ArmResult {
|
|
39
|
+
instanceId: string;
|
|
40
|
+
/** null when unscorable (disk-skip / harness failure) — never coerced to a flip. */
|
|
41
|
+
passed: boolean | null;
|
|
42
|
+
unscorable: boolean;
|
|
43
|
+
/** Total cost of the run for this instance (USD). Optional; cost guard skips when absent. */
|
|
44
|
+
costUsd?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface PilotPower {
|
|
47
|
+
discordant: number;
|
|
48
|
+
netEffect: number;
|
|
49
|
+
/** Smallest all-improve discordant count that can reach significance at alpha. */
|
|
50
|
+
minDiscordantForSignificance: number;
|
|
51
|
+
/**
|
|
52
|
+
* True ⇒ the discordant count is large enough that SOME split could reach
|
|
53
|
+
* significance. This is capability, NOT the result: `powered` says the run
|
|
54
|
+
* *could* have been decisive in either direction, not that distilled won.
|
|
55
|
+
* A powered run can still resolve to `within-noise` (e.g. an even split).
|
|
56
|
+
*/
|
|
57
|
+
powered: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface ArmComparison {
|
|
60
|
+
paired: PairedComparison;
|
|
61
|
+
/** distilled mean cost ÷ baseline mean cost on the both-solve (concordant-pass) set; null when empty/absent. */
|
|
62
|
+
costRatio: number | null;
|
|
63
|
+
costNonInferior: boolean;
|
|
64
|
+
}
|
|
65
|
+
export type ShipVerdict = 'ship-distilled' | 'ship-retrieval-baseline' | 'inconclusive';
|
|
66
|
+
export interface ThreeArmResult {
|
|
67
|
+
distilledVsSeeds: ArmComparison & {
|
|
68
|
+
capabilityPass: boolean;
|
|
69
|
+
};
|
|
70
|
+
distilledVsRaw: ArmComparison & {
|
|
71
|
+
bitterLessonPass: boolean;
|
|
72
|
+
};
|
|
73
|
+
pilot: PilotPower;
|
|
74
|
+
shipVerdict: ShipVerdict;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Pilot power for an exact McNemar run. A run can only be significant if its
|
|
78
|
+
* discordant count reaches `minDiscordantForSignificance` (the smallest n with
|
|
79
|
+
* `mcnemarExact(n, 0) < alpha`). Below that, no split is significant, so a null
|
|
80
|
+
* is uninformative (§11 — the first run is a pilot, a Haiku null uninformative).
|
|
81
|
+
*/
|
|
82
|
+
export declare function pilotPower(improved: number, regressed: number, alpha?: number): PilotPower;
|
|
83
|
+
export declare function threeArmMeasurement(arms: {
|
|
84
|
+
seedsOnly: ArmResult[];
|
|
85
|
+
rawEvidence: ArmResult[];
|
|
86
|
+
distilled: ArmResult[];
|
|
87
|
+
}, opts?: {
|
|
88
|
+
alpha?: number;
|
|
89
|
+
costMargin?: number;
|
|
90
|
+
}): ThreeArmResult;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-arm measurement — the §11 capability gate for distillation-v1
|
|
3
|
+
* (spec/2026-07-06-distillation-v1.md §11, D9/D11).
|
|
4
|
+
*
|
|
5
|
+
* REUSES the capability-eval rig — `comparePaired` / `mcnemarExact` (exact
|
|
6
|
+
* McNemar) from `client/src/eval/paired.ts`. This module is only the analysis
|
|
7
|
+
* layer that composes those into the three-arm gate; the actual per-arm run
|
|
8
|
+
* (executing the harness against the `cap-v0` slate with each corpus snapshot
|
|
9
|
+
* pre-installed, graded by the swe-rebench-v2 grader) is wired at run time and
|
|
10
|
+
* feeds this its per-instance results.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Wilson intervals (`wilson.ts`) are deliberately NOT used here. This is a
|
|
13
|
+
* matched (paired) design — the same slate under each arm — so exact McNemar on
|
|
14
|
+
* the discordant pairs is the correct test. Marginal Wilson intervals would
|
|
15
|
+
* reintroduce the between-instance variance that the paired design exists to
|
|
16
|
+
* remove (see `paired.ts`), so bolting one on would weaken, not strengthen, the
|
|
17
|
+
* comparison. Do not add it.
|
|
18
|
+
*
|
|
19
|
+
* NOTE (§11.1 — process gate, not code): the IUT composition below — capability
|
|
20
|
+
* = resolve-rate superiority AND cost non-inferiority — inverts cap-v0's
|
|
21
|
+
* quality/cost roles and MUST be confirmed with the capability-eval session
|
|
22
|
+
* before a `ship-distilled` verdict is treated as final. This module encodes the
|
|
23
|
+
* shape; it cannot ratify it.
|
|
24
|
+
*
|
|
25
|
+
* Arms: **seeds-only** (control), **raw-evidence** (the shipped v1 product
|
|
26
|
+
* baseline, D11), **distilled** (the bet). Two comparisons:
|
|
27
|
+
* - distilled vs seeds — the capability claim: **resolve-rate superiority**
|
|
28
|
+
* (trustworthy McNemar, distilled-favored) AND **cost non-inferior** on the
|
|
29
|
+
* both-solve set.
|
|
30
|
+
* - distilled vs raw-evidence — the **Bitter-Lesson test (D9)**: the distiller
|
|
31
|
+
* earns its place only if it beats raw-evidence retrieval; otherwise the
|
|
32
|
+
* retrieval baseline ships (D11).
|
|
33
|
+
*
|
|
34
|
+
* The first run is a **pilot** (§11): if the distilled-vs-seeds discordant count
|
|
35
|
+
* cannot reach significance, the verdict is `inconclusive`, not a false null.
|
|
36
|
+
*/
|
|
37
|
+
import { comparePaired, mcnemarExact, } from '@jinn-network/core';
|
|
38
|
+
const DEFAULT_ALPHA = 0.05;
|
|
39
|
+
const DEFAULT_COST_MARGIN = 0.1;
|
|
40
|
+
/**
|
|
41
|
+
* Pilot power for an exact McNemar run. A run can only be significant if its
|
|
42
|
+
* discordant count reaches `minDiscordantForSignificance` (the smallest n with
|
|
43
|
+
* `mcnemarExact(n, 0) < alpha`). Below that, no split is significant, so a null
|
|
44
|
+
* is uninformative (§11 — the first run is a pilot, a Haiku null uninformative).
|
|
45
|
+
*/
|
|
46
|
+
export function pilotPower(improved, regressed, alpha = DEFAULT_ALPHA) {
|
|
47
|
+
const discordant = improved + regressed;
|
|
48
|
+
let minN = 1;
|
|
49
|
+
while (mcnemarExact(minN, 0) >= alpha)
|
|
50
|
+
minN++;
|
|
51
|
+
return {
|
|
52
|
+
discordant,
|
|
53
|
+
netEffect: improved - regressed,
|
|
54
|
+
minDiscordantForSignificance: minN,
|
|
55
|
+
powered: discordant >= minN,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function toPaired(arm) {
|
|
59
|
+
return arm.map((r) => ({ instance_id: r.instanceId, passed: r.passed, unscorable: r.unscorable }));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Cost ratio (treatment ÷ baseline mean) on the set of instances BOTH arms
|
|
63
|
+
* scored as passed (the both-solve set). Returns:
|
|
64
|
+
* - `null` when NO both-solve instance carries cost data on both arms — there
|
|
65
|
+
* is no basis to compare, so the guard is SKIPPED (not "verified
|
|
66
|
+
* non-inferior"; the caller must treat a null ratio on a ship verdict as
|
|
67
|
+
* "cost unverified" and confirm cost tracking was on).
|
|
68
|
+
* - `Infinity` when the baseline is free (baseSum === 0) but the treatment is
|
|
69
|
+
* not — a paid treatment over a free baseline is infinitely worse, which
|
|
70
|
+
* must FAIL the guard, not silently pass (the old `baseSum === 0 → null`
|
|
71
|
+
* branch failed open on exactly the seeds-only control most likely to be
|
|
72
|
+
* near-zero cost).
|
|
73
|
+
* - `0` when both are free.
|
|
74
|
+
*/
|
|
75
|
+
function costOnBothSolve(baseline, treatment) {
|
|
76
|
+
const basePass = new Map(baseline.map((r) => [r.instanceId, r]));
|
|
77
|
+
let baseSum = 0;
|
|
78
|
+
let treatSum = 0;
|
|
79
|
+
let n = 0;
|
|
80
|
+
for (const t of treatment) {
|
|
81
|
+
const b = basePass.get(t.instanceId);
|
|
82
|
+
if (!b || b.passed !== true || t.passed !== true)
|
|
83
|
+
continue;
|
|
84
|
+
if (typeof b.costUsd !== 'number' || typeof t.costUsd !== 'number')
|
|
85
|
+
continue;
|
|
86
|
+
baseSum += b.costUsd;
|
|
87
|
+
treatSum += t.costUsd;
|
|
88
|
+
n++;
|
|
89
|
+
}
|
|
90
|
+
if (n === 0)
|
|
91
|
+
return null; // no both-solve cost data → no basis → guard skipped
|
|
92
|
+
if (baseSum === 0)
|
|
93
|
+
return treatSum === 0 ? 0 : Infinity; // free baseline: equal if treat free, else fails
|
|
94
|
+
return treatSum / baseSum;
|
|
95
|
+
}
|
|
96
|
+
function compareArm(baseline, treatment, alpha, costMargin) {
|
|
97
|
+
const paired = comparePaired(toPaired(baseline), toPaired(treatment), { alpha });
|
|
98
|
+
const costRatio = costOnBothSolve(baseline, treatment);
|
|
99
|
+
// Non-inferior when there is no cost basis (null → guard SKIPPED, cost
|
|
100
|
+
// UNVERIFIED — not verified-cheap) or the treatment is not materially more
|
|
101
|
+
// expensive on the both-solve set. Infinity (free baseline, paid treatment)
|
|
102
|
+
// fails the guard.
|
|
103
|
+
const costNonInferior = costRatio === null ? true : costRatio <= 1 + costMargin;
|
|
104
|
+
return { paired, costRatio, costNonInferior };
|
|
105
|
+
}
|
|
106
|
+
export function threeArmMeasurement(arms, opts = {}) {
|
|
107
|
+
const alpha = opts.alpha ?? DEFAULT_ALPHA;
|
|
108
|
+
const costMargin = opts.costMargin ?? DEFAULT_COST_MARGIN;
|
|
109
|
+
const vsSeeds = compareArm(arms.seedsOnly, arms.distilled, alpha, costMargin);
|
|
110
|
+
const vsRaw = compareArm(arms.rawEvidence, arms.distilled, alpha, costMargin);
|
|
111
|
+
const pilot = pilotPower(vsSeeds.paired.improved, vsSeeds.paired.regressed, alpha);
|
|
112
|
+
// Capability (the ship claim): resolve-rate superiority over seeds AND cost non-inferior.
|
|
113
|
+
const capabilityPass = vsSeeds.paired.verdict === 'trustworthy' && vsSeeds.costNonInferior;
|
|
114
|
+
// Bitter-Lesson (D9): the distiller earns its place only if it beats raw-evidence.
|
|
115
|
+
const bitterLessonPass = vsRaw.paired.verdict === 'trustworthy';
|
|
116
|
+
let shipVerdict;
|
|
117
|
+
if (!pilot.powered) {
|
|
118
|
+
shipVerdict = 'inconclusive';
|
|
119
|
+
}
|
|
120
|
+
else if (capabilityPass && bitterLessonPass) {
|
|
121
|
+
shipVerdict = 'ship-distilled';
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Retrieval-over-evidence is the product baseline regardless (D11): the
|
|
125
|
+
// distiller either didn't beat raw-evidence or didn't clear the capability gate.
|
|
126
|
+
shipVerdict = 'ship-retrieval-baseline';
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
distilledVsSeeds: { ...vsSeeds, capabilityPass },
|
|
130
|
+
distilledVsRaw: { ...vsRaw, bitterLessonPass },
|
|
131
|
+
pilot,
|
|
132
|
+
shipVerdict,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** True when `candidate` resolves to `root` or one of its descendants. */
|
|
2
|
+
export declare function isInsidePackageDir(root: string, candidate: string): boolean;
|
|
3
|
+
export interface PackageTreeFile {
|
|
4
|
+
/** Package-relative path below the selected output root. */
|
|
5
|
+
path: string;
|
|
6
|
+
content: string | Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Install a package tree without following pre-existing links below `root`.
|
|
10
|
+
*
|
|
11
|
+
* All content is first written beneath a private sibling directory. A missing
|
|
12
|
+
* destination is published by one directory rename. An existing destination
|
|
13
|
+
* retains merge/overwrite semantics: every output path is preflighted before
|
|
14
|
+
* the first mutation, existing regular files are moved to private backups,
|
|
15
|
+
* and staged files are atomically renamed into place. This replacement model
|
|
16
|
+
* leaves hardlinked outside inodes unchanged and works on Windows, where
|
|
17
|
+
* renaming directly over an existing file is not reliably supported.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writePackageTreeSafely(root: string, files: PackageTreeFile[]): void;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
function escapesPackageDir(relativePath) {
|
|
4
|
+
return relativePath === '..'
|
|
5
|
+
|| relativePath.startsWith(`..${sep}`)
|
|
6
|
+
|| isAbsolute(relativePath);
|
|
7
|
+
}
|
|
8
|
+
/** True when `candidate` resolves to `root` or one of its descendants. */
|
|
9
|
+
export function isInsidePackageDir(root, candidate) {
|
|
10
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
11
|
+
return rel === '' || !escapesPackageDir(rel);
|
|
12
|
+
}
|
|
13
|
+
function nodeErrorCode(error) {
|
|
14
|
+
return error && typeof error === 'object' && 'code' in error
|
|
15
|
+
? String(error.code)
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
function identity(stat) {
|
|
19
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
20
|
+
}
|
|
21
|
+
function sameIdentity(left, right) {
|
|
22
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
23
|
+
}
|
|
24
|
+
function lstatIfPresent(path) {
|
|
25
|
+
try {
|
|
26
|
+
return lstatSync(path);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
30
|
+
return undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function assertDirectory(path, label, expected) {
|
|
35
|
+
const before = lstatSync(path);
|
|
36
|
+
if (before.isSymbolicLink()) {
|
|
37
|
+
throw new Error(`${label} must not be a symlink: ${path}`);
|
|
38
|
+
}
|
|
39
|
+
if (!before.isDirectory()) {
|
|
40
|
+
throw new Error(`${label} must be a directory: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
const beforeIdentity = identity(before);
|
|
43
|
+
if (expected && !sameIdentity(beforeIdentity, expected)) {
|
|
44
|
+
throw new Error(`${label} changed while the skill was being installed: ${path}`);
|
|
45
|
+
}
|
|
46
|
+
// Opening a directory is not portable on Windows. POSIX gets an additional
|
|
47
|
+
// lstat/open/fstat identity check and O_NOFOLLOW defence for the terminal
|
|
48
|
+
// component. Node does not expose openat/renameat, so callers also revalidate
|
|
49
|
+
// the full component chain immediately before every commit operation.
|
|
50
|
+
if (process.platform === 'win32')
|
|
51
|
+
return beforeIdentity;
|
|
52
|
+
const fd = openSync(path, constants.O_RDONLY
|
|
53
|
+
| (constants.O_NOFOLLOW ?? 0)
|
|
54
|
+
| (constants.O_DIRECTORY ?? 0));
|
|
55
|
+
try {
|
|
56
|
+
const opened = fstatSync(fd);
|
|
57
|
+
if (!opened.isDirectory()) {
|
|
58
|
+
throw new Error(`${label} changed while it was being opened: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
const openedIdentity = identity(opened);
|
|
61
|
+
if (!sameIdentity(beforeIdentity, openedIdentity)) {
|
|
62
|
+
throw new Error(`${label} changed while it was being opened: ${path}`);
|
|
63
|
+
}
|
|
64
|
+
return openedIdentity;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function assertRegularTarget(path, label) {
|
|
71
|
+
const stat = lstatIfPresent(path);
|
|
72
|
+
if (!stat)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (stat.isSymbolicLink()) {
|
|
75
|
+
throw new Error(`${label} must not be a symlink: ${path}`);
|
|
76
|
+
}
|
|
77
|
+
if (!stat.isFile()) {
|
|
78
|
+
throw new Error(`${label} must be a regular file: ${path}`);
|
|
79
|
+
}
|
|
80
|
+
return stat;
|
|
81
|
+
}
|
|
82
|
+
function pathSegments(relativePath) {
|
|
83
|
+
return relativePath.split(sep).filter((part) => part !== '');
|
|
84
|
+
}
|
|
85
|
+
function assertOutputTreeSafe(root, files, expectedRoot) {
|
|
86
|
+
const rootStat = lstatIfPresent(root);
|
|
87
|
+
if (!rootStat)
|
|
88
|
+
return undefined;
|
|
89
|
+
const rootIdentity = assertDirectory(root, 'skill install directory', expectedRoot);
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const segments = pathSegments(file.relativePath);
|
|
92
|
+
let current = root;
|
|
93
|
+
let missingParent = false;
|
|
94
|
+
for (const segment of segments.slice(0, -1)) {
|
|
95
|
+
current = join(current, segment);
|
|
96
|
+
const stat = lstatIfPresent(current);
|
|
97
|
+
if (!stat) {
|
|
98
|
+
missingParent = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
assertDirectory(current, 'skill companion path component');
|
|
102
|
+
}
|
|
103
|
+
if (!missingParent)
|
|
104
|
+
assertRegularTarget(file.target, 'skill output target');
|
|
105
|
+
}
|
|
106
|
+
return rootIdentity;
|
|
107
|
+
}
|
|
108
|
+
function ensureTargetParent(root, relativePath, rootIdentity, created) {
|
|
109
|
+
const parents = [
|
|
110
|
+
{ path: root, identity: assertDirectory(root, 'skill install directory', rootIdentity) },
|
|
111
|
+
];
|
|
112
|
+
let current = root;
|
|
113
|
+
for (const segment of pathSegments(relativePath).slice(0, -1)) {
|
|
114
|
+
current = join(current, segment);
|
|
115
|
+
let stat = lstatIfPresent(current);
|
|
116
|
+
if (!stat) {
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(current);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// A concurrent creator is acceptable only if it made the exact kind of
|
|
122
|
+
// entry we require; assertDirectory below rejects links and files.
|
|
123
|
+
if (nodeErrorCode(error) !== 'EEXIST')
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
stat = lstatIfPresent(current);
|
|
127
|
+
if (!stat)
|
|
128
|
+
throw new Error(`skill companion directory disappeared: ${current}`);
|
|
129
|
+
const directoryIdentity = assertDirectory(current, 'skill companion path component');
|
|
130
|
+
created.push({ path: current, identity: directoryIdentity });
|
|
131
|
+
parents.push({ path: current, identity: directoryIdentity });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
parents.push({
|
|
135
|
+
path: current,
|
|
136
|
+
identity: assertDirectory(current, 'skill companion path component'),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Close the check/use window as far as portable Node permits: every opened
|
|
140
|
+
// component must still name the same directory immediately before commit.
|
|
141
|
+
for (const parent of parents) {
|
|
142
|
+
assertDirectory(parent.path, parent.path === root ? 'skill install directory' : 'skill companion path component', parent.identity);
|
|
143
|
+
}
|
|
144
|
+
return parents;
|
|
145
|
+
}
|
|
146
|
+
function removeCreatedDirectories(created) {
|
|
147
|
+
for (const directory of [...created].reverse()) {
|
|
148
|
+
try {
|
|
149
|
+
const stat = lstatIfPresent(directory.path);
|
|
150
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory())
|
|
151
|
+
continue;
|
|
152
|
+
if (!sameIdentity(identity(stat), directory.identity))
|
|
153
|
+
continue;
|
|
154
|
+
rmdirSync(directory.path);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Non-empty means a prior directory already owns committed content or a
|
|
158
|
+
// concurrent writer used it. Either way, recursive cleanup is unsafe.
|
|
159
|
+
if (nodeErrorCode(error) !== 'ENOTEMPTY' && nodeErrorCode(error) !== 'EEXIST')
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function rollbackCommittedFiles(committed) {
|
|
165
|
+
const failures = [];
|
|
166
|
+
for (const file of [...committed].reverse()) {
|
|
167
|
+
try {
|
|
168
|
+
for (const parent of file.parents) {
|
|
169
|
+
assertDirectory(parent.path, 'skill output parent during rollback', parent.identity);
|
|
170
|
+
}
|
|
171
|
+
if (file.installed) {
|
|
172
|
+
const target = lstatIfPresent(file.target);
|
|
173
|
+
if (target
|
|
174
|
+
&& !target.isSymbolicLink()
|
|
175
|
+
&& target.isFile()
|
|
176
|
+
&& sameIdentity(identity(target), file.installed)) {
|
|
177
|
+
unlinkSync(file.target);
|
|
178
|
+
}
|
|
179
|
+
else if (target) {
|
|
180
|
+
failures.push(`installed target changed before rollback: ${file.target}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (file.backup) {
|
|
184
|
+
if (lstatIfPresent(file.target)) {
|
|
185
|
+
failures.push(`cannot restore occupied target: ${file.target}`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
renameSync(file.backup, file.target);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
failures.push(`${file.target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (failures.length > 0) {
|
|
197
|
+
throw new Error(`skill install rollback incomplete — ${failures.join('; ')}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Install a package tree without following pre-existing links below `root`.
|
|
202
|
+
*
|
|
203
|
+
* All content is first written beneath a private sibling directory. A missing
|
|
204
|
+
* destination is published by one directory rename. An existing destination
|
|
205
|
+
* retains merge/overwrite semantics: every output path is preflighted before
|
|
206
|
+
* the first mutation, existing regular files are moved to private backups,
|
|
207
|
+
* and staged files are atomically renamed into place. This replacement model
|
|
208
|
+
* leaves hardlinked outside inodes unchanged and works on Windows, where
|
|
209
|
+
* renaming directly over an existing file is not reliably supported.
|
|
210
|
+
*/
|
|
211
|
+
export function writePackageTreeSafely(root, files) {
|
|
212
|
+
const absoluteRoot = resolve(root);
|
|
213
|
+
const normalized = new Map();
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
const target = resolve(absoluteRoot, file.path);
|
|
216
|
+
const relativePath = relative(absoluteRoot, target);
|
|
217
|
+
if (relativePath === ''
|
|
218
|
+
|| escapesPackageDir(relativePath)) {
|
|
219
|
+
throw new Error(`skill output path escapes the install directory: ${file.path}`);
|
|
220
|
+
}
|
|
221
|
+
if (normalized.has(relativePath)) {
|
|
222
|
+
throw new Error(`duplicate skill output path: ${file.path}`);
|
|
223
|
+
}
|
|
224
|
+
normalized.set(relativePath, { relativePath, target, content: file.content });
|
|
225
|
+
}
|
|
226
|
+
const outputs = [...normalized.values()];
|
|
227
|
+
// Reject a stable unsafe tree before even creating the private staging area.
|
|
228
|
+
const initialRootIdentity = assertOutputTreeSafe(absoluteRoot, outputs);
|
|
229
|
+
mkdirSync(dirname(absoluteRoot), { recursive: true });
|
|
230
|
+
const stagingRoot = mkdtempSync(join(dirname(absoluteRoot), '.jinn-skill-install-'));
|
|
231
|
+
const payloadRoot = join(stagingRoot, 'payload');
|
|
232
|
+
const backupRoot = join(stagingRoot, 'backup');
|
|
233
|
+
const committed = [];
|
|
234
|
+
const createdDirectories = [];
|
|
235
|
+
let cleanupStaging = true;
|
|
236
|
+
try {
|
|
237
|
+
mkdirSync(payloadRoot);
|
|
238
|
+
for (const file of outputs) {
|
|
239
|
+
const staged = join(payloadRoot, file.relativePath);
|
|
240
|
+
mkdirSync(dirname(staged), { recursive: true });
|
|
241
|
+
if (lstatIfPresent(staged)) {
|
|
242
|
+
throw new Error(`duplicate skill output path on this filesystem: ${file.relativePath}`);
|
|
243
|
+
}
|
|
244
|
+
writeFileSync(staged, file.content);
|
|
245
|
+
}
|
|
246
|
+
const rootIdentity = assertOutputTreeSafe(absoluteRoot, outputs, initialRootIdentity);
|
|
247
|
+
if (!rootIdentity) {
|
|
248
|
+
// rename(2) replaces a raced final symlink rather than following it on
|
|
249
|
+
// POSIX, and fails closed on Windows when the destination is occupied.
|
|
250
|
+
renameSync(payloadRoot, absoluteRoot);
|
|
251
|
+
assertDirectory(absoluteRoot, 'installed skill directory');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
mkdirSync(backupRoot, { mode: 0o700 });
|
|
255
|
+
for (let index = 0; index < outputs.length; index += 1) {
|
|
256
|
+
const file = outputs[index];
|
|
257
|
+
const parents = ensureTargetParent(absoluteRoot, file.relativePath, rootIdentity, createdDirectories);
|
|
258
|
+
const existing = assertRegularTarget(file.target, 'skill output target');
|
|
259
|
+
for (const parent of parents) {
|
|
260
|
+
assertDirectory(parent.path, parent.path === absoluteRoot ? 'skill install directory' : 'skill companion path component', parent.identity);
|
|
261
|
+
}
|
|
262
|
+
const commit = { target: file.target, parents };
|
|
263
|
+
committed.push(commit);
|
|
264
|
+
if (existing) {
|
|
265
|
+
const backup = join(backupRoot, String(index));
|
|
266
|
+
// Rename never follows the final component. A hardlink or a raced
|
|
267
|
+
// symlink is moved as a directory entry, not opened for writing.
|
|
268
|
+
renameSync(file.target, backup);
|
|
269
|
+
commit.backup = backup;
|
|
270
|
+
}
|
|
271
|
+
for (const parent of parents) {
|
|
272
|
+
assertDirectory(parent.path, 'skill output parent before commit', parent.identity);
|
|
273
|
+
}
|
|
274
|
+
renameSync(join(payloadRoot, file.relativePath), file.target);
|
|
275
|
+
const installed = assertRegularTarget(file.target, 'installed skill output');
|
|
276
|
+
if (!installed)
|
|
277
|
+
throw new Error(`installed skill output disappeared: ${file.target}`);
|
|
278
|
+
commit.installed = identity(installed);
|
|
279
|
+
for (const parent of parents) {
|
|
280
|
+
assertDirectory(parent.path, 'skill output parent after commit', parent.identity);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
try {
|
|
286
|
+
rollbackCommittedFiles(committed);
|
|
287
|
+
removeCreatedDirectories(createdDirectories);
|
|
288
|
+
}
|
|
289
|
+
catch (rollbackError) {
|
|
290
|
+
// Backups may be the only remaining copy of overwritten user files. Keep
|
|
291
|
+
// the private directory for manual recovery rather than deleting them.
|
|
292
|
+
cleanupStaging = false;
|
|
293
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; `
|
|
294
|
+
+ `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}; `
|
|
295
|
+
+ `recovery files retained at ${stagingRoot}`);
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
if (cleanupStaging)
|
|
301
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
}
|