Compare commits

..
Author SHA1 Message Date
core-builder-b b6f57d8dd7 Merge remote-tracking branch 'origin/main' into core-builder-b/needs-me-read
Conflicts in the migration lists (src/db/all-migrations.ts and
test/helpers/test-app.ts): main appended 162
(session_address_read_indexes) where this branch appended 164
(queue_items_reply_to_index). Both are kept, in order: 161, 162, 164.
Nothing is listed for 163; the runner applies missing names in sorted
order, so the gap is fine.
2026-11-12 05:51:34 +00:00
core-builder-b b6b73d7c63 fix(daemon): an older Slack reply's cutoff is the ask's first close and the first tagged answer's place
Review round 3 on the needs-me read.

- The close cutoff for an untagged older Slack reply is now the ask's first close
  (its earliest transition out of an open state), whatever its state now. Before,
  it followed the current state, so reopening an ask, or closing it again, turned
  a thread reply already excluded as correspondence into an answer.
- The first tagged answer's cutoff is now its place in the read's own order
  (ts_created, then rowid) rather than its timestamp. An older reply recorded
  just before it in the same millisecond is kept; one recorded after it isn't.

Cost: a genuine untagged answer that arrived only after an earlier close and a
reopen is no longer listed in `answers`. The ask's humanAnswers and its
transitions still carry it, and tagged answers after the reopen are listed.
2026-11-11 23:56:03 +00:00
core-builder-b 020430314d Merge remote-tracking branch 'origin/main' into core-builder-b/needs-me-read
# Conflicts:
#	packages/daemon/src/db/all-migrations.ts
#	packages/daemon/test/helpers/test-app.ts
2026-11-11 23:21:55 +00:00
core-builder-b 8e6c49f782 fix(daemon): an older untagged Slack reply is an answer only before the ask's first tagged answer and its close
Review round 2 on the needs-me read. An untagged Slack reply (from before the
human-answer tag) has the same shape as later correspondence on the thread, so it
counts as an answer only when it was created before the ask's first tagged answer
and no later than the ask's close, and only each person's first. A real older
answer before a re-park's tagged answer is kept beside it. A reply after an
answer, or after the ask was closed in place, is correspondence and isn't listed.
2026-11-11 23:20:50 +00:00
core-builder-b 6329634e66 fix(daemon): keep needs-me answers in scope, count only real answers, index reply_to
Review round 1 on the needs-me read:

- A scoped MCP caller's rig_queue_show no longer carries `answers`. They are other
  rows, from people, to the item's asker, and the scope check covers only the item
  itself. Owner reads are unchanged.
- `answers` applies getHumanAnswer's whole rule rather than part of it. The ask must
  be an ask to a person (never an update). The row goes back to the asker, from
  someone who may answer it, never a shared channel. An older untagged Slack reply
  counts only when the ask has no tagged answer, and only each person's first. So
  correspondence, a reply after the answer and replies to updates are no longer
  listed.
- Migration 164 indexes queue_items(reply_to), partial on non-null like 159. The
  answer lookup and the decision revision's reply lookups ran a full queue scan; the
  attention read now runs one per open request. A query-plan cell pins both reads
  to the index.
- The build object is described as it is: in a source run it's
  { semver: null, commit: null }.

Corrected cost: a single-row read adds the ask's transitions (to name episodes) and,
per answer row, its getById and transitions read, besides the indexed reply_to
lookup. The attention read adds one decision revision per open request.
2026-11-11 23:04:05 +00:00
core-builder-bandClaude Opus 5.5 6e5bc883ce feat(daemon): read what needs a person and the answers to it by link (team-operations.v1 needs-me.read)
An app that shows a person what needs them, and how their answers relate to each
request, had to find an answer's request by searching the answer's text for its
"In reply to:" line. The link already exists in the queue (an answer row's
reply_to, its human-answer tag and an ID derived from the request) but no read
returned it from the request's side.

- GET /api/queue/<id> adds `answers`: every answer row returned to the request,
  oldest first, with who, when, the words without the footer, the park it
  answered, and the identity recorded when the answer row was created.
- GET /api/attention items add `revision` (the request's decision revision, the
  same value an answer can name as expectedRevision) and `about` (a park is its
  own request; an ask is about the open rows blocked on it). The response adds a
  `watermark`: the event seq read before the snapshot, to follow /api/events from.
- GET /api/team-operations/capabilities: contract team-operations.v1, its needs-me
  capabilities (read supported; answer and follow not yet), and the build label
  kept separate. Older daemons 404, and clients keep their current reads.

Read-only additions: no write, delivery or wake path changes, and no new refusal
or error code. Recording one answer of several still emits no event; that is a
separate change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 22:15:47 +00:00
39 changed files with 519 additions and 760 deletions

No files matched your search

-5
View File
@@ -405,11 +405,6 @@ Each startup file and action specifies when it applies:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
**A file keeps one copy of each block.**
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
+34
View File
@@ -151,6 +151,40 @@ An outbox entry's `deliveryState` reports the send attempt; only `delivered` con
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
### What needs a person, and how their answers link back (`team-operations.v1`)
`GET /api/team-operations/capabilities` says which team-operations jobs this daemon supports, separately from its
build:
```json
{ "contract": "team-operations.v1",
"capabilities": { "needs-me.read": "supported", "needs-me.answer": "not-supported", "needs-me.follow": "not-supported" },
"build": { "semver": "…", "commit": "…" } }
```
A capability reads `supported`, `not-supported` or `unknown`, and a missing key means `unknown`. A daemon without the
route (HTTP 404) predates the contract: keep reading what it already returns. In a source run, `build` is
`{ semver: null, commit: null }`.
With `needs-me.read` supported:
- **What needs the person:** `GET /api/attention?view=<person-id>` (the same read as the Feed). Each queue item adds
`revision` and `about`. `revision` is the request's opaque decision revision: the same value as that row's
`decisionRevision`, and what an answer can name as `expectedRevision` so it applies only to the version the person
read. `about` lists the requests it concerns by id: a row parked on the person is itself the request, and an ask is
about the open rows blocked on it (at most 100; empty when none are recorded).
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
already reflected in it. Recording one answer of several emits no event yet, so re-read the snapshot (or the row)
rather than relying on events alone for partial answers.
- **The answers returned to a request:** `GET /api/queue/<qitemId>` adds `answers`, oldest first, each
`{ qitemId, by, at, text, episode, recordedIdentity }`. They are found by the answer row's link to the request, not
by its text, so don't parse the `In reply to:` line in an answer's body. `text` is the person's answer without that
footer. `episode` names the park an answer belongs to, or is null. `recordedIdentity` is the identity provenance
recorded when the answer row was created. An answer row's own read has `replyTo`, the request it answers. A partial
set of choices stays on the request's `humanAnswers` until the whole set is returned. A runner's permission request
or a host pairing request is answered in place, with no answer row.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
+3 -15
View File
@@ -342,21 +342,9 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
reboot. It does not detect a reboot as such. It uses the same restore as
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
until the seats are ready. A kernel stopped with `rig down kernel`, including
one whose seats were already detached, stays down on later starts; start it again
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
While the kernel's other seats are running, `rig up kernel --existing` refuses
(it restores only a kernel with no live session), so start one stopped seat with
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
kernel snapshot that restore ranks first: the newest usable automatic one (taken
before `rig down`, on the periodic schedule, or at a runtime switch), even when a
manual one is newer, and otherwise the newest usable one. `--plan` names the
snapshot it picked and any newer usable one, and `--snapshot-id <id>` picks one
yourself. Under the default restore policy, which the kernel's seats keep, it
resumes the seat's saved conversation; a seat it can't resume is reported as
waiting for a decision, not as started.
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
with no continuity source: use it only when you want a fresh occupant, since the
seat's earlier conversation isn't carried.
one whose seats were already detached, stays down on later starts. So do a kernel
with only some seats stopped and seats you unclaimed. Start one again with
`rig up kernel --existing`.
When the tmux server is gone, an explicit `rig down` of any rig can start an
empty server and probe again. It records the stop and clears eligible bindings
only after confirming the sessions are absent. This can leave an empty tmux
-6
View File
@@ -137,7 +137,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
conflicts?: Array<{ existingPath: string; reason: string }>;
verification?: { passed: boolean };
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
notes?: string[];
error?: string;
errors?: string[];
code?: string;
@@ -205,11 +204,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
}
}
if (res.data.notes && res.data.notes.length > 0) {
console.log("Left as it is:");
for (const note of res.data.notes) console.log(` ${note}`);
}
});
// rig package rollback <installId>
+7 -5
View File
@@ -305,15 +305,17 @@ function projectForExternal<T>(row: T, view: ExternalView): T {
return fields as T;
}
/** A handed-on item as a scoped caller receives it: without the daemon's own successor fields (successors,
* successorsTruncated, successorNote), which name rows and remote ids regardless of this caller's scope. Its answer is
* show's scope-checked successor (successorOf). */
function withoutDaemonSuccessors<T>(row: T): T {
/** An item as a scoped caller receives it: without the daemon's own fields that carry other rows regardless of this
* caller's scope. Those are the successor fields (successors, successorsTruncated, successorNote), which name rows and
* remote ids; a handed-on item's answer is show's scope-checked successor (successorOf). And the answers people returned
* to the item (answers), which are other rows, from people, to its asker. */
function withoutDaemonLinkedRows<T>(row: T): T {
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
const fields = { ...(row as Record<string, unknown>) };
delete fields.successors;
delete fields.successorsTruncated;
delete fields.successorNote;
delete fields.answers;
return fields as T;
}
@@ -1445,7 +1447,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const view = externalView(scope);
let successor = handedOn ? await successorOf(client, qitemId, row.closureTarget, view ? scope!.item : undefined) : undefined;
if (scope && !view && successor && "destinationSession" in successor && !scope.successor(successor.destinationSession)) successor = { unknown: true, code: "out_of_scope" };
const shownItem = scope ? withoutDaemonSuccessors(item.data) : item.data;
const shownItem = scope ? withoutDaemonLinkedRows(item.data) : item.data;
const data = view
? { item: projectForExternal(shownItem, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
: { item: shownItem, transitions: transitions.data };
@@ -989,6 +989,21 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(back.text).not.toContain("qitem-split-hidden");
});
it("a scoped show of its own item never carries the answers a person returned to it; the owner's read does", async () => {
const served = await listen();
const asExternal = await connect(served.url);
const own = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "my task" })).structured.qitemId as string;
// The item asked of a person, and their answer row back to the caller: another row, from a person, linked to it.
db.prepare("UPDATE queue_items SET destination_session = 'human-x@external' WHERE qitem_id = ?").run(own);
await repo.createHumanAnswer({ sourceSession: "human-x@external", destinationSession: CALLER, body: "The person's private reasoning",
inboundReplyTo: own, nudge: false } as never);
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }] });
const shown = await asExternal("rig_queue_show", { qitemId: own });
expect(shown.isError).toBe(false);
expect(shown.structured.item).not.toHaveProperty("answers");
expect(shown.text).not.toContain("private reasoning");
});
it("an id-reuse conflict never names another item's parties, and still names the external caller's own", async () => {
const served = await listen();
const asExternal = await connect(served.url);
-22
View File
@@ -207,20 +207,6 @@ function createMockDaemon() {
}));
return;
}
if (sourceRef.includes("skipped")) {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
installId: "inst-skip-1",
packageId: "pkg-1",
packageName: "guidance-pkg",
applied: [],
deferred: [],
conflicts: [],
verification: { passed: true },
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
}));
return;
}
if (sourceRef.includes("merge")) {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
@@ -397,14 +383,6 @@ describe("rig package", () => {
expect(output).toContain("inst-merge-1");
});
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
const output = logs.join("\n");
expect(output).toContain("Left as it is:");
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
expect(exitCode).toBeUndefined();
});
// Test 8: install conflicts (409) → exitCode 1
it("install conflicts: prints conflicts, exitCode 1", async () => {
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": [
"openrig",
@@ -8,7 +8,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
}
+8
View File
@@ -25,6 +25,11 @@ export interface AttentionItem {
/** The settings page where the person decides this request directly, without an agent (a pairing request's Approve and
* Deny): a TUI settings category, and the request it opens on there. */
settingsLink?: { category: string; focus: string; label: string };
/** A queue request's decision revision: the same opaque token its single-row read returns and a settling act can name. */
revision?: string;
/** The requests this is about, by ID: a parked row is itself the request; an ask is about the open rows blocked on it
* (at most 100). Empty when no request is recorded as waiting on it. */
about?: string[];
}
export interface AttentionDetail {
item: AttentionItem;
@@ -38,6 +43,9 @@ export interface AttentionRead {
sources: Array<{ source: string; state: "available" | "unavailable" | "partial"; detail: string }>;
detail: AttentionDetail | null;
detailError: string | null;
/** The event log's newest seq, read before this snapshot: following /api/events from it (Last-Event-ID) replays every
* event since, possibly some already reflected here, never one missed. Absent without an event log. */
watermark?: number;
/** Whose queue this Feed shows (`view`), and who acts on it. A view, never an identity: acts on what it shows carry the
* primary user's authority, the person at the terminal, whoever's queue it is. `requested` names a view that matched no
* registered person. Absent when no person is registered. */
+2
View File
@@ -10,6 +10,7 @@ import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
import { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -327,4 +328,5 @@ export const ALL_MIGRATIONS: Migration[] = [
networkBindingsSchema,
networkReceiptsSchema,
sessionAddressReadIndexesSchema,
queueItemsReplyToIndexSchema,
];
@@ -0,0 +1,10 @@
import type { Migration } from "../migrate.js";
/** An ask's answers and its decision revision find rows by their `reply_to`. Without this, each lookup scans the whole
* queue, once per request on the attention read. Partial: only replies carry the column. */
export const queueItemsReplyToIndexSchema: Migration = {
name: "164_queue_items_reply_to_index.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_queue_items_reply_to ON queue_items(reply_to) WHERE reply_to IS NOT NULL;
`,
};
@@ -643,9 +643,6 @@ export class BootstrapOrchestrator {
if (!outcome.ok) {
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
packageInstallFailed = true;
} else {
// A file the install left as it is, and why: the rest installed, and the rig still imports.
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
}
}
}
+26 -30
View File
@@ -4,7 +4,6 @@ import type { InstallPlanEntry } from "./install-planner.js";
import type { PolicyResult } from "./install-policy.js";
import type { RefinedInstallPlan } from "./conflict-detector.js";
import { InstallRepository, type JournalEntry } from "./install-repository.js";
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
export interface EngineFsOps {
readFile: (filePath: string) => string;
@@ -20,8 +19,6 @@ export interface InstallResult {
applied: JournalEntry[];
deferred: InstallPlanEntry[];
conflicts: InstallPlanEntry[];
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
notes: string[];
}
export interface RollbackResult {
@@ -55,19 +52,10 @@ export class InstallEngine {
): InstallResult {
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
const applied: JournalEntry[] = [];
const notes: string[] = [];
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
try {
for (const entry of policyResult.approved) {
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
if (unreadable) {
notes.push(unreadable);
continue;
}
// Ensure target directory
this.fs.mkdirp(path.dirname(entry.targetPath));
@@ -133,7 +121,6 @@ export class InstallEngine {
applied,
deferred: plan.deferred,
conflicts: plan.conflicts,
notes,
};
}
@@ -183,14 +170,6 @@ export class InstallEngine {
return { restored, deleted };
}
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
* copy, or null. */
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
}
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
if (!entry.sourcePath) return;
@@ -199,17 +178,34 @@ export class InstallEngine {
if (this.fs.exists(entry.targetPath)) {
const existing = this.fs.readFile(entry.targetPath);
const found = findManagedBlocks(existing, packageName);
if (found.blocks.length === 0) {
const startMarker = BLOCK_START(packageName);
const endMarker = BLOCK_END(packageName);
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
// Prefer the OpenRig form; fall back to legacy markers from
// prior installs so re-applying a guidance package over a
// pre-rename file replaces the old block instead of appending a
// duplicate.
let startIdx = existing.indexOf(startMarker);
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
let matchedEndLength = endMarker.length;
if (startIdx === -1 || endIdx === -1) {
const legacyStartIdx = existing.indexOf(legacyStart);
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
startIdx = legacyStartIdx;
endIdx = legacyEndIdx;
matchedEndLength = legacyEnd.length;
}
}
if (startIdx !== -1 && endIdx !== -1) {
// Update existing block (rewrites legacy markers to OpenRig form).
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
this.fs.writeFile(entry.targetPath, updated);
} else {
// Insert new block at end
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
} else {
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
const first = found.blocks[0]!.body;
const updated = mergeManagedBlockText(existing, packageName, block, [],
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
}
} else {
// New file
+1 -13
View File
@@ -45,9 +45,7 @@ export class InstallVerifier {
this.fs = fs;
}
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
* skipped verifies as that, not as an empty journal. */
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
verify(installId: string): VerificationResult {
const install = this.installRepo.getInstall(installId);
if (!install) {
return {
@@ -78,16 +76,6 @@ export class InstallVerifier {
const journal = this.installRepo.getJournalEntries(installId);
const applyEntries = journal.filter((e) => e.action !== "rollback");
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
return {
passed: true,
installId,
entries: [],
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
};
}
// Empty journal for applied install is a verification failure
if (applyEntries.length === 0) {
return {
@@ -2,8 +2,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import type Database from "better-sqlite3";
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
@@ -93,8 +92,6 @@ export function decideManagedBlock(content: string, blockId: string, source: str
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
const named = opts.sourcePath ?? "its source";
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
if (unreadable && standing === "unrecorded") {
return { standing: "unknown", write: false,
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
@@ -106,36 +103,10 @@ export function decideManagedBlock(content: string, blockId: string, source: str
return { standing, write: true };
}
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
* and the block's own decision stands. Returns whether the file changed. */
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
let recorded: string | undefined;
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
try {
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const pruned = pruneManagedBlockCopies(existing, blockId,
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
if (tidy === existing) return false;
files.writeFile(targetPath, tidy);
if (pruned !== null) {
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
}
return true;
} catch (err) {
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
return false;
}
}
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
* same start can't lose its record. Returns whether the block was written. */
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
* whether the block was written. */
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
warnings?: string[]; replaceBlockIds?: string[];
@@ -144,19 +115,15 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
if (!opts.records) {
if (opts.onlyIfPresent) return false;
return merge();
merge();
return true;
}
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
if (decision.note) opts.warnings?.push(decision.note);
if (!decision.write) {
if (decision.standing === "edited" || decision.standing === "unrecorded") {
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
}
return false;
}
if (!merge()) return false;
if (!decision.write) return false;
merge();
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
return true;
}
+45 -143
View File
@@ -41,95 +41,15 @@ export function managedBlockText(blockId: string, content: string): string {
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
* run of whitespace, line breaks included, between its words. */
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
}
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
* counted in `unpaired`: where its block ends, or starts, can't be read. */
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
const blocks: FoundManagedBlock[] = [];
let unpaired = 0;
for (let i = 0; i < markers.length; i++) {
const marker = markers[i]!;
const next = markers[i + 1];
if (marker.begin && next && !next.begin) {
const bodyStart = marker.index + marker.length;
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
i++;
} else {
unpaired++;
}
}
return { blocks, unpaired };
}
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
}
/**
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
* of these gets the block at its end.
*/
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
keepLater?: (body: string) => boolean): string | null {
const own = findManagedBlocks(existing, blockId);
if (own.blocks.length === 0 && own.unpaired > 0) return null;
const keptLater: string[] = [];
const edits: Array<{ start: number; end: number; text: string }> = [];
own.blocks.forEach((found, i) => {
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
});
for (const id of new Set(replaceBlockIds)) {
if (id === blockId) continue;
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
}
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
// A block of one id inside another's is left to the outer edit.
const ordered: typeof edits = [];
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
let updated = existing;
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
// (#2372), and blank lines a person left after the last block stay.
return updated.endsWith("\n") ? updated : `${updated}\n`;
}
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
* takes the separator the writer put there. */
function withSeparator(content: string, start: number): number {
let from = start;
while (from > 0 && content[from - 1] === "\n") from--;
return from;
}
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
* the block in `warnings`, when a marker of it has lost its pair. */
export function mergeManagedBlock(
fs: ManagedBlockMergeFsOps,
targetPath: string,
blockId: string,
content: string,
options?: MergeManagedBlockOptions,
): boolean {
): void {
const begin = MANAGED_BLOCK_START(blockId);
const end = MANAGED_BLOCK_END(blockId);
const block = managedBlockText(blockId, content);
if (!fs.exists(targetPath)) {
@@ -137,39 +57,49 @@ export function mergeManagedBlock(
fs.writeFile(targetPath, block);
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
options?.warnings?.push(...warnings);
return true;
return;
}
const existing = fs.readFile(targetPath);
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
if (merged === null) {
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
return false;
}
fs.writeFile(targetPath, merged);
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
return true;
}
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
const replaceableIds = allReplaceIds.filter((id) => {
const candidateBegin = MANAGED_BLOCK_START(id);
const candidateEnd = MANAGED_BLOCK_END(id);
const legacyBegin = LEGACY_BLOCK_START(id);
const legacyEnd = LEGACY_BLOCK_END(id);
return (
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
);
});
/** The note for a marker without its pair left beside a block that was written. */
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
+ "pair was left as it is; remove it or restore its pair";
}
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
* exactly as it is. Markers already in the current or legacy form stay as they are. */
export function withCanonicalMarkers(content: string, blockId: string): string {
let updated = content;
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
const begin = content.slice(found.start, found.bodyStart);
const end = content.slice(found.bodyEnd, found.end);
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
if (exact) continue;
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
if (replaceableIds.length > 0) {
let updated = existing;
for (const id of replaceableIds) {
const candidateBegin = MANAGED_BLOCK_START(id);
const candidateEnd = MANAGED_BLOCK_END(id);
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
// A replacer function inserts the block literally; a replacement string
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
const replacement = id === blockId ? block : "";
updated = updated.replace(regex, () => replacement);
// Legacy marker variant from prior installs — replace with the
// OpenRig form (or strip when not the active block id).
const legacyBegin = LEGACY_BLOCK_START(id);
const legacyEnd = LEGACY_BLOCK_END(id);
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
updated = updated.replace(legacyRegex, () => replacement);
}
if (!updated.includes(begin) || !updated.includes(end)) {
updated = `${updated.trim()}\n\n${block}`.trim();
}
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
// (#2372), and blank lines a person left after the last block stay.
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
return;
}
return updated;
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
}
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
@@ -206,47 +136,19 @@ export function managedBlockOpenings(content: string, blockId: string): number {
* - `current`: it holds the source's text;
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
* - `edited`: it holds something else, which someone wrote by hand;
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
* A block written more than once counts as edited when any copy is. */
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
hash: (body: string) => string): ManagedBlockStanding {
const found = findManagedBlocks(content, blockId);
const bodies = found.blocks.map((block) => block.body);
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
const bodies = managedBlockBodies(content, blockId);
if (bodies.length === 0) return "absent";
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
if (recordedHash === undefined) return "unrecorded";
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
}
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
const { blocks } = findManagedBlocks(content, blockId);
if (blocks.length < 2) return null;
const kept: FoundManagedBlock[] = [];
const removed: FoundManagedBlock[] = [];
for (const block of blocks) {
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
else kept.push(block);
}
const people = kept.filter((block) => !isOpenRigs(block.body));
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
if (removed.length === 0) return null;
let updated = content;
for (const block of removed.sort((a, b) => b.start - a.start)) {
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
const from = withSeparator(content, block.start);
let to = block.end;
if (from === 0) while (updated[to] === "\n") to++;
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
}
return updated;
}
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
import { applyPolicy } from "./install-policy.js";
export type PackageInstallOutcome =
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
| { ok: false; code: "conflict_blocked"; message: string }
| { ok: false; code: "policy_rejected"; message: string }
| { ok: false; code: "manifest_hash_mismatch"; message: string }
@@ -101,7 +101,7 @@ export class PackageInstallService {
}
// Verify
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
const verification = this.installVerifier.verify(result.installId);
if (!verification.passed) {
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
}
@@ -112,7 +112,6 @@ export class PackageInstallService {
packageId: pkg.id,
applied: result.applied.length,
deferred: result.deferred.length,
...(result.notes?.length ? { notes: result.notes } : {}),
};
}
}
@@ -21,17 +21,12 @@
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
// expected normal-state response until a separate publish authorization.
import fs from "node:fs";
import nodePath from "node:path";
import { createHash, randomUUID } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
* (an icon) arrives intact. Optional: without them files are read and written as text. */
readBytes?(path: string): Uint8Array;
writeBytes?(path: string, content: Uint8Array): void;
/** Copy raw bytes, failing if the recovery path already exists. */
copyFileExclusive(source: string, target: string): void;
exists(path: string): boolean;
@@ -165,7 +160,7 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
) {
this.preserveMode(srcPath, destPath);
}
@@ -175,7 +170,7 @@ export class PluginVendorService {
}
}
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
this.fs.mkdirp(targetDir);
@@ -184,34 +179,23 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.write(destPath, content);
this.fs.writeFile(destPath, content);
this.preserveMode(srcPath, destPath);
}
}
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
private read(path: string): FileContent {
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
}
private write(path: string, content: FileContent): void {
if (typeof content === "string") this.fs.writeFile(path, content);
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
}
/** Preserve every replaced file before touching a target, including its version authority. */
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
try {
for (const [rel, content] of new Map(updates)) {
const dest = nodePath.join(targetDir, rel);
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
const backup = nodePath.join(backupDir, rel);
this.fs.mkdirp(nodePath.dirname(backup));
this.fs.copyFileExclusive(dest, backup);
@@ -292,7 +276,7 @@ export class PluginVendorService {
continue;
}
}
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
this.fs.mkdirp(targetDir);
for (const [relPath, content] of updates) {
@@ -300,13 +284,13 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.read(destPath)) === hashContent(content)
hashContent(this.fs.readFile(destPath)) === hashContent(content)
) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.write(destPath, content);
this.fs.writeFile(destPath, content);
this.preserveMode(srcPath, destPath);
}
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
@@ -355,37 +339,6 @@ export class PluginVendorService {
}
}
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
type FileContent = string | Uint8Array;
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
function hashContent(content: FileContent): string {
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
export function nodePluginVendorFs(): PluginVendorFs {
return {
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
readBytes: (p) => fs.readFileSync(p),
writeBytes: (p, c) => fs.writeFileSync(p, c),
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p) => fs.existsSync(p),
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
listFiles: (dir) => {
const r: string[] = [];
/** Collect relative file paths recursively. */
const w = (d: string, pre: string) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
}
};
w(dir, "");
return r;
},
statMode: (p) => fs.statSync(p).mode,
chmod: (p, m) => fs.chmodSync(p, m),
};
}
@@ -2190,6 +2190,51 @@ export class QueueRepository {
.map((row) => ({ qitemId: row.qitem_id, body: row.body ?? "" }));
}
/** Every answer returned to this ask, oldest first, under getHumanAnswer's rule for whoever answered: an ask to a person
* (never an update), a row back to the asker linked by reply_to, from someone who may answer it (never a shared
* channel). An answer row carries the human-answer tag; the unnamed primary user's own is one. An untagged Slack reply
* (from before that tag) counts only when it came before the ask's first tagged answer (in this read's order) and no
* later than the ask's first close, even if it was reopened since, and only each person's first: after an answer or a
* close, a reply on the thread is correspondence. Each gives who, when,
* their words without the footer, the park it answered (null for the ask itself or an older Slack reply), and the
* identity recorded when the answer row was created. An ask answered in place (a runner's permission request, a host
* pairing request) has no answer row: its answer is on the ask. */
answersForAsk(askId: string): Array<{ qitemId: string; by: string; at: string; text: string; episode: string | null;
recordedIdentity: { provenance: string | null; principal: string | null } | null }> {
const ask = this.getById(askId);
if (!this.hasReplyToColumn || !ask || ask.humanIntent === "update" || !this.isPersonAddress(ask.destinationSession)) return [];
const candidates = (this.db.prepare(`SELECT qitem_id, source_session, ts_created,
EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer') AS tagged
FROM queue_items
WHERE reply_to = ? AND destination_session = ?
AND (EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer')
OR (qitem_id LIKE 'qitem-slack-inbound-%' AND COALESCE(summary, '') NOT LIKE '[Recovered after gap] %'))
ORDER BY ts_created, rowid`).all(askId, ask.sourceSession) as Array<{ qitem_id: string; source_session: string; ts_created: string; tagged: number }>)
.filter((row) => !this.isSharedChannel(row.source_session)
&& (this.answersAsk(ask, row.source_session) || (row.tagged === 1 && row.source_session === UNATTRIBUTED_ACTOR)));
const firstTagged = candidates.findIndex((row) => row.tagged === 1);
const history = this.transitionLog.listForQitem(askId);
// The ask's first close, whatever its state now: a reopen doesn't make later thread replies its answers.
const firstClose = history.find((transition) => !isBlockerLive(transition.state))?.ts;
const legacyFirst = new Set<string>();
const rows = candidates.filter((row, index) => row.tagged === 1 || ((firstTagged < 0 || index < firstTagged)
&& (!firstClose || row.ts_created <= firstClose) && !legacyFirst.has(row.source_session) && !!legacyFirst.add(row.source_session)));
if (rows.length === 0) return [];
// An answer to a park's question has the ID made from that park's episode, so the episode is read back from the ID.
const episodes = new Map(history
.filter((transition) => transition.ownerNotificationKind === "human-required")
.map((transition) => `${askId}:${transition.transitionId}`)
.map((key) => [humanAnswerId(askId, key), key] as const));
return rows.flatMap(({ qitem_id }) => {
const row = this.getById(qitem_id);
if (!row) return [];
const created = this.transitionLog.listForQitem(qitem_id)[0];
return [{ qitemId: row.qitemId, by: row.sourceSession, at: row.tsCreated, text: humanAnswerText(row.body),
episode: episodes.get(row.qitemId) ?? null,
recordedIdentity: created ? { provenance: created.identityProvenance, principal: created.identityPrincipal } : null }];
});
}
/** This person's answer to the current question of a decision parked on them, if one was returned. */
currentEpisodeAnswer(qitemId: string, actorSession: string): QueueItem | null {
const episode = this.currentHumanRequiredEpisode(qitemId);
+28 -34
View File
@@ -17,21 +17,25 @@ import nodePath from "node:path";
import { lstatSync } from "node:fs";
import type Database from "better-sqlite3";
import {
MANAGED_BLOCK_START,
MANAGED_BLOCK_END,
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
findManagedBlocks,
mergeManagedBlock,
stripManagedBlocks,
type ManagedBlockMergeFsOps,
isPerSeatRoleGuidance,
isPerSeatRoleStartupFile,
} from "./managed-blocks.js";
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
import { resolveConcreteHint } from "./runtime-adapter.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
import type { NodeStartupSnapshot } from "./types.js";
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
import { guidanceFileIsLink } from "./footprint-choice.js";
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
export interface GuidanceItem {
blockId: string;
@@ -93,10 +97,15 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
} catch { return null; }
}
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
return findManagedBlocks(content, blockId).blocks.length > 0;
const ordered = (start: string, end: string): boolean => {
const begin = content.indexOf(start);
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
};
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
}
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
@@ -190,13 +199,14 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
type BlockState = "complete" | "absent" | "incomplete";
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
* second copy beside one that can't be read. */
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
function blockState(content: string, blockId: string): BlockState {
const found = findManagedBlocks(content, blockId);
if (found.blocks.length > 0) return "complete";
return found.unpaired > 0 ? "incomplete" : "absent";
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
return "absent";
}
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
@@ -218,13 +228,11 @@ export interface GuidanceRepair {
refreshed: string[];
/** #2456: blocks left as they are although they differ from their source, each named with the reason
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
* a kept block's removed repeats (`guidance_block_copies_removed`). */
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
kept: string[];
}
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
"guidance_block_markers_damaged: "];
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
* naming each one, or undefined when none was. */
@@ -253,8 +261,7 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
* reported gap. */
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
@@ -272,27 +279,17 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
for (const item of guidance.items) {
const state = states.get(item.blockId);
if (state === "complete") {
if (item.unresolved) continue;
if (!records || item.unresolved) continue;
let source: string;
try {
if (!fs.exists(item.sourcePath)) continue;
source = fs.readFile(item.sourcePath);
} catch { continue; }
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
if (!records) {
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
continue;
}
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
if (decision.note) repair.kept.push(decision.note);
if (decision.write && decision.standing !== "current") {
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
continue;
}
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
continue;
}
if (state === "incomplete") {
@@ -316,9 +313,6 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
if (toWrite.length === 0) return repair;
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
// next rig down would remove exactly what is added.
// Read again: removing a kept block's repeats above may have changed the file.
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
for (const block of toWrite) {
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
@@ -327,7 +321,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
}
for (const block of toWrite) {
try {
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
} catch (err) {
@@ -0,0 +1,27 @@
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
/** The team-operations contract an app reads before relying on a job's fields: one contract version with per-job
* capabilities, distinct from the build label. A capability is supported, not-supported or unknown; an absent key reads
* unknown. */
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
export type CapabilityValue = "supported" | "not-supported" | "unknown";
/** needs-me: see what needs me, answer as me, follow it through. This build reads it (what needs the person, each
* request's revision and what it's about, the answers returned to it, and a snapshot watermark). Answering under this
* contract (a stable answer identity) and following a request through to its result aren't in it yet; the existing
* answer paths keep working outside it. */
export const TEAM_OPERATIONS_CAPABILITIES: Record<string, CapabilityValue> = {
"needs-me.read": "supported",
"needs-me.answer": "not-supported",
"needs-me.follow": "not-supported",
};
export function teamOperationsCapabilities(info: BuildInfo = BUILD_INFO) {
return {
contract: TEAM_OPERATIONS_CONTRACT,
capabilities: TEAM_OPERATIONS_CAPABILITIES,
// A source run has null build fields rather than an invented version.
build: { semver: info.semver, commit: info.commit },
};
}
+5 -1
View File
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import type { AttentionRead, AttentionItem } from "../attention-surface.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import type { EventBus } from "../domain/event-bus.js";
import { isHumanSeatSessionRef } from "../domain/session-name.js";
import { unansweredQuestions } from "../domain/human-questions.js";
import type { HealthProjectionService } from "../domain/health-detectors.js";
@@ -20,6 +21,8 @@ export function attentionRoutes(): Hono {
app.get("/", c => {
const wanted = c.req.query("item");
const result: AttentionRead = { scope: "instance", readAt: new Date().toISOString(), items: [], sources: [], detail: null, detailError: null };
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
if (eventBus) result.watermark = eventBus.maxSeq();
// Whether an item is in the queue this Feed shows; an item opened by id is shown in full whatever the view.
let inView = (_recipient: string | null | undefined): boolean => true;
const add = (item: AttentionItem, lines: () => string[], files: () => Array<{ label: string; path: string }> = () => []) => {
@@ -86,7 +89,8 @@ export function attentionRoutes(): Hono {
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
// A pairing request is also decided directly on this host's settings page, opened on this request (its row).
...(q.evidenceRef?.startsWith("pair-request:") ? { settingsLink: { category: "instance", focus: q.qitemId, label: "Open pairing settings (Approve or Deny directly)" } } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}`,
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId] };
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
const notSent = unbound || quiet.has(q.qitemId);
const route = unanswered
+1 -3
View File
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
}
// Verify
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
const verification = installVerifier.verify(result.installId);
if (!verification.passed) {
eventBus.emit({ type: "package.install_failed", packageName: resolved.manifest.name, code: "verification_failed", message: "Post-apply verification failed" });
return c.json({
@@ -281,7 +281,6 @@ packagesRoutes.post("/install", async (c) => {
code: "verification_failed",
installId: result.installId,
verification,
...(result.notes?.length ? { notes: result.notes } : {}),
}, 500);
}
@@ -303,7 +302,6 @@ packagesRoutes.post("/install", async (c) => {
conflicts: result.conflicts,
verification,
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
...(result.notes?.length ? { notes: result.notes } : {}),
}, 201);
});
+2
View File
@@ -1546,6 +1546,8 @@ export function queueRoutes(): Hono {
...item,
// #2446: what a settling Mission Control act can be guarded with (expectedRevision).
decisionRevision: repo.decisionRevision(item),
// The answer rows returned to this row, read by their link rather than their text; empty when none came back.
answers: repo.answersForAsk(qitemId),
// A closed row's outcome (its note and reason) lives only in the transition log; omitted while the row is open.
...(closing ? { closingTransition: {
transitionId: closing.transitionId,
+2
View File
@@ -38,6 +38,7 @@ import type { ClaimService } from "./domain/claim-service.js";
import type { SelfAttachService } from "./domain/self-attach-service.js";
import { rigsRoutes } from "./routes/rigs.js";
import { OPERATION_CONTRACT, OPERATION_KINDS } from "./domain/operation-correlation.js";
import { teamOperationsCapabilities } from "./domain/team-operations.js";
import { sessionsRoutes, nodesRoutes, sessionAdminRoutes } from "./routes/sessions.js";
import { adaptersRoutes } from "./routes/adapters.js";
import { eventsRoute } from "./routes/events.js";
@@ -795,6 +796,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/rigs", rigsRoutes);
app.get("/api/operations/capabilities", (c) => c.json({ contract: OPERATION_CONTRACT, kinds: OPERATION_KINDS }));
app.get("/api/team-operations/capabilities", (c) => c.json(teamOperationsCapabilities()));
app.route("/api/rigs/:rigId/sessions", sessionsRoutes);
// Slice 24 — per-rig CMUX workspace launcher.
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
+23 -2
View File
@@ -1049,9 +1049,30 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (repo currently empty as of 2026-05-10; vendored
// copy is the source of truth at v0).
try {
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
const userPluginsDir = getDefaultOpenRigPath("plugins");
const realFs = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => {
const r: string[] = [];
/** Collect relative file paths recursively. */
function w(d: string, pre: string) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
}
}
w(dir, "");
return r;
},
statMode: (p: string) => fs.statSync(p).mode,
chmod: (p: string, m: number) => fs.chmodSync(p, m),
};
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
@@ -1065,7 +1086,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const vendorService = new PluginVendorService({
vendoredAssetsDir,
userPluginsDir,
fs: nodePluginVendorFs(),
fs: realFs,
httpClient,
logger: (...args) => console.log("[openrig]", ...args),
});
@@ -1387,38 +1387,6 @@ exports:
expect(result.errors.some((e) => e.includes("skipped"))).toBe(true);
});
it("a package whose only guidance entry is left as it is still imports the rig, with the note in warnings", async () => {
writePkg(path.join(tmpDir, "test-pkg"), VALID_MANIFEST_YAML, { "skills/helper/SKILL.md": "# Helper" });
const guidanceManifest = `
schema_version: 1
name: guidance-pkg
version: "1.0.0"
summary: Guidance only
compatibility:
runtimes: [claude-code]
exports:
guidance:
- source: guidance/rules.md
name: rules
kind: claude_md
supported_scopes: [project_shared]
default_scope: project_shared
merge_strategy: managed_block
`.trim();
writePkg(path.join(tmpDir, "guidance-pkg"), guidanceManifest, { "guidance/rules.md": "Follow these rules." });
const damaged = "# Project\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), damaged);
const specPath = writeSpec(SPEC_WITH_PACKAGES_YAML.replace(" - ./test-pkg", " - ./test-pkg\n - ./guidance-pkg"));
const result = await buildOrchestrator().bootstrap({ mode: "apply", sourceRef: specPath, autoApprove: true });
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
expect(result.errors).toEqual([]);
expect(result.status).toBe("completed");
expect(result.stages.find((s) => s.stage === "import_rig")?.status).not.toBe("skipped");
expect(result.warnings).toContainEqual(expect.stringMatching(/^Package '\.\/guidance-pkg' \(claude-code\): guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/));
});
// === P7-T05: Bundle source tests ===
// T25: Real bundle bootstrap happy path
+2 -1
View File
@@ -6,6 +6,7 @@ import { queueInboxCheckinsSchema } from "../../src/db/migrations/158_queue_inbo
import { queueItemsHandedOffFromIndexSchema } from "../../src/db/migrations/159_queue_items_handed_off_from_index.js";
import { networkBindingsSchema } from "../../src/db/migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "../../src/db/migrations/161_network_receipts.js";
import { queueItemsReplyToIndexSchema } from "../../src/db/migrations/164_queue_items_reply_to_index.js";
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
@@ -169,7 +170,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, queueItemsReplyToIndexSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -234,48 +234,6 @@ describe("InstallEngine", () => {
expect(fs.readFileSync(targetPath, "utf8")).toBe(`${prefix}<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n`);
});
it("keeps one copy of its block: the first is updated in place and the repeats go", () => {
const pkg = seedPackage();
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
const targetPath = path.join(repoRoot, "AGENTS.md");
const old = "<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->";
fs.writeFileSync(targetPath, `# Header\n\n${old}\n\n${old}\n# Footer\n`);
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
expect(fs.readFileSync(targetPath, "utf8")).toBe(
"# Header\n\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n");
});
it("keeps a later copy someone edited, and every different edit, with the text between", () => {
const pkg = seedPackage();
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
const targetPath = path.join(repoRoot, "AGENTS.md");
const copy = (body: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\n${body}\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->`;
fs.writeFileSync(targetPath, `# Header\n\n${copy("Old content.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
expect(fs.readFileSync(targetPath, "utf8")).toBe(
`# Header\n\n${copy("Updated guidance.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
});
it("installs the rest when a guidance block lost its END: that file is left as it is and named, with no copy added", () => {
const pkg = seedPackage();
const skillSource = writeSource("skills/foo/SKILL.md", "# Foo Skill");
const skillTarget = path.join(repoRoot, ".agents/skills/foo/SKILL.md");
const guidanceSource = writeSource("guidance/AGENTS.md", "Updated guidance.");
const guidanceTarget = path.join(repoRoot, "AGENTS.md");
const text = "# Header\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n# Footer\n";
fs.writeFileSync(guidanceTarget, text);
const skill = makeEntry({ exportName: "foo", targetPath: skillTarget, sourcePath: skillSource });
const guidance = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath: guidanceTarget, sourcePath: guidanceSource });
const result = new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([skill, guidance]), makePlan([skill, guidance]), pkg.id, repoRoot);
expect(fs.readFileSync(guidanceTarget, "utf8")).toBe(text);
expect(fs.readFileSync(skillTarget, "utf8")).toBe("# Foo Skill");
expect(result.applied.map((e) => e.targetPath)).toEqual([skillTarget]);
expect(result.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*AGENTS\.md: test-pkg has a BEGIN or END marker without its pair/)]);
expect(installRepo.getInstall(result.installId)?.status).toBe("applied");
});
// Test 7: Backup created before overwrite
it("backup created before overwrite", () => {
const pkg = seedPackage();
@@ -1,195 +0,0 @@
// A guidance file keeps exactly one copy of each OpenRig-managed block, however often OpenRig writes it: after an editor
// or formatter touched its markers, after copies collected, and when a marker lost its pair. A person's edit is kept.
import { describe, expect, it } from "vitest";
import {
managedBlockStanding,
managedBlockText,
mergeManagedBlock,
type ManagedBlockMergeFsOps,
} from "../src/domain/managed-blocks.js";
import { managedBlockBodyHash, writeManagedGuidanceBlock, type ManagedBlockRecords } from "../src/domain/managed-block-records.js";
import { restoreMissingGuidance } from "../src/domain/restore-guidance.js";
const FILE = "/repo/AGENTS.md";
const SOURCE = "/specs/culture.md";
/** An in-memory guidance file and source, so nothing here touches a real instruction file. */
function memory(files: Record<string, string>): ManagedBlockMergeFsOps & { files: Record<string, string> } {
return {
files,
exists: (p) => p in files,
readFile: (p) => files[p]!,
writeFile: (p, content) => { files[p] = content; },
};
}
function recordsOf(entries: Record<string, string> = {}): ManagedBlockRecords & { entries: Record<string, string> } {
return {
entries,
get: (file, id) => entries[`${file}#${id}`],
set: (file, id, body) => { entries[`${file}#${id}`] = managedBlockBodyHash(body); },
};
}
const count = (text: string, id = "CULTURE.md") => text.split(`<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`).length - 1;
describe("the writer keeps one copy", () => {
it("reads markers a formatter rewrapped as the block, and replaces it in place at every launch", () => {
const touched = "# Notes\n\n<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md\n-->\nold culture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n\nafter\n";
const fs = memory({ [FILE]: touched });
const sizes: number[] = [];
const written: unknown[] = [];
for (let launch = 0; launch < 4; launch++) {
written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "new culture"));
sizes.push(fs.files[FILE]!.length);
}
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n\nafter\n`);
expect(new Set(sizes).size).toBe(1);
expect(written).toEqual([true, true, true, true]);
});
it("collapses copies that collected to the first, and keeps a person's text before, between and after them", () => {
const block = managedBlockText("CULTURE.md", "culture");
const fs = memory({ [FILE]: `# Notes\n\n${block}\n\nperson text\n\n${block}\n\n${block}\n\nthe end\n` });
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
expect(fs.files[FILE]).toBe(`# Notes\n\n${block}\n\nperson text\n\nthe end\n`);
});
it.each([
["a BEGIN whose END was lost", "# Notes\n\n<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK -->\n"],
["an END whose BEGIN was lost", "# Notes\n\n<!-- BEGIN MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n"],
])("adds no copy beside %s, at any launch, and names it", (_name, text) => {
const fs = memory({ [FILE]: text });
const warnings: string[] = [];
const written: unknown[] = [];
for (let launch = 0; launch < 3; launch++) written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "culture", { warnings }));
expect(fs.files[FILE]).toBe(text);
expect(warnings).toHaveLength(3);
expect(written).toEqual([false, false, false]);
expect(warnings[0]).toMatch(/^guidance_block_markers_damaged: \/repo\/AGENTS\.md: CULTURE\.md has a BEGIN or END marker without its pair/);
});
it("still replaces a complete copy when a lost marker sits beside it, leaves that marker, and names it", () => {
const lone = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nstray\n";
const fs = memory({ [FILE]: `${lone}\n${managedBlockText("CULTURE.md", "old")}\n` });
const warnings: string[] = [];
const written = mergeManagedBlock(fs, FILE, "CULTURE.md", "new", { warnings });
expect(fs.files[FILE]).toBe(`${lone}\n${managedBlockText("CULTURE.md", "new")}\n`);
expect(written).toBe(true);
expect(warnings).toEqual([expect.stringMatching(/^guidance_block_marker_unpaired: \/repo\/AGENTS\.md: CULTURE\.md was written/)]);
});
it("appends to a file with no marker at all, as before", () => {
const fs = memory({ [FILE]: "# Notes\n" });
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
expect(fs.files[FILE]).toBe(`# Notes\n\n\n${managedBlockText("CULTURE.md", "culture")}`);
});
it("reads a lost marker as damaged, not absent", () => {
expect(managedBlockStanding("<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash))
.toBe("damaged");
expect(managedBlockStanding("# no block\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash)).toBe("absent");
});
});
describe("a launch's guidance write keeps a person's edit and drops only repeats", () => {
const launch = (fs: ManagedBlockMergeFsOps, records: ManagedBlockRecords, source: string, warnings: string[]) =>
writeManagedGuidanceBlock(fs, FILE, "CULTURE.md", source, { records, sourcePath: SOURCE, warnings });
it("refreshes OpenRig's own copies to one", () => {
const old = managedBlockText("CULTURE.md", "old culture");
const fs = memory({ [FILE]: `# Notes\n\n${old}\n\n${old}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("old culture") });
expect(launch(fs, records, "new culture", [])).toBe(true);
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n`);
});
it("keeps copies a formatter changed as edits, and leaves one of them", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const fs = memory({ [FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n${formatted}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const warnings: string[] = [];
expect(launch(fs, records, "* culture", warnings)).toBe(false);
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n`);
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
});
it("keeps a person's edited copy and drops OpenRig's unedited copy beside it", () => {
const edited = managedBlockText("CULTURE.md", "culture, with my change");
const fs = memory({ [FILE]: `${managedBlockText("CULTURE.md", "culture")}\n\n${edited}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") });
expect(launch(fs, records, "culture", [])).toBe(false);
expect(fs.files[FILE]).toBe(`${edited}\n`);
});
it("keeps two different edited copies: it can't tell which one is meant", () => {
const text = `${managedBlockText("CULTURE.md", "edit one")}\n\n${managedBlockText("CULTURE.md", "edit two")}\n`;
const fs = memory({ [FILE]: text });
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") }), "culture", [])).toBe(false);
expect(fs.files[FILE]).toBe(text);
});
it("records nothing and adds no copy beside a lost marker", () => {
const text = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n";
const fs = memory({ [FILE]: text });
const records = recordsOf();
const warnings: string[] = [];
const written: unknown[] = [];
for (let i = 0; i < 3; i++) written.push(launch(fs, records, "culture", warnings));
expect(fs.files[FILE]).toBe(text);
expect(written).toEqual([false, false, false]);
expect(records.entries).toEqual({});
expect(warnings.every((w) => w.startsWith("guidance_block_markers_damaged: "))).toBe(true);
});
it("keeps going when the file can't be written: the edited block is kept as decided, and the tidy is named", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const text = `${formatted}\n\n${formatted}\n`;
const fs = memory({ [FILE]: text });
fs.writeFile = () => { throw new Error("EACCES: permission denied"); };
const warnings: string[] = [];
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") }), "* culture", warnings)).toBe(false);
expect(fs.files[FILE]).toBe(text);
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_not_removed"]);
});
});
describe("restore keeps one copy", () => {
it("removes repeats of a current block, and a second restore changes nothing", () => {
const current = managedBlockText("CULTURE.md", "* culture");
const fs = memory({ [FILE]: `# Notes\n\n${current}\n\nmine\n\n${current}\n`, [SOURCE]: "* culture" });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const guidance = { targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] };
restoreMissingGuidance(guidance, fs, records);
const once = fs.files[FILE];
restoreMissingGuidance(guidance, fs, records);
expect(once).toBe(`# Notes\n\n${current}\n\nmine\n`);
expect(fs.files[FILE]).toBe(once);
});
it("without write records keeps the block's text, and still removes its exact repeats and puts rewrapped markers back", () => {
const rewrapped = "<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nmy edit\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->";
const fs = memory({ [FILE]: `${rewrapped}\n\n${managedBlockText("CULTURE.md", "my edit")}\n`, [SOURCE]: "* culture" });
restoreMissingGuidance({ targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] }, fs);
expect(fs.files[FILE]).toBe(`${managedBlockText("CULTURE.md", "my edit")}\n`);
});
it("removes a kept block's repeats, and puts back nothing beside a lost marker", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const fs = memory({
[FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`,
[SOURCE]: "* culture",
"/specs/role.md": "role",
});
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const repair = restoreMissingGuidance({ targetPath: FILE, items: [
{ blockId: "CULTURE.md", sourcePath: SOURCE },
{ blockId: "ROLE.md", sourcePath: "/specs/role.md" },
] }, fs, records);
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`);
expect(count(fs.files[FILE]!)).toBe(1);
expect(repair.restored).toEqual([]);
expect(repair.gaps.join("\n")).toContain("ROLE.md: /repo/AGENTS.md holds only part of this block");
expect(repair.kept.map((k) => k.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
});
});
+207
View File
@@ -0,0 +1,207 @@
// team-operations.v1, needs-me.read: what needs the person, each request's revision and what it's about, and the answers
// returned to it, read by their links rather than by searching answer text. Real SQLite (all migrations), the real queue,
// the Mission Control answer path, the queue's single-row read and the attention read. Nothing native.
import { afterEach, describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { queueRoutes } from "../src/routes/queue.js";
import { attentionRoutes } from "../src/routes/attention.js";
import type { AttentionRead } from "../src/attention-surface.js";
import { teamOperationsCapabilities } from "../src/domain/team-operations.js";
const PERSON = "human-test@external", ASKER = "requester@test-rig";
const registry = { ok: true as const, entities: [{ entityId: "human-test", class: "human" as const, displayName: "human-test",
address: PERSON, connectorBindings: [], prefs: { deliveryClass: "A" as const } }] };
const QUESTIONS = [
{ id: "q1", question: "Ship it?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
{ id: "q2", question: "Tell the team?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
];
const closes: Array<() => void> = [];
afterEach(() => { for (const close of closes.splice(0)) close(); });
function fixture() {
const db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db);
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
c.set("missionControlWriteContract" as never, contract as never);
c.set("settingsStore" as never, { resolveOne: () => ({ value: "/nonexistent-needs-me-fixture" }) } as never);
await next();
});
app.route("/api/mission-control", missionControlRoutes());
app.route("/api/queue", queueRoutes());
app.route("/api/attention", attentionRoutes());
closes.push(() => db.close());
const ask = async () => (await repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Synthetic two-question ask",
summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
const read = async (id: string) => (await app.request(`/api/queue/${id}`)).json() as Promise<Record<string, any>>;
const attention = async () => (await app.request("/api/attention")).json() as Promise<AttentionRead>;
const act = async (body: Record<string, unknown>) => {
const response = await app.request("/api/mission-control/action", { method: "POST",
headers: { "Content-Type": "application/json", "X-OpenRig-Session": PERSON }, body: JSON.stringify({ actorSession: PERSON, ...body }) });
return { status: response.status, body: await response.json() as Record<string, any> };
};
return { db, bus, repo, ask, read, attention, act };
}
describe("needs-me.read: answers on the ask, by link", () => {
it("an ask lists no answer until its set is returned, then the one answer row, with who, when and the words", async () => {
const f = fixture(); const id = await f.ask();
expect((await f.read(id)).answers).toEqual([]);
expect(await f.act({ verb: "answer", qitemId: id, questionId: "q1", optionId: "yes" })).toMatchObject({ status: 200 });
// A partial answer is recorded on the ask (humanAnswers); nothing has been returned to the asker yet.
expect(await f.read(id)).toMatchObject({ humanAnswers: { q1: "yes" }, answers: [] });
const done = await f.act({ verb: "answer", qitemId: id, questionId: "q2", optionId: "no" });
expect(done).toMatchObject({ status: 200 });
const { answers } = await f.read(id);
expect(answers).toEqual([{ qitemId: done.body.createdQitemId, by: PERSON, at: expect.any(String),
text: "Ship it?: Yes\nTell the team?: No", episode: null, recordedIdentity: expect.anything() }]);
// The other direction is the answer row's own link.
expect(await f.read(answers[0].qitemId)).toMatchObject({ replyTo: id, answers: [] });
});
it("an answer in the person's words reads back as those words, without the footer", async () => {
const f = fixture(); const id = await f.ask();
expect(await f.act({ verb: "answer", qitemId: id, decision: "Hold until Friday" })).toMatchObject({ status: 200 });
expect((await f.read(id)).answers).toMatchObject([{ by: PERSON, text: "Hold until Friday" }]);
});
it("names the park an answer belongs to when the ask is parked on its person", async () => {
const f = fixture(); const id = await f.ask();
f.repo.update({ qitemId: id, actorSession: ASKER, state: "blocked", blockedOn: PERSON, summary: "Still need your call",
evidenceRef: "/fixture/decision", transitionNote: "parked on its person" });
const episode = f.repo.currentHumanRequiredEpisode(id)!.key;
// As the Slack path records a parked ask's answer: under that park's question.
const parked = await f.repo.createHumanAnswer({ sourceSession: PERSON, destinationSession: ASKER, body: "Go ahead\n\n---\nIn reply to: x",
tags: ["human-answer"], inboundReplyTo: id, episodeKey: episode, nudge: false });
expect((await f.read(id)).answers).toMatchObject([{ qitemId: parked.qitemId, by: PERSON, text: "Go ahead", episode }]);
});
it("counts only real answers: not correspondence, another reply after the answer, a reply to an update, or a row to someone else", async () => {
const f = fixture();
const legacy = (qitemId: string, replyTo: string, source: string, destination: string, at = new Date().toISOString()) => {
f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
.run(qitemId, at, at, source, destination, "pending", "routine", `Words in ${qitemId}`, replyTo, "[]");
};
// An older Slack reply from before the answer tag: the asked person's first one counts, and a second one doesn't.
const old = await f.ask();
legacy("qitem-slack-inbound-old-1", old, PERSON, ASKER);
legacy("qitem-slack-inbound-old-2", old, PERSON, ASKER);
// A reply from someone who can't answer the ask, and one sent to someone other than the asker.
legacy("qitem-slack-inbound-agent", old, "owner@test-rig", ASKER);
legacy("qitem-slack-inbound-elsewhere", old, PERSON, "owner@test-rig");
expect((await f.read(old)).answers).toEqual([expect.objectContaining({ qitemId: "qitem-slack-inbound-old-1", episode: null })]);
// Once a tagged answer exists, a later untagged reply on the ask is correspondence, not a second answer.
const id = await f.ask();
const done = await f.act({ verb: "answer", qitemId: id, decision: "Ship it" });
legacy("qitem-slack-inbound-later", id, PERSON, ASKER);
expect((await f.read(id)).answers).toEqual([expect.objectContaining({ qitemId: done.body.createdQitemId, text: "Ship it" })]);
// Replies posted to an update's thread are never its answers.
const update = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "FYI", humanIntent: "update", nudge: false })).qitemId;
legacy("qitem-slack-inbound-on-update", update, PERSON, ASKER);
expect((await f.read(update)).answers).toEqual([]);
// Closed with no answer row (answered in place), then a reply on its thread: correspondence, not its answer.
const inPlace = await f.ask();
f.repo.update({ qitemId: inPlace, actorSession: PERSON, state: "done", closureReason: "no-follow-on", transitionNote: "decided in place" });
legacy("qitem-slack-inbound-after-close", inPlace, PERSON, ASKER, new Date(Date.now() + 60_000).toISOString());
expect((await f.read(inPlace)).answers).toEqual([]);
// Reopening the ask, or closing it again later, doesn't make that reply its answer.
f.repo.update({ qitemId: inPlace, actorSession: ASKER, state: "pending", reopen: true, transitionNote: "reopened to ask again" });
expect((await f.read(inPlace)).answers).toEqual([]);
f.repo.update({ qitemId: inPlace, actorSession: PERSON, state: "done", closureReason: "no-follow-on", transitionNote: "decided again" });
expect((await f.read(inPlace)).answers).toEqual([]);
});
it("at the same time as the first tagged answer, an older reply counts only when it was recorded first", async () => {
const f = fixture();
const at = new Date(Date.now() - 60_000).toISOString();
const insert = (qitemId: string, askId: string, tags: string) => f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
.run(qitemId, at, at, PERSON, ASKER, "pending", "routine", `Words in ${qitemId}`, askId, tags);
const legacyFirst = await f.ask();
insert("qitem-slack-inbound-tie-first", legacyFirst, "[]");
insert("qitem-tagged-tie-second", legacyFirst, '["human-answer"]');
expect((await f.read(legacyFirst)).answers.map((a: { qitemId: string }) => a.qitemId)).toEqual(["qitem-slack-inbound-tie-first", "qitem-tagged-tie-second"]);
const taggedFirst = await f.ask();
insert("qitem-tagged-tie-first", taggedFirst, '["human-answer"]');
insert("qitem-slack-inbound-tie-second", taggedFirst, "[]");
expect((await f.read(taggedFirst)).answers.map((a: { qitemId: string }) => a.qitemId)).toEqual(["qitem-tagged-tie-first"]);
});
it("keeps a real older Slack answer that came before a re-park's tagged answer, and lists both", async () => {
const f = fixture(); const id = await f.ask();
const at = new Date(Date.now() - 60_000).toISOString();
f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
.run("qitem-slack-inbound-before-tag", at, at, PERSON, ASKER, "pending", "routine", "Yes, ship it", id, "[]");
// That answer closed the ask; later it was deliberately reopened onto its person, and answered again under the tag.
f.repo.update({ qitemId: id, actorSession: ASKER, state: "done", closureReason: "no-follow-on", transitionNote: "answered in Slack" });
f.repo.update({ qitemId: id, actorSession: ASKER, state: "blocked", blockedOn: PERSON, reopen: true, summary: "One more call",
evidenceRef: "/fixture/decision", transitionNote: "reopened for a follow-up decision" });
const episode = f.repo.currentHumanRequiredEpisode(id)!.key;
const parked = await f.repo.createHumanAnswer({ sourceSession: PERSON, destinationSession: ASKER, body: "Also tell the team",
tags: ["human-answer"], inboundReplyTo: id, episodeKey: episode, nudge: false });
expect((await f.read(id)).answers).toMatchObject([
{ qitemId: "qitem-slack-inbound-before-tag", text: "Yes, ship it", episode: null },
{ qitemId: parked.qitemId, text: "Also tell the team", episode },
]);
});
it("finds an ask's replies through the reply_to index, not a scan of the queue", async () => {
const f = fixture(); const id = await f.ask();
const replyToQueries = new Set<string>();
const prepare = f.db.prepare.bind(f.db);
f.db.prepare = ((sql: string) => {
if (/reply_to = \?/.test(sql)) replyToQueries.add(sql);
return prepare(sql);
}) as typeof f.db.prepare;
await f.read(id);
await f.attention();
f.db.prepare = prepare;
// The answer lookup and the decision revision's reply lookups, as the reads actually ran them.
expect(replyToQueries.size).toBeGreaterThanOrEqual(2);
for (const sql of replyToQueries) {
const params = (sql.match(/\?/g) ?? []).map(() => "x");
const plan = (f.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{ detail: string }>).map((row) => row.detail).join("\n");
expect(plan, sql).toMatch(/SEARCH queue_items USING INDEX idx_queue_items_reply_to/);
}
});
});
describe("needs-me.read: the attention snapshot", () => {
it("gives each request its revision and what it's about, and a watermark read before the snapshot", async () => {
const f = fixture(); const id = await f.ask();
const waiting = await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: "Waits on the ask", nudge: false });
f.repo.claim({ qitemId: waiting.qitemId, destinationSession: "owner@test-rig" });
f.repo.update({ qitemId: waiting.qitemId, actorSession: "owner@test-rig", state: "blocked", blockedOn: id });
const park = await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: "Parked on the person", nudge: false });
f.repo.claim({ qitemId: park.qitemId, destinationSession: "owner@test-rig" });
f.repo.update({ qitemId: park.qitemId, actorSession: "owner@test-rig", state: "blocked", blockedOn: PERSON });
const before = f.bus.maxSeq();
const snapshot = await f.attention();
expect(snapshot.watermark).toBe(before);
const item = (qitemId: string) => snapshot.items.find((i) => i.id === `queue:${qitemId}`);
expect(item(id)).toMatchObject({ revision: (await f.read(id)).decisionRevision, about: [waiting.qitemId] });
expect(item(park.qitemId)).toMatchObject({ revision: (await f.read(park.qitemId)).decisionRevision, about: [park.qitemId] });
});
});
describe("team-operations.v1 capabilities", () => {
it("names the contract, needs-me's capabilities and the build label separately", () => {
expect(teamOperationsCapabilities({ semver: "9.9.9", commit: "abc", dirty: false, builtAt: null })).toEqual({
contract: "team-operations.v1",
capabilities: { "needs-me.read": "supported", "needs-me.answer": "not-supported", "needs-me.follow": "not-supported" },
build: { semver: "9.9.9", commit: "abc" },
});
expect(teamOperationsCapabilities().build).toEqual({ semver: null, commit: null });
});
});
@@ -90,8 +90,7 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
// Hook + skills wiring
expect(manifest["skills"]).toBe("./skills");
expect(manifest["hooks"]).toBe("./hooks/claude.json");
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
expect(manifest["license"]).toBeDefined();
});
@@ -322,32 +322,6 @@ roles:
expect(claudeMd).toContain("# Existing content");
});
it("POST /api/packages/install whose only guidance entry is left as it is → 201 with the note, verified", async () => {
writePkg(pkgDir, GUIDANCE_MANIFEST_YAML, {
"guidance/rules.md": "Follow these rules.",
});
const damaged = "# Existing content\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), damaged);
const res = await app.request("/api/packages/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceRef: pkgDir,
targetRoot: targetDir,
runtime: "claude-code",
allowMerge: true,
}),
});
expect(fs.readFileSync(path.join(targetDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.applied).toEqual([]);
expect(body.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/)]);
expect(body.verification.passed).toBe(true);
});
// --- Test 8: POST /install mixed policy: skills approved, guidance rejected ---
it("POST /api/packages/install mixed policy → 201 with applied + policyRejected", async () => {
writePkg(pkgDir, MIXED_MANIFEST_YAML, {
@@ -1,74 +0,0 @@
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
// must arrive exactly as it ships, and a text file must arrive as it always did.
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
const httpClient = async () => ({ ok: false, status: 404 });
function scratch(): { assets: string; plugins: string } {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
dirs.push(root);
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
}
function shipPlugin(assets: string, version: string, icon = ICON): void {
const plugin = nodePath.join(assets, "openrig");
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
}
const service = (assets: string, plugins: string) =>
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
describe("vendoring a plugin with a binary file", () => {
it("installs the icon byte for byte, and its text files as before", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
expect(sha(installed)).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
await service(assets, plugins).ensureVendored("openrig");
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
expect(fs.existsSync(backups)).toBe(false);
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
fs.rmSync(assets, { recursive: true });
shipPlugin(assets, "0.2.0", newer);
await service(assets, plugins).ensureVendored("openrig");
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
.filter((path) => fs.existsSync(path));
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
});
it("projects a skill's binary file into a global skill root byte for byte", () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
});