Compare commits

...
23 changed files with 1149 additions and 22 deletions

No files matched your search

+6
View File
@@ -5411,3 +5411,9 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 takeover, before implementation | Clean base 45dca69f6 on beakley/row217. Recovering Launchpad handoff for citation guard, live settings, page store and runner retry. Acceptance is C2-06/design 5.2 plus sage-3595/3599/3602. Tracking agents/beakley/work/queue-217/BUILD.md; no product changes or Node/tests; Sage CLEAR required.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 untested draft checkpoint | Continued Launchpad e25ef0373; added runner retry/text/note implementation, pure protocol constants and sage-3640 approved single read-only mount, CLI documentation and new acceptance test files. Static checks caught missing projects compatibility, async request mutation and Git pathspec matching; bounded fixes and regression tests authored. git diff --check clean. No Node/test/mutant executed; Sage holds CLEAR. 283/216/285 dependency checks and all acceptance execution remain open. Packet agents/beakley/work/queue-217/MANIFEST.md.
2026-10-23 | Beakley (88b97f65) | Row 217 early-look follow-up, Untested | Webby 0939c0682 E1 adds ordinary mallory human-swap test; E2 adds stored trailer-derived agent/file event evidence; E6 creative-sender matrix and E7 runtime readRaw tests authored. Sage-3668/3670 accepts direct writer substitution because setter keys exclude replyLimits (organization.set exists, correcting review premise), and requires distinct availability refusal for E4. Real held-lock/socket and runner-reason regressions plus M5 authored before that production fix; expected RED awaits Sage CLEAR. No Node/tests/mutants, no old-test edits, no green claim.
+157
View File
@@ -0,0 +1,157 @@
# Row 217 — C2-06 citation guard, page store and runner retry
Owner: Beakley (88b97f65-97db-4f05-8c6e-081f92bc3501), assigned by Mos Q245.
Issue: #1718. Reviewer: Webby. Branch: `beakley/row217`.
Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-06.
Status: Untested draft on Launchpad handoff `e25ef0373`; targeted execution awaits Sage CLEAR.
## Plan and boundaries
Continue Launchpad's implementation from its handed-off ref. Complete the guard,
live reply limits and M11 writer fence, broker page store and check evidence,
CLI setter and runner retry/note behavior against the brief and design 5.2.
Supply acceptance tests and mutants, then request Sage's test slot and Webby's
review. Sage owns queue movement, gate and landing. Push only `beakley/*`, add
commits without amend/force or attribution lines. No credential reads.
## Standing rulings received from Sage (sage-3610)
- sage-3595: stub supporting scope until row 283 lands, then rebase onto it and
add real-fixture tests. Landing follows 283. `runtime.mjs` wiring approved.
- Check evidence belongs in an `action.allowed` event in the same transaction
as message insertion; no new event kind. Read the library at the cited commit.
- In library businesses only, JSON over 32,768 characters gets the
`request-too-large` retry. `role.claim` adds `library` only for a library business.
- Store `check.citations` entries with `scope`, `agent`, and `file` (the file's
last commit at the cited commit). On `thread.messages`, add `last` at the served
tip, one git call per distinct path per read. Scope/agent are broker evidence.
- sage-3599: optional `BusError.reason`, guard refusals only, capped at 300
characters. Add bus server/client to manifest. Reasonless envelopes stay
byte-identical.
- `render.mjs` obsolete notice hits belong to row 225.
- Node and tests require Sage CLEAR (sage-3610 supersedes the initial permission
for targeted new tests). Full suites, webui builds and gate require shared slot.
- Existing test edits require Mos GO through Sage; list every file and numstat
below before seeking that approval.
## Recovery and coordination
Initial base `45dca69f6`, clean. `scripts/mosaic queue next beakley` refused:
linked worktrees cannot run the queue. Reported to Sage/Mos in beakley-0001;
Sage will change ownership at a queue gap. No queue files changed.
Read AGENTS.md, agents/README.md, C2-06 brief, design 5.2 and the row 225 BUILD
contract. Requested Launchpad handoff in beakley-0002. Its header timestamp was
mistakenly typed ahead of the actual clock; sequence key identifies the message.
## Existing test changes
None yet. No tests or Node commands run.
## Handoff adopted and independent work (2026-10-23)
Fast-forwarded `beakley/row217` to Launchpad `e25ef0373` on `45dca69f6`.
The full original handoff/rulings remain at
`agents/launchpad/work/queue-217/BUILD.md`. No handoff code was rebuilt from scratch.
Implemented the runner path: role.claim library switch; exact fixed texts; note
inside JSON text with citations retained and no JSON clip; fixed texts defer
notes; one same-session retry for guard codes and both existing size caps;
retry fallback chosen from the second refusal; separate usage turn id for retry.
Outside library businesses the existing send path remains intact.
Added the CLI README usage paragraph. New acceptance tests cover library parsing,
real Git reachability/quotes/scope/origin, settings fallback, M11 direct writer
and serialization, CLI dispatch, broker insert/check atomicity, live settings,
page bounds/lifecycle, socket reasons, per-path last reads, request snapshots,
and runner RESULT/typed send_message, fixed texts, long JSON and unslop behavior.
These are authored evidence only: none has executed.
Static defects corrected locally: a missing projects object now means no library;
the guard sends its captured request arguments after the asynchronous check;
origin/last Git logs use literal pathspecs; a web quote can match any retained
fetch of the cited URL. Each has new-file regression coverage.
## Scope additions and cross-row contract
- `packages/library/src/reply-protocol.mjs`: shared GUARD_CODES/FIXED_TEXTS,
import-free, so runner imports do not reach host Git modules. guard.mjs
reexports them to preserve Launchpad's API. Fethry confirmed no renderer import
is needed when the renderer simply displays fixed texts as ordinary text.
- `packages/seat/src/sandbox.mjs`: one RUNNER_FILES entry for that pure file,
approved by sage-3640. No other mount change. The existing sandbox.test.mjs
import-closure test must pass unchanged; seat joins the gate list.
- Runtime hunks remain exactly Launchpad's import and library guard/limits/last
adapters, plus library.limits.set dispatch. Drift checks wait on 216 and 285.
- Status contract confirmed with Fethry (beakley-0004, fethry-0009): nested
`replyLimits: {requireCitation, citationsPerReply, fallback: [keys],
problem: "reply-limits-unreadable" | null}`. Check entries remain ordered
`scope/agent/file`, plus `last` on reads; no changed boolean.
## Verification and remaining acceptance
- Static `git diff --check`: clean so far. No Node, suite, test or mutant run.
- Existing test file edits: none (numstat 0/0 for every pre-existing test).
All new test files are listed in manifest.r1.sha256; no Mos approval requested.
- `mutants.py` is authored for isolated HEAD archives with green controls:
M1 remove both writer fences; M2 compare incoming human; M3 split evidence
from insert; M4 drop client reason. Not executed.
- Still required: Sage CLEAR for targeted runs; fix first-run failures; supporting
scope acceptance against row 283; 216 index/readLibrary fixture comparison;
285/runtime drift check; full library/business/bus/cli/harness/seat suites and
every scripts/test-*.sh on Sage's gate; mutant execution; Webby approval.
- Before in-review, perform brief's obsolete-code/key search and report outside
ownership hits to Sage. render.mjs/render.test.mjs hits belong to 225.
- No queue changes, protected branch pushes, gate claims or acceptance claims.
Static obsolete-code/key search completed: only packages/library/src/render.mjs:6 and packages/library/tests/render.test.mjs:58 match no-cited-answer; both belong to row 225 per standing ruling. No owned-path hit. Repeat before in-review after dependency changes.
## Webby early look 0939c0682 / webby-0249 and sage-3668/3670
This remains an untested draft. The follow-up adds tests only; production E4
changes wait for the held-lock regression's first RED under Sage CLEAR.
- E1: added an ordinary actor `mallory` that sets both `human` to itself and
`replyLimits`. The existing role/tester actors did not distinguish M2 because
the actor-shape exclusions rejected them independently of the human comparison.
M2 is still unrun; no kill claimed.
- E2: added a real bus event assertion for `agent: "acme/scribe"` from a file's
Mosaic-Agent trailer. The cited tip includes a later unrelated commit, so the
event must store the file commit rather than the library tip. thread.messages
must show `last === file` for that unchanged path. Supporting scope still waits
for row 283's real fixture, as ruled.
- E3: sage-3668 accepts direct writer.write tests under chief and tester actors
as substitutes for the brief's setter cases (brief lines 483-491 at base
45dca69f6). Correction to the early-look premise: organization.set DOES exist,
broker.mjs:4701/4709, but ORGANIZATION_KEYS excludes replyLimits; instances.set
also cannot carry it. Direct writes exercise the shared M11 boundary those
setters cannot reach with that input. Sage confirmed the accurate reason in
sage-3670; the unrelated actor case remains required.
- E4: traced readSyncState -> withLibraryLock -> library-sync-busy after five
seconds -> servedMirror catch-all -> null served -> misleading citation-invalid.
New real-flock/socket test first proves the cite valid, holds the lock, asserts
the exact citation-unavailable reason the agent receives, verifies no insert,
and checks fixed fallback delivery and recovery after unlock. Additional tests
distinguish malformed sync state from absent/new mirrors, and assert that the
actual runner retry request contains that reason. These tests are expected RED
until the fix. Planned production change: only an absent mirror or legitimate
empty state yields null; other failures become GuardError citation-unavailable;
bodies without path cites do not require the mirror lock. Add the code to
GUARD_CODES so the runner sends the agent that reason on its one retry. Per
sage-3670, existing four fallback bodies can remain only if that delivery is
verified. M5 restores catch-all behavior; authored, not run.
- E5: first-library-project selection is a Launchpad handoff implementation
choice, not a validated rule that a business can only have one library.
Cites into another library remain a limitation. No mapping/schema rule added.
- E6: the broker's full guard table now runs as both coder and research.
- E7: new citation-runtime.test.mjs starts the real runtime, reads limits from
the real organization file, then changes, corrupts and removes that fixture
file and checks library.status without replacing the library adapter.
No Node, tests or mutants ran. No test that predated row 217 changed.
## Webby follow-up 192049c98 / webby-0258
E1, E2, E6 and E7 closed statically; no executed acceptance claimed. E3 correction and E5 limitation accepted. E4 tests accepted statically; real RED then production fix/green remain on hold for Sage CLEAR, with supporting scope waiting on 283. F1: citation-helpers status now uses its mutable served value, matching guard/last after setServed instead of reporting the original fixture commit. Test-only fixture correction, unrun. manifest.r3.sha256 records the updated helper; no production or old-test changes.
+68
View File
@@ -0,0 +1,68 @@
# Row 217 draft manifest — Untested
Issue #1718, Case 2 C2-06. Base `45dca69f6`, continued from Launchpad
`e25ef0373`. This is a checkpoint for static review, not an in-review or
acceptance claim. `manifest.r1.sha256` freezes the product and test files.
## Product scope
| Path | Change |
| --- | --- |
| packages/library/src/guard.mjs | Schema and seven checks, real cited-commit reads, origins and latest path commits |
| packages/library/src/limits.mjs | Pure defaults, validation and conservative unreadable-file fallback |
| packages/library/src/reply-protocol.mjs | Import-free guard codes and four runner fixed texts |
| packages/library/src/index.mjs | Guard and limits exports |
| packages/business/src/business.mjs | Permit any replyLimits value without widening another schema |
| packages/business/src/organization.mjs | M11 fence on input and serialized output, human setter, raw live reader |
| packages/bus/src/broker.mjs | Async pre-insert guard, atomic existing-kind check event, per-session pages, settings verb/status, optional library claim flag and read-time last evidence |
| packages/bus/src/runtime.mjs | Inject guard/live limits/last and dispatch human setter (sage-3595) |
| packages/bus/src/server.mjs | Await guarded sends and thread reads; optional guard reason (sage-3599) |
| packages/bus/src/client.mjs | Preserve optional capped reason (sage-3599) |
| packages/harness/src/runner.mjs | Library texts, one retry, JSON note/clip behavior and retry usage tracking |
| packages/seat/src/sandbox.mjs | Exactly one read-only RUNNER_FILES addition for the pure protocol (sage-3640) |
| packages/cli/src/limits.mjs | Human limits replies file command |
| packages/cli/README.md | Reply settings usage |
No dependency, SQL schema, event kind, role authority or credential changes.
The additional sandbox file carries only constants and has no imports.
## New tests and fixtures
| Path | Acceptance focus |
| --- | --- |
| packages/library/tests/guard.test.mjs | Seven table checks, cap boundaries, real Git origins/reachability/literal names, web quotes and limits fallback |
| packages/business/tests/reply-limits.test.mjs | Human-only M11, old-human comparison, dotted agent identity, serialization, invalid values and raw reads |
| packages/bus/tests/citation-helpers.mjs | Shared made-up repository and real broker fixture |
| packages/bus/tests/citation-guard.test.mjs | Refusal insertion counts, transaction failure, live settings/status, page bounds, socket envelopes, file/last and async snapshot |
| packages/cli/tests/reply-limits.test.mjs | Revision read, exact setter args and usage refusals |
| packages/harness/tests/fixtures/citation-adapter.mjs | Scripted local harness, typed calls and unslop events |
| packages/harness/tests/citation-runner.test.mjs | Every RESULT refusal and typed send_message, one retry, both size caps, plain multibyte answers, fixed texts and notes, old non-library behavior |
No existing test changed. Existing role.claim pins and the seat import-closure
test stay untouched. Existing test numstat: none.
## Review focus and outstanding evidence
The check event and message insert must roll back together. Scope/agent/file
come from Git; `last` is read once per distinct path per read. Guard refusals
alone have a reason, bounded at 300 characters without quote or URL content.
Captured arguments must be the ones inserted after async validation. Every
plain or JSON length refusal shares the same single retry budget.
All tests and four isolated mutants are unrun pending Sage CLEAR. The supporting
scope tests require row 283; the index-set comparison requires row 216; runtime
drift must be checked against 216/285 before in-review. Landing follows 283.
Full gate: library, business, bus, cli, harness and seat, plus every
scripts/test-*.sh. Webby reviews the final tested manifest; Sage owns the gate,
queue and landing. No push to a protected branch is authorized.
## Early-look follow-up packet (manifest.r2.sha256)
Tests-only follow-up to Webby 0939c0682. Adds ordinary-actor M2 discrimination,
stored agent/file evidence at an unrelated tip, creative-sender table coverage,
and real runtime readRaw coverage. New citation-availability.test.mjs and runner
availability case are intentionally unrun regressions for E4/sage-3668/3670.
Production availability handling is not implemented yet: run that targeted RED
on CLEAR before applying its fix, then rerun the same cases. M5 restores the
catch-all. This packet is not ready for a green gate or manifest approval.
@@ -0,0 +1,21 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
29114051a9bc5271e48978a9552d64b51b0ac4810f94a1f98940bf617b148df8 packages/bus/tests/citation-guard.test.mjs
dc2d5836f10f591fc174aaf9587a3bff793b24ac29efe9ab68c6a9243d1d1c2c packages/bus/tests/citation-helpers.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
bec04a894bc2af000548283ed7719e4de1c6d56688f42238a113390e9b2d3fbe packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
ec7ef0cbf493217eb9229895cae2fe7ef603f41913840e59a73220df387c81ca packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -0,0 +1,23 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
62d8a55945d4b700e22c8c49b6420a5e25719d266901a2ad20c72514735e65e9 packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -0,0 +1,23 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
8a2d4b636f8d4f926c484254c33ecf7b7e4722acef3250d2730593984d5ac06b packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Run only on Sage CLEAR. Mutate isolated HEAD archives; never edit the checkout.
A green control run is required before each negative result counts as a kill.
Usage: python3 agents/beakley/work/queue-217/mutants.py OUTPUT_DIRECTORY
"""
from pathlib import Path
import io
import os
import subprocess
import sys
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[4]
OUT = Path(sys.argv[1]).resolve()
OUT.mkdir(parents=True, exist_ok=True)
WRITER = "packages/business/src/organization.mjs"
BROKER = "packages/bus/src/broker.mjs"
CLIENT = "packages/bus/src/client.mjs"
ATOMIC = "if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));"
REENTER = "return this.#reenter(cap, s, { verb: 'message.send', args }, check);"
SPLIT = """const result = this.#reenter(cap, s, { verb: 'message.send', args }, check);
if (check) this.#store.transaction(() => this.#event(s, 'action.allowed', { action: 'message.checked', message: result.id, check }, result.id, checkEventId(result.id)));
return result;"""
MUTANTS = [
("M1-writer-fence", [(WRITER, " replyLimitsFence(old, document, actor);", ""),
(WRITER, " replyLimitsFence(old, written, actor);", "")],
["packages/business/tests/reply-limits.test.mjs"]),
("M2-incoming-human", [(WRITER, "actor === old.human", "actor === document.human")],
["packages/business/tests/reply-limits.test.mjs"]),
("M3-split-check-transaction", [(BROKER, ATOMIC, ""), (BROKER, REENTER, SPLIT)],
["packages/bus/tests/citation-guard.test.mjs"]),
("M5-hide-library-unavailable", [("packages/library/src/guard.mjs",
"refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.');",
"served = null;")], ["packages/bus/tests/citation-availability.test.mjs"]),
("M4-drop-client-reason", [(CLIENT, "new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)", "new BusError(r.error)")],
["packages/bus/tests/citation-guard.test.mjs"]),
]
archive = subprocess.check_output(["git", "archive", "HEAD", "packages", "roles", "scripts", "tools", "docs", "package.json", "RELEASE"], cwd=ROOT)
summary = []
for name, changes, tests in MUTANTS:
with tempfile.TemporaryDirectory(prefix="row217-mutant-") as directory:
work = Path(directory)
with tarfile.open(fileobj=io.BytesIO(archive)) as source:
source.extractall(work, filter="data")
modules = ROOT / "node_modules"
if modules.exists():
os.symlink(modules.resolve(), work / "node_modules", target_is_directory=True)
cmd = ["node", "--test", *tests]
with (OUT / f"{name}-control.log").open("w") as log:
control = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if control.returncode:
raise SystemExit(f"{name}: control RED, no mutant result claimed")
for file, before, after in changes:
path = work / file
text = path.read_text()
if text.count(before) != 1:
raise SystemExit(f"{name}: expected exactly one mutation anchor in {file}")
path.write_text(text.replace(before, after))
with (OUT / f"{name}.log").open("w") as log:
mutant = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if mutant.returncode == 0:
raise SystemExit(f"{name}: SURVIVED")
summary.append(f"{name}: killed (control green, mutant exit {mutant.returncode})")
(OUT / "summary.txt").write_text("\n".join(summary) + "\n")
print(summary[-1], flush=True)
+2
View File
@@ -1593,3 +1593,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
2026-10-23 | Beakley (88b97f65, Codex gpt-6.1-sol) | Row 217 #1718 takeover under Q245 | Registered beakley/row217; read brief/design and standing Sage rulings; awaiting Launchpad ref before implementation. Queue next refused linked worktree; reported to Sage. Untested, no Node/test run pending CLEAR.
+2 -2
View File
@@ -5196,7 +5196,7 @@ export class Broker {
}
// C2-06: the business's library project, the first in file order, or null.
#libraryProject(business) {
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
return Object.entries(this.#business(business).projects ?? {}).find(([, p]) => p.kind === 'library')?.[0] ?? null;
}
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
// is guarded, whatever the sender's definition.
@@ -5230,7 +5230,7 @@ export class Broker {
const Guard = this.#library?.GuardError;
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
}
return this.#reenter(cap, s, request, check);
return this.#reenter(cap, s, { verb: 'message.send', args }, check);
}
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
@@ -0,0 +1,56 @@
// E4/sage-3668: real flock contention must not turn a valid cite into an invalid-cite refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureMirror, mirrorPath, SYNC_STATE_FILE, withLibraryLock } from '../../library/src/mirror.mjs';
import { guardReply } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { citationWorld, citedBody, fixtureCite } from './citation-helpers.mjs';
test('C2-06 held library lock gives the agent citation-unavailable and its retry reason', { timeout: 25000 }, async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
writeFileSync(join(mirror, SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
lastFetch: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
f.adapter.guard = (ctx, body, opts) => guardReply({ ...ctx, dataRoot }, body, opts);
f.call(f.agent, 'role.claim');
const socket = join(f.root, 'availability.sock'), server = await serve({ broker: f.broker, path: socket });
t.after(() => server.close()); const client = new Client({ path: socket, cap: f.agent, timeout: 15000 });
const args = { to: 'human', body: citedBody([fixtureCite(f.commit)]) };
await client.call('message.send', args); // Establish that the cite and real mirror are valid.
const before = f.store.get('SELECT count(*) n FROM messages').n;
await withLibraryLock(mirror, async () => {
await assert.rejects(client.call('message.send', args), e => {
assert.equal(e.code, 'citation-unavailable');
assert.equal(e.reason, 'The library is busy or unavailable. Retry the answer when the library is available.');
return true;
});
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
// A fixed fallback carries no library cite and must not need the contested lock.
await client.call('message.send', { to: 'human', body: '(no answer passed the citation check)' });
});
await client.call('message.send', args); // No lock mutation or lost valid cite after release.
});
test('C2-06 malformed sync state is availability failure, never a fabricated invalid cite', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
writeFileSync(join(mirror, SYNC_STATE_FILE), '{broken', { mode: 0o600 });
await assert.rejects(guardReply(context, citedBody([fixtureCite(f.commit)]), {
limits: f.limits(), definition: 'coder', pages: [],
}), { code: 'citation-unavailable' });
});
test('C2-06 no mirror or freshly initialized empty mirror still means no served citation', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
const check = () => guardReply(context, citedBody([fixtureCite(f.commit)]), { limits: f.limits(), definition: 'coder', pages: [] });
await assert.rejects(check(), { code: 'citation-invalid' });
await ensureMirror(context); await assert.rejects(check(), { code: 'citation-invalid' });
});
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
for (const definition of ['coder', 'research']) test(`C2-06 ${definition} send_message guard covers every table refusal; inserts nothing`, async t => {
const f = citationWorld(t, { definition }); f.call(f.agent, 'role.claim');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
for (const [code, body] of guardCases(f.commit)) {
const count = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body }), e => e.code === code && e.reason.length <= 300);
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count, code);
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
}
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const row = f.store.get("SELECT body,subject FROM events WHERE json_extract(body,'$.action')='message.checked'");
assert.equal(row.subject, sent.id);
assert.deepEqual(JSON.parse(row.body).check, { citations: [{ scope: 'canon', agent: null, file: f.commit }] });
});
test('C2-06 stored check carries the file author trailer at an unrelated cited tip', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(f.repo, path), readFileSync(join(f.repo, path), 'utf8') + 'A fixture note.\n');
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Fixture note\n\nMosaic-Agent: acme/scribe');
const file = git(f.repo, 'rev-parse', 'HEAD');
writeFileSync(join(f.repo, 'Inbox Note.md'), 'Unrelated fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'unrelated');
const cited = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(cited);
assert.notEqual(cited, file);
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(cited)]) });
const event = f.store.get("SELECT body FROM events WHERE subject=? AND json_extract(body,'$.action')='message.checked'", sent.id);
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'canon', agent: 'acme/scribe', file }] });
const [row] = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(row.check.citations, [{ scope: 'canon', agent: 'acme/scribe', file, last: file }]);
});
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
WHEN json_extract(NEW.body,'$.action')='message.checked' BEGIN SELECT RAISE(ABORT,'fixture check failure'); END`);
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) }));
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, 0);
});
test('C2-06 live limits change both directions; unreadable file keeps checks and exact fixed texts', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
const send = body => f.call(f.agent, 'message.send', { to: 'human', body });
await send('free');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
await assert.rejects(send('free'), { code: 'citation-missing' });
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: false } })); await send('free');
for (const invalid of ['{broken', 'null', '[]']) {
writeFileSync(f.settings, invalid);
const before = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(send('free'), { code: 'citation-missing' });
await assert.rejects(send(citedBody(Array(21).fill(fixtureCite(f.commit)))), { code: 'citations-over-limit' });
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
for (const text of Object.values(FIXED_TEXTS)) await send(text);
assert.equal((await f.call(f.human, 'library.status', { project: 'books' })).replyLimits.problem, 'reply-limits-unreadable');
}
rmSync(f.settings);
await assert.rejects(send('free'), { code: 'citation-missing' });
});
test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at disconnect', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const fetch = async (n, text = `page ${n}`) => f.broker.requestWeb(f.agent, { verb: 'web.fetch', args: { url: `https://fixture.invalid/${n}` } },
{ handle: async () => ({ result: { text }, summary: { outcome: 'ok' } }) });
const send = (n, quote) => f.call(f.agent, 'message.send', { to: 'human', body: citedBody([{ url: `https://fixture.invalid/${n}`, quote }]) });
await fetch(0); await send(0, 'page 0');
for (let n = 1; n <= 20; n++) await fetch(n);
await assert.rejects(send(0, 'page 0'), { code: 'citation-unfetched' }); await send(1, 'page 1');
await fetch(21, 'x'.repeat(200 * 1024) + 'AFTER_CAP');
await send(21, 'xxxx'); await assert.rejects(send(21, 'AFTER_CAP'), { code: 'citation-unfetched' });
f.broker.disconnect(f.agent);
f.broker.endLaunch({ business: 'acme', run: 'coder-run', reason: 'fixture', exitCode: 0 });
const replacement = f.broker.bindLaunch({ ...f.launch, run: 'next-run' });
f.call(replacement, 'role.claim');
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const client = new Client({ path, cap: f.agent });
await assert.rejects(client.call('message.send', { to: 'human', body: 'fixture' }), e => e.code === 'citation-invalid' && e.reason === ('guard reason '.repeat(50)).slice(0, 300));
const raw = await new Promise((resolve, reject) => {
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
const f = citationWorld(t, { library: false });
assert.deepEqual(f.call(f.agent, 'role.claim'), { role: 'coder', run: 'coder-run' });
assert.ok(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }).id);
});
test('C2-06 thread messages carry file/last evidence with one read per distinct path', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit), fixtureCite(f.commit)]) });
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const rows = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.equal(rows.length, 2); assert.deepEqual(f.reads, [path]);
for (const row of rows) for (const c of row.check.citations) {
assert.equal(c.file, f.commit); assert.equal(c.last, f.commit); assert.equal(c.agent, null);
}
writeFileSync(join(f.repo, path), 'A changed fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'changed fixture');
const changed = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(changed); f.reads.length = 0;
const newer = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(f.reads, [path]); assert.equal(newer[0].check.citations[0].file, f.commit); assert.equal(newer[0].check.citations[0].last, changed);
});
test('C2-06 guarded request snapshot cannot change between check and insert', async t => {
let finish;
const f = citationWorld(t, { guard: () => new Promise(r => { finish = r; }) }); f.call(f.agent, 'role.claim');
const request = { verb: 'message.send', args: { to: 'human', body: 'checked fixture' } };
const sent = f.broker.request(f.agent, request); request.args.body = 'unchecked replacement';
finish(null); await sent;
assert.equal(f.store.get("SELECT body FROM messages WHERE from_role='coder'").body, 'checked fixture');
});
test('C2-06 library.limits.set is human-only and invalid settings leave file and receipts unchanged', async t => {
let writer;
const f = citationWorld(t, { organization: (id, verb, args, actor) => {
assert.equal(verb, 'library.limits.set'); return setReplyLimits(writer, id, args, actor);
} });
const dir = join(f.root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), businessDoc(f.root));
writer = organizationWriter({ dir, rolesDir: rolesCopy(f.root) });
f.adapter.replyLimits = () => resolveReplyLimits(writer.readRaw('acme'));
f.call(f.agent, 'role.claim');
await f.call(f.human, 'library.limits.set', { revision: writer.read('acme').revision, replyLimits: { requireCitation: true } });
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }), { code: 'citation-missing' });
const before = readFileSync(file), log = readFileSync(writer.logFile('acme'));
const args = { revision: writer.read('acme').revision, replyLimits: { citationsPerReply: 0 } };
for (const [cap, code] of [[f.human, 'invalid-request'], [f.agent, 'human-required'], [f.tester, 'tester-refused']]) {
assert.throws(() => f.call(cap, 'library.limits.set', args), { code });
assert.deepEqual(readFileSync(file), before); assert.deepEqual(readFileSync(writer.logFile('acme')), log);
}
assert.throws(() => f.call(f.human, 'library.limits.set', { ...args, replyLimits: { invented: true } }), { code: 'invalid-request' });
});
+65
View File
@@ -0,0 +1,65 @@
// C2-06: real broker, real Git reads and fixture-only library. Reused by runner acceptance tests.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
export const citedBody = citations => JSON.stringify({ text: 'A fixture answer.', citations });
export const fixtureCite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
export function citationWorld(t, { definition = 'coder', library = true, guard: overrideGuard, organization = null, sendAuthority = true } = {}) {
const r = repo(t), root = r.root, state = join(root, 'bus'); mkdirSync(state, { mode: 0o700 });
const store = new Store(state); t.after(() => store.close());
const map = mapping(), settings = join(root, 'business.json');
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
...(library ? { projects: { books: map } } : {}),
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
const limits = () => {
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
catch { return resolveReplyLimits({ readable: false }); }
};
let served = r.commit;
const reads = [];
const adapter = {
GuardError,
replyLimits: limits,
guard: overrideGuard ?? ((context, body, opts) => checkReply(body, {
limits: opts.limits, mapping: map, library: libraryReader(r.repo, served, map),
web: { allowed: ['companion', 'research'].includes(opts.definition), pages: opts.pages },
})),
last: async (context, paths) => {
const out = new Map(); for (const path of new Set(paths)) { reads.push(path); out.set(path, await libraryReader(r.repo, served, map).last(path)); } return out;
},
readFile: async () => ({}), status: async () => ({ project: 'books', served }),
};
const broker = new Broker({ store, businesses: { acme: business }, library: adapter, organization });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const project = THREAD_DEFINITIONS.includes(definition)
? broker.request(human, { verb: 'project.create', args: { name: 'Citation fixture' } }) : null;
const thread = project
? broker.request(human, { verb: 'thread.create', args: { project: project.id, name: 'Fixture thread', instance: 'coder' } }) : null;
const launch = { business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi', ...(thread ? { thread: thread.thread ?? thread.id } : {}) };
const agent = broker.bindLaunch(launch);
const tester = broker.bindTester({ business: 'acme' });
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
return { ...r, root, store, business, broker, agent, human, tester, settings, limits, call, adapter, reads, launch, thread, setServed: commit => { served = commit; } };
}
export function guardCases(commit) {
const good = fixtureCite(commit);
return [
['reply-invalid', JSON.stringify({ text: 'x', citations: [], invented: true })],
['citation-missing', 'Uncited fixture answer.'],
['citations-over-limit', citedBody(Array(21).fill(good))],
['citation-invalid', citedBody([{ ...good, quote: 'wrong fixture quote' }])],
['playground-cited-as-canon', citedBody([{ ...good, path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER, as: 'canon' }])],
['citation-cross-universe', citedBody([good, { ...good, path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER }])],
['citation-unfetched', citedBody([{ url: 'https://fixture.invalid/page', quote: 'no fetch' }])],
];
}
@@ -0,0 +1,29 @@
// E7: exercise startBroker's real live readRaw adapter, rather than replacing the library adapter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
const root = mkdtempSync(join(tmpdir(), 'citation-runtime-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root), rolesDir = rolesCopy(root), dir = join(root, 'config'), dataRoot = join(root, 'data');
doc.projects.books = mappingEntry(); doc.replyLimits = { requireCitation: false, citationsPerReply: 3 };
const file = writeJson(join(dir, 'businesses/acme.json'), doc);
const business = validateBusinessDocument(doc, file, { rolesDir }); mkdirSync(dataRoot, { mode: 0o700 });
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir } });
t.after(() => runtime.close());
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const status = () => runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
assert.deepEqual((await status()).replyLimits, { requireCitation: false, citationsPerReply: 3, fallback: [], problem: null });
doc.replyLimits = { requireCitation: true, citationsPerReply: 7 }; writeJson(file, doc);
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 7, fallback: [], problem: null });
writeFileSync(file, '{broken');
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 20, fallback: [], problem: 'reply-limits-unreadable' });
rmSync(file);
assert.equal((await status()).replyLimits.problem, 'reply-limits-unreadable');
});
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter, setReplyLimits } from '../src/organization.mjs';
import { validateBusinessDocument } from '../src/business.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
function fixture(t, human = 'jason') {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root); doc.human = human; doc.replyLimits = { requireCitation: false, citationsPerReply: 20 };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc), rolesDir = rolesCopy(root);
const writer = organizationWriter({ dir, rolesDir });
const next = structuredClone(doc); next.vars['tracker.pollSeconds'] = 61;
writer.write('acme', next, { revision: writer.read('acme').revision, actor: human });
return { root, file, writer, rolesDir, log: writer.logFile('acme') };
}
function refused(f, actor, change) {
const before = readFileSync(f.file), log = readFileSync(f.log), { document, revision } = f.writer.read('acme');
change(document);
assert.throws(() => f.writer.write('acme', document, { revision, actor }), { code: 'organization-reply-limits-human-only' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
test('C2-06 M11 human replacement works; omitted settings return to defaults', t => {
const f = fixture(t);
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: { requireCitation: true } }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, { requireCitation: true });
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: {} }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, {});
});
test('C2-06 M11 agents and tester cannot change settings, including by changing human', t => {
const f = fixture(t);
for (const actor of ['chief-of-staff.chief', 'tester', 'coder.coder'])
refused(f, actor, d => { d.replyLimits.requireCitation = true; });
refused(f, 'chief-of-staff.chief', d => { d.human = 'chief-of-staff.chief'; d.replyLimits.requireCitation = true; });
const dotted = fixture(t, 'coder.coder');
refused(dotted, 'coder.coder', d => { d.replyLimits.citationsPerReply = 1; });
});
test('C2-06 M11 serialization cannot replace settings after the first fence', t => {
const f = fixture(t);
refused(f, 'chief-of-staff.chief', d => {
Object.defineProperty(d.replyLimits, 'toJSON', { value: () => ({ requireCitation: true }) });
});
});
// E1: unlike tester/role actors, mallory passes the other actor-shape exclusions.
// Comparing document.human instead of old.human must fail this case.
test('C2-06 M11 an unrelated actor cannot appoint itself human and change reply limits', t => {
const f = fixture(t);
refused(f, 'mallory', d => { d.human = 'mallory'; d.replyLimits.requireCitation = true; });
});
test('C2-06 invalid setter values preserve the business and changes.jsonl', t => {
const f = fixture(t), before = readFileSync(f.file), log = readFileSync(f.log);
for (const replyLimits of [{ citationsPerReply: 0 }, { citationsPerReply: 1001 }, { requireCitation: 'true' }, { invented: true }, []]) {
assert.throws(() => setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits }, 'jason'), { code: 'invalid-request' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
});
test('C2-06 validator accepts hand-edited settings; raw reader fails closed on invalid files', t => {
const f = fixture(t), doc = f.writer.read('acme').document;
for (const replyLimits of ['true', 1, null, [true], {}, { requireCitation: 'true' }, { citationsPerReply: 0 }]) {
const next = { ...doc, replyLimits };
assert.doesNotThrow(() => validateBusinessDocument(next, f.file, { rolesDir: f.rolesDir }));
writeJson(f.file, next); assert.deepEqual(f.writer.readRaw('acme').document.replyLimits, replyLimits);
}
for (const bad of ['{broken', 'null', '[]', '1']) {
writeFileSync(f.file, bad); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
}
rmSync(f.file); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
});
+6
View File
@@ -959,6 +959,12 @@ writer adds `since`, the time the limit was set, and keeps it on later saves.
A patch that sends a different `since` is refused. Only these paths are token
limits; the agent limits above are never read as one, whatever their names.
`mosaic limits replies --file LIMITS [--business ID]` replaces the citation settings
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
The next library reply uses the new settings without restarting the bus.
`mosaic limits reset WORD... [--business ID]` moves one token limit's `since`
to now, so its count restarts. The path is one word per level, as the plain
output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`.
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { limitsCommand } from '../src/limits.mjs';
test('C2-06 CLI replies reads revision and sends only the JSON settings to the human verb', async t => {
const dir = mkdtempSync(join(tmpdir(), 'reply-cli-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'settings.json'); writeFileSync(file, JSON.stringify({ requireCitation: true, citationsPerReply: 7 }));
const calls = [], output = [];
await limitsCommand(['replies', '--file', file, '--business', 'acme'], { stdout: { write: s => output.push(s) } }, parsed => {
assert.equal(parsed.values['--business'], 'acme');
return async (verb, args) => { calls.push([verb, args]); return verb === 'limits' ? { revision: 'r1' } : { revision: 'r2' }; };
});
assert.deepEqual(calls, [['limits', {}], ['library.limits.set', { revision: 'r1', replyLimits: { requireCitation: true, citationsPerReply: 7 } }]]);
assert.match(output.join(''), /r2/);
});
test('C2-06 CLI replies requires a file and refuses tester writes before a bus call', async () => {
for (const args of [['replies'], ['replies', '--tester', '--file', 'unused'], ['replies', '--file', 'unused', '--models', 'unused']])
await assert.rejects(limitsCommand(args, { stdout: { write() {} } }, () => { throw Error('bus touched'); }), { exitCode: 4 });
});
+57 -4
View File
@@ -38,6 +38,7 @@ import { createServer, connect } from "node:net";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
@@ -458,8 +459,9 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
return EXIT.founder;
}
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
let library = false;
try {
await client.call("role.claim");
library = (await client.call("role.claim"))?.library === true;
} catch (e) {
// No answer is not a refusal: the broker is down or the outcome is unknown.
if (e.code === "outcome-unknown") {
@@ -582,9 +584,60 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
if (m.class !== "RESULT") {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
if (!library) {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
} else {
const send = async (answer) => {
const raw = answer.ok ? answer.text || FIXED_TEXTS.empty : FIXED_TEXTS.failed;
const fixed = Object.values(FIXED_TEXTS).includes(raw);
let body = raw;
if (!fixed) {
let value;
try { value = JSON.parse(raw); } catch {}
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
// Malformed objects still reach the guard and its reply-invalid refusal.
if (typeof value.text === "string") value.text += unslop.note();
body = JSON.stringify(value);
} else body = clip(raw) + unslop.note();
}
try {
await client.call("message.send", { to: m.from_role, body, class: "RESULT", in_reply_to: m.id });
if (!fixed) unslop.sent();
} catch (error) {
// The existing broker body cap is smaller than the socket's 64 KiB cap.
// Both follow the one-retry rule, without changing non-library sends.
if (error.code === "invalid-request" && body.length > 32768) {
error = Object.assign(new Error("request-too-large"), { code: "request-too-large" });
}
throw error;
}
};
try { await send(turn); }
catch (error) {
if (!GUARD_CODES.includes(error.code) && error.code !== "request-too-large") throw error;
log(`runner: reply to ${m.id} refused: ${error.code}${error.reason ? `: ${error.reason}` : ""}`);
const reason = error.code === "request-too-large"
? "The message exceeds the 64 KiB request limit or the 32768-character body limit. Send a shorter answer."
: error.reason ?? "The reply did not pass the citation check.";
++n;
try { await client.call("usage.begin", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage baseline unavailable: ${e.code ?? "failed"}`); }
const retry = await runTurn({ session, request: `${block ?? ""}\n${turnRequest(m)}\n\nYour answer was refused with ${error.code}: ${reason}\nReply again in this same session.`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
if (stopping) break loop;
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
try { await send(retry); }
catch (again) {
if (!GUARD_CODES.includes(again.code) && again.code !== "request-too-large") throw again;
log(`runner: retry to ${m.id} refused: ${again.code}`);
await send({ ok: true, text: again.code === "request-too-large" ? FIXED_TEXTS.tooLong : FIXED_TEXTS.uncited });
}
}
}
}
await client.call("message.read", { id: m.id });
} catch (e) {
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { serve } from '../../bus/src/server.mjs';
import { FIXED_TEXTS } from '../../library/src/reply-protocol.mjs';
import { GuardError } from '../../library/src/guard.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from '../../bus/tests/citation-helpers.mjs';
const RUNNER = new URL('../src/runner.mjs', import.meta.url).pathname;
const ADAPTER = new URL('./fixtures/citation-adapter.mjs', import.meta.url).pathname;
async function until(fn) {
const stop = Date.now() + 15000;
while (Date.now() < stop) { const value = fn(); if (value) return value; await new Promise(r => setTimeout(r, 15)); }
throw Error('fixture timed out');
}
async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true, guard } = {}) {
const f = citationWorld(t, { library, sendAuthority, guard }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle');
mkdirSync(bundle, { recursive: true }); mkdirSync(join(f.root, 'ws'));
const steps = stepsFor(f.commit), brokerSocket = join(f.root, 'broker.sock');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation } }));
const server = await serve({ broker: f.broker, path: brokerSocket });
writeFileSync(join(runDir, 'fixture-steps.json'), JSON.stringify(steps));
const adapter = join(runDir, 'adapter.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`);
writeFileSync(join(bundle, 'prompt.md'), 'Fixture only.\n');
writeFileSync(join(bundle, 'tools.json'), JSON.stringify([{ name: 'send_message', verb: 'message.send', action: 'message.send' }]));
writeFileSync(join(bundle, 'policy.json'), JSON.stringify({ needs: [], credentials: [] }));
writeFileSync(join(runDir, 'session.json'), JSON.stringify({ business: 'acme', instance: 'coder', run: 'coder-run', harness: 'pi',
provider: 'fixture', model: 'fixture', brokerSocket, workspace: join(f.root, 'ws'), sessionDir: join(runDir, 'session'), adapter,
bundle: { prompt: join(bundle, 'prompt.md'), tools: join(bundle, 'tools.json'), policy: join(bundle, 'policy.json') },
toolSocket: join(runDir, 'tools.sock'), turnMarker: join(runDir, 'turn.done'), turnEnd: 'marker', harnessEnv: {}, pollInterval: 20, turnTimeout: 10 }));
const child = spawn(process.execPath, [RUNNER, runDir], { stdio: ['pipe', 'ignore', 'pipe'], env: { PATH: process.env.PATH } });
let log = ''; child.stderr.on('data', b => { log += b; }); child.stdin.on('error', () => {}); child.stdin.end(JSON.stringify({ cap: f.agent }) + '\n');
const exited = once(child, 'exit');
t.after(async () => { child.kill('SIGTERM'); await exited; await server.close(); });
await until(() => f.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const ask = async () => {
const sent = await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
const reply = await until(() => f.store.get('SELECT * FROM messages WHERE in_reply_to=?', sent.id));
await until(() => f.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
return reply;
};
return { ...f, runDir, ask, log: () => log, count: () => Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) };
}
for (const code of ['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid', 'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']) {
test(`C2-06 RESULT ${code} retries once, delivers only the valid answer and names reason`, async t => {
const f = await setup(t, commit => [{ text: guardCases(commit).find(c => c[0] === code)[1] }, { text: citedBody([fixtureCite(commit)]) }]);
const reply = await f.ask(); assert.equal(reply.body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.match(requests[1], new RegExp(code)); assert.match(requests[1], /refused with/);
assert.match(f.log(), new RegExp(code));
});
}
test('C2-06 RESULT two guard refusals use the citation fixed text and exactly one retry', async t => {
const f = await setup(t, () => [{ text: 'uncited' }, { text: 'uncited again' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
for (const size of [34000, 70000]) {
test(`C2-06 RESULT JSON ${size} characters retries with a shorter valid answer`, async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(size), citations: [fixtureCite(commit)] }) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.match(readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8'), /64 KiB/);
});
}
test('C2-06 RESULT two oversized JSON bodies use the length fixed text with citations required', async t => {
const f = await setup(t, commit => Array(2).fill({ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 multibyte plain bodies also receive the one request-size retry', async t => {
const f = await setup(t, commit => [{ text: '界'.repeat(32000) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
});
test('C2-06 two multibyte plain bodies use the length fallback', async t => {
const f = await setup(t, () => Array(2).fill({ text: '界'.repeat(32000) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 RESULT a length refusal then citation refusal shares one retry and citation fallback', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }, { text: 'uncited' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
test('C2-06 fixed empty/failed replies defer unslop note until the next cited reply', async t => {
const f = await setup(t, commit => [{ text: '', unslop: true }, { fail: true }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.empty); assert.equal((await f.ask()).body, FIXED_TEXTS.failed);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 3);
});
test('C2-06 cited JSON above the old 32000 clip retains its citations and text', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(32200), citations: [fixtureCite(commit)] }) }]);
const reply = JSON.parse((await f.ask()).body); assert.equal(reply.text.length, 32200); assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.equal(f.count(), 1);
});
test('C2-06 a flagged cited answer keeps the note inside text and citations whole', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), unslop: true }]);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 1);
});
test('C2-06 send_message typed tool runs every guard code before the valid RESULT', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), tools: guardCases(commit).map(([, body]) => ({ to: 'human', body })) }]);
await f.ask(); const results = readFileSync(join(f.runDir, 'fixture-tools.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(results.length, 7); results.forEach((r, i) => { assert.equal(r.ok, false); assert.match(r.error, new RegExp(guardCases(f.commit)[i][0])); });
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
});
test('C2-06 outside library the old failed text, clip and unslop behavior hold', async t => {
const f = await setup(t, () => [{ fail: true }, { text: 'x'.repeat(40000), unslop: true }], { library: false });
assert.match((await f.ask()).body, /^The turn failed:/);
const reply = (await f.ask()).body; assert.match(reply, /answer cut at 32000 characters/); assert.match(reply, /unslop/i); assert.equal(f.count(), 2);
});
test('C2-06 outside library a refused send keeps the existing no-retry behavior', async t => {
const f = await setup(t, () => [{ text: 'answer without send authority' }], { library: false, sendAuthority: false });
await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
await until(() => /runner: reply to .* failed:/.test(f.log()));
assert.equal(f.count(), 1); assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 0);
assert.equal(existsSync(join(f.runDir, 'fixture-requests.jsonl')), true);
});
// E4: the agent must receive the availability reason in its actual retry turn,
// rather than being told only that the source citation was invalid.
test('C2-06 the retry turn delivers citation-unavailable reason to the agent', async t => {
let sends = 0;
const reason = 'The library is busy or unavailable. Retry the answer when the library is available.';
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]) }, { text: citedBody([fixtureCite(commit)]) }], {
guard: async () => { if (sends++ === 0) throw new GuardError('citation-unavailable', reason); return null; },
});
await f.ask(); assert.equal(f.count(), 2);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(requests[1].includes(`citation-unavailable: ${reason}`), true);
});
+21
View File
@@ -0,0 +1,21 @@
// A fixture-only scripted harness. No provider or real credential is used.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { callTool } from '../../src/tools.mjs';
const root = dirname(process.env.MOSAIC_TURN_MARKER), countFile = join(root, 'fixture-count');
const n = existsSync(countFile) ? Number(readFileSync(countFile, 'utf8')) : 0;
const steps = JSON.parse(readFileSync(join(root, 'fixture-steps.json'), 'utf8'));
const step = steps[n] ?? { fail: true };
writeFileSync(countFile, String(n + 1));
appendFileSync(join(root, 'fixture-requests.jsonl'), JSON.stringify(process.env.MOSAIC_REQUEST) + '\n');
if (step.unslop) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ev: 'flagged', findings: [{ source: 'fixture', id: 'fixture' }] }) + '\n');
for (const args of step.tools ?? []) {
let result;
try { result = { ok: true, result: await callTool(process.env.MOSAIC_TOOL_SOCKET, 'send_message', args) }; }
catch (e) { result = { ok: false, error: e.message }; }
appendFileSync(join(root, 'fixture-tools.jsonl'), JSON.stringify(result) + '\n');
}
if (step.fail) process.exit(3);
process.stdout.write(step.text ?? '');
writeFileSync(process.env.MOSAIC_TURN_MARKER, 'done\n');
+7 -16
View File
@@ -8,17 +8,8 @@ import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
import { plain } from './limits.mjs';
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
import { FIXED_TEXTS } from './reply-protocol.mjs';
export { GUARD_CODES, FIXED_TEXTS } from './reply-protocol.mjs';
// Web cites are for these definitions only (design 5.2).
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
@@ -107,9 +98,9 @@ export async function checkReply(body, { limits, mapping, library, web }) {
if (!Object.hasOwn(c, 'url')) return;
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
const quote = squash(c.quote);
const page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url);
if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !squash(page.text).includes(quote)) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
const pages = web.pages.filter(p => p.url === c.url || p.finalUrl === c.url);
if (!pages.length) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !pages.some(page => squash(page.text).includes(quote))) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
});
const out = [];
for (const [i, c] of citations.entries())
@@ -150,7 +141,7 @@ export function libraryReader(mirror, served, mapping) {
async origin(commit, path) {
if (!mirror) return { agent: null, file: null };
try {
const [file, trailer = ''] = (await git(mirror, ['log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
const [file, trailer = ''] = (await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
} catch { return { agent: null, file: null }; }
},
@@ -158,7 +149,7 @@ export function libraryReader(mirror, served, mapping) {
async last(path) {
if (!mirror || !served) return null;
try {
const value = await git(mirror, ['log', '-1', '--format=%H', served, '--', path]);
const value = await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H', served, '--', path]);
return SHA.test(value) ? value : null;
} catch { return null; }
},
+12
View File
@@ -0,0 +1,12 @@
// C2-06: shared pure protocol constants for the host guard and sandboxed runner.
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { checkReply, GuardError, libraryReader, parseReply, FIXED_TEXTS } from '../src/guard.mjs';
import { resolveReplyLimits, checkReplyLimits } from '../src/limits.mjs';
import { BASE, MARKER, mapping, repo, git } from './helpers.mjs';
const defaults = resolveReplyLimits({ readable: true, document: {} });
const body = citations => JSON.stringify({ text: 'A fixture answer.', citations });
const cite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
const context = r => ({ limits: defaults, mapping: mapping(), library: libraryReader(r.repo, r.commit, mapping()), web: { allowed: true, pages: [] } });
const refusal = code => e => e instanceof GuardError && e.code === code && typeof e.reason === 'string' && e.reason.length <= 300;
test('C2-06 reply schema: free bodies, exact objects and safe refusal reasons', async t => {
const c = context(repo(t));
for (const text of ['plain', 'null', '42', '[]', '"text"', '{broken']) {
assert.equal(parseReply(text).cited, false);
assert.equal(await checkReply(text, c), null);
}
assert.deepEqual(await checkReply(body([]), c), { citations: [] });
for (const value of [{}, { text: 'x', citations: [], scope: 'canon' }, { text: 1, citations: [] },
{ text: 'x', citations: {} }, { text: 'x', citations: [{ url: 'https://invalid.test', quote: 'x', agent: 'forged' }] },
{ text: 'x', citations: [cite('a'.repeat(40), { as: 'supporting' })] }])
await assert.rejects(checkReply(JSON.stringify(value), c), refusal('reply-invalid'));
});
test('C2-06 citation setting and all four exact fixed texts', async t => {
const c = context(repo(t)); c.limits = { ...defaults, requireCitation: true };
await assert.rejects(checkReply('plain', c), refusal('citation-missing'));
await assert.rejects(checkReply(body([]), c), refusal('citation-missing'));
for (const text of Object.values(FIXED_TEXTS)) {
assert.equal(await checkReply(text, c), null);
await assert.rejects(checkReply(text + ' ', c), refusal('citation-missing'));
}
});
test('C2-06 default citation cap passes 20 and refuses 21, even with optional citations', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body(Array(20).fill(cite(r.commit))), c)).citations.length, 20);
await assert.rejects(checkReply(body(Array(21).fill(cite(r.commit))), c), refusal('citations-over-limit'));
});
test('C2-06 real Git citation scope, reachability, lines and normalized quotes', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body([cite(r.commit, { quote: 'Grey\n eyes.' })]), c)).citations[0].scope, 'canon');
for (const extra of [{ path: 'Inbox Note.md', lines: [1, 1], quote: MARKER },
{ path: 'Book One/Board.canvas' }, { path: 'Book One/Characters/Missing.md' },
{ commit: '0'.repeat(40) }, { commit: git(r.repo, 'rev-parse', 'HEAD^{tree}') },
{ lines: [999, 999] }, { quote: 'not in her fixture' }])
await assert.rejects(checkReply(body([cite(r.commit, extra)]), c), refusal('citation-invalid'));
// A future commit exists locally but is not reachable from the served commit.
writeFileSync(join(r.repo, 'Inbox Note.md'), 'new\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'future');
await assert.rejects(checkReply(body([cite(git(r.repo, 'rev-parse', 'HEAD'))]), c), refusal('citation-invalid'));
assert.equal((await checkReply(body([cite(r.commit)]), c)).citations[0].scope, 'canon');
});
test('C2-06 playground cites pass but cannot state canon', async t => {
const r = repo(t), c = context(r), p = cite(r.commit, { path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER });
assert.equal((await checkReply(body([p]), c)).citations[0].scope, 'playground');
await assert.rejects(checkReply(body([{ ...p, as: 'canon' }]), c), refusal('playground-cited-as-canon'));
});
test('C2-06 independent universes refuse; a hybrid and only its sources pass', async t => {
const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n' }), c = context(r);
const other = cite(r.commit, { path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER });
await assert.rejects(checkReply(body([cite(r.commit), other]), c), refusal('citation-cross-universe'));
c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
c.library = libraryReader(r.repo, r.commit, c.mapping);
const hybrid = cite(r.commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3);
});
test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => {
const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' };
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
c.web.pages = [{ url: web.url, finalUrl: 'https://fixture.invalid/final', text: 'The kept\n text is here.' }];
assert.deepEqual(await checkReply(body([web]), c), { citations: [{ scope: 'web', agent: null }] });
assert.equal((await checkReply(body([{ ...web, url: c.web.pages[0].finalUrl }]), c)).citations.length, 1);
c.web.pages.push({ url: web.url, text: 'A later fetch.' });
assert.equal((await checkReply(body([{ ...web, quote: 'later fetch' }]), c)).citations.length, 1);
await assert.rejects(checkReply(body([{ ...web, quote: 'discarded bytes' }]), c), refusal('citation-unfetched'));
c.web.allowed = false;
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
});
test('C2-06 file origin is the path commit and trailer, while last follows served changes', async t => {
const r = repo(t), path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(r.repo, path), 'Agent fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Note\n\nMosaic-Agent: acme/scribe');
const file = git(r.repo, 'rev-parse', 'HEAD');
writeFileSync(join(r.repo, 'Inbox Note.md'), 'Unrelated.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'unrelated');
const cited = git(r.repo, 'rev-parse', 'HEAD'), reader = libraryReader(r.repo, cited, mapping());
assert.notEqual(cited, file);
assert.deepEqual(await reader.origin(cited, path), { agent: 'acme/scribe', file });
assert.equal(await reader.last(path), file);
writeFileSync(join(r.repo, path), 'Human fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'human');
assert.notEqual(await libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()).last(path), file);
});
test('C2-06 origin uses literal paths even when a filename contains a glob', async t => {
const path = 'Book One/Characters/Mara*.md';
const r = repo(t, { ...BASE, [path]: 'Literal fixture.\n' }), original = r.commit;
writeFileSync(join(r.repo, 'Book One/Characters/Mara Vell.md'), 'Other fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Other path\n\nMosaic-Agent: acme/other');
const reader = libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping());
assert.deepEqual(await reader.origin(git(r.repo, 'rev-parse', 'HEAD'), path), { agent: null, file: original });
assert.equal(await reader.last(path), original);
});
test('C2-06 reply limits fall back independently without coercion or inherited keys', () => {
for (const value of ['true', 1, null, [true], {}]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: value, citationsPerReply: 5 } } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 5); assert.deepEqual(r.fallback, ['requireCitation']);
}
for (const value of [0, 1001, 2.5, true, '5']) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: true, citationsPerReply: value } } });
assert.equal(r.requireCitation, true); assert.equal(r.citationsPerReply, 20); assert.deepEqual(r.fallback, ['citationsPerReply']);
}
for (const value of [null, [], 1, 'settings', undefined]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: value } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 20);
}
assert.equal(resolveReplyLimits({ readable: true, document: { replyLimits: Object.create({ requireCitation: true }) } }).requireCitation, false);
for (const document of [undefined, null, [], 'invalid']) assert.equal(resolveReplyLimits({ readable: true, document }).requireCitation, true);
assert.equal(resolveReplyLimits({ readable: false }).requireCitation, true);
assert.deepEqual(checkReplyLimits({ requireCitation: true, citationsPerReply: 1000 }), { requireCitation: true, citationsPerReply: 1000 });
for (const value of [{ unknown: 1 }, { requireCitation: 'true' }, { citationsPerReply: 0 }, null, []]) assert.equal(checkReplyLimits(value), null);
});
+1
View File
@@ -60,6 +60,7 @@ export const RUNNER_FILES = Object.freeze([
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/plugins/src/kinds.mjs",
"packages/library/src/reply-protocol.mjs",
"packages/processing-control/src/state.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));