@jinn-network/jinn-layer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/contribution-adapter.d.ts +18 -0
- package/dist/adapters/contribution-adapter.js +134 -0
- package/dist/adapters/corpus-adapter.d.ts +12 -0
- package/dist/adapters/corpus-adapter.js +212 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/json-map-store.d.ts +4 -0
- package/dist/adapters/json-map-store.js +31 -0
- package/dist/adapters/local-learning-adapter.d.ts +25 -0
- package/dist/adapters/local-learning-adapter.js +49 -0
- package/dist/adapters/skills-adapter.d.ts +8 -0
- package/dist/adapters/skills-adapter.js +55 -0
- package/dist/admission-policy.d.ts +38 -0
- package/dist/admission-policy.js +17 -0
- package/dist/bin/jinn-distill-mcp.d.ts +2 -0
- package/dist/bin/jinn-distill-mcp.js +10 -0
- package/dist/bin/jinn-layer.d.ts +7 -0
- package/dist/bin/jinn-layer.js +15 -0
- package/dist/bridge-fetch-evidence.d.ts +102 -0
- package/dist/bridge-fetch-evidence.js +677 -0
- package/dist/bridge-verdict-source.d.ts +47 -0
- package/dist/bridge-verdict-source.js +216 -0
- package/dist/bridge.d.ts +247 -0
- package/dist/bridge.js +439 -0
- package/dist/capture.d.ts +98 -0
- package/dist/capture.js +617 -0
- package/dist/cli.d.ts +148 -0
- package/dist/cli.js +2221 -0
- package/dist/cluster-selection.d.ts +34 -0
- package/dist/cluster-selection.js +135 -0
- package/dist/cluster.d.ts +89 -0
- package/dist/cluster.js +142 -0
- package/dist/consume.d.ts +139 -0
- package/dist/consume.js +280 -0
- package/dist/corpus-probes.d.ts +61 -0
- package/dist/corpus-probes.js +120 -0
- package/dist/corpus-store.d.ts +16 -0
- package/dist/corpus-store.js +101 -0
- package/dist/distill-captures.d.ts +56 -0
- package/dist/distill-captures.js +283 -0
- package/dist/distill-feedback.d.ts +20 -0
- package/dist/distill-feedback.js +30 -0
- package/dist/distill-llm.d.ts +138 -0
- package/dist/distill-llm.js +438 -0
- package/dist/distill-mcp-server.d.ts +30 -0
- package/dist/distill-mcp-server.js +281 -0
- package/dist/distill-mode.d.ts +62 -0
- package/dist/distill-mode.js +107 -0
- package/dist/distill-progress.d.ts +78 -0
- package/dist/distill-progress.js +69 -0
- package/dist/distill-prompt.d.ts +34 -0
- package/dist/distill-prompt.js +85 -0
- package/dist/distill-render.d.ts +123 -0
- package/dist/distill-render.js +303 -0
- package/dist/distill-runs.d.ts +28 -0
- package/dist/distill-runs.js +39 -0
- package/dist/distill-traces.d.ts +67 -0
- package/dist/distill-traces.js +211 -0
- package/dist/distill.d.ts +180 -0
- package/dist/distill.js +337 -0
- package/dist/distiller.d.ts +118 -0
- package/dist/distiller.js +142 -0
- package/dist/envelope.d.ts +9 -0
- package/dist/envelope.js +9 -0
- package/dist/eval-prep.d.ts +129 -0
- package/dist/eval-prep.js +523 -0
- package/dist/execution-publish.d.ts +62 -0
- package/dist/execution-publish.js +72 -0
- package/dist/gate.d.ts +39 -0
- package/dist/gate.js +106 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +50 -0
- package/dist/ipfs-cid.d.ts +18 -0
- package/dist/ipfs-cid.js +149 -0
- package/dist/layer-default.d.ts +3 -0
- package/dist/layer-default.js +10 -0
- package/dist/ledger.d.ts +62 -0
- package/dist/ledger.js +104 -0
- package/dist/measurement.d.ts +90 -0
- package/dist/measurement.js +134 -0
- package/dist/path-safety.d.ts +19 -0
- package/dist/path-safety.js +303 -0
- package/dist/pipeline.d.ts +94 -0
- package/dist/pipeline.js +124 -0
- package/dist/plugin-wiring.d.ts +4 -0
- package/dist/plugin-wiring.js +54 -0
- package/dist/preview.d.ts +31 -0
- package/dist/preview.js +28 -0
- package/dist/process-contract.d.ts +303 -0
- package/dist/process-contract.js +118 -0
- package/dist/publish-skill.d.ts +86 -0
- package/dist/publish-skill.js +116 -0
- package/dist/publish.d.ts +294 -0
- package/dist/publish.js +1090 -0
- package/dist/scripts/scan-skills-registry.d.ts +24 -0
- package/dist/scripts/scan-skills-registry.js +207 -0
- package/dist/seed-import/curated-batch.d.ts +36 -0
- package/dist/seed-import/curated-batch.js +103 -0
- package/dist/seed-import/episode-execute.d.ts +50 -0
- package/dist/seed-import/episode-execute.js +235 -0
- package/dist/seed-import/episode-fetch.d.ts +119 -0
- package/dist/seed-import/episode-fetch.js +137 -0
- package/dist/seed-import/episode-plan.d.ts +11 -0
- package/dist/seed-import/episode-plan.js +27 -0
- package/dist/seed-import/episode-report.d.ts +39 -0
- package/dist/seed-import/episode-report.js +36 -0
- package/dist/seed-import/execute.d.ts +61 -0
- package/dist/seed-import/execute.js +245 -0
- package/dist/seed-import/fetch.d.ts +62 -0
- package/dist/seed-import/fetch.js +82 -0
- package/dist/seed-import/licence.d.ts +17 -0
- package/dist/seed-import/licence.js +36 -0
- package/dist/seed-import/plan.d.ts +10 -0
- package/dist/seed-import/plan.js +21 -0
- package/dist/seed-import/report.d.ts +34 -0
- package/dist/seed-import/report.js +31 -0
- package/dist/seed-import/state.d.ts +72 -0
- package/dist/seed-import/state.js +120 -0
- package/dist/signal.d.ts +36 -0
- package/dist/signal.js +48 -0
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +21 -0
- package/dist/skill-package.d.ts +70 -0
- package/dist/skill-package.js +141 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +77 -0
- package/dist/snapshot-transcript.d.ts +54 -0
- package/dist/snapshot-transcript.js +117 -0
- package/dist/three-arm-run.d.ts +27 -0
- package/dist/three-arm-run.js +41 -0
- package/package.json +60 -0
package/dist/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
|
+
}
|