@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/cli.js ADDED
@@ -0,0 +1,2221 @@
1
+ /**
2
+ * jinn-layer CLI — the human surface over the harness-layer consume path.
3
+ *
4
+ * Verbs (same command-module spirit as client/src/cli/commands/, kept
5
+ * self-contained so the package stays embeddable):
6
+ *
7
+ * jinn-layer corpus search "<query>" [--limit N] [--json]
8
+ * jinn-layer corpus get <ref> [--json] [--out <dir>]
9
+ * jinn-layer capture preview <task-file> [--json]
10
+ *
11
+ * Output is human-readable by default (this is a discovery surface);
12
+ * --json emits the typed result as JSON (artifact content base64-encoded).
13
+ * `capture preview` renders the scrub report: the redaction diff (original
14
+ * values shown on the terminal only — they never leave the machine) and the
15
+ * envelope exactly as it would publish. Its --json output strips the
16
+ * original values so it stays persistence-safe.
17
+ */
18
+ import { parseArgs } from 'node:util';
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { createInterface } from 'node:readline';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { DEFAULT_IPFS_GATEWAY_URL, DEFAULT_TESTNET_DISCOVERY_URL, } from './consume.js';
25
+ import { corpusProbes, CORPUS_ONBOARDING_K } from './corpus-probes.js';
26
+ import { createJinnPlugin, } from '@jinn-network/plugin';
27
+ import { defaultEvidenceIndexPath, inspectEvidenceStore, reindexEvidenceStore, } from '@jinn-network/core';
28
+ import { buildDefaultLayer } from './layer-default.js';
29
+ import { buildPluginDepsFromEnv } from './plugin-wiring.js';
30
+ import { PROCESS_CONTRACT_VERSION, SessionEndRequestV1Schema, SessionPickupRequestV1Schema, envelope as processEnvelope, contributionLedgerRow, sessionPickupEnvelope, sessionEndEnvelope, trackingCorpus, } from './process-contract.js';
31
+ import { capture, parseCapturedTask } from './capture.js';
32
+ import { preview, stripBeforeValues } from './preview.js';
33
+ import { createMemoryLedger, DEFAULT_LEDGER_PATH, ledger, toLedgerRow } from './ledger.js';
34
+ import { publish } from './publish.js';
35
+ import { createGithubSeedSource, parseSeedListEntry, } from './seed-import/fetch.js';
36
+ import { plan as seedPlan } from './seed-import/plan.js';
37
+ import { execute as seedExecute } from './seed-import/execute.js';
38
+ import { parseImportReport, renderImportReport } from './seed-import/report.js';
39
+ import { createLocalEpisodeSeedSource } from './seed-import/episode-fetch.js';
40
+ import { planEpisodes } from './seed-import/episode-plan.js';
41
+ import { executeEpisodes } from './seed-import/episode-execute.js';
42
+ import { parseEpisodeImportReport, renderEpisodeImportReport } from './seed-import/episode-report.js';
43
+ import { createFileSeedImportState } from './seed-import/state.js';
44
+ import { extractSkill } from './skill.js';
45
+ import { isInsidePackageDir, writePackageTreeSafely } from './path-safety.js';
46
+ import { IPFS_RAW_CODEC, parseIpfsCid } from './ipfs-cid.js';
47
+ import { runDistillationPipeline } from './pipeline.js';
48
+ import { modelLabel, runEvalPrep } from './eval-prep.js';
49
+ import { createVerdictSource } from './bridge-verdict-source.js';
50
+ import { createEvidenceFetcher, } from './bridge-fetch-evidence.js';
51
+ import { createClaudeDistiller, createClaudeMetaDistiller, createCodexDistiller, createCodexMetaDistiller, DEFAULT_CODEX_MODEL, DEFAULT_MODEL as DEFAULT_CLAUDE_DISTILL_MODEL, DISTILLER_CATALOG, } from './distill-llm.js';
52
+ import { createLocalDistiller, createLocalSkillSink } from './distiller.js';
53
+ import { parseSkillMarkdown } from './skill-package.js';
54
+ import { DEFAULT_DISTILL_MODE_PATH, readDistillDefaults, readDistillMode, writeDistillDefaults, writeDistillMode, } from './distill-mode.js';
55
+ import { DEFAULT_CAPTURES_DIR, DEFAULT_DISTILL_CAPTURE_LIMIT, DEFAULT_EPISODES_DIR, DEFAULT_SKILLS_INSTALL_DIR, coveredSessionIds, loadRecentDistillSources, provenanceLabels, stagingDirFor, } from './distill-captures.js';
56
+ import { renderConsentDisclosure, renderPreview, renderConfirmLocal, renderModeSet, renderDistillerSet, renderDistillerModels, renderDeferredRun, renderRecorded, renderEmpty, renderRunSummary, renderSkillsPanel, renderReview, renderFailure, renderResumeNothing, } from './distill-render.js';
57
+ import { createNdjsonProgressEmitter, newRunId, } from './distill-progress.js';
58
+ import { appendDistillRun, readDistillRuns, DEFAULT_DISTILL_RUNS_PATH, } from './distill-runs.js';
59
+ const USAGE = `Usage: jinn-layer <command> [args]
60
+
61
+ Commands:
62
+ reindex [--repair|--dry-run] [--json] [--episodes-dir <dir>] [--index-path <file>]
63
+ Rebuild the machine-local derived evidence index.
64
+ --repair also normalizes the known null quartet
65
+ and rescues misnamed episode files.
66
+ --dry-run only reports readability; it writes nothing.
67
+ contract --json Print process contract version 1
68
+ session pickup Read a v1 pickup request on stdin
69
+ session end Read a complete EpisodeV1 request on stdin
70
+ history --json Derive local session history from canonical stores
71
+ contribution preview [--ack] --json Show the next sanitized first-share preview;
72
+ --ack records the one-time acknowledgement
73
+ contribution ledger --json Show privacy-safe canonical contribution states
74
+ contribution disable --json Disable every unpublished authorization
75
+ corpus search "<query>" [--limit N] [--json] Search corpus records (substring match on
76
+ solverType / role / artifactType / refs)
77
+ corpus get <ref> [--json] [--out <dir>] Fetch a record by ref (manifest CID from a
78
+ search result), including artifact content
79
+ corpus probe "<slug>" [--json] Run the corpus doctor probes for a repo slug
80
+ (corpus-reachable + corpus-content) in one
81
+ round-trip
82
+ skills install <ref> [--out <dir>] [--json] Install the skill carried by a corpus record
83
+ (jinn.skill.v1 artifact, or the legacy seeded
84
+ trace shape): writes SKILL.md + companion files
85
+ to <dir> (default ./<skill-name slug>)
86
+ capture preview <task-file> [--json] [--full] Scrub a captured task and show a compact,
87
+ readable summary of what would leave this
88
+ machine (task, tools, grouped redaction
89
+ counts, one line per step). --full also prints
90
+ the per-field before→after audit and the full
91
+ envelope JSON; --json emits the machine report.
92
+ ledger [--path <file>] [--json] The contribution ledger: what left this
93
+ machine, with anchor tx explorer links
94
+ publish <task-file> [--veto] [--json] Scrub, consent, publish and anchor a captured
95
+ task on testnet (--veto records locally and
96
+ publishes nothing)
97
+ derive-env Print export lines for this operator's publish
98
+ identity (JINN_LAYER_PRIVATE_KEY/_SAFE_ADDRESS/
99
+ _AGENT_ID) derived from ~/.jinn-client:
100
+ eval "\$(jinn-layer derive-env)" then
101
+ jinn-layer publish. Reads the keystore +
102
+ password; the key stays in-process (stdout only,
103
+ never on disk)
104
+ seed plan --source <list-file> [--out <report-file>] [--json]
105
+ Fetch + licence-check the disclosed seed list
106
+ (owner/repo[#path] per line); ZERO writes —
107
+ the report is what a human approves
108
+ seed execute <report-file> --source <list-file> [--json]
109
+ Publish the approved import rows (APPROVAL
110
+ GATE: run only after the plan report is
111
+ signed off). Idempotent: unchanged seeds
112
+ republish nothing; changed seeds supersede
113
+ the prior record
114
+ seed plan --episodes-dir <dir> [--out <report-file>] [--json]
115
+ List + validate evidence-episode seed files
116
+ (docs/runbooks/stage1-evidence-seeding.md);
117
+ ZERO writes
118
+ seed execute <report-file> --episodes-dir <dir> [--json]
119
+ Publish the approved evidence episodes
120
+ (APPROVAL GATE, idempotent + supersedes,
121
+ same as the skill lane above)
122
+ distill run [--limit N] [--out <dir>] [--meta]
123
+ [--distiller claude|codex] [--local-only]
124
+ [--anchor-mode per-record|manifest]
125
+ [--measure-per-record-control]
126
+ Run the distillation pipeline over the
127
+ swe-rebench-v2 verdict ledger: verdict→solution
128
+ join → distill → local SKILL.md packages.
129
+ Held-out slate instances are excluded. Writes
130
+ <out>/<name>/SKILL.md and prints the summary.
131
+ --meta also runs stage-2 cross-instance
132
+ meta-distill over the stage-1 skills.
133
+ --local-only avoids chain writes and evidence
134
+ anchors, using in-memory publish deps.
135
+ --anchor-mode manifest creates one anchor per raw-block-sized partition,
136
+ records receipt gas, and safely resumes its durable journal.
137
+ --measure-per-record-control adds one live
138
+ receipt-bound capture anchor for comparison.
139
+ distill [--where local|defer|off] [--install all|<name>|none] [--resume]
140
+ [--episodes <dir>] [--captures <legacy-dir>] [--limit N] [--out <dir>]
141
+ [--distiller claude|codex] [--distiller-model <model>] [--json]
142
+ [--progress ndjson] [--cluster-timeout <seconds>]
143
+ Rung-1 own-captures loop: distill YOUR recent local
144
+ captures into skills, fully local (no corpus publish,
145
+ no chain anchor). A heavy local pass, so you control
146
+ WHERE it runs (#1490):
147
+ --where local distill here now (a spend; confirms)
148
+ --where defer hold captures, run nothing (default)
149
+ --where off stop reserving captures
150
+ --where sets the persistent mode and echoes it —
151
+ nothing runs. A bare 'distill' with no recorded mode
152
+ shows first-run consent (interactive) or takes the
153
+ safe default (defer) non-interactively.
154
+ A LOCAL run distills to a STAGING area and installs
155
+ NOTHING by default — you review the skills (what each
156
+ came from and will help with) and choose:
157
+ --install all install every distilled skill
158
+ --install <name> install just that one
159
+ --install none stage only (default)
160
+ Interactively (TTY) the same choice is an [A]ll /
161
+ [1] first / [S]kip prompt. Installed skills land as
162
+ <out>/<name>/SKILL.md (default
163
+ ~/.jinn-client/harness-layer/skills, the active dir
164
+ /jinn skills reads); the rest wait in <out>-staged.
165
+ --resume distills only the captures no distilled skill
166
+ (installed OR staged) already covers.
167
+ --json reports the distilled and installed sets
168
+ separately.
169
+ --distiller-model is the arbitrage knob: it is the
170
+ model that WRITES the skills, distinct from the
171
+ cheap runtime model your captures ran under.
172
+ --progress ndjson streams machine-readable run
173
+ events (run_start/cluster_*/heartbeat/run_end,
174
+ one JSON object per line, stamped v/ts/runId) on
175
+ stderr for wrappers; run_end is always emitted.
176
+ Non-interactive use only — stdout --json is
177
+ unaffected; ignore stderr lines without a v field.
178
+ --cluster-timeout caps each cluster's distiller
179
+ subprocess (seconds; default 600). On expiry the
180
+ child is killed, that cluster lands in errors,
181
+ and the run continues.
182
+ distill status [--episodes <dir>] [--captures <legacy-dir>] [--out <dir>] [--json]
183
+ The loop's state in one read: mode, reserved
184
+ captures (total + not yet distilled), staged and
185
+ installed counts, resolved distiller, last run.
186
+ Pure reads — never prompts, spends, or writes.
187
+ distill runs [--limit N] [--json] Recorded runs, newest-first (one record per run
188
+ outcome: ok / partial / empty).
189
+ distill staged [--out <dir>] [--json] Skills waiting in the staging area (<out>-staged):
190
+ name, what each helps with, provenance. Read-only.
191
+ distill install [<name> ...] [--all] [--out <dir>] [--json]
192
+ Install previously STAGED skills into the active
193
+ dir — no distillation, no consent gate, no LLM
194
+ calls; these are artifacts an earlier LOCAL run
195
+ already produced. Installed staged copies are
196
+ removed (staging holds only what's not installed).
197
+ distill eval-prep [--limit N] [--out <dir>] [--json] [--meta] [--select-only]
198
+ [--group-cap N] [--concurrency N] [--force] [--retry-errors]
199
+ [--models gpt-5.4-mini,gpt-5.5]
200
+ [--max-clusters N] [--max-contrastive N]
201
+ [--max-lessons N] [--max-patterns N]
202
+ Bridge/gate/cluster once, freeze a useful
203
+ cluster set, then run each Codex model over
204
+ exactly those clusters. Always local-only.
205
+ --meta runs per-model cross-instance
206
+ meta-distill over each model's accepted
207
+ stage-1 skills.
208
+ --select-only writes selection/raw evidence
209
+ artifacts and skips all model calls.
210
+ Existing --out dirs resume by default;
211
+ --force rebuilds from scratch, and
212
+ --retry-errors reruns error records.
213
+
214
+ Environment:
215
+ JINN_DISCOVERY_URL Override the discovery indexer URL (default: testnet Ponder indexer)
216
+ JINN_IPFS_GATEWAY_URL Override the IPFS gateway (default: https://gateway.autonolas.tech)
217
+
218
+ Environment (publish — testnet anchor identity):
219
+ JINN_EARNING_DIR Earning dir derive-env reads (default: ~/.jinn-client/earning)
220
+ JINN_LAYER_PRIVATE_KEY Operator agent EOA key (required; signs envelope + anchor tx)
221
+ JINN_LAYER_SAFE_ADDRESS Operator Safe address (required)
222
+ JINN_LAYER_AGENT_ID ERC-8004 agent NFT id (required)
223
+ JINN_RPC_URL Base Sepolia RPC (default: publicnode)
224
+ JINN_LAYER_IDENTITY_REGISTRY IdentityRegistry address (default: Base Sepolia 0x8004A818…BD9e)
225
+ JINN_IPFS_REGISTRY_URL IPFS registry for uploads (default: https://registry.autonolas.tech)
226
+ JINN_LAYER_ENDPOINT Artifact access endpoint recorded on publish (default: http://127.0.0.1:7331)
227
+ JINN_LAYER_LEDGER_PATH Ledger file (default: ~/.jinn-client/harness-layer/ledger.jsonl)
228
+ JINN_DB_PATH SQLite anchor telemetry store (manifest mode)
229
+
230
+ Environment (distill — reads the testnet ledger + spends model solves):
231
+ JINN_DISCOVERY_URL Ponder indexer base (default: testnet jinn-indexer)
232
+ JINN_IPFS_GATEWAY_URL IPFS gateway for envelope fetches (default: https://gateway.autonolas.tech)
233
+ JINN_DISTILL_PROVIDER Distiller provider: claude or codex (default: claude)
234
+ JINN_DISTILL_MODEL Distiller model (defaults: claude-opus-4-8 for claude, gpt-5.5 for codex)
235
+ (publish env above) distill also captures + anchors the bridged evidence on testnet unless --local-only
236
+
237
+ Environment (distill — the rung-1 local episode loop; runs fully locally):
238
+ JINN_LAYER_EPISODES_DIR Canonical EpisodeV1 dir (default: ~/.jinn-client/harness-layer/episodes)
239
+ JINN_LAYER_CAPTURES_DIR Deprecated legacy CapturedTask read dir (default: ~/.jinn-client/harness-layer/captures)
240
+ JINN_LAYER_DISTILL_MODE_PATH Where-it-runs mode file (default: ~/.jinn-client/harness-layer/distill.json)
241
+ JINN_LAYER_DISTILL_RUNS_PATH Run log (default: ~/.jinn-client/harness-layer/distill-runs.jsonl)
242
+ JINN_DISTILL_PROVIDER Distiller provider: claude or codex (default: claude)
243
+ JINN_DISTILL_MODEL Distiller model (overridden by --distiller-model)
244
+ `;
245
+ const DEFAULT_CLI_SEARCH_LIMIT = 20;
246
+ /**
247
+ * Byte ceiling on any single IPFS object fetched by the distill pipeline
248
+ * (PR #1476 security review): envelopes are KBs; donation-wrapped
249
+ * system_snapshots observed live are ≤ ~400 KB. 64 MiB leaves generous
250
+ * headroom while preventing a malicious multi-GB wrapper from OOMing the run.
251
+ */
252
+ const MAX_IPFS_FETCH_BYTES = 64 * 1024 * 1024;
253
+ /**
254
+ * Build the live IPFS JSON port with a caller-selectable finite byte ceiling.
255
+ *
256
+ * The stream is cancelled on the first over-limit chunk, so hostile evidence
257
+ * is never fully buffered before the cap is enforced. The verifier resolver
258
+ * uses the second argument for its tighter authenticated-artifact ceiling.
259
+ */
260
+ export function createBoundedIpfsJsonFetcher(options) {
261
+ const gateway = options.gateway.replace(/\/$/, '');
262
+ const defaultMaxBytes = options.defaultMaxBytes ?? MAX_IPFS_FETCH_BYTES;
263
+ const timeoutMs = options.timeoutMs ?? 30_000;
264
+ const fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
265
+ if (!Number.isSafeInteger(defaultMaxBytes) || defaultMaxBytes <= 0) {
266
+ throw new Error('IPFS default fetch ceiling must be a positive safe integer');
267
+ }
268
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
269
+ throw new Error('IPFS fetch timeout must be a positive safe integer');
270
+ }
271
+ return async (cid, maxBytes = defaultMaxBytes) => {
272
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
273
+ throw new Error('IPFS fetch ceiling must be a positive safe integer');
274
+ }
275
+ // Bound length before the structural decoder: CIDv0 base58 decoding is
276
+ // intentionally dependency-free but superlinear for enormous strings.
277
+ const parsedCid = cid.length <= 256 ? parseIpfsCid(cid) : null;
278
+ if (!parsedCid) {
279
+ throw new Error(`ipfs ${JSON.stringify(cid)}: expected a valid IPFS CID`);
280
+ }
281
+ const requestUrl = new URL(`${gateway}/ipfs/${encodeURIComponent(cid)}`).href;
282
+ const response = await fetchImpl(requestUrl, {
283
+ redirect: 'error',
284
+ signal: AbortSignal.timeout(timeoutMs),
285
+ });
286
+ if (response.redirected
287
+ || (response.url !== '' && response.url !== requestUrl)) {
288
+ throw new Error(`ipfs ${cid}: gateway redirects are not allowed`);
289
+ }
290
+ if (!response.ok)
291
+ throw new Error(`ipfs ${cid}: HTTP ${response.status}`);
292
+ const rawLength = response.headers.get('content-length');
293
+ if (rawLength !== null) {
294
+ const declaredLength = Number(rawLength);
295
+ if (Number.isSafeInteger(declaredLength)
296
+ && declaredLength >= 0
297
+ && declaredLength > maxBytes) {
298
+ throw new Error(`ipfs ${cid}: ${declaredLength} bytes exceeds the ${maxBytes}-byte fetch ceiling`);
299
+ }
300
+ }
301
+ if (!response.body) {
302
+ throw new Error(`ipfs ${cid}: response has no body`);
303
+ }
304
+ const reader = response.body.getReader();
305
+ const chunks = [];
306
+ let length = 0;
307
+ try {
308
+ while (true) {
309
+ const next = await reader.read();
310
+ if (next.done)
311
+ break;
312
+ length += next.value.byteLength;
313
+ if (length > maxBytes) {
314
+ try {
315
+ await reader.cancel('IPFS response exceeded fetch ceiling');
316
+ }
317
+ catch {
318
+ // Preserve the deterministic ceiling error if cancellation races.
319
+ }
320
+ throw new Error(`ipfs ${cid}: ${length} bytes exceeds the ${maxBytes}-byte fetch ceiling`);
321
+ }
322
+ chunks.push(next.value);
323
+ }
324
+ }
325
+ finally {
326
+ reader.releaseLock();
327
+ }
328
+ const bytes = new Uint8Array(length);
329
+ let offset = 0;
330
+ for (const chunk of chunks) {
331
+ bytes.set(chunk, offset);
332
+ offset += chunk.byteLength;
333
+ }
334
+ if (parsedCid.codec === IPFS_RAW_CODEC) {
335
+ const actualDigest = createHash('sha256').update(bytes).digest();
336
+ if (!actualDigest.equals(Buffer.from(parsedCid.sha256Digest))) {
337
+ throw new Error(`ipfs ${cid}: fetched content digest does not match requested CID`);
338
+ }
339
+ }
340
+ let text;
341
+ try {
342
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
343
+ }
344
+ catch (error) {
345
+ throw new Error(`ipfs ${cid}: invalid UTF-8: ${error instanceof Error ? error.message : String(error)}`);
346
+ }
347
+ try {
348
+ return JSON.parse(text);
349
+ }
350
+ catch (error) {
351
+ throw new Error(`ipfs ${cid}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
352
+ }
353
+ };
354
+ }
355
+ /**
356
+ * Default install directory name for `skills install`: a safe slug of the
357
+ * publisher-controlled skill name (#1394). The name is corpus data, never a
358
+ * path — anything outside [a-z0-9._-] collapses to '-', leading/trailing
359
+ * dots and dashes are stripped, and an empty result falls back to 'skill'.
360
+ */
361
+ function skillDirSlug(name) {
362
+ const slug = name
363
+ .toLowerCase()
364
+ .replace(/[^a-z0-9._-]+/g, '-')
365
+ .replace(/^[-.]+|[-.]+$/g, '');
366
+ return slug === '' ? 'skill' : slug;
367
+ }
368
+ /** Warn-once latch for the legacy single-l env name (issue #1532). */
369
+ let warnedLegacyModePathEnv = false;
370
+ /** Where the persisted `distill` mode lives; env-overridable for tests / CI. */
371
+ function distillModePath() {
372
+ const current = process.env['JINN_LAYER_DISTILL_MODE_PATH'];
373
+ if (current !== undefined)
374
+ return current;
375
+ const legacy = process.env['JINN_LAYER_DISTIL_MODE_PATH'];
376
+ if (legacy !== undefined) {
377
+ if (!warnedLegacyModePathEnv) {
378
+ warnedLegacyModePathEnv = true;
379
+ console.warn('[distill] JINN_LAYER_DISTIL_MODE_PATH is renamed to JINN_LAYER_DISTILL_MODE_PATH (double l); the old name still works but will be dropped');
380
+ }
381
+ return legacy;
382
+ }
383
+ return DEFAULT_DISTILL_MODE_PATH;
384
+ }
385
+ /** Count the skill directories (subdirectories carrying a SKILL.md) under dir. */
386
+ function countSkillDirs(dir) {
387
+ if (!existsSync(dir))
388
+ return 0;
389
+ let count = 0;
390
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
391
+ if (entry.isDirectory() && existsSync(join(dir, entry.name, 'SKILL.md')))
392
+ count += 1;
393
+ }
394
+ return count;
395
+ }
396
+ /**
397
+ * Parse every staged skill under `stagingDir` (#1536). An unparseable
398
+ * SKILL.md is skipped with a stderr warning — one bad artifact must not
399
+ * hide the rest of the staging area.
400
+ */
401
+ function listStaged(stagingDir) {
402
+ if (!existsSync(stagingDir))
403
+ return [];
404
+ const pkgs = [];
405
+ for (const entry of readdirSync(stagingDir, { withFileTypes: true })) {
406
+ if (!entry.isDirectory())
407
+ continue;
408
+ const md = join(stagingDir, entry.name, 'SKILL.md');
409
+ if (!existsSync(md))
410
+ continue;
411
+ try {
412
+ pkgs.push(parseSkillMarkdown(readFileSync(md, 'utf-8')));
413
+ }
414
+ catch (err) {
415
+ console.warn(`[distill] skipping unparseable staged skill ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
416
+ }
417
+ }
418
+ return pkgs;
419
+ }
420
+ /**
421
+ * Install skills into the active dir and remove their staged copies — the
422
+ * single install path (#1536), shared by the inline run flow and the
423
+ * `distill install` subverb, so staging always holds exactly what is not
424
+ * installed. NO LLM calls, NO consent gate: these are artifacts an earlier
425
+ * consented LOCAL run already produced.
426
+ */
427
+ async function installStagedSkills(pkgs, stagingDir, activeDir) {
428
+ const sink = createLocalSkillSink(activeDir);
429
+ const installed = [];
430
+ for (const pkg of pkgs) {
431
+ await sink(pkg);
432
+ rmSync(join(stagingDir, pkg.name), { recursive: true, force: true });
433
+ installed.push({ name: pkg.name, path: join(activeDir, pkg.name, 'SKILL.md') });
434
+ }
435
+ return installed;
436
+ }
437
+ function ask(rl, q) {
438
+ return new Promise((resolve) => rl.question(q, (a) => resolve(a)));
439
+ }
440
+ /**
441
+ * The default (TTY-only) first-run consent prompt. Prints the disclosure, then a
442
+ * one-line readline menu — L(ocal, confirmed) · F/blank (defer, the safe
443
+ * default) · O(ff) · P(review). Mirrors `cli/commands/auth.ts`'s readline
444
+ * pattern; the keyboard state-machine in the 1490 mockup is a visual spec, not a
445
+ * raw-mode TUI. Not unit-tested (tests inject `promptConsent`); kept small.
446
+ */
447
+ async function readlineConsentPrompt(o) {
448
+ o.writer.write(renderConsentDisclosure({ captureCount: o.captureCount, distillModel: o.distillModel }) + '\n');
449
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
450
+ try {
451
+ for (;;) {
452
+ const a = (await ask(rl, '\ndistill where? [L]ocal now · [F] defer (default) · [O]ff · [P]review: ')).trim().toLowerCase();
453
+ if (a === '' || a === 'f' || a === 'defer')
454
+ return 'defer';
455
+ if (a === 'o' || a === 'off')
456
+ return 'off';
457
+ if (a === 'p' || a === 'preview') {
458
+ o.writer.write(renderPreview({
459
+ captures: o.captures.map((c) => ({ summary: c.task.summary, when: '', size: '' })),
460
+ distillModel: o.distillModel,
461
+ }) + '\n');
462
+ continue;
463
+ }
464
+ if (a === 'l' || a === 'local') {
465
+ o.writer.write(renderConfirmLocal({ captureCount: o.captureCount, distillModel: o.distillModel }) + '\n');
466
+ const c = (await ask(rl, '\nRun a frontier pass now? [y/N]: ')).trim().toLowerCase();
467
+ if (c === 'y' || c === 'yes')
468
+ return 'local';
469
+ // Anything else backs out to the menu — a spend is never the default.
470
+ }
471
+ }
472
+ }
473
+ finally {
474
+ rl.close();
475
+ }
476
+ }
477
+ /**
478
+ * The default (TTY-only) install-review prompt. Prints the forward-framed review
479
+ * (what each skill will help with next), then reads an explicit all / one / skip
480
+ * choice. Same proportionate ladder as consent; nothing installs until the
481
+ * operator answers. Not unit-tested (tests inject `promptInstall`).
482
+ */
483
+ async function readlineInstallPrompt(o) {
484
+ o.writer.write(renderReview({ distillModel: o.distillModel, captureCount: o.captureCount, skills: o.skills }) + '\n');
485
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
486
+ try {
487
+ for (;;) {
488
+ const a = (await ask(rl, '\ninstall? [A]ll · [1] just the first · [S]kip (default): ')).trim().toLowerCase();
489
+ if (a === '' || a === 's' || a === 'skip')
490
+ return 'none';
491
+ if (a === 'a' || a === 'all')
492
+ return 'all';
493
+ if (a === '1' || a === 'first' || a === 'one')
494
+ return 'first';
495
+ // Unrecognised → re-ask; skip is the safe default on empty.
496
+ }
497
+ }
498
+ finally {
499
+ rl.close();
500
+ }
501
+ }
502
+ /**
503
+ * Render an envelope `generatedAt` as ISO. Producers are inconsistent about
504
+ * unit — most stamp unix seconds, some stamp milliseconds — so use the usual
505
+ * magnitude heuristic (>= 1e12 means it cannot be seconds until year 33658).
506
+ */
507
+ function generatedAtIso(value) {
508
+ if (!Number.isFinite(value) || value <= 0)
509
+ return '-';
510
+ const ms = value >= 1e12 ? value : value * 1000;
511
+ return new Date(ms).toISOString();
512
+ }
513
+ function renderHit(hit) {
514
+ const lines = [
515
+ hit.title,
516
+ ` ref ${hit.ref}`,
517
+ ` operator agentId=${hit.operator.agentId || '-'} safe=${hit.operator.safeAddress || '-'}`,
518
+ ` evidence tier ${hit.evidenceTier}`,
519
+ ` generated at ${generatedAtIso(hit.generatedAt)}`,
520
+ // publishedAt is backing-dependent (block number on the HTTP indexer,
521
+ // unix time elsewhere) — print raw, never as a date.
522
+ ` published ${hit.publishedAt}`,
523
+ ` task ${hit.task ? hit.task.cid : '-'}`,
524
+ ` artifacts ${hit.artifactTypes.join(', ') || '-'}`,
525
+ ];
526
+ return lines.join('\n');
527
+ }
528
+ const CONTENT_PREVIEW_BYTES = 2000;
529
+ function renderRecord(record, outDir) {
530
+ const lines = [
531
+ `${record.envelope.solverType} / ${record.envelope.role}`,
532
+ ` ref ${record.ref}`,
533
+ ` operator agentId=${record.provenance.operator.agentId || '-'} safe=${record.provenance.operator.safeAddress || '-'}`,
534
+ ` evidence tier ${record.provenance.evidenceTier}`,
535
+ ` published ${record.provenance.publishedAt}`,
536
+ '',
537
+ `${record.artifacts.length} artifact(s)`,
538
+ ];
539
+ for (const a of record.artifacts) {
540
+ lines.push('', `artifact ${a.sha256}`, ` type ${a.artifactType}`, ` size ${a.sizeBytes} bytes`, ` source ${a.source}`);
541
+ if (outDir) {
542
+ const path = join(outDir, a.sha256);
543
+ writeFileSync(path, a.content);
544
+ lines.push(` saved ${path}`);
545
+ }
546
+ else {
547
+ const preview = a.content.subarray(0, CONTENT_PREVIEW_BYTES).toString('utf-8');
548
+ const truncated = a.sizeBytes > CONTENT_PREVIEW_BYTES ? `\n … truncated (${a.sizeBytes} bytes total; use --out <dir> for full content)` : '';
549
+ lines.push(` content:\n${preview}${truncated}`);
550
+ }
551
+ }
552
+ return lines.join('\n');
553
+ }
554
+ function recordToJson(record) {
555
+ return {
556
+ ...record,
557
+ artifacts: record.artifacts.map((a) => ({
558
+ sha256: a.sha256,
559
+ artifactType: a.artifactType,
560
+ source: a.source,
561
+ sizeBytes: a.sizeBytes,
562
+ contentBase64: a.content.toString('base64'),
563
+ })),
564
+ };
565
+ }
566
+ /** Cap displayed before/after values so one huge attribute stays readable. */
567
+ const DIFF_VALUE_PREVIEW_CHARS = 400;
568
+ function renderDiffValue(value) {
569
+ if (value === undefined)
570
+ return '(dropped — field is not published at all)';
571
+ const s = typeof value === 'string' ? value : JSON.stringify(value);
572
+ const flat = s.replace(/\n/g, '\\n');
573
+ return flat.length > DIFF_VALUE_PREVIEW_CHARS
574
+ ? `${flat.slice(0, DIFF_VALUE_PREVIEW_CHARS)}… (${flat.length} chars)`
575
+ : flat;
576
+ }
577
+ /** Collect items into a Map keyed by `key`, preserving first-seen key order. */
578
+ function groupBy(items, key) {
579
+ const out = new Map();
580
+ for (const item of items) {
581
+ const group = out.get(key(item)) ?? [];
582
+ group.push(item);
583
+ out.set(key(item), group);
584
+ }
585
+ return out;
586
+ }
587
+ /**
588
+ * Group redaction entries by field: the pipeline reports one entry per
589
+ * detection, so a busy attribute produces many entries with identical
590
+ * before/after — one block per field with the union of firing stages is the
591
+ * readable audit view.
592
+ */
593
+ function renderRedactionsByField(redactions) {
594
+ const byField = groupBy(redactions, (r) => r.field);
595
+ const lines = [];
596
+ for (const [field, group] of byField) {
597
+ const stages = [...new Set(group.map((r) => `${r.stage}${r.detail ? ` (${r.detail})` : ''}`))];
598
+ const first = group[0];
599
+ const last = group[group.length - 1];
600
+ lines.push(` ${field}`);
601
+ lines.push(` stages ${stages.join(', ')}`);
602
+ if ('before' in first)
603
+ lines.push(` before ${renderDiffValue(first.before)}`);
604
+ lines.push(` after ${renderDiffValue(last.after)}`);
605
+ lines.push('');
606
+ }
607
+ lines.pop(); // trailing blank
608
+ return lines;
609
+ }
610
+ function renderScrubReport(report, full) {
611
+ const env = report.envelope;
612
+ const truncated = env.trajectory.reduce((n, s) => n + (s.truncatedKeys?.length ?? 0), 0);
613
+ const distinctFields = new Set(report.redactions.map((r) => r.field)).size;
614
+ const lines = [
615
+ 'scrub preview — what would leave this machine',
616
+ ` task ${env.task.summary}`,
617
+ ` tags ${env.task.distributionTags.join(', ')}`,
618
+ ` harness ${env.environment.harness.name}@${env.environment.harness.version}`,
619
+ ` model ${env.environment.model}`,
620
+ ` outcome ${env.outcome.status} / strength ${env.outcome.verificationStrength}`,
621
+ ` steps ${env.trajectory.length}`,
622
+ ` tools ${env.environment.tools.length > 0 ? env.environment.tools.join(', ') : '(none)'}`,
623
+ '',
624
+ ];
625
+ // Redactions grouped by stage → detail, with counts — the safety core.
626
+ if (report.redactions.length === 0) {
627
+ lines.push('no redactions — the scrub pipeline found nothing to remove');
628
+ }
629
+ else {
630
+ lines.push(`redactions ${report.redactions.length} across ${distinctFields} field(s), ${truncated} truncation(s)`);
631
+ for (const [stage, group] of groupBy(report.redactions, (r) => r.stage)) {
632
+ lines.push(` ${stage} ${group.length}`);
633
+ const byDetail = new Map();
634
+ for (const r of group) {
635
+ const key = r.detail ?? '(no detail)';
636
+ byDetail.set(key, (byDetail.get(key) ?? 0) + 1);
637
+ }
638
+ for (const [detail, count] of byDetail) {
639
+ lines.push(` ${detail} ×${count}`);
640
+ }
641
+ }
642
+ }
643
+ // One short line per step — no attributes dump.
644
+ lines.push('', 'steps:');
645
+ env.trajectory.forEach((s, i) => {
646
+ const parts = [`${s.redactedKeys.length} redacted`];
647
+ const tk = s.truncatedKeys?.length ?? 0;
648
+ if (tk > 0)
649
+ parts.push(`${tk} truncated`);
650
+ lines.push(` [${i}] ${s.name} ${parts.join(', ')}`);
651
+ });
652
+ if (full) {
653
+ if (report.redactions.length > 0) {
654
+ lines.push('', `${report.redactions.length} redaction(s) — "before" is shown here only and never leaves this machine`, '', ...renderRedactionsByField(report.redactions));
655
+ }
656
+ lines.push('', 'envelope as it would publish:', JSON.stringify(env, null, 2));
657
+ }
658
+ return lines.join('\n');
659
+ }
660
+ /** Base Sepolia explorer — the testnet all v0 anchors land on. */
661
+ const TESTNET_EXPLORER_TX_URL = 'https://sepolia.basescan.org/tx/';
662
+ function renderLedgerEntry(entry) {
663
+ const lines = [
664
+ `${entry.ts} ${entry.status}`,
665
+ ` task ${entry.taskSummary}`,
666
+ ` tier ${entry.verifiabilityTier}`,
667
+ ` ref ${entry.envelopeRef ?? '- (not published)'}`,
668
+ ` anchor ${entry.anchorTx ? `${TESTNET_EXPLORER_TX_URL}${entry.anchorTx}` : '-'}`,
669
+ ];
670
+ return lines.join('\n');
671
+ }
672
+ function buildLocalOnlyPublishDeps() {
673
+ let n = 0;
674
+ const nextLocalCid = (kind) => `local:${kind}:${++n}`;
675
+ return {
676
+ participant: { safeAddress: `0x${'1'.repeat(40)}`, agentEoa: `0x${'2'.repeat(40)}` },
677
+ signer: { address: `0x${'2'.repeat(40)}` },
678
+ clientGitSha: 'local-only',
679
+ defaultArtifactEndpoint: 'http://127.0.0.1:7331',
680
+ ledger: createMemoryLedger(),
681
+ publishArtifact: async () => ({ cid: nextLocalCid('artifact') }),
682
+ publishEnvelope: async () => ({ cid: nextLocalCid('envelope') }),
683
+ anchorEnvelope: async () => ({ blockNumber: null }),
684
+ signEnvelope: async () => ({
685
+ algo: 'secp256k1',
686
+ signer: `0x${'2'.repeat(40)}`,
687
+ hash: `0x${'0'.repeat(64)}`,
688
+ sig: `0x${'f'.repeat(130)}`,
689
+ }),
690
+ };
691
+ }
692
+ function parseDistillProvider(raw) {
693
+ return raw === 'claude' || raw === 'codex' ? raw : null;
694
+ }
695
+ function defaultDistillerFactory(provider, model, timeoutMs) {
696
+ const timeout = timeoutMs !== undefined ? { timeoutMs } : {};
697
+ if (provider === 'codex') {
698
+ return {
699
+ distill: createCodexDistiller({ model, ...timeout }),
700
+ metaDistill: createCodexMetaDistiller({ model, ...timeout }),
701
+ };
702
+ }
703
+ return {
704
+ distill: createClaudeDistiller({ model, ...timeout }),
705
+ metaDistill: createClaudeMetaDistiller({ model, ...timeout }),
706
+ };
707
+ }
708
+ /**
709
+ * Resolve the per-cluster distiller deadline (#1534): `--cluster-timeout`
710
+ * (seconds) > `JINN_DISTILL_CLUSTER_TIMEOUT_S` > undefined (the port default).
711
+ * Returns milliseconds, or an error message for a bad value.
712
+ */
713
+ function resolveClusterTimeoutMs(flagValue) {
714
+ const raw = flagValue ?? process.env['JINN_DISTILL_CLUSTER_TIMEOUT_S'];
715
+ if (raw === undefined)
716
+ return undefined;
717
+ const seconds = Number(raw);
718
+ if (!Number.isFinite(seconds) || !Number.isInteger(seconds) || seconds <= 0) {
719
+ return { error: `error: --cluster-timeout must be a positive integer number of seconds (got ${JSON.stringify(raw)})` };
720
+ }
721
+ return seconds * 1000;
722
+ }
723
+ async function readStdinToEnd() {
724
+ const chunks = [];
725
+ for await (const chunk of process.stdin) {
726
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
727
+ }
728
+ return Buffer.concat(chunks).toString('utf8');
729
+ }
730
+ function interfaceError(writer, error) {
731
+ writer.write(`error: invalid process request: ${error instanceof Error ? error.message : String(error)}\n`);
732
+ return 2;
733
+ }
734
+ /** Returns the process exit code (0 = success). */
735
+ export async function runJinnLayerCli(argv, opts = {}) {
736
+ const writer = opts.writer ?? process.stdout;
737
+ const reader = opts.reader ?? readStdinToEnd;
738
+ const [verb, subverb, ...rest] = argv;
739
+ const isCorpus = verb === 'corpus' && (subverb === 'search' || subverb === 'get' || subverb === 'probe');
740
+ const isCapturePreview = verb === 'capture' && subverb === 'preview';
741
+ const isLedger = verb === 'ledger';
742
+ const isPublish = verb === 'publish';
743
+ const isSeed = verb === 'seed' && (subverb === 'plan' || subverb === 'execute');
744
+ const isSkillsInstall = verb === 'skills' && subverb === 'install';
745
+ const isDistillRun = verb === 'distill' && subverb === 'run';
746
+ const isDistillEvalPrep = verb === 'distill' && subverb === 'eval-prep';
747
+ const isDistillModels = verb === 'distill' && subverb === 'models';
748
+ const isDistill = verb === 'distill' && !isDistillRun && !isDistillEvalPrep && !isDistillModels;
749
+ const isDeriveEnv = verb === 'derive-env';
750
+ const isContract = verb === 'contract';
751
+ const isSessionPickup = verb === 'session' && subverb === 'pickup';
752
+ const isSessionEnd = verb === 'session' && subverb === 'end';
753
+ const isHistory = verb === 'history';
754
+ const isContributionPreview = verb === 'contribution' && subverb === 'preview';
755
+ const isContributionLedger = verb === 'contribution' && subverb === 'ledger';
756
+ const isContributionDisable = verb === 'contribution' && subverb === 'disable';
757
+ const isReindex = verb === 'reindex';
758
+ if (!isCorpus && !isCapturePreview && !isLedger && !isPublish && !isSeed && !isSkillsInstall && !isDistillRun && !isDistillEvalPrep && !isDistillModels && !isDistill && !isDeriveEnv && !isContract && !isSessionPickup && !isSessionEnd && !isHistory && !isContributionPreview && !isContributionLedger && !isContributionDisable && !isReindex) {
759
+ writer.write(USAGE);
760
+ return verb === undefined || verb === 'help' || verb === '--help' ? 0 : 2;
761
+ }
762
+ if (isReindex) {
763
+ const reindexArgs = subverb === undefined ? rest : [subverb, ...rest];
764
+ const requestedJson = reindexArgs.includes('--json');
765
+ let parsed;
766
+ try {
767
+ parsed = parseArgs({
768
+ args: reindexArgs,
769
+ options: {
770
+ repair: { type: 'boolean', default: false },
771
+ 'dry-run': { type: 'boolean', default: false },
772
+ json: { type: 'boolean', default: false },
773
+ 'episodes-dir': { type: 'string' },
774
+ 'index-path': { type: 'string' },
775
+ },
776
+ strict: true,
777
+ allowPositionals: false,
778
+ });
779
+ }
780
+ catch (error) {
781
+ const message = `invalid reindex command: ${error instanceof Error ? error.message : String(error)}`;
782
+ writer.write(requestedJson
783
+ ? `${JSON.stringify({
784
+ status: 'error',
785
+ mode: 'reindex',
786
+ repair: reindexArgs.includes('--repair'),
787
+ error: message,
788
+ })}\n`
789
+ : `error: ${message}\n\n${USAGE}`);
790
+ return 2;
791
+ }
792
+ if (parsed.values.repair && parsed.values['dry-run']) {
793
+ const episodesDir = parsed.values['episodes-dir']
794
+ ?? process.env['JINN_LAYER_EPISODES_DIR']
795
+ ?? DEFAULT_EPISODES_DIR;
796
+ const indexPath = parsed.values['index-path']
797
+ ?? process.env['JINN_LAYER_EVIDENCE_INDEX_PATH']
798
+ ?? defaultEvidenceIndexPath(episodesDir);
799
+ const message = 'invalid reindex command: --repair and --dry-run are mutually exclusive';
800
+ writer.write(parsed.values.json
801
+ ? `${JSON.stringify({
802
+ status: 'error',
803
+ mode: 'reindex',
804
+ episodesDir,
805
+ indexPath,
806
+ repair: true,
807
+ error: message,
808
+ })}\n`
809
+ : `error: ${message}\n\n${USAGE}`);
810
+ return 2;
811
+ }
812
+ const episodesDir = parsed.values['episodes-dir']
813
+ ?? process.env['JINN_LAYER_EPISODES_DIR']
814
+ ?? DEFAULT_EPISODES_DIR;
815
+ const configuredIndexPath = parsed.values['index-path']
816
+ ?? process.env['JINN_LAYER_EVIDENCE_INDEX_PATH']
817
+ ?? defaultEvidenceIndexPath(episodesDir);
818
+ const dryRun = parsed.values['dry-run'];
819
+ const indexPath = dryRun ? null : configuredIndexPath;
820
+ try {
821
+ const report = dryRun
822
+ ? inspectEvidenceStore({ episodesDir })
823
+ : reindexEvidenceStore({
824
+ episodesDir,
825
+ indexPath: configuredIndexPath,
826
+ repair: parsed.values.repair,
827
+ });
828
+ const publicationFailed = !dryRun && !report.indexUpdated;
829
+ const status = report.unreadableFiles > 0 || publicationFailed ? 'degraded' : 'ok';
830
+ if (parsed.values.json) {
831
+ writer.write(`${JSON.stringify({
832
+ status,
833
+ mode: dryRun ? 'inspect' : 'reindex',
834
+ episodesDir,
835
+ indexPath,
836
+ repair: parsed.values.repair,
837
+ report,
838
+ })}\n`);
839
+ }
840
+ else {
841
+ writer.write([
842
+ `Evidence ${dryRun ? 'inspection' : 'reindex'} ${status}: ${report.indexedEpisodes}/${report.scannedFiles} files indexed`,
843
+ `Index: ${indexPath === null
844
+ ? 'not rebuilt (--dry-run)'
845
+ : report.indexUpdated
846
+ ? indexPath
847
+ : `not updated (${report.indexError ?? 'unknown publication failure'})`}`,
848
+ `Repairs: ${report.nullFieldsRemoved} null fields removed; ${report.renamedFiles} files renamed`,
849
+ `Legacy unstamped: ${report.legacyUnstampedFiles}`,
850
+ `Synthetic fixtures excluded: ${report.syntheticExcludedFiles}`,
851
+ ...report.syntheticExcluded.map((row) => ` ${row.path}: ${row.reason}`),
852
+ `Unreadable: ${report.unreadableFiles}`,
853
+ ...report.unreadable.map((row) => ` ${row.path}: ${row.reason}`),
854
+ '',
855
+ ].join('\n'));
856
+ }
857
+ return report.unreadableFiles > 0 || publicationFailed ? 1 : 0;
858
+ }
859
+ catch (error) {
860
+ const message = error instanceof Error ? error.message : String(error);
861
+ if (parsed.values.json) {
862
+ writer.write(`${JSON.stringify({
863
+ status: 'error',
864
+ mode: dryRun ? 'inspect' : 'reindex',
865
+ episodesDir,
866
+ indexPath,
867
+ repair: parsed.values.repair,
868
+ error: message,
869
+ })}\n`);
870
+ }
871
+ else {
872
+ writer.write(`error: evidence reindex failed: ${message}\n`);
873
+ }
874
+ return 1;
875
+ }
876
+ }
877
+ if (isContract) {
878
+ if (!((subverb === undefined && rest.length === 0) || (subverb === '--json' && rest.length === 0))) {
879
+ writer.write('error: invalid contract command; use jinn-layer contract --json\n');
880
+ return 2;
881
+ }
882
+ writer.write(`${JSON.stringify({
883
+ contractVersion: PROCESS_CONTRACT_VERSION,
884
+ })}\n`);
885
+ return 0;
886
+ }
887
+ if (isSessionPickup || isSessionEnd) {
888
+ if (rest.length > 0) {
889
+ writer.write(`error: invalid ${isSessionPickup ? 'session pickup' : 'session end'} command; request JSON belongs on stdin\n`);
890
+ return 2;
891
+ }
892
+ let raw;
893
+ try {
894
+ raw = JSON.parse(await reader());
895
+ }
896
+ catch (error) {
897
+ return interfaceError(writer, error);
898
+ }
899
+ try {
900
+ const deps = buildPluginDepsFromEnv(opts.pluginOverrides ?? {});
901
+ if (isSessionPickup) {
902
+ const request = SessionPickupRequestV1Schema.parse(raw);
903
+ const tracked = trackingCorpus(deps);
904
+ const result = await createJinnPlugin(tracked.deps)
905
+ .session(request.meta)
906
+ .firstTurnPickup(request.firstMessage);
907
+ writer.write(`${JSON.stringify(sessionPickupEnvelope(result, tracked.status(), tracked.reason()))}\n`);
908
+ }
909
+ else {
910
+ const request = SessionEndRequestV1Schema.parse(raw);
911
+ const result = await createJinnPlugin(deps).completeSession(request);
912
+ writer.write(`${JSON.stringify(sessionEndEnvelope(result))}\n`);
913
+ }
914
+ return 0;
915
+ }
916
+ catch (error) {
917
+ return interfaceError(writer, error);
918
+ }
919
+ }
920
+ if (isHistory) {
921
+ if (subverb !== '--json' || rest.length !== 0) {
922
+ writer.write('error: invalid history command; use jinn-layer history --json\n');
923
+ return 2;
924
+ }
925
+ const result = await createJinnPlugin(buildPluginDepsFromEnv(opts.pluginOverrides ?? {}))
926
+ .history();
927
+ const status = result.unavailable ? 'unavailable' : result.degraded ? 'degraded' : 'ok';
928
+ writer.write(`${JSON.stringify(processEnvelope(status, { entries: result.entries }, result.reason))}\n`);
929
+ return 0;
930
+ }
931
+ if (isContributionPreview) {
932
+ const flags = new Set(rest);
933
+ if ([...flags].some((arg) => arg !== '--ack' && arg !== '--json') || flags.size !== rest.length) {
934
+ writer.write('error: invalid contribution preview command\n');
935
+ return 2;
936
+ }
937
+ const result = await createJinnPlugin(buildPluginDepsFromEnv(opts.pluginOverrides ?? {}))
938
+ .previewContribution(flags.has('--ack'));
939
+ if (result.status === 'ok') {
940
+ writer.write(`${JSON.stringify(processEnvelope('ok', result.value))}\n`);
941
+ }
942
+ else if (result.status === 'degraded') {
943
+ writer.write(`${JSON.stringify(processEnvelope('degraded', result.value, result.reason))}\n`);
944
+ }
945
+ else {
946
+ writer.write(`${JSON.stringify(processEnvelope('unavailable', undefined, result.reason))}\n`);
947
+ }
948
+ return 0;
949
+ }
950
+ if (isContributionLedger) {
951
+ if (rest.length !== 1 || rest[0] !== '--json') {
952
+ writer.write('error: invalid contribution ledger command; use contribution ledger --json\n');
953
+ return 2;
954
+ }
955
+ const result = await createJinnPlugin(buildPluginDepsFromEnv(opts.pluginOverrides ?? {}))
956
+ .contributionLedger();
957
+ if (result.status === 'ok') {
958
+ writer.write(`${JSON.stringify(processEnvelope('ok', {
959
+ rows: result.value.map(contributionLedgerRow),
960
+ }))}\n`);
961
+ }
962
+ else if (result.status === 'degraded') {
963
+ writer.write(`${JSON.stringify(processEnvelope('degraded', {
964
+ rows: (result.value ?? []).map(contributionLedgerRow),
965
+ }, result.reason))}\n`);
966
+ }
967
+ else {
968
+ writer.write(`${JSON.stringify(processEnvelope('unavailable', undefined, result.reason))}\n`);
969
+ }
970
+ return 0;
971
+ }
972
+ if (isContributionDisable) {
973
+ if (rest.some((arg) => arg !== '--json') || rest.length > 1) {
974
+ writer.write('error: invalid contribution disable command\n');
975
+ return 2;
976
+ }
977
+ const result = await createJinnPlugin(buildPluginDepsFromEnv(opts.pluginOverrides ?? {}))
978
+ .disableContributionPublication();
979
+ if (result.status === 'ok') {
980
+ writer.write(`${JSON.stringify(processEnvelope('ok', result.value))}\n`);
981
+ }
982
+ else if (result.status === 'degraded') {
983
+ writer.write(`${JSON.stringify(processEnvelope('degraded', result.value, result.reason))}\n`);
984
+ }
985
+ else {
986
+ writer.write(`${JSON.stringify(processEnvelope('unavailable', undefined, result.reason))}\n`);
987
+ }
988
+ return 0;
989
+ }
990
+ // `ledger`, `publish`, `distill`, and `derive-env` have no subverb — their args start at argv[1].
991
+ const flat = isLedger || isPublish || isDistill || isDeriveEnv;
992
+ let parsed;
993
+ try {
994
+ parsed = parseArgs({
995
+ args: flat ? (subverb === undefined ? rest : [subverb, ...rest]) : rest,
996
+ options: {
997
+ limit: { type: 'string', default: String(DEFAULT_CLI_SEARCH_LIMIT) },
998
+ json: { type: 'boolean', default: false },
999
+ full: { type: 'boolean', default: false },
1000
+ out: { type: 'string' },
1001
+ path: { type: 'string' },
1002
+ veto: { type: 'boolean', default: false },
1003
+ source: { type: 'string' },
1004
+ 'episodes-dir': { type: 'string' },
1005
+ meta: { type: 'boolean', default: false },
1006
+ distiller: { type: 'string' },
1007
+ 'distiller-model': { type: 'string' },
1008
+ 'set-distiller': { type: 'string' },
1009
+ 'set-distiller-model': { type: 'string' },
1010
+ episodes: { type: 'string' },
1011
+ captures: { type: 'string' },
1012
+ 'local-only': { type: 'boolean', default: false },
1013
+ 'anchor-mode': { type: 'string', default: 'per-record' },
1014
+ 'measure-per-record-control': { type: 'boolean', default: false },
1015
+ where: { type: 'string' },
1016
+ resume: { type: 'boolean', default: false },
1017
+ install: { type: 'string' },
1018
+ progress: { type: 'string' },
1019
+ 'cluster-timeout': { type: 'string' },
1020
+ all: { type: 'boolean', default: false },
1021
+ models: { type: 'string' },
1022
+ 'max-clusters': { type: 'string' },
1023
+ 'max-contrastive': { type: 'string' },
1024
+ 'max-lessons': { type: 'string' },
1025
+ 'max-patterns': { type: 'string' },
1026
+ 'group-cap': { type: 'string' },
1027
+ concurrency: { type: 'string' },
1028
+ 'select-only': { type: 'boolean', default: false },
1029
+ force: { type: 'boolean', default: false },
1030
+ 'retry-errors': { type: 'boolean', default: false },
1031
+ },
1032
+ allowPositionals: true,
1033
+ });
1034
+ }
1035
+ catch (err) {
1036
+ writer.write(`error: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
1037
+ return 2;
1038
+ }
1039
+ if (isCapturePreview) {
1040
+ const taskFile = parsed.positionals[0];
1041
+ if (taskFile === undefined) {
1042
+ writer.write(`error: capture preview requires a <task-file> argument (a captured-task JSON file)\n\n${USAGE}`);
1043
+ return 2;
1044
+ }
1045
+ const task = parseCapturedTask(JSON.parse(readFileSync(taskFile, 'utf-8')));
1046
+ const report = preview(await capture(task));
1047
+ if (parsed.values.json) {
1048
+ // Persistence-safe projection: --json output may be piped to disk, so
1049
+ // the original (pre-scrub) values are stripped — they are for the
1050
+ // operator's eyes on their own terminal only.
1051
+ writer.write(JSON.stringify({
1052
+ envelope: report.envelope,
1053
+ redactions: stripBeforeValues(report.redactions),
1054
+ }) + '\n');
1055
+ }
1056
+ else {
1057
+ writer.write(renderScrubReport(report, Boolean(parsed.values.full)) + '\n');
1058
+ }
1059
+ return 0;
1060
+ }
1061
+ if (isSeed) {
1062
+ const listFile = parsed.values.source;
1063
+ const episodesDir = parsed.values['episodes-dir'];
1064
+ if (listFile !== undefined && episodesDir !== undefined) {
1065
+ writer.write(`error: seed commands require exactly one of --source or --episodes-dir\n\n${USAGE}`);
1066
+ return 2;
1067
+ }
1068
+ const buildSource = () => {
1069
+ if (opts.seedSource)
1070
+ return opts.seedSource;
1071
+ if (!listFile) {
1072
+ throw new Error('seed commands require --source <list-file> (owner/repo[#path] per line) or an injected source');
1073
+ }
1074
+ const entries = readFileSync(listFile, 'utf-8')
1075
+ .split('\n')
1076
+ .map((l) => l.trim())
1077
+ .filter((l) => l !== '' && !l.startsWith('#'))
1078
+ .map(parseSeedListEntry);
1079
+ return createGithubSeedSource({
1080
+ entries,
1081
+ ...(process.env['GITHUB_TOKEN'] ? { token: process.env['GITHUB_TOKEN'] } : {}),
1082
+ });
1083
+ };
1084
+ // Evidence-episode source (issue #1771): a separate, parallel lane over
1085
+ // --episodes-dir, chosen instead of --source when either is present. The
1086
+ // two lanes never mix in one report — episodes are first-party content
1087
+ // with no licence gate, so their report shape differs from ImportReport.
1088
+ const isEpisodes = Boolean(opts.episodeSource) || episodesDir !== undefined;
1089
+ const buildEpisodeSource = () => {
1090
+ if (opts.episodeSource)
1091
+ return opts.episodeSource;
1092
+ if (!episodesDir) {
1093
+ throw new Error('seed commands require --episodes-dir <dir> (seed-episode JSON files) or an injected episodeSource');
1094
+ }
1095
+ return createLocalEpisodeSeedSource(episodesDir);
1096
+ };
1097
+ if (subverb === 'plan') {
1098
+ if (isEpisodes) {
1099
+ const episodeReport = await planEpisodes(buildEpisodeSource());
1100
+ const outFile = parsed.values.out;
1101
+ if (outFile)
1102
+ writeFileSync(outFile, JSON.stringify(episodeReport, null, 2) + '\n');
1103
+ if (parsed.values.json) {
1104
+ writer.write(JSON.stringify(episodeReport) + '\n');
1105
+ }
1106
+ else {
1107
+ writer.write(renderEpisodeImportReport(episodeReport) + '\n');
1108
+ writer.write(outFile
1109
+ ? `\nReport written to ${outFile}. APPROVAL GATE: review (edit verdicts if needed), then run seed execute ${outFile} --episodes-dir ${episodesDir ?? '<dir>'}.\n`
1110
+ : '\nAPPROVAL GATE: re-run with --out <report-file>, review, then seed execute.\n');
1111
+ }
1112
+ return 0;
1113
+ }
1114
+ const report = await seedPlan(buildSource());
1115
+ const outFile = parsed.values.out;
1116
+ if (outFile)
1117
+ writeFileSync(outFile, JSON.stringify(report, null, 2) + '\n');
1118
+ if (parsed.values.json) {
1119
+ writer.write(JSON.stringify(report) + '\n');
1120
+ }
1121
+ else {
1122
+ writer.write(renderImportReport(report) + '\n');
1123
+ writer.write(outFile
1124
+ ? `\nReport written to ${outFile}. APPROVAL GATE: review (edit verdicts if needed), then run seed execute ${outFile}.\n`
1125
+ : '\nAPPROVAL GATE: re-run with --out <report-file>, review, then seed execute.\n');
1126
+ }
1127
+ return 0;
1128
+ }
1129
+ // seed execute <report-file>
1130
+ const reportFile = parsed.positionals[0];
1131
+ if (reportFile === undefined) {
1132
+ writer.write(`error: seed execute requires a <report-file> argument (the approved plan report)\n\n${USAGE}`);
1133
+ return 2;
1134
+ }
1135
+ if (!opts.publishDeps) {
1136
+ writer.write('error: seed execute requires an injected client publish adapter; '
1137
+ + 'the standalone layer keeps outbound publication parked\n');
1138
+ return 2;
1139
+ }
1140
+ const deps = opts.publishDeps;
1141
+ // File-backed by default (issue #1771): real `seed execute` runs persist
1142
+ // idempotency state across invocations (path overridable via
1143
+ // JINN_LAYER_SEED_STATE_PATH, mirroring JINN_LAYER_LEDGER_PATH). Tests
1144
+ // inject an in-memory store. A corrupt state file fails closed (semantics
1145
+ // in state.ts); its diagnostic is collected here and surfaced on THIS
1146
+ // command's own output in addition to the per-row error.
1147
+ const stateWarnings = [];
1148
+ const state = opts.seedImportState ?? createFileSeedImportState(process.env['JINN_LAYER_SEED_STATE_PATH'], { onWarning: (message) => stateWarnings.push(message) });
1149
+ if (isEpisodes) {
1150
+ const episodeReport = parseEpisodeImportReport(JSON.parse(readFileSync(reportFile, 'utf-8')));
1151
+ const result = await executeEpisodes(episodeReport, buildEpisodeSource(), deps, { state });
1152
+ if (parsed.values.json) {
1153
+ writer.write(JSON.stringify({
1154
+ ...result,
1155
+ ...(stateWarnings.length > 0 ? { warnings: stateWarnings } : {}),
1156
+ }) + '\n');
1157
+ }
1158
+ else {
1159
+ const lines = [
1160
+ `${result.imported.length} imported, ${result.skipped.length} skipped, ${result.errors.length} error(s)`,
1161
+ ...stateWarnings.map((w) => ` WARNING ${w}`),
1162
+ ];
1163
+ for (const row of result.imported) {
1164
+ lines.push(` IMPORTED ${row.id}`, ` ref ${row.envelopeRef}`, ` anchor ${row.anchorTx ? `${TESTNET_EXPLORER_TX_URL}${row.anchorTx}` : '-'}`, ...(row.supersedes ? [` supersedes ${row.supersedes}`] : []), ...(row.ledgerWarning ? [` WARNING ${row.ledgerWarning}`] : []), ...(row.stateWarning ? [` WARNING ${row.stateWarning}`] : []));
1165
+ }
1166
+ for (const row of result.skipped)
1167
+ lines.push(` skipped ${row.id} — ${row.reason}`);
1168
+ for (const row of result.errors)
1169
+ lines.push(` ERROR ${row.id} — ${row.error}`);
1170
+ writer.write(lines.join('\n') + '\n');
1171
+ }
1172
+ return result.errors.length > 0 ||
1173
+ result.imported.some((row) => row.stateWarning !== undefined || row.ledgerWarning !== undefined)
1174
+ ? 1
1175
+ : 0;
1176
+ }
1177
+ const report = parseImportReport(JSON.parse(readFileSync(reportFile, 'utf-8')));
1178
+ const result = await seedExecute(report, buildSource(), deps, { state });
1179
+ if (parsed.values.json) {
1180
+ writer.write(JSON.stringify({
1181
+ ...result,
1182
+ ...(stateWarnings.length > 0 ? { warnings: stateWarnings } : {}),
1183
+ }) + '\n');
1184
+ }
1185
+ else {
1186
+ const lines = [
1187
+ `${result.imported.length} imported, ${result.skipped.length} skipped, ${result.errors.length} error(s)`,
1188
+ ...stateWarnings.map((w) => ` WARNING ${w}`),
1189
+ ];
1190
+ for (const row of result.imported) {
1191
+ lines.push(` IMPORTED ${row.skill}`, ` ref ${row.envelopeRef}`, ` anchor ${row.anchorTx ? `${TESTNET_EXPLORER_TX_URL}${row.anchorTx}` : '-'}`, ...(row.ledgerWarning ? [` WARNING ${row.ledgerWarning}`] : []), ...(row.stateWarning ? [` WARNING ${row.stateWarning}`] : []));
1192
+ }
1193
+ for (const row of result.skipped)
1194
+ lines.push(` skipped ${row.skill} — ${row.reason}`);
1195
+ for (const row of result.errors)
1196
+ lines.push(` ERROR ${row.skill} — ${row.error}`);
1197
+ writer.write(lines.join('\n') + '\n');
1198
+ }
1199
+ return result.errors.length > 0 ||
1200
+ result.imported.some((row) => row.stateWarning !== undefined || row.ledgerWarning !== undefined)
1201
+ ? 1
1202
+ : 0;
1203
+ }
1204
+ if (isDistillModels) {
1205
+ const json = parsed.values.json;
1206
+ const persisted = readDistillDefaults(distillModePath());
1207
+ // Resolve exactly as a run would (minus per-run model-vs-provider nuance):
1208
+ // flag > env > persisted > provider default, so the marked row is what a
1209
+ // bare `distill` would actually use.
1210
+ const rawProvider = parsed.values.distiller ??
1211
+ process.env['JINN_DISTILL_PROVIDER'] ??
1212
+ persisted.distiller ??
1213
+ 'claude';
1214
+ const provider = parseDistillProvider(rawProvider) ?? 'claude';
1215
+ const defaultModel = provider === 'codex' ? DEFAULT_CODEX_MODEL : DEFAULT_CLAUDE_DISTILL_MODEL;
1216
+ const model = parsed.values['distiller-model'] ??
1217
+ process.env['JINN_DISTILL_MODEL'] ??
1218
+ persisted.distillerModel ??
1219
+ defaultModel;
1220
+ const resolved = { provider, model };
1221
+ if (json) {
1222
+ writer.write(JSON.stringify({ catalog: DISTILLER_CATALOG, resolved }) + '\n');
1223
+ }
1224
+ else {
1225
+ writer.write(renderDistillerModels({ catalog: DISTILLER_CATALOG, resolved }) + '\n');
1226
+ }
1227
+ return 0;
1228
+ }
1229
+ if (isDistill) {
1230
+ const dd = opts.distillDeps ?? {};
1231
+ const json = parsed.values.json;
1232
+ // Share the progress stream and durable run log identity for this invocation.
1233
+ const runId = newRunId();
1234
+ const startedAt = new Date().toISOString();
1235
+ const invokedAtMs = Date.now();
1236
+ // --progress ndjson (#1533): machine-readable run events on stderr, so a
1237
+ // wrapper (the harness background runner) can show progress ambiently.
1238
+ // stdout stays reserved for the --json result. `run_end` is emitted on
1239
+ // every terminal branch — a watcher treats it as the end-of-run signal.
1240
+ const progressFlag = parsed.values.progress;
1241
+ if (progressFlag !== undefined && progressFlag !== 'ndjson') {
1242
+ writer.write(`error: --progress must be "ndjson" (got ${JSON.stringify(progressFlag)})\n\n${USAGE}`);
1243
+ return 2;
1244
+ }
1245
+ const progress = progressFlag === 'ndjson'
1246
+ ? createNdjsonProgressEmitter(dd.progressStream ?? process.stderr, runId)
1247
+ : undefined;
1248
+ const endedEmpty = () => progress?.runEnd({ outcome: 'empty', clusterCount: 0, published: [], rejectedCount: 0, errorCount: 0, installed: [] });
1249
+ const modePath = distillModePath();
1250
+ const persisted = readDistillDefaults(modePath);
1251
+ // Distiller provider + model — the arbitrage knob. The model resolved here
1252
+ // WRITES the skills; it is deliberately distinct from the cheap runtime
1253
+ // model each capture ran under (`environment.model`), which `distill` never
1254
+ // touches. Resolution: flag > env > persisted default > provider default.
1255
+ const rawProvider = parsed.values.distiller ??
1256
+ process.env['JINN_DISTILL_PROVIDER'] ??
1257
+ persisted.distiller ??
1258
+ 'claude';
1259
+ const distillProvider = parseDistillProvider(rawProvider);
1260
+ if (!distillProvider) {
1261
+ writer.write(`error: distiller must be "claude" or "codex" (got ${JSON.stringify(rawProvider)})\n\n${USAGE}`);
1262
+ return 2;
1263
+ }
1264
+ const defaultDistillModel = distillProvider === 'codex' ? DEFAULT_CODEX_MODEL : DEFAULT_CLAUDE_DISTILL_MODEL;
1265
+ // The persisted model belongs to the persisted provider. If the effective
1266
+ // provider was overridden away from it by a flag/env, the persisted model
1267
+ // no longer applies — fall to the new provider's default instead.
1268
+ const providerOverridden = parsed.values.distiller !== undefined ||
1269
+ process.env['JINN_DISTILL_PROVIDER'] !== undefined;
1270
+ const persistedModel = providerOverridden && persisted.distiller !== undefined && persisted.distiller !== distillProvider
1271
+ ? undefined
1272
+ : persisted.distillerModel;
1273
+ const distillModel = parsed.values['distiller-model'] ??
1274
+ process.env['JINN_DISTILL_MODEL'] ??
1275
+ persistedModel ??
1276
+ defaultDistillModel;
1277
+ const clusterTimeoutMs = resolveClusterTimeoutMs(parsed.values['cluster-timeout']);
1278
+ if (typeof clusterTimeoutMs === 'object') {
1279
+ writer.write(`${clusterTimeoutMs.error}\n\n${USAGE}`);
1280
+ return 2;
1281
+ }
1282
+ const distill = dd.distill ?? (dd.distillerFactory ?? defaultDistillerFactory)(distillProvider, distillModel, clusterTimeoutMs).distill;
1283
+ const runsPath = process.env['JINN_LAYER_DISTILL_RUNS_PATH'] ?? DEFAULT_DISTILL_RUNS_PATH;
1284
+ const logRun = (outcome, facts) => {
1285
+ try {
1286
+ appendDistillRun({ runId, startedAt, durationMs: Date.now() - invokedAtMs, outcome, distillModel, ...facts }, runsPath);
1287
+ }
1288
+ catch {
1289
+ // A local observability failure must never fail the distillation run.
1290
+ }
1291
+ };
1292
+ const emptyFacts = { clusterCount: 0, published: [], rejectedCount: 0, errorCount: 0, installed: [] };
1293
+ // Subverbs (#1535 status/runs, #1536 staged/install). Routed before the
1294
+ // mode machinery: none of them prompt or spend, and only `install` writes
1295
+ // (moving already-produced artifacts out of staging).
1296
+ const sub = parsed.positionals[0];
1297
+ if (sub !== undefined && sub !== 'runs' && sub !== 'status' && sub !== 'staged' && sub !== 'install') {
1298
+ writer.write(`error: unknown distill subcommand ${JSON.stringify(sub)} (expected: run, runs, status, staged, install)\n\n${USAGE}`);
1299
+ return 2;
1300
+ }
1301
+ if (sub === 'staged') {
1302
+ const activeDir = parsed.values.out ?? DEFAULT_SKILLS_INSTALL_DIR;
1303
+ const stagingDir = stagingDirFor(activeDir);
1304
+ const staged = listStaged(stagingDir);
1305
+ if (json) {
1306
+ writer.write(JSON.stringify(staged.map((p) => ({ name: p.name, description: p.description, provenance: p.jinn.provenance }))) + '\n');
1307
+ }
1308
+ else if (staged.length === 0) {
1309
+ writer.write(`nothing staged in ${stagingDir}\n`);
1310
+ }
1311
+ else {
1312
+ const skills = staged.map((p) => ({
1313
+ name: p.name,
1314
+ installed: false,
1315
+ provenance: p.jinn.provenance,
1316
+ helpsWith: p.description,
1317
+ }));
1318
+ writer.write(renderSkillsPanel(skills, 'staged — waiting for install') + '\n');
1319
+ writer.write(`install with: distill install <name> · distill install --all\n`);
1320
+ }
1321
+ return 0;
1322
+ }
1323
+ if (sub === 'install') {
1324
+ const activeDir = parsed.values.out ?? DEFAULT_SKILLS_INSTALL_DIR;
1325
+ const stagingDir = stagingDirFor(activeDir);
1326
+ const names = parsed.positionals.slice(1);
1327
+ const all = parsed.values.all;
1328
+ if (names.length === 0 && !all) {
1329
+ writer.write(`error: distill install needs skill <name>(s) or --all\n\n${USAGE}`);
1330
+ return 2;
1331
+ }
1332
+ const staged = listStaged(stagingDir);
1333
+ const byName = new Map(staged.map((p) => [p.name, p]));
1334
+ let chosen;
1335
+ if (all) {
1336
+ chosen = staged;
1337
+ }
1338
+ else {
1339
+ const unknown = names.filter((n) => !byName.has(n));
1340
+ if (unknown.length > 0) {
1341
+ const available = staged.length > 0 ? staged.map((p) => p.name).join(', ') : 'nothing staged';
1342
+ writer.write(`error: not staged: ${unknown.join(', ')} (staged: ${available})\n`);
1343
+ return 2;
1344
+ }
1345
+ chosen = names.map((n) => byName.get(n));
1346
+ }
1347
+ const installed = await installStagedSkills(chosen, stagingDir, activeDir);
1348
+ if (json) {
1349
+ writer.write(JSON.stringify({ installed, stagingDir, activeDir }) + '\n');
1350
+ }
1351
+ else if (installed.length === 0) {
1352
+ writer.write(`nothing staged in ${stagingDir}\n`);
1353
+ }
1354
+ else {
1355
+ writer.write(installed.map((s) => `installed ${s.name} → ${s.path}`).join('\n') + '\n');
1356
+ }
1357
+ return 0;
1358
+ }
1359
+ if (sub === 'runs') {
1360
+ const limN = Number.parseInt(parsed.values.limit, 10);
1361
+ const limit = argv.some((a) => a === '--limit' || a.startsWith('--limit=')) && Number.isFinite(limN) && limN > 0 ? limN : 20;
1362
+ const runs = readDistillRuns(limit, runsPath);
1363
+ if (json)
1364
+ writer.write(JSON.stringify(runs) + '\n');
1365
+ else if (runs.length === 0)
1366
+ writer.write('no distill runs recorded yet\n');
1367
+ else
1368
+ writer.write(runs.map((r) => `${r.startedAt} ${r.outcome.padEnd(7)} ${r.published.length} distilled (${r.published.length > 0 ? r.published.join(', ') : 'no skills'}) · ${r.installed.length} installed · ${r.distillModel}`).join('\n') + '\n');
1369
+ return 0;
1370
+ }
1371
+ if (sub === 'status') {
1372
+ const mode = readDistillMode(modePath);
1373
+ const statusEpisodesOverride = parsed.values.episodes ?? process.env['JINN_LAYER_EPISODES_DIR'];
1374
+ const statusLegacyOverride = parsed.values.captures ?? process.env['JINN_LAYER_CAPTURES_DIR'];
1375
+ const statusLegacyCapturesDir = statusLegacyOverride
1376
+ ?? (statusEpisodesOverride ? undefined : DEFAULT_CAPTURES_DIR);
1377
+ const statusEpisodesDir = statusEpisodesOverride
1378
+ // An explicit legacy source is an isolation boundary as well as a
1379
+ // compatibility flag: do not also inspect the operator's default store.
1380
+ ?? statusLegacyOverride
1381
+ ?? DEFAULT_EPISODES_DIR;
1382
+ const statusCaptures = await loadRecentDistillSources({
1383
+ episodesDir: statusEpisodesDir,
1384
+ ...(statusLegacyCapturesDir ? { legacyCapturesDir: statusLegacyCapturesDir } : {}),
1385
+ limit: DEFAULT_DISTILL_CAPTURE_LIMIT,
1386
+ });
1387
+ const statusActiveDir = parsed.values.out ?? DEFAULT_SKILLS_INSTALL_DIR;
1388
+ const statusStagingDir = stagingDirFor(statusActiveDir);
1389
+ const covered = coveredSessionIds([statusActiveDir, statusStagingDir]);
1390
+ const status = {
1391
+ mode,
1392
+ modePath,
1393
+ /** @deprecated Machine compatibility alias for the effective primary source. */
1394
+ capturesDir: statusEpisodesDir,
1395
+ episodesDir: statusEpisodesDir,
1396
+ legacyCapturesDir: statusLegacyCapturesDir ?? null,
1397
+ capturesCount: statusCaptures.length,
1398
+ uncoveredCount: statusCaptures.filter((capture) => !covered.has(capture.session.sessionId)).length,
1399
+ stagingDir: statusStagingDir,
1400
+ stagedCount: countSkillDirs(statusStagingDir),
1401
+ activeDir: statusActiveDir,
1402
+ installedCount: countSkillDirs(statusActiveDir),
1403
+ distillProvider,
1404
+ distillModel,
1405
+ lastRun: readDistillRuns(1, runsPath)[0] ?? null,
1406
+ };
1407
+ if (json)
1408
+ writer.write(JSON.stringify(status) + '\n');
1409
+ else {
1410
+ const last = status.lastRun ? `${status.lastRun.startedAt} ${status.lastRun.outcome} (${status.lastRun.published.length} distilled, ${status.lastRun.installed.length} installed)` : 'never';
1411
+ writer.write([`mode ${status.mode}`, `episodes ${status.capturesCount} available (${status.uncoveredCount} not yet distilled) in ${status.episodesDir}`, `legacy ${status.legacyCapturesDir ? `read-only compatibility in ${status.legacyCapturesDir}` : 'not requested'}`, `staged ${status.stagedCount} in ${status.stagingDir}`, `installed ${status.installedCount} in ${status.activeDir}`, `distiller ${status.distillProvider} · ${status.distillModel}`, `last run ${last}`].join('\n') + '\n');
1412
+ }
1413
+ return 0;
1414
+ }
1415
+ // 1c — `--where <mode>`: the persistent setter. Scriptable, non-interactive;
1416
+ // it records the mode and echoes the resulting behaviour, and runs nothing.
1417
+ const whereFlag = parsed.values.where;
1418
+ if (whereFlag !== undefined) {
1419
+ if (whereFlag !== 'local' && whereFlag !== 'defer' && whereFlag !== 'off') {
1420
+ writer.write(`error: --where must be "local", "defer" or "off" (got ${JSON.stringify(whereFlag)})\n\n${USAGE}`);
1421
+ return 2;
1422
+ }
1423
+ writeDistillMode(whereFlag, modePath);
1424
+ writer.write((json ? JSON.stringify({ where: whereFlag }) : renderModeSet(whereFlag)) + '\n');
1425
+ return 0;
1426
+ }
1427
+ // 1496 — `--set-distiller[-model]`: the persistent distiller default setter.
1428
+ // Scriptable, non-interactive; records the default and echoes it, runs nothing.
1429
+ const setProvider = parsed.values['set-distiller'];
1430
+ const setModel = parsed.values['set-distiller-model'];
1431
+ if (setProvider !== undefined || setModel !== undefined) {
1432
+ const patch = {};
1433
+ if (setProvider !== undefined) {
1434
+ const provider = parseDistillProvider(setProvider);
1435
+ if (!provider) {
1436
+ writer.write(`error: distiller must be "claude" or "codex" (got ${JSON.stringify(setProvider)})\n\n${USAGE}`);
1437
+ return 2;
1438
+ }
1439
+ patch.distiller = provider;
1440
+ }
1441
+ if (setModel !== undefined) {
1442
+ if (setModel === '') {
1443
+ writer.write(`error: --set-distiller-model must be a non-empty model id\n\n${USAGE}`);
1444
+ return 2;
1445
+ }
1446
+ patch.distillerModel = setModel;
1447
+ }
1448
+ writeDistillDefaults(patch, modePath);
1449
+ writer.write((json ? JSON.stringify(patch) : renderDistillerSet(patch)) + '\n');
1450
+ return 0;
1451
+ }
1452
+ // Source: canonical local episodes plus deprecated CapturedTask reads.
1453
+ const episodesOverride = parsed.values.episodes ??
1454
+ process.env['JINN_LAYER_EPISODES_DIR'];
1455
+ const legacyOverride = parsed.values.captures ??
1456
+ process.env['JINN_LAYER_CAPTURES_DIR'];
1457
+ const legacyCapturesDir = legacyOverride ?? (episodesOverride ? undefined : DEFAULT_CAPTURES_DIR);
1458
+ const episodesDir = episodesOverride ??
1459
+ legacyOverride ??
1460
+ DEFAULT_EPISODES_DIR;
1461
+ const capN = Number.parseInt(parsed.values.limit, 10);
1462
+ const capLimit = argv.some((a) => a === '--limit' || a.startsWith('--limit=')) && Number.isFinite(capN) && capN > 0
1463
+ ? capN
1464
+ : DEFAULT_DISTILL_CAPTURE_LIMIT;
1465
+ const captures = await loadRecentDistillSources({
1466
+ episodesDir,
1467
+ ...(legacyCapturesDir ? { legacyCapturesDir } : {}),
1468
+ limit: capLimit,
1469
+ });
1470
+ // 1d — empty: nothing to distill, whatever the mode. Short-circuits consent.
1471
+ if (captures.length === 0) {
1472
+ if (json) {
1473
+ writer.write(JSON.stringify({ clusterCount: 0, distilled: { published: [], rejected: [], errors: [] }, capturesConsidered: 0, distillModel }) + '\n');
1474
+ }
1475
+ else {
1476
+ writer.write(renderEmpty({ capturesDir: episodesDir }) + '\n');
1477
+ }
1478
+ endedEmpty();
1479
+ logRun('empty', emptyFacts);
1480
+ return 0;
1481
+ }
1482
+ const resume = parsed.values.resume;
1483
+ const mode = readDistillMode(modePath);
1484
+ const isTty = dd.isTty ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
1485
+ // Decide the mode this invocation acts on. `--resume` only continues an
1486
+ // already-consented LOCAL run — it never grants consent itself, so a
1487
+ // `--resume` under any other mode falls through to normal mode handling
1488
+ // (unset → first-run consent, defer/off → run nothing). This closes the
1489
+ // bypass where `distill --resume` on a fresh machine would spend without ever
1490
+ // passing the consent gate.
1491
+ let acting;
1492
+ if (resume && mode === 'local') {
1493
+ acting = 'local';
1494
+ }
1495
+ else if (mode === 'unset') {
1496
+ const prompt = dd.promptConsent ??
1497
+ (isTty
1498
+ ? () => readlineConsentPrompt({ captureCount: captures.length, distillModel, capturesDir: episodesDir, captures, writer })
1499
+ : undefined);
1500
+ if (!prompt) {
1501
+ // Non-interactive first run: the safe default is defer, and it is NOT
1502
+ // persisted — a later interactive run still gets the consent prompt.
1503
+ if (json) {
1504
+ writer.write(JSON.stringify({ mode: 'defer', ran: false, consent: 'unset', capturesConsidered: captures.length }) + '\n');
1505
+ }
1506
+ else {
1507
+ writer.write(renderConsentDisclosure({ captureCount: captures.length, distillModel }) + '\n\n');
1508
+ writer.write(renderDeferredRun({ captureCount: captures.length, capturesDir: episodesDir }) + '\n');
1509
+ }
1510
+ endedEmpty();
1511
+ return 0;
1512
+ }
1513
+ const chosen = await prompt();
1514
+ writeDistillMode(chosen, modePath);
1515
+ writer.write(renderRecorded(chosen, { captureCount: captures.length }) + '\n');
1516
+ if (chosen !== 'local') {
1517
+ endedEmpty();
1518
+ return 0;
1519
+ }
1520
+ acting = 'local';
1521
+ }
1522
+ else {
1523
+ acting = mode;
1524
+ }
1525
+ if (acting === 'off') {
1526
+ if (json)
1527
+ writer.write(JSON.stringify({ mode: 'off', ran: false, capturesConsidered: captures.length }) + '\n');
1528
+ else
1529
+ writer.write(renderRecorded('off', { captureCount: captures.length }) + '\n');
1530
+ endedEmpty();
1531
+ return 0;
1532
+ }
1533
+ if (acting === 'defer') {
1534
+ if (json)
1535
+ writer.write(JSON.stringify({ mode: 'defer', ran: false, capturesConsidered: captures.length }) + '\n');
1536
+ else
1537
+ writer.write(renderDeferredRun({ captureCount: captures.length, capturesDir: episodesDir }) + '\n');
1538
+ endedEmpty();
1539
+ return 0;
1540
+ }
1541
+ // acting === 'local' → distill to STAGING, review, install-on-choice.
1542
+ // `--out` is the ACTIVE skills dir (installed skills live here — what /jinn
1543
+ // skills and resume read). Distilled-but-not-installed skills wait in a
1544
+ // sibling staging dir until the operator installs them; nothing goes live
1545
+ // without an explicit choice (the consent-consistent default).
1546
+ const activeDir = parsed.values.out ?? DEFAULT_SKILLS_INSTALL_DIR;
1547
+ const stagingDir = stagingDirFor(activeDir);
1548
+ mkdirSync(activeDir, { recursive: true });
1549
+ mkdirSync(stagingDir, { recursive: true });
1550
+ // --resume: distill only captures no distilled skill (installed OR staged)
1551
+ // already covers.
1552
+ let toDistill = captures;
1553
+ if (resume) {
1554
+ const covered = coveredSessionIds([activeDir, stagingDir]);
1555
+ toDistill = captures.filter((c) => !covered.has(c.session.sessionId));
1556
+ if (toDistill.length === 0) {
1557
+ if (json)
1558
+ writer.write(JSON.stringify({ mode: 'local', ran: false, resume: true, capturesConsidered: captures.length }) + '\n');
1559
+ else
1560
+ writer.write(renderResumeNothing({ captureCount: captures.length }) + '\n');
1561
+ endedEmpty();
1562
+ logRun('empty', emptyFacts);
1563
+ return 0;
1564
+ }
1565
+ }
1566
+ const summaryBySession = new Map(toDistill.map((c) => [c.session.sessionId, c.task.summary]));
1567
+ // Progress wiring (#1533): map each cluster back to the source capture's
1568
+ // task summary — the label the operator recognises in an ambient tick.
1569
+ const labelByCluster = new Map();
1570
+ const labelFor = (refs) => {
1571
+ const sessionId = refs[0]?.replace(/^local-capture:/, '');
1572
+ const summary = sessionId === undefined ? undefined : summaryBySession.get(sessionId);
1573
+ return summary === undefined ? undefined : summary.length > 80 ? summary.slice(0, 79) + '…' : summary;
1574
+ };
1575
+ progress?.runStart({
1576
+ capturesConsidered: captures.length,
1577
+ toDistill: toDistill.length,
1578
+ resume,
1579
+ distillProvider,
1580
+ distillModel,
1581
+ capturesDir: episodesDir,
1582
+ stagingDir,
1583
+ activeDir,
1584
+ });
1585
+ // Distill to STAGING — nothing is installed yet.
1586
+ const distiller = createLocalDistiller({
1587
+ distill,
1588
+ sink: createLocalSkillSink(stagingDir),
1589
+ distribution: 'coding',
1590
+ distillModel,
1591
+ ...(dd.slateInstanceIds ? { slate: { instanceIds: dd.slateInstanceIds } } : {}),
1592
+ ...(progress
1593
+ ? {
1594
+ onPlan: (plan) => {
1595
+ for (const p of plan) {
1596
+ const label = labelFor(p.refs);
1597
+ if (label !== undefined)
1598
+ labelByCluster.set(p.clusterId, label);
1599
+ }
1600
+ progress.clusterPlan({
1601
+ clusterCount: plan.length,
1602
+ clusters: plan.map((p) => ({
1603
+ clusterId: p.clusterId,
1604
+ index: p.index,
1605
+ captureCount: p.captureCount,
1606
+ ...(labelByCluster.has(p.clusterId) ? { label: labelByCluster.get(p.clusterId) } : {}),
1607
+ })),
1608
+ });
1609
+ },
1610
+ onCluster: (ev) => {
1611
+ const label = labelByCluster.get(ev.clusterId);
1612
+ if (ev.phase === 'start') {
1613
+ progress.clusterStart({ clusterId: ev.clusterId, index: ev.index, total: ev.total, ...(label !== undefined ? { label } : {}) });
1614
+ }
1615
+ else {
1616
+ progress.clusterEnd({
1617
+ clusterId: ev.clusterId,
1618
+ index: ev.index,
1619
+ total: ev.total,
1620
+ outcome: ev.outcome ?? 'error',
1621
+ ...(label !== undefined ? { label } : {}),
1622
+ ...(ev.skillName !== undefined ? { skillName: ev.skillName } : {}),
1623
+ ...(ev.reason !== undefined ? { reason: ev.reason } : {}),
1624
+ ...(ev.error !== undefined ? { error: ev.error } : {}),
1625
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
1626
+ });
1627
+ }
1628
+ },
1629
+ }
1630
+ : {}),
1631
+ });
1632
+ const result = await distiller.distill(toDistill);
1633
+ const published = result.distilled.published;
1634
+ // 1d — a run failed mid-way: staged skills are kept, point at --resume. No
1635
+ // install is offered on a partial run.
1636
+ if (result.distilled.errors.length > 0) {
1637
+ if (json) {
1638
+ writer.write(JSON.stringify({ ...result, stagingDir, activeDir, installed: [], capturesConsidered: toDistill.length, distillModel }) + '\n');
1639
+ }
1640
+ else {
1641
+ writer.write(renderFailure({ distillModel, distilledCount: published.length, errors: result.distilled.errors }) + '\n');
1642
+ }
1643
+ progress?.runEnd({
1644
+ outcome: 'partial',
1645
+ clusterCount: result.clusterCount,
1646
+ published: published.map((p) => p.pkg.name),
1647
+ rejectedCount: result.distilled.rejected.length,
1648
+ errorCount: result.distilled.errors.length,
1649
+ installed: [],
1650
+ stagingDir,
1651
+ });
1652
+ logRun('partial', {
1653
+ clusterCount: result.clusterCount,
1654
+ published: published.map((p) => p.pkg.name),
1655
+ rejectedCount: result.distilled.rejected.length,
1656
+ errorCount: result.distilled.errors.length,
1657
+ installed: [],
1658
+ });
1659
+ return 1;
1660
+ }
1661
+ // Resolve the install choice: --install flag > interactive review/prompt >
1662
+ // stage-only. The non-interactive default installs NOTHING — the whole point
1663
+ // of decoupling distill from install is that skills go live only on a choice.
1664
+ const publishedNames = published.map((p) => p.pkg.name);
1665
+ let installNames = new Set();
1666
+ if (published.length > 0) {
1667
+ const installFlag = parsed.values.install;
1668
+ if (installFlag !== undefined) {
1669
+ if (installFlag === 'all')
1670
+ installNames = new Set(publishedNames);
1671
+ else if (installFlag === 'none')
1672
+ installNames = new Set();
1673
+ else if (publishedNames.includes(installFlag))
1674
+ installNames = new Set([installFlag]);
1675
+ else {
1676
+ writer.write(`error: --install "${installFlag}" — no distilled skill by that name ` +
1677
+ `(available: ${publishedNames.join(', ')}; or "all" / "none")\n`);
1678
+ return 2;
1679
+ }
1680
+ }
1681
+ else {
1682
+ const reviewSkills = published.map((p) => ({
1683
+ name: p.pkg.name,
1684
+ installed: false,
1685
+ provenance: provenanceLabels(p.pkg, summaryBySession),
1686
+ helpsWith: p.pkg.description,
1687
+ }));
1688
+ const prompt = dd.promptInstall ??
1689
+ (isTty
1690
+ ? () => readlineInstallPrompt({ distillModel, captureCount: toDistill.length, skills: reviewSkills, writer })
1691
+ : undefined);
1692
+ if (prompt) {
1693
+ const choice = await prompt(publishedNames);
1694
+ installNames =
1695
+ choice === 'all'
1696
+ ? new Set(publishedNames)
1697
+ : choice === 'first'
1698
+ ? new Set(publishedNames.slice(0, 1))
1699
+ : new Set();
1700
+ }
1701
+ // else non-interactive → stage only (installNames stays empty).
1702
+ }
1703
+ }
1704
+ // Install the chosen skills into the active dir via the existing local sink,
1705
+ // and clear their staged copies so staging holds only what's not installed.
1706
+ await installStagedSkills(published.filter((p) => installNames.has(p.pkg.name)).map((p) => p.pkg), stagingDir, activeDir);
1707
+ if (json) {
1708
+ writer.write(JSON.stringify({
1709
+ ...result,
1710
+ stagingDir,
1711
+ activeDir,
1712
+ installed: [...installNames],
1713
+ capturesConsidered: toDistill.length,
1714
+ distillModel,
1715
+ }) + '\n');
1716
+ }
1717
+ else {
1718
+ // 1b — the outcome panel: each distilled skill with its actual install
1719
+ // state, forward `helps` value, and `from` provenance.
1720
+ const skills = published.map((p) => ({
1721
+ name: p.pkg.name,
1722
+ installed: installNames.has(p.pkg.name),
1723
+ provenance: provenanceLabels(p.pkg, summaryBySession),
1724
+ helpsWith: p.pkg.description,
1725
+ }));
1726
+ writer.write(renderRunSummary({ distillModel, captureCount: toDistill.length, skills }) + '\n');
1727
+ }
1728
+ progress?.runEnd({
1729
+ outcome: 'ok',
1730
+ clusterCount: result.clusterCount,
1731
+ published: publishedNames,
1732
+ rejectedCount: result.distilled.rejected.length,
1733
+ errorCount: 0,
1734
+ installed: [...installNames],
1735
+ stagingDir,
1736
+ });
1737
+ logRun('ok', {
1738
+ clusterCount: result.clusterCount,
1739
+ published: publishedNames,
1740
+ rejectedCount: result.distilled.rejected.length,
1741
+ errorCount: 0,
1742
+ installed: [...installNames],
1743
+ });
1744
+ return 0;
1745
+ }
1746
+ if (isDistillRun || isDistillEvalPrep) {
1747
+ const dd = opts.distillRunDeps ?? {};
1748
+ // Only honor an EXPLICIT --limit; otherwise fetch the corpus broadly so
1749
+ // attempt groups stay complete (#1478) rather than inheriting the 20-row
1750
+ // search default (which silently truncates groups mid-instance).
1751
+ const userSetLimit = rest.some((a) => a === '--limit' || a.startsWith('--limit='));
1752
+ const rawLimit = parsed.values.limit;
1753
+ const parsedLimit = Number(rawLimit);
1754
+ if (userSetLimit
1755
+ && (!/^[0-9]+$/.test(rawLimit)
1756
+ || !Number.isSafeInteger(parsedLimit)
1757
+ || parsedLimit <= 0)) {
1758
+ writer.write(`error: --limit must be a positive safe integer (got ${JSON.stringify(rawLimit)})\n\n${USAGE}`);
1759
+ return 2;
1760
+ }
1761
+ const limit = userSetLimit ? parsedLimit : undefined;
1762
+ const outDir = parsed.values.out ?? mkdtempSync(join(tmpdir(), 'jinn-distill-'));
1763
+ mkdirSync(outDir, { recursive: true });
1764
+ const localOnly = parsed.values['local-only'];
1765
+ const anchorMode = parsed.values['anchor-mode'];
1766
+ const measurePerRecordControl = parsed.values['measure-per-record-control'];
1767
+ if (anchorMode !== 'per-record' && anchorMode !== 'manifest') {
1768
+ writer.write(`error: --anchor-mode must be "per-record" or "manifest" (got ${JSON.stringify(anchorMode)})\n\n${USAGE}`);
1769
+ return 2;
1770
+ }
1771
+ if (localOnly && anchorMode === 'manifest') {
1772
+ writer.write(`error: --anchor-mode manifest requires live publish deps and cannot be combined with --local-only\n\n${USAGE}`);
1773
+ return 2;
1774
+ }
1775
+ if (measurePerRecordControl && anchorMode !== 'manifest') {
1776
+ writer.write(`error: --measure-per-record-control requires --anchor-mode manifest\n\n${USAGE}`);
1777
+ return 2;
1778
+ }
1779
+ if (measurePerRecordControl && localOnly) {
1780
+ writer.write(`error: --measure-per-record-control requires live publish deps and cannot be combined with --local-only\n\n${USAGE}`);
1781
+ return 2;
1782
+ }
1783
+ const rawProvider = parsed.values.distiller ?? process.env['JINN_DISTILL_PROVIDER'] ??
1784
+ (isDistillEvalPrep ? 'codex' : 'claude');
1785
+ const distillProvider = parseDistillProvider(rawProvider);
1786
+ if (!distillProvider) {
1787
+ writer.write(`error: distiller must be "claude" or "codex" (got ${JSON.stringify(rawProvider)})\n\n${USAGE}`);
1788
+ return 2;
1789
+ }
1790
+ if (!dd.slateInstanceIds) {
1791
+ writer.write('error: distill run requires an injected held-out slate; '
1792
+ + 'the standalone layer will not guess this safety boundary\n');
1793
+ return 2;
1794
+ }
1795
+ const slateInstanceIds = dd.slateInstanceIds;
1796
+ // Verdict source over the testnet indexer (default) unless one is injected.
1797
+ const verdictSource = dd.verdictSource ??
1798
+ createVerdictSource({
1799
+ graphqlUrl: process.env['JINN_DISCOVERY_URL'] ?? DEFAULT_TESTNET_DISCOVERY_URL,
1800
+ });
1801
+ // The evidence fetcher's live ports: autonolas gateway + Ponder GraphQL.
1802
+ // Injected in tests; built from env here (mirrors distill-run-live.ts).
1803
+ const fetchEvidence = dd.fetchEvidence ??
1804
+ (() => {
1805
+ const gateway = (process.env['JINN_IPFS_GATEWAY_URL'] ?? DEFAULT_IPFS_GATEWAY_URL).replace(/\/$/, '');
1806
+ const base = (process.env['JINN_DISCOVERY_URL'] ?? DEFAULT_TESTNET_DISCOVERY_URL).replace(/\/$/, '');
1807
+ const graphqlUrl = base.endsWith('/graphql') ? base : `${base}/graphql`;
1808
+ const ipfs = createBoundedIpfsJsonFetcher({ gateway });
1809
+ const resolveVerifierFacts = dd.verifierFactsResolverFactory?.(ipfs);
1810
+ if (!dd.authenticateEnvelope || !dd.resolvePublisherSafe) {
1811
+ throw new Error('live evidence fetching requires execution-envelope authentication '
1812
+ + 'and ERC-8004 publisher Safe resolution from the production composition root');
1813
+ }
1814
+ return createEvidenceFetcher({
1815
+ ipfs,
1816
+ authenticateEnvelope: dd.authenticateEnvelope,
1817
+ resolvePublisherSafe: dd.resolvePublisherSafe,
1818
+ ...(resolveVerifierFacts ? { resolveVerifierFacts } : {}),
1819
+ gql: async (query, variables) => {
1820
+ const res = await fetch(graphqlUrl, {
1821
+ method: 'POST',
1822
+ headers: { 'content-type': 'application/json' },
1823
+ body: JSON.stringify({ query, variables }),
1824
+ });
1825
+ const body = (await res.json());
1826
+ if (body.errors)
1827
+ throw new Error(`gql: ${JSON.stringify(body.errors)}`);
1828
+ return body.data;
1829
+ },
1830
+ });
1831
+ })();
1832
+ if (isDistillEvalPrep) {
1833
+ const parsePositiveIntFlag = (name) => {
1834
+ const raw = parsed.values[name];
1835
+ if (raw === undefined)
1836
+ return undefined;
1837
+ const value = Number.parseInt(raw, 10);
1838
+ if (!Number.isFinite(value) || value < 0) {
1839
+ throw new Error(`--${name} must be a non-negative integer`);
1840
+ }
1841
+ return value;
1842
+ };
1843
+ const parseRequiredPositiveIntFlag = (name) => {
1844
+ const raw = parsed.values[name];
1845
+ if (raw === undefined)
1846
+ return undefined;
1847
+ const value = Number.parseInt(raw, 10);
1848
+ if (!Number.isFinite(value) || value <= 0) {
1849
+ throw new Error(`--${name} must be a positive integer`);
1850
+ }
1851
+ return value;
1852
+ };
1853
+ const rawModels = parsed.values.models ??
1854
+ (distillProvider === 'claude'
1855
+ ? 'claude-haiku-4-5-20251001,claude-opus-4-8'
1856
+ : 'gpt-5.4-mini,gpt-5.5');
1857
+ const models = rawModels.split(',').map((m) => m.trim()).filter((m) => m.length > 0);
1858
+ const selectOnly = parsed.values['select-only'];
1859
+ if (!selectOnly && models.length === 0) {
1860
+ writer.write(`error: --models must include at least one model\n\n${USAGE}`);
1861
+ return 2;
1862
+ }
1863
+ const distillerFactory = dd.distillerFactory ?? defaultDistillerFactory;
1864
+ const modelConfigs = selectOnly ? [] : models.map((model) => {
1865
+ const ports = distillerFactory(distillProvider, model);
1866
+ return {
1867
+ label: modelLabel(model),
1868
+ model,
1869
+ distill: ports.distill,
1870
+ metaDistill: ports.metaDistill,
1871
+ };
1872
+ });
1873
+ const maxClusters = parsePositiveIntFlag('max-clusters');
1874
+ const maxContrastive = parsePositiveIntFlag('max-contrastive');
1875
+ const maxLessons = parsePositiveIntFlag('max-lessons');
1876
+ const maxPatterns = parsePositiveIntFlag('max-patterns');
1877
+ const groupCap = parseRequiredPositiveIntFlag('group-cap');
1878
+ const concurrency = parseRequiredPositiveIntFlag('concurrency');
1879
+ const metaEnabled = parsed.values.meta;
1880
+ let result;
1881
+ try {
1882
+ result = await runEvalPrep({
1883
+ verdictSource,
1884
+ fetchEvidence,
1885
+ publishDeps: buildLocalOnlyPublishDeps(),
1886
+ slate: { instanceIds: slateInstanceIds },
1887
+ models: modelConfigs,
1888
+ outDir,
1889
+ distribution: 'coding',
1890
+ selectOnly,
1891
+ meta: metaEnabled,
1892
+ force: parsed.values.force,
1893
+ retryErrors: parsed.values['retry-errors'],
1894
+ selection: {
1895
+ ...(maxClusters !== undefined ? { maxClusters } : {}),
1896
+ ...(maxContrastive !== undefined ? { maxContrastive } : {}),
1897
+ ...(maxLessons !== undefined ? { maxLessons } : {}),
1898
+ ...(maxPatterns !== undefined ? { maxPatterns } : {}),
1899
+ },
1900
+ ...(groupCap !== undefined ? { groupCap } : {}),
1901
+ ...(concurrency !== undefined ? { concurrency } : {}),
1902
+ ...(limit !== undefined ? { limit } : {}),
1903
+ });
1904
+ }
1905
+ catch (err) {
1906
+ writer.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
1907
+ return 1;
1908
+ }
1909
+ if (parsed.values.json) {
1910
+ writer.write(JSON.stringify({ ...result, outDir }) + '\n');
1911
+ }
1912
+ else {
1913
+ const lines = [
1914
+ `eval-prep: selected ${result.selection.length} of ${result.manifest.clusterCount} cluster(s)`,
1915
+ `bridge: ${result.manifest.bridge.bridged} bridged, ${result.manifest.bridge.excludedHeldOut} held-out, ${result.manifest.bridge.deduped} deduped, ${result.manifest.bridge.errors} error(s)`,
1916
+ 'mode: local-only (no chain publishes or anchors)',
1917
+ ];
1918
+ if (result.manifest.selectOnly)
1919
+ lines.push('mode: select-only (no model calls)');
1920
+ if (result.manifest.bridge.verdictsTruncated) {
1921
+ lines.push(`warning: verdict fetch hit the ${limit}-row limit — attempt groups may be PARTIAL; raise --limit to cover the corpus (#1478)`);
1922
+ }
1923
+ for (const model of result.models) {
1924
+ lines.push(`model ${model.label} (${model.model}): published ${model.published.length}, rejected ${model.rejected.length}, errors ${model.errors.length}`);
1925
+ if (model.metaDistilled) {
1926
+ lines.push(` meta: published ${model.metaDistilled.published.length}, rejected ${model.metaDistilled.rejected.length}, errors ${model.metaDistilled.errors.length}`);
1927
+ }
1928
+ }
1929
+ lines.push('', `eval artifacts written under: ${outDir}`);
1930
+ writer.write(lines.join('\n') + '\n');
1931
+ }
1932
+ return result.models.some((m) => m.errors.length > 0 || (m.metaDistilled?.errors.length ?? 0) > 0) ? 1 : 0;
1933
+ }
1934
+ // Resolve the distiller model once — it drives BOTH the model call and the
1935
+ // `distillModel` recorded in provenance (§5), so the record matches the run.
1936
+ const defaultDistillModel = distillProvider === 'codex' ? DEFAULT_CODEX_MODEL : DEFAULT_CLAUDE_DISTILL_MODEL;
1937
+ const distillModel = process.env['JINN_DISTILL_MODEL'] ?? defaultDistillModel;
1938
+ const runClusterTimeoutMs = resolveClusterTimeoutMs(parsed.values['cluster-timeout']);
1939
+ if (typeof runClusterTimeoutMs === 'object') {
1940
+ writer.write(`${runClusterTimeoutMs.error}\n\n${USAGE}`);
1941
+ return 2;
1942
+ }
1943
+ const distillerFactory = dd.distillerFactory ?? defaultDistillerFactory;
1944
+ const distillPorts = dd.distill && dd.metaDistill ? null : distillerFactory(distillProvider, distillModel, runClusterTimeoutMs);
1945
+ const distill = dd.distill ?? distillPorts.distill;
1946
+ const metaEnabled = parsed.values.meta;
1947
+ const metaDistillPort = dd.metaDistill ?? distillPorts.metaDistill;
1948
+ if (!localOnly && !dd.publishDeps) {
1949
+ writer.write('error: live distillation requires an injected client publish adapter; '
1950
+ + 'use --local-only or provide the adapter\n');
1951
+ return 2;
1952
+ }
1953
+ const publishDeps = localOnly
1954
+ ? buildLocalOnlyPublishDeps()
1955
+ : dd.publishDeps;
1956
+ // Local-fs publishSkill: write <out>/<name>/SKILL.md (shared with LocalDistiller).
1957
+ const publishSkill = createLocalSkillSink(outDir);
1958
+ const result = await runDistillationPipeline({
1959
+ verdictSource,
1960
+ fetchEvidence,
1961
+ distill,
1962
+ publishDeps,
1963
+ publishSkill,
1964
+ slate: { instanceIds: slateInstanceIds },
1965
+ distribution: 'coding',
1966
+ distillModel,
1967
+ anchorMode,
1968
+ ...(measurePerRecordControl ? { measurePerRecordControl: true } : {}),
1969
+ ...(metaEnabled ? { meta: true, metaDistill: metaDistillPort } : {}),
1970
+ ...(limit !== undefined ? { limit } : {}),
1971
+ });
1972
+ if (parsed.values.json) {
1973
+ writer.write(JSON.stringify({ ...result, outDir }, (_key, value) => typeof value === 'bigint' ? value.toString() : value) + '\n');
1974
+ }
1975
+ else {
1976
+ const lines = [
1977
+ `distilled: published ${result.distilled.published.length}, rejected ${result.distilled.rejected.length}, errors ${result.distilled.errors.length}`,
1978
+ `bridge: ${result.bridge.bridged.length} bridged, ${result.bridge.excludedHeldOut.length} held-out, ${result.bridge.deduped.length} deduped, ${result.bridge.errors.length} error(s)`,
1979
+ `clusters: ${result.clusterCount}`,
1980
+ ];
1981
+ if (localOnly)
1982
+ lines.push('mode: local-only (no chain publishes or anchors)');
1983
+ if (result.verdictsTruncated) {
1984
+ lines.push(`warning: verdict fetch hit the ${limit}-row limit — attempt groups may be PARTIAL; raise --limit to cover the corpus (#1478)`);
1985
+ }
1986
+ if (result.bridge.manifestBatches?.length) {
1987
+ for (const batch of result.bridge.manifestBatches) {
1988
+ if (!batch.confirmed) {
1989
+ lines.push(`manifest anchor unconfirmed ${batch.manifestCid} at ` +
1990
+ `${batch.anchorTx ?? 'unknown'} — ${batch.memberRefs.length} members uploaded; ` +
1991
+ 'reconcile before retrying');
1992
+ }
1993
+ else {
1994
+ lines.push(`manifest anchored ${batch.manifestCid} at ${batch.anchorTx ?? 'unknown'} — ` +
1995
+ `${batch.memberRefs.length} members, ` +
1996
+ `gasUsed=${batch.gasUsed?.toString() ?? 'unknown'}, ` +
1997
+ `feeWei=${batch.feeWei?.toString() ?? 'unknown'}`);
1998
+ }
1999
+ }
2000
+ }
2001
+ if (result.bridge.control) {
2002
+ lines.push(`per-record control anchored ${result.bridge.control.memberRef} at ` +
2003
+ `${result.bridge.control.anchorTx} — ` +
2004
+ `gasUsed=${result.bridge.control.gasUsed?.toString() ?? 'unknown'}, ` +
2005
+ `feeWei=${result.bridge.control.feeWei?.toString() ?? 'unknown'}`);
2006
+ }
2007
+ for (const p of result.distilled.published)
2008
+ lines.push(` PUBLISHED ${p.skillKind} ${p.envelopeRef} (${p.clusterId})`);
2009
+ for (const e of result.bridge.errors)
2010
+ lines.push(` BRIDGE-ERROR ${e.requestId} — ${e.error}`);
2011
+ for (const r of result.distilled.rejected)
2012
+ lines.push(` rejected ${r.clusterId} — ${r.reason}`);
2013
+ for (const e of result.distilled.errors)
2014
+ lines.push(` ERROR ${e.clusterId} — ${e.error}`);
2015
+ if (result.metaDistilled) {
2016
+ lines.push('', `meta-distilled: published ${result.metaDistilled.published.length}, rejected ${result.metaDistilled.rejected.length}, errors ${result.metaDistilled.errors.length}`);
2017
+ for (const p of result.metaDistilled.published) {
2018
+ lines.push(` META ${p.skillKind} ${p.envelopeRef} (${p.metaClusterId}) evidenceTokens=${p.pkg.jinn.evidenceTokens} skillTokens=${p.pkg.jinn.skillTokens}`);
2019
+ }
2020
+ for (const r of result.metaDistilled.rejected)
2021
+ lines.push(` meta-rejected ${r.metaClusterId} — ${r.reason}`);
2022
+ for (const e of result.metaDistilled.errors)
2023
+ lines.push(` META-ERROR ${e.metaClusterId} — ${e.error}`);
2024
+ }
2025
+ lines.push('', `skills written under: ${outDir}`);
2026
+ writer.write(lines.join('\n') + '\n');
2027
+ }
2028
+ return ((anchorMode === 'manifest' && result.bridge.errors.length > 0) ||
2029
+ result.distilled.errors.length > 0 ||
2030
+ (result.metaDistilled?.errors.length ?? 0) > 0) ? 1 : 0;
2031
+ }
2032
+ if (isDeriveEnv) {
2033
+ writer.write('error: derive-env requires the client wallet adapter; '
2034
+ + 'the independently published layer does not link wallet code\n');
2035
+ return 2;
2036
+ }
2037
+ if (isPublish) {
2038
+ const taskFile = subverb;
2039
+ if (taskFile === undefined || taskFile.startsWith('--')) {
2040
+ writer.write(`error: publish requires a <task-file> argument (a captured-task JSON file)\n\n${USAGE}`);
2041
+ return 2;
2042
+ }
2043
+ const task = parseCapturedTask(JSON.parse(readFileSync(taskFile, 'utf-8')));
2044
+ const pending = await capture(task);
2045
+ if (!opts.publishDeps) {
2046
+ writer.write('error: publish requires an injected client publish adapter; '
2047
+ + 'the standalone layer keeps outbound publication parked\n');
2048
+ return 2;
2049
+ }
2050
+ const deps = opts.publishDeps;
2051
+ const result = await publish(pending, deps, { veto: parsed.values.veto });
2052
+ if (parsed.values.json) {
2053
+ writer.write(JSON.stringify(result) + '\n');
2054
+ }
2055
+ else if (result.vetoed) {
2056
+ writer.write('Vetoed — nothing published; recorded in the ledger as vetoed (local only).\n');
2057
+ }
2058
+ else {
2059
+ writer.write([
2060
+ 'Published.',
2061
+ ` ref ${result.envelopeRef}`,
2062
+ ` anchor ${result.anchorTx ? `${TESTNET_EXPLORER_TX_URL}${result.anchorTx}` : '- (anchor tx pending)'}`,
2063
+ ` fetch it jinn-layer corpus get ${result.envelopeRef}`,
2064
+ '',
2065
+ ].join('\n'));
2066
+ }
2067
+ return 0;
2068
+ }
2069
+ if (isLedger) {
2070
+ const path = parsed.values.path ?? DEFAULT_LEDGER_PATH;
2071
+ const entries = ledger(path);
2072
+ if (parsed.values.json) {
2073
+ writer.write(JSON.stringify(entries.map(toLedgerRow)) + '\n');
2074
+ }
2075
+ else if (entries.length === 0) {
2076
+ writer.write(`No contributions yet — the ledger at ${path} is empty.\n`);
2077
+ }
2078
+ else {
2079
+ writer.write(`${entries.length} contribution(s)\n\n${entries.map(renderLedgerEntry).join('\n\n')}\n`);
2080
+ }
2081
+ return 0;
2082
+ }
2083
+ const layer = opts.layer ?? buildDefaultLayer();
2084
+ if (isSkillsInstall) {
2085
+ const ref = parsed.positionals[0];
2086
+ if (ref === undefined) {
2087
+ writer.write(`error: skills install requires a <ref> argument (a manifest CID from a search result)\n\n${USAGE}`);
2088
+ return 2;
2089
+ }
2090
+ const record = await layer.corpus.get(ref);
2091
+ let extracted;
2092
+ try {
2093
+ extracted = extractSkill(record);
2094
+ }
2095
+ catch (err) {
2096
+ writer.write(`error: record ${ref} carries a malformed jinn.skill.v1 artifact — ${err instanceof Error ? err.message : String(err)}\n`);
2097
+ return 1;
2098
+ }
2099
+ if (extracted === null) {
2100
+ writer.write(`error: record ${ref} carries no skill (neither a jinn.skill.v1 artifact nor the seeded trace shape)\n`);
2101
+ return 1;
2102
+ }
2103
+ const { skill } = extracted;
2104
+ // Default dir: a safe slug of the publisher-controlled name, contained
2105
+ // in cwd (containment is belt-and-suspenders alongside the slug; --out
2106
+ // is operator-chosen and taken as given).
2107
+ const outFlag = parsed.values.out;
2108
+ const dir = outFlag ?? join(process.cwd(), skillDirSlug(skill.skill.name));
2109
+ if (outFlag === undefined && !isInsidePackageDir(process.cwd(), dir)) {
2110
+ writer.write(`error: skill name ${JSON.stringify(skill.skill.name)} does not resolve to a directory inside the current directory — pass --out <dir>\n`);
2111
+ return 1;
2112
+ }
2113
+ // Verify every companion digest and resolved target BEFORE any write —
2114
+ // an aborted install must leave nothing on disk.
2115
+ const companions = [];
2116
+ for (const file of skill.files) {
2117
+ const bytes = Buffer.from(file.contentBase64, 'base64');
2118
+ const digest = createHash('sha256').update(bytes).digest('hex');
2119
+ if (digest !== file.sha256) {
2120
+ writer.write(`error: companion file ${file.path} sha256 mismatch (expected ${file.sha256}, got ${digest})\n`);
2121
+ return 1;
2122
+ }
2123
+ const target = join(dir, file.path);
2124
+ if (!isInsidePackageDir(dir, target)) {
2125
+ writer.write(`error: companion file ${file.path} escapes the install directory\n`);
2126
+ return 1;
2127
+ }
2128
+ companions.push({ path: file.path, bytes });
2129
+ }
2130
+ try {
2131
+ writePackageTreeSafely(dir, [
2132
+ { path: 'SKILL.md', content: skill.skill.skillMd },
2133
+ ...companions.map((file) => ({ path: file.path, content: file.bytes })),
2134
+ ]);
2135
+ }
2136
+ catch (error) {
2137
+ writer.write(`error: could not safely install skill to ${dir} — ${error instanceof Error ? error.message : String(error)}\n`);
2138
+ return 1;
2139
+ }
2140
+ if (parsed.values.json) {
2141
+ writer.write(JSON.stringify({
2142
+ dir,
2143
+ name: skill.skill.name,
2144
+ shape: extracted.shape,
2145
+ files: skill.files.map((f) => f.path),
2146
+ provenance: skill.provenance,
2147
+ }) + '\n');
2148
+ }
2149
+ else {
2150
+ const p = skill.provenance;
2151
+ writer.write([
2152
+ `Installed ${skill.skill.name} (${extracted.shape}) to ${dir}`,
2153
+ ` files SKILL.md${skill.files.length > 0 ? `, ${skill.files.map((f) => f.path).join(', ')}` : ''}`,
2154
+ ` provenance ${p.kind}${p.solverType ? ` (${p.solverType})` : ''}`,
2155
+ ` operator ${p.operator.safeAddress}`,
2156
+ ` sources ${p.sourceEnvelopeCids.join(', ') || '-'}`,
2157
+ ...(p.seed ? [` seed ${p.seed.skill} — ${p.seed.source} (${p.seed.licence ?? 'no licence'})`] : []),
2158
+ '',
2159
+ ].join('\n'));
2160
+ }
2161
+ return 0;
2162
+ }
2163
+ if (subverb === 'probe') {
2164
+ const slug = parsed.positionals[0];
2165
+ if (slug === undefined) {
2166
+ writer.write(`error: corpus probe requires a <slug> argument (e.g. "owner/repo")\n\n${USAGE}`);
2167
+ return 2;
2168
+ }
2169
+ const checks = await corpusProbes({ layer, repoSlug: slug, k: CORPUS_ONBOARDING_K });
2170
+ if (parsed.values.json) {
2171
+ writer.write(JSON.stringify(checks) + '\n');
2172
+ }
2173
+ else {
2174
+ const lines = [];
2175
+ for (const check of checks) {
2176
+ lines.push(`[${check.ok ? 'ok ' : 'fail'}] ${check.name}: ${check.detail}`);
2177
+ if (check.remedy)
2178
+ lines.push(` remedy: ${check.remedy}`);
2179
+ }
2180
+ writer.write(lines.join('\n') + '\n');
2181
+ }
2182
+ return 0;
2183
+ }
2184
+ if (subverb === 'search') {
2185
+ const query = parsed.positionals[0];
2186
+ if (query === undefined) {
2187
+ writer.write(`error: corpus search requires a <query> argument (use "" for all records)\n\n${USAGE}`);
2188
+ return 2;
2189
+ }
2190
+ const n = Number.parseInt(parsed.values.limit, 10);
2191
+ const limit = Math.min(Math.max(Number.isFinite(n) ? n : DEFAULT_CLI_SEARCH_LIMIT, 1), 500);
2192
+ const hits = await layer.corpus.search(query, { limit });
2193
+ if (parsed.values.json) {
2194
+ writer.write(JSON.stringify(hits) + '\n');
2195
+ }
2196
+ else if (hits.length === 0) {
2197
+ writer.write('No corpus records matched.\n');
2198
+ }
2199
+ else {
2200
+ writer.write(`${hits.length} result(s)\n\n${hits.map(renderHit).join('\n\n')}\n`);
2201
+ }
2202
+ return 0;
2203
+ }
2204
+ // corpus get
2205
+ const ref = parsed.positionals[0];
2206
+ if (ref === undefined) {
2207
+ writer.write(`error: corpus get requires a <ref> argument (a manifest CID from a search result)\n\n${USAGE}`);
2208
+ return 2;
2209
+ }
2210
+ const outDir = parsed.values.out;
2211
+ if (outDir)
2212
+ mkdirSync(outDir, { recursive: true });
2213
+ const record = await layer.corpus.get(ref);
2214
+ if (parsed.values.json) {
2215
+ writer.write(JSON.stringify(recordToJson(record)) + '\n');
2216
+ }
2217
+ else {
2218
+ writer.write(renderRecord(record, outDir) + '\n');
2219
+ }
2220
+ return 0;
2221
+ }