@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.
Files changed (131) hide show
  1. package/dist/adapters/contribution-adapter.d.ts +18 -0
  2. package/dist/adapters/contribution-adapter.js +134 -0
  3. package/dist/adapters/corpus-adapter.d.ts +12 -0
  4. package/dist/adapters/corpus-adapter.js +212 -0
  5. package/dist/adapters/index.d.ts +17 -0
  6. package/dist/adapters/index.js +17 -0
  7. package/dist/adapters/json-map-store.d.ts +4 -0
  8. package/dist/adapters/json-map-store.js +31 -0
  9. package/dist/adapters/local-learning-adapter.d.ts +25 -0
  10. package/dist/adapters/local-learning-adapter.js +49 -0
  11. package/dist/adapters/skills-adapter.d.ts +8 -0
  12. package/dist/adapters/skills-adapter.js +55 -0
  13. package/dist/admission-policy.d.ts +38 -0
  14. package/dist/admission-policy.js +17 -0
  15. package/dist/bin/jinn-distill-mcp.d.ts +2 -0
  16. package/dist/bin/jinn-distill-mcp.js +10 -0
  17. package/dist/bin/jinn-layer.d.ts +7 -0
  18. package/dist/bin/jinn-layer.js +15 -0
  19. package/dist/bridge-fetch-evidence.d.ts +102 -0
  20. package/dist/bridge-fetch-evidence.js +677 -0
  21. package/dist/bridge-verdict-source.d.ts +47 -0
  22. package/dist/bridge-verdict-source.js +216 -0
  23. package/dist/bridge.d.ts +247 -0
  24. package/dist/bridge.js +439 -0
  25. package/dist/capture.d.ts +98 -0
  26. package/dist/capture.js +617 -0
  27. package/dist/cli.d.ts +148 -0
  28. package/dist/cli.js +2221 -0
  29. package/dist/cluster-selection.d.ts +34 -0
  30. package/dist/cluster-selection.js +135 -0
  31. package/dist/cluster.d.ts +89 -0
  32. package/dist/cluster.js +142 -0
  33. package/dist/consume.d.ts +139 -0
  34. package/dist/consume.js +280 -0
  35. package/dist/corpus-probes.d.ts +61 -0
  36. package/dist/corpus-probes.js +120 -0
  37. package/dist/corpus-store.d.ts +16 -0
  38. package/dist/corpus-store.js +101 -0
  39. package/dist/distill-captures.d.ts +56 -0
  40. package/dist/distill-captures.js +283 -0
  41. package/dist/distill-feedback.d.ts +20 -0
  42. package/dist/distill-feedback.js +30 -0
  43. package/dist/distill-llm.d.ts +138 -0
  44. package/dist/distill-llm.js +438 -0
  45. package/dist/distill-mcp-server.d.ts +30 -0
  46. package/dist/distill-mcp-server.js +281 -0
  47. package/dist/distill-mode.d.ts +62 -0
  48. package/dist/distill-mode.js +107 -0
  49. package/dist/distill-progress.d.ts +78 -0
  50. package/dist/distill-progress.js +69 -0
  51. package/dist/distill-prompt.d.ts +34 -0
  52. package/dist/distill-prompt.js +85 -0
  53. package/dist/distill-render.d.ts +123 -0
  54. package/dist/distill-render.js +303 -0
  55. package/dist/distill-runs.d.ts +28 -0
  56. package/dist/distill-runs.js +39 -0
  57. package/dist/distill-traces.d.ts +67 -0
  58. package/dist/distill-traces.js +211 -0
  59. package/dist/distill.d.ts +180 -0
  60. package/dist/distill.js +337 -0
  61. package/dist/distiller.d.ts +118 -0
  62. package/dist/distiller.js +142 -0
  63. package/dist/envelope.d.ts +9 -0
  64. package/dist/envelope.js +9 -0
  65. package/dist/eval-prep.d.ts +129 -0
  66. package/dist/eval-prep.js +523 -0
  67. package/dist/execution-publish.d.ts +62 -0
  68. package/dist/execution-publish.js +72 -0
  69. package/dist/gate.d.ts +39 -0
  70. package/dist/gate.js +106 -0
  71. package/dist/index.d.ts +49 -0
  72. package/dist/index.js +50 -0
  73. package/dist/ipfs-cid.d.ts +18 -0
  74. package/dist/ipfs-cid.js +149 -0
  75. package/dist/layer-default.d.ts +3 -0
  76. package/dist/layer-default.js +10 -0
  77. package/dist/ledger.d.ts +62 -0
  78. package/dist/ledger.js +104 -0
  79. package/dist/measurement.d.ts +90 -0
  80. package/dist/measurement.js +134 -0
  81. package/dist/path-safety.d.ts +19 -0
  82. package/dist/path-safety.js +303 -0
  83. package/dist/pipeline.d.ts +94 -0
  84. package/dist/pipeline.js +124 -0
  85. package/dist/plugin-wiring.d.ts +4 -0
  86. package/dist/plugin-wiring.js +54 -0
  87. package/dist/preview.d.ts +31 -0
  88. package/dist/preview.js +28 -0
  89. package/dist/process-contract.d.ts +303 -0
  90. package/dist/process-contract.js +118 -0
  91. package/dist/publish-skill.d.ts +86 -0
  92. package/dist/publish-skill.js +116 -0
  93. package/dist/publish.d.ts +294 -0
  94. package/dist/publish.js +1090 -0
  95. package/dist/scripts/scan-skills-registry.d.ts +24 -0
  96. package/dist/scripts/scan-skills-registry.js +207 -0
  97. package/dist/seed-import/curated-batch.d.ts +36 -0
  98. package/dist/seed-import/curated-batch.js +103 -0
  99. package/dist/seed-import/episode-execute.d.ts +50 -0
  100. package/dist/seed-import/episode-execute.js +235 -0
  101. package/dist/seed-import/episode-fetch.d.ts +119 -0
  102. package/dist/seed-import/episode-fetch.js +137 -0
  103. package/dist/seed-import/episode-plan.d.ts +11 -0
  104. package/dist/seed-import/episode-plan.js +27 -0
  105. package/dist/seed-import/episode-report.d.ts +39 -0
  106. package/dist/seed-import/episode-report.js +36 -0
  107. package/dist/seed-import/execute.d.ts +61 -0
  108. package/dist/seed-import/execute.js +245 -0
  109. package/dist/seed-import/fetch.d.ts +62 -0
  110. package/dist/seed-import/fetch.js +82 -0
  111. package/dist/seed-import/licence.d.ts +17 -0
  112. package/dist/seed-import/licence.js +36 -0
  113. package/dist/seed-import/plan.d.ts +10 -0
  114. package/dist/seed-import/plan.js +21 -0
  115. package/dist/seed-import/report.d.ts +34 -0
  116. package/dist/seed-import/report.js +31 -0
  117. package/dist/seed-import/state.d.ts +72 -0
  118. package/dist/seed-import/state.js +120 -0
  119. package/dist/signal.d.ts +36 -0
  120. package/dist/signal.js +48 -0
  121. package/dist/signing.d.ts +8 -0
  122. package/dist/signing.js +21 -0
  123. package/dist/skill-package.d.ts +70 -0
  124. package/dist/skill-package.js +141 -0
  125. package/dist/skill.d.ts +21 -0
  126. package/dist/skill.js +77 -0
  127. package/dist/snapshot-transcript.d.ts +54 -0
  128. package/dist/snapshot-transcript.js +117 -0
  129. package/dist/three-arm-run.d.ts +27 -0
  130. package/dist/three-arm-run.js +41 -0
  131. 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
+ }