sibyl-system 1.3.0 → 1.3.2

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/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  A standalone [opencode](https://opencode.ai) plugin:
6
6
 
7
7
  - **`sibyl_consult`** — a three-voter review council (MELCHIOR / BALTHASAR / CASPER)
8
- that audits an artifact against a goal and returns a **fail-closed 2/3 verdict**.
8
+ that audits an artifact against a goal and returns a **fail-closed verdict**
9
+ (`APPROVE` / `REJECT` / `CANNOT_ANSWER`).
9
10
  - **`sibyl_swarm`** — a lightweight workflow swarm: an ARCHITECT persona plans a
10
11
  dependency-ordered task graph, deterministic workers execute it in waves, and
11
12
  the result is aggregated into a verdict.
@@ -24,8 +25,10 @@ Council voters, swarm workers and the swarm judge run as **child sessions of the
24
25
  calling session** (`parentID` from the tool context): the TUI session picker
25
26
  lists root sessions only, so no sibyl ballot ever clutters your top-level
26
27
  session list. Transcripts stay in the DB, reachable from the parent session and
27
- mirrored into the run's sealed reply files. An empty caller session falls back
28
- to top-level creation. `sibyl_review` roles run fully isolated under /tmp with
28
+ mirrored into the run's sealed reply files. A seatless (CLI/headless) caller
29
+ falls back to top-level creation, and its voter rows are **garbage-collected
30
+ on success** (the reply files are the durable record; failed votes keep their
31
+ DB rows for salvage). `sibyl_review` roles run fully isolated under /tmp with
29
32
  their own HOME/DB (`src/lane/isolated.ts`), never touching yours.
30
33
 
31
34
  ## v1.1 — the review chamber (民主集中制)
@@ -44,6 +47,7 @@ Laws: docs/isolation-laws.md. Mechanism: docs/democratic-centralism.md.
44
47
  node dist/cli.js run --target doc.md --goal "Is this sound?" --model your-provider/your-model
45
48
  node dist/cli.js status --tail 5
46
49
  node dist/cli.js spotcheck <runId>
50
+ node dist/cli.js run --target - --profile exam --scenario scenarios/<id>.json ... # behavioral exam venue (bell preflight mandatory)
47
51
  ```
48
52
 
49
53
  ## Install
@@ -94,7 +98,7 @@ stderr, registers nothing, and returns empty hooks.
94
98
  | Tool | Arguments | Behavior |
95
99
  |------|-----------|----------|
96
100
  | `sibyl_consult` | `{ artifact, goal }` | `artifact` is a file path or inline multi-line text (≤ 256 KiB). The three councilors audit it in parallel; each reply is parsed into a verdict (one in-session JSON-only repair shot per voter). Returns the tally, merged reasons/must-fix, run id, and per-voter reply file paths. |
97
- | `sibyl_swarm` | `{ artifact, goal, judge? }` | ARCHITECT decomposes goal + artifact into a strict-JSON workflow schema; workers are minted deterministically and dispatched in dependency waves; drafts land in the run's space dir. Verdict: `APPROVE` / `REJECT` / `EXHAUSTED`. With `judge: true`, one extra judge pass may replace the derived verdict — an unrecognized or failed judge reply keeps the derived one. |
101
+ | `sibyl_swarm` | `{ artifact, goal, judge? }` | ARCHITECT decomposes goal + artifact into a strict-JSON workflow schema; workers are minted deterministically and dispatched in dependency waves; drafts land in the run's space dir. Verdict: `APPROVE` / `REJECT` / `EXHAUSTED`, forced to `CANNOT_ANSWER` when a declared worker left no terminal row (W2) or zero drafts were produced. With `judge: true`, one extra judge pass may replace the derived verdict — an unrecognized or failed judge reply keeps the derived one. |
98
102
  | `sibyl_status` | `{ runId? }` | Read-only. Lists all recorded runs (newest last) + the last chamber-ledger rows, or shows one run's full record and space dir. |
99
103
  | `sibyl_review` | `{ target, goal, seed?, maxRounds? }` | v1.1 general democratic-centralism chamber: launches the isolated evidence→clash→judge pipeline detached and returns a receipt (explicitly NOT a verdict); the single voice lands in the run record at terminal state. CLI twin: `sibyl-chamber`. |
100
104
  | `sibyl_anchor_check` | `{ sessionId, index?, body?, expectSha256?, label? }` | Read-only verifier for "human anchor" claims (an order/approval message the owner says they sent): re-checks the claim against the engine's own session store instead of trusting a transcribed string. Closed 4-state verdict `MATCH` / `MISMATCH` / `ABSENT` / `ERROR`; the receipt carries a hash of the canonical view read, so later store mutation shows up as a view-hash difference. No `expectSha256` claim → non-verdict. |
@@ -119,7 +123,7 @@ defaults.
119
123
  | `timeoutMs` | `240000` | Per-child-session timeout (create + prompt share the budget). Integer ≥ 1. |
120
124
  | `concurrencyK` | `4` | Cap on parallel workers per wave (also capped by the schema's own `concurrency`). Integer 1–8. |
121
125
  | `staggerMs` | `2000` | Delay between worker launches within a wave, to avoid rate-limit bursts. Integer ≥ 0. |
122
- | `lane` | see below | v1.1 isolated-lane mechanics for `sibyl_review` / `sibyl-chamber` (`src/lane/isolated.ts`). Sub-keys: `runRoot` (string, default `"/tmp"`) — parent dir for run dirs; `opencodeBin` (non-empty string; discovery order: `$OPENCODE_BIN` → `~/.local/bin/opencode` → `/usr/local/bin/opencode` → `/usr/bin/opencode` → bare PATH name `"opencode"`; never empty, a wrong guess fails loud at spawn naming the bin) — the binary launched for each isolated role; `configSource` (default `~/.config/opencode/opencode.jsonc`) — **read-copied** into each role home, never written back; `roleTimeoutMs` (integer ≥ 5000, default `600000`) — per-role wall-clock budget. |
126
+ | `lane` | see below | v1.1 isolated-lane mechanics for `sibyl_review` / `sibyl-chamber` (`src/lane/isolated.ts`). Sub-keys: `runRoot` (string, default `"/tmp"`) — parent dir for run dirs; `opencodeBin` (non-empty string; discovery order: `$OPENCODE_BIN` → `~/.local/bin/opencode` → `/usr/local/bin/opencode` → `/usr/bin/opencode` → bare PATH name `"opencode"`; never empty, a wrong guess fails loud at spawn naming the bin) — the binary launched for each isolated role; `configSource` (default `~/.config/opencode/opencode.jsonc`) — **read-copied** into each role home, never written back; `roleTimeoutMs` (integer ≥ 5000, default `600000`) — per-role wall-clock budget; `examMaskDirs` (array of absolute paths, default `[]`) — dirs tmpfs-masked from exam candidates (grader files, other runs); empty = an unsandboxed venue that says so plainly on the voice face. |
123
127
  | `modelPolicy` | `{ allowedPrefixes: ["local-"] }` | v1.1 E4/F1 seating allow-list: only pool models whose `providerID/modelID` starts with one of these prefixes may take a seat; everything else is denied at launch. Array of non-empty strings, **at least 1 entry** (an empty list is rejected as a deny-everything typo). Set this to your own machine's providers — the shipped default is a prefix pattern, not a specific model. |
124
128
  | `chamber` | see below | v1.1 review-chamber seats and rounds. Sub-keys: `maxRounds` (integer 1–8, default `3`) — clash/cross-critique round cap; `roles` (`{ evidence, pro, con, judge }` → pool slot names, each default `"default"`); `judgePool` (non-empty array of slot names, default `["default"]`) — the slots the independent judge may be drawn from (E2 pool+seed committed before launch). |
125
129
 
@@ -140,6 +144,8 @@ fails the release if any vendor model id reappears on a shipped surface.
140
144
 
141
145
  ## State layout
142
146
 
147
+ - **Chamber ledger**: `~/.sibyl/chamber-ledger.jsonl` — append-only, one JSON
148
+ record per line, serial-stamped at append (A5); `sibyl_status` reads its tail.
143
149
  - **Runs file**: `~/.sibyl/runs.json` (Windows: `%USERPROFILE%\.sibyl\runs.json`) —
144
150
  one record per run (id, kind, status, verdict tally, operator notes), co-located
145
151
  with `spaces/` under the one `~/.sibyl` state root, so npm upgrades (which ship a
@@ -158,13 +164,18 @@ fails the release if any vendor model id reappears on a shipped surface.
158
164
  Plainly stated:
159
165
 
160
166
  - Approval needs **≥ 2 of 3 approvals and zero error/missing votes**.
161
- Anything else — including 2A + 1 error or 2A + 1 missing — is **REJECT**.
167
+ - When the council genuinely spoke (all three ballots substantive, or ≥ 2
168
+ substantive rejects) and approval was not reached, the verdict is **REJECT**.
169
+ - When error/missing seats leave no decision (2A + 1 error, 2A + 1 missing,
170
+ a tie with an error seat), the verdict is **CANNOT_ANSWER** — a wounded
171
+ instrument, NOT a substantive reject, and it can never approve. Repair the
172
+ infra and re-run (since v1.2; pinned by `test/council.test.ts`).
162
173
  - Error votes, missing votes, and malformed verdicts all count against approval.
163
174
  - A reply that still doesn't parse after its single repair shot becomes a
164
175
  0-confidence REJECT ballot with reason `verdict-unparseable: …`.
165
176
  - There is no lenient mode, by design.
166
177
 
167
- ## Ballot-integrity laws (W1–W3, added 2026-10-05 after the campaign postmortems)
178
+ ## Ballot-integrity laws (W1–W4, drafted 2026-10-04/05 after the campaign postmortems; shipped in v1.3.0)
168
179
 
169
180
  - **Convener recusal (W1).** Every consult/swarm/review run resolves the
170
181
  convening execution chain through the engine's own session store
@@ -189,7 +200,9 @@ Plainly stated:
189
200
  ## Security notes
190
201
 
191
202
  - Consult/swarm send the artifact text to your configured model pool providers.
192
- - The plugin writes only under its state paths: the runs file and the per-run space.
203
+ - The plugin writes under its state paths (the runs file and the per-run space) and,
204
+ for chamber/exam lanes, sealed run directories under `lane.runRoot`
205
+ (default `/tmp`) — role homes, transcripts, pidfiles, CHECKSUMS.
193
206
  - Voter/worker turns run with `bash`, `edit`, and `write` disabled per prompt.
194
207
 
195
208
  ## Architecture (`src/`)
@@ -206,7 +219,9 @@ index.ts plugin entry: parse options → share one RunStore + client
206
219
  │ validation → exactly one repair → fail-closed REJECT
207
220
  ├── council/ councilor personas + tallyVotes (majority2of3 default,
208
221
  │ unanimous exported); pure aggregation, zero IO
209
- ├── state/ RunStore: atomic RMW runs.json, per-run space dirs
222
+ ├── state/ RunStore: atomic RMW runs.json, per-run space dirs;
223
+ │ v1.1 chamber records: EOF-append ledger (A5), face-last
224
+ │ regeneration, CHECKSUMS + spotcheck (A4)
210
225
  ├── swarm/ planner (ARCHITECT schema) → minter (deterministic worker
211
226
  │ roster) → dispatcher (dependency waves, stagger,
212
227
  │ suspend-on-rate-limit, resume) → aggregate (report +
@@ -218,8 +233,6 @@ index.ts plugin entry: parse options → share one RunStore + client
218
233
  │ fail-closed, dissent sealed by path+hash)
219
234
  ├── exam/ v1.1 E3: scenario-as-data behavioral exams, mechanical
220
235
  │ transcript/disk signal grading, canary veto
221
- ├── state/ RunStore + v1.1 chamber records: EOF-append ledger (A5),
222
- │ face-last regeneration, CHECKSUMS + spotcheck (A4)
223
236
  ├── personas.ts registry: 3 councilors + ARCHITECT + judge/repair texts,
224
237
  │ model slots — the single source the W3 face hashes
225
238
  ├── terminal.ts W2 terminal-row grammar (ok|error|timeout), parse + the
@@ -240,7 +253,7 @@ index.ts plugin entry: parse options → share one RunStore + client
240
253
 
241
254
  This is **not** the oh-my-openagent swarm. It has no `team_*` tools, no
242
255
  cross-agent message bus, and no fleet-orchestration dependency of any kind —
243
- it is a clean-room implementation (~3.5K lines of product code, comments excluded) that happens to
256
+ it is a clean-room implementation (~5K lines of product code, comments excluded) that happens to
244
257
  share the problem space. `sibyl_swarm` is a single tool call driving a
245
258
  PLAN→MINT→DISPATCH→AGGREGATE pipeline over ordinary child sessions.
246
259
 
@@ -248,7 +261,7 @@ PLAN→MINT→DISPATCH→AGGREGATE pipeline over ordinary child sessions.
248
261
 
249
262
  ```bash
250
263
  npm run typecheck # tsc --noEmit, strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes
251
- npm test # 346 unit tests, fully offline (no network, no LLM)
264
+ npm test # full unit suite, fully offline (no network, no LLM; ~420 at v1.3.0 — the run prints the exact count)
252
265
  npm run build # esbuild bundle → dist/index.js + dist/cli.js (ESM)
253
266
  node smoke/run-smoke.mjs # offline smoke of the shipped surface (see smoke/README.md)
254
267
  ./ship.sh # the full release gate: all of the above + portability scan of the packed tarball
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ var __export = (target, all) => {
8
8
  // src/cli.ts
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { spawn as spawn2 } from "node:child_process";
11
+ import { existsSync as existsSync3 } from "node:fs";
11
12
  import { copyFile as copyFile2, mkdir as mkdir6, readFile as readFile6 } from "node:fs/promises";
12
13
  import { basename, dirname as dirname4, isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "node:path";
13
14
 
@@ -847,6 +848,17 @@ import { createWriteStream, existsSync } from "node:fs";
847
848
  import { copyFile, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
848
849
  import { dirname as dirname2, join as join3 } from "node:path";
849
850
  var DEFAULT_PINNED_PATH = "/usr/bin:/bin";
851
+ function composeSandboxArgs(opts) {
852
+ const argv = [opts.bwrapBin, "--ro-bind", "/", "/", "--dev", "/dev", "--die-with-parent"];
853
+ for (const m of opts.maskDirs) argv.push("--tmpfs", m);
854
+ for (const k of opts.keepDirs) argv.push("--dir", posixDirname(k), "--bind", k, k);
855
+ argv.push("--", opts.bin, ...opts.args);
856
+ return argv;
857
+ }
858
+ function posixDirname(p) {
859
+ const i = p.lastIndexOf("/");
860
+ return i <= 0 ? "/" : p.slice(0, i);
861
+ }
850
862
  function rolePaths(cfg, role) {
851
863
  const roleDir = join3(cfg.runDir, role);
852
864
  const home = join3(roleDir, "home");
@@ -911,8 +923,12 @@ async function launchRole(cfg, role, spec) {
911
923
  await writeFile3(transcriptPath, "", "utf8");
912
924
  await writeFile3(stderrPath, "", "utf8");
913
925
  const args = composeRunArgs(spec);
926
+ const bwrap = cfg.bwrapBin ?? "/usr/bin/bwrap";
927
+ const mask = cfg.sandboxMaskDirs ?? [];
928
+ const sandboxed = mask.length > 0 && existsSync(bwrap);
929
+ const spawnArgv = sandboxed ? composeSandboxArgs({ bwrapBin: bwrap, maskDirs: mask, keepDirs: [cfg.runDir], bin: cfg.opencodeBin, args }) : [cfg.opencodeBin, ...args];
914
930
  return new Promise((resolvePromise) => {
915
- const child = spawn(cfg.opencodeBin, args, {
931
+ const child = spawn(spawnArgv[0], spawnArgv.slice(1), {
916
932
  cwd: paths.workspace,
917
933
  env,
918
934
  detached: true,
@@ -934,7 +950,8 @@ async function launchRole(cfg, role, spec) {
934
950
  transcriptPath,
935
951
  sessionId: extractSessionId(transcript),
936
952
  startedAt,
937
- endedAt: (/* @__PURE__ */ new Date()).toISOString()
953
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
954
+ sandboxed
938
955
  };
939
956
  if (spawnError2 !== void 0) result.error = spawnError2;
940
957
  resolvePromise(result);
@@ -20222,7 +20239,10 @@ var pluginOptionsSchema = external_exports.object({
20222
20239
  runRoot: external_exports.string().min(1).default("/tmp"),
20223
20240
  opencodeBin: external_exports.string().min(1).prefault(discoverOpencodeBin),
20224
20241
  configSource: external_exports.string().prefault(() => join6(homedir2(), ".config", "opencode", "opencode.jsonc")),
20225
- roleTimeoutMs: external_exports.number().int().min(5e3).default(6e5)
20242
+ roleTimeoutMs: external_exports.number().int().min(5e3).default(6e5),
20243
+ /** Exam venue: absolute dirs tmpfs-masked from the candidate (grader files,
20244
+ * other runs). Empty = venue runs unsandboxed and SAYS SO on the voice face. */
20245
+ examMaskDirs: external_exports.array(external_exports.string().min(2)).default([])
20226
20246
  }).prefault({}),
20227
20247
  /** v1.1 E4/F1: seats only from allowed model-id prefixes; deny at launch. */
20228
20248
  modelPolicy: external_exports.object({
@@ -20394,7 +20414,7 @@ async function resolveRun(flags) {
20394
20414
  if (scenarios.length === 0) return { ok: false, error: "--profile exam needs at least one --scenario" };
20395
20415
  const only = splitModelId(modelFlag ?? "") ?? null;
20396
20416
  if (only === null) return { ok: false, error: "--profile exam requires --model provider/model" };
20397
- return { ok: true, run: { runId, runDir, laneCfg, timeoutMs, chamber: null, exam: { scenarios, modelId: modelFlag }, killInjection } };
20417
+ return { ok: true, run: { runId, runDir, laneCfg, timeoutMs, chamber: null, exam: { scenarios, modelId: modelFlag }, killInjection, examMaskDirs: opts?.lane.examMaskDirs ?? [] } };
20398
20418
  }
20399
20419
  if (profile !== "review") return { ok: false, error: `unknown --profile ${profile}` };
20400
20420
  const slots = {
@@ -20427,7 +20447,7 @@ async function resolveRun(flags) {
20427
20447
  // wired below (type seam: lane needs cfg, cfg needs lane)
20428
20448
  };
20429
20449
  chamber.lane = makeIsolatedLane(chamber, laneCfg, timeoutMs);
20430
- return { ok: true, run: { runId, runDir, laneCfg, timeoutMs, chamber, exam: null, killInjection } };
20450
+ return { ok: true, run: { runId, runDir, laneCfg, timeoutMs, chamber, exam: null, killInjection, examMaskDirs: opts?.lane.examMaskDirs ?? [] } };
20431
20451
  }
20432
20452
  function slotToId(slot, pool) {
20433
20453
  const m = pool[slot] ?? pool["default"];
@@ -20451,8 +20471,8 @@ function makeIsolatedLane(cfg, laneCfg, timeoutMs) {
20451
20471
  }
20452
20472
  };
20453
20473
  }
20454
- function makeExamDriver(laneCfgBase, runDir, modelId, timeoutMs) {
20455
- const lc = { runDir, opencodeBin: laneCfgBase.opencodeBin, configSource: laneCfgBase.configSource };
20474
+ function makeExamDriver(laneCfgBase, runDir, modelId, timeoutMs, maskDirs, bwrapProbe) {
20475
+ const lc = { runDir, opencodeBin: laneCfgBase.opencodeBin, configSource: laneCfgBase.configSource, sandboxMaskDirs: maskDirs, bwrapBin: bwrapProbe };
20456
20476
  let turnNo = 0;
20457
20477
  const run = async (message, resume) => {
20458
20478
  turnNo += 1;
@@ -20469,11 +20489,14 @@ function makeExamDriver(laneCfgBase, runDir, modelId, timeoutMs) {
20469
20489
  return { facts: { ok: o.ok, rc: o.rc, signal: o.signal, timedOut: o.timedOut }, transcript, sessionId: o.sessionId };
20470
20490
  };
20471
20491
  return {
20472
- start: async (prompt) => await run(prompt),
20473
- continueTurn: async (sid, message) => await run(message, sid)
20492
+ sandboxed: maskDirs.length > 0 && existsSync3(bwrapProbe),
20493
+ driver: {
20494
+ start: async (prompt) => await run(prompt),
20495
+ continueTurn: async (sid, message) => await run(message, sid)
20496
+ }
20474
20497
  };
20475
20498
  }
20476
- function examVoice(results, runId, runDir) {
20499
+ function examVoice(results, runId, runDir, venueSandbox) {
20477
20500
  const lines = ["SIBYL \u2014 ONE CONCLUSION, ONE VOICE [SIBYL-ONE-VOICE]"];
20478
20501
  let anyFail = false;
20479
20502
  let anyHuman = false;
@@ -20487,6 +20510,7 @@ function examVoice(results, runId, runDir) {
20487
20510
  const conclusion = anyFail ? "REJECT" : anyHuman ? "NEEDS_HUMAN" : "APPROVE";
20488
20511
  lines.splice(1, 0, `conclusion: ${conclusion} terminal: CONVERGED run: ${runId}`);
20489
20512
  lines.push(`honesty: PERFORMANCE-ONLY-IN-LOOP (single-uid box until C-09/C-14 rulings)`);
20513
+ lines.push(`venue sandbox: ${venueSandbox}`);
20490
20514
  lines.push(`verify every recorded byte: ${spotcheckCommand(runDir)}`);
20491
20515
  return lines.join("\n");
20492
20516
  }
@@ -20550,7 +20574,7 @@ async function main(argv) {
20550
20574
  return 0;
20551
20575
  }
20552
20576
  if (run.exam !== null) {
20553
- const driver = makeExamDriver(run.laneCfg, run.runDir, run.exam.modelId, run.timeoutMs);
20577
+ const { driver, sandboxed } = makeExamDriver(run.laneCfg, run.runDir, run.exam.modelId, run.timeoutMs, run.examMaskDirs, "/usr/bin/bwrap");
20554
20578
  const results = [];
20555
20579
  const scPaths = typeof flags["scenario"] === "string" ? [flags["scenario"]] : [];
20556
20580
  if (flags["allow-unbellied"] !== true) {
@@ -20564,7 +20588,8 @@ async function main(argv) {
20564
20588
  }
20565
20589
  }
20566
20590
  for (const sc of run.exam.scenarios) results.push(await runExamScenario(sc, run.runDir, driver));
20567
- console.log(examVoice(results, run.runId, run.runDir));
20591
+ await writeChecksums(run.runDir, await listChecksumTargets(run.runDir));
20592
+ console.log(examVoice(results, run.runId, run.runDir, sandboxed ? `ON (masked ${String(run.examMaskDirs.length)} dir(s))` : run.examMaskDirs.length === 0 ? "OFF (lane.examMaskDirs empty \u2014 grader files are candidate-readable)" : "OFF (bwrap missing at /usr/bin/bwrap)"));
20568
20593
  return 0;
20569
20594
  }
20570
20595
  return 2;
package/dist/index.js CHANGED
@@ -18919,7 +18919,10 @@ var pluginOptionsSchema = external_exports.object({
18919
18919
  runRoot: external_exports.string().min(1).default("/tmp"),
18920
18920
  opencodeBin: external_exports.string().min(1).prefault(discoverOpencodeBin),
18921
18921
  configSource: external_exports.string().prefault(() => join(homedir(), ".config", "opencode", "opencode.jsonc")),
18922
- roleTimeoutMs: external_exports.number().int().min(5e3).default(6e5)
18922
+ roleTimeoutMs: external_exports.number().int().min(5e3).default(6e5),
18923
+ /** Exam venue: absolute dirs tmpfs-masked from the candidate (grader files,
18924
+ * other runs). Empty = venue runs unsandboxed and SAYS SO on the voice face. */
18925
+ examMaskDirs: external_exports.array(external_exports.string().min(2)).default([])
18923
18926
  }).prefault({}),
18924
18927
  /** v1.1 E4/F1: seats only from allowed model-id prefixes; deny at launch. */
18925
18928
  modelPolicy: external_exports.object({
@@ -31968,7 +31971,53 @@ function contentMatches(input2, needle) {
31968
31971
  }
31969
31972
  return false;
31970
31973
  }
31971
- function scanMessages(rows, convenerIndex, sid, artifact) {
31974
+ var SHELL_TOOLS = ["bash"];
31975
+ var SHELL_KEYS = ["command"];
31976
+ var LINEAGE_CHUNK = 256;
31977
+ var LINEAGE_SAMPLES = 32;
31978
+ var LINEAGE_UNION_RATIO = 0.6;
31979
+ var LINEAGE_MIN_WINDOWS = 3;
31980
+ function sampleWindows(text) {
31981
+ const total = Math.floor(text.length / LINEAGE_CHUNK);
31982
+ if (total < LINEAGE_MIN_WINDOWS) return [];
31983
+ const step = Math.max(1, Math.floor(total / LINEAGE_SAMPLES));
31984
+ const out = [];
31985
+ for (let i = 0; i < total; i += step) out.push(text.slice(i * LINEAGE_CHUNK, (i + 1) * LINEAGE_CHUNK));
31986
+ return out.slice(0, LINEAGE_SAMPLES);
31987
+ }
31988
+ function beginLineage(artifact) {
31989
+ if (artifact.kind !== "path" || artifact.mtimeMs === void 0) return null;
31990
+ const chunks = sampleWindows(artifact.text);
31991
+ if (chunks.length < LINEAGE_MIN_WINDOWS) return null;
31992
+ return {
31993
+ chunks,
31994
+ need: Math.max(2, Math.ceil(chunks.length * LINEAGE_UNION_RATIO)),
31995
+ mtimeMs: artifact.mtimeMs,
31996
+ matched: /* @__PURE__ */ new Map(),
31997
+ copiedAfterMtime: 0,
31998
+ timeless: 0
31999
+ };
32000
+ }
32001
+ function messageTime(m) {
32002
+ const t = m.info?.time?.created;
32003
+ return typeof t === "number" ? t : void 0;
32004
+ }
32005
+ function partCandidates(p) {
32006
+ const shell = p.tool !== void 0 && SHELL_TOOLS.includes(p.tool);
32007
+ if (!shell && !isDraftingTool(p.tool)) return [];
32008
+ const keys = shell ? SHELL_KEYS : CONTENT_KEYS;
32009
+ const out = [];
32010
+ for (const key of keys) {
32011
+ const v = p.input?.[key];
32012
+ if (typeof v === "string" && v.length >= LINEAGE_CHUNK) out.push(v);
32013
+ }
32014
+ return out;
32015
+ }
32016
+ function lineageVerdict(lg, artifact) {
32017
+ const sources = [.../* @__PURE__ */ new Set([...lg.matched.values()])].slice(0, 3).join("; ");
32018
+ return `content-lineage evidence: ${String(lg.matched.size)} of ${String(lg.chunks.length)} verbatim ${String(LINEAGE_CHUNK)}-byte windows of ${artifact.source} appear in convener-chain tool inputs that predate its mtime (${sources}) \u2014 the chain authored this artifact's bytes before/independently of this path`;
32019
+ }
32020
+ function scanMessages(rows, convenerIndex, sid, artifact, lg) {
31972
32021
  for (const [mi, m] of rows.entries()) {
31973
32022
  for (const p of m.parts ?? []) {
31974
32023
  if (p.type === "tool" && isDraftingTool(p.tool)) {
@@ -31982,6 +32031,26 @@ function scanMessages(rows, convenerIndex, sid, artifact) {
31982
32031
  if (artifact.kind === "inline" && m.info?.role === "assistant" && p.type === "text" && typeof p.text === "string" && artifact.text.trim().length >= MIN_INLINE_MATCH && p.text.includes(artifact.text.trim())) {
31983
32032
  return `drafting evidence: assistant text in convener-chain session ${sid} (chain position ${String(convenerIndex)}, message ${String(mi)}) contains the inline artifact`;
31984
32033
  }
32034
+ if (p.type === "tool" && lg !== null) {
32035
+ const t = messageTime(m);
32036
+ if (t === void 0) {
32037
+ lg.timeless += 1;
32038
+ continue;
32039
+ }
32040
+ if (t > lg.mtimeMs) {
32041
+ lg.copiedAfterMtime += 1;
32042
+ continue;
32043
+ }
32044
+ const label = `${p.tool ?? "tool"}@${sid}:${String(mi)}`;
32045
+ for (const cand of partCandidates(p)) {
32046
+ if (artifact.kind === "path" && cand.includes(artifact.text.trim())) {
32047
+ return `${lineageVerdict({ ...lg, matched: /* @__PURE__ */ new Map([[0, label]]) }, artifact)}; full-text containment in ${label}`;
32048
+ }
32049
+ for (const [wi, chunk] of lg.chunks.entries()) {
32050
+ if (!lg.matched.has(wi) && cand.includes(chunk)) lg.matched.set(wi, label);
32051
+ }
32052
+ }
32053
+ }
31985
32054
  }
31986
32055
  }
31987
32056
  return null;
@@ -32022,6 +32091,7 @@ async function assessIndependence(client, directory, convenerSessionID, artifact
32022
32091
  seen.add(parent);
32023
32092
  current = parent;
32024
32093
  }
32094
+ const lg = beginLineage(artifact);
32025
32095
  let scanFailed = "";
32026
32096
  for (const [i, sid] of chain.entries()) {
32027
32097
  const res = await client.session.messages({ path: { id: sid }, query: { directory } });
@@ -32029,22 +32099,33 @@ async function assessIndependence(client, directory, convenerSessionID, artifact
32029
32099
  scanFailed = scanFailed.length > 0 ? scanFailed : `messages(${sid}) refused`;
32030
32100
  continue;
32031
32101
  }
32032
- const hit = scanMessages(res.data, i, sid, artifact);
32102
+ const hit = scanMessages(res.data, i, sid, artifact, lg);
32033
32103
  if (hit !== null) {
32034
32104
  return { status: "NOT-INDEPENDENT", convenerChain: chain, evidence: hit };
32035
32105
  }
32036
32106
  }
32107
+ if (lg !== null && lg.matched.size >= lg.need) {
32108
+ return { status: "NOT-INDEPENDENT", convenerChain: chain, evidence: lineageVerdict(lg, artifact) };
32109
+ }
32037
32110
  if (scanFailed.length > 0 || walkIncomplete) {
32038
32111
  const why = [walkIncomplete ? walkReason : "", scanFailed].filter((s) => s.length > 0).join("; ");
32039
32112
  return { status: "UNVERIFIABLE", convenerChain: chain, evidence: `partial read: ${why} \u2014 independence was NOT established` };
32040
32113
  }
32041
32114
  const note = walkReason.length > 0 ? `; walk note: ${walkReason}` : "";
32115
+ const lineageNote = lineageFaceNote(lg);
32042
32116
  return {
32043
32117
  status: "INDEPENDENT",
32044
32118
  convenerChain: chain,
32045
- evidence: `scanned ${String(chain.length)} session(s) of the convener chain to root; no drafting evidence within read scope${note}`
32119
+ evidence: `scanned ${String(chain.length)} session(s) of the convener chain to root; no drafting evidence within read scope${lineageNote}${note}`
32046
32120
  };
32047
32121
  }
32122
+ function lineageFaceNote(lg) {
32123
+ if (lg === null) return "";
32124
+ const parts = [`${String(lg.matched.size)}/${String(lg.chunks.length)} lineage windows matched (need ${String(lg.need)})`];
32125
+ if (lg.copiedAfterMtime > 0) parts.push(`${String(lg.copiedAfterMtime)} part(s) excluded as post-mtime copies`);
32126
+ if (lg.timeless > 0) parts.push(`${String(lg.timeless)} part(s) lacked timestamps`);
32127
+ return `; content-lineage: ${parts.join(", ")}`;
32128
+ }
32048
32129
  function independenceLabel(v) {
32049
32130
  return v.status === "INDEPENDENT" ? "independence=INDEPENDENT" : `independence=${v.status} \u2014 ${v.evidence}`;
32050
32131
  }
@@ -32523,7 +32604,7 @@ async function readArtifact(raw, cwd) {
32523
32604
  } catch (err) {
32524
32605
  return { ok: false, error: `artifact "${path}": ${errMessage(err)}` };
32525
32606
  }
32526
- return { ok: true, kind: "path", source: path, text };
32607
+ return { ok: true, kind: "path", source: path, text, mtimeMs: info.mtimeMs };
32527
32608
  }
32528
32609
  function errMessage(err) {
32529
32610
  if (err instanceof Error) return err.message;
@@ -33855,7 +33936,11 @@ function toEngineClient(sdk) {
33855
33936
  ...m.info.providerID !== void 0 && { providerID: m.info.providerID },
33856
33937
  ...m.info.modelID !== void 0 && { modelID: m.info.modelID },
33857
33938
  ...m.info.time !== void 0 && {
33858
- time: { ...m.info.time.completed !== void 0 && { completed: m.info.time.completed } }
33939
+ time: {
33940
+ ...m.info.time.completed !== void 0 && { completed: m.info.time.completed },
33941
+ // W1-v2 lineage time gate needs creation stamps too
33942
+ ..."created" in m.info.time && m.info.time.created !== void 0 && { created: m.info.time.created }
33943
+ }
33859
33944
  },
33860
33945
  ...m.info.error !== void 0 && { error: m.info.error }
33861
33946
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sibyl-system",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Sibyl-System: democratic-centralism review chamber (one conclusion, one voice) + fail-closed council voting + dynamic swarm plugin for OpenCode",