mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-02 08:35:15 +08:00
* feat(daemon): let a rig choose CLAUDE.local.md for Claude managed blocks (#25) A rig spec can now declare managed_blocks: claude-code: CLAUDE.local.md so OpenRig writes its managed instruction blocks for Claude Code members to CLAUDE.local.md instead of a git-tracked CLAUDE.md. The default stays CLAUDE.md and Codex members stay on AGENTS.md. - Validation accepts only the claude-code key with CLAUDE.md or CLAUDE.local.md, and rejects anything else before a member launches. - The selection is stored on the rig (migration 085) and bound once in StartupOrchestrator.startNode, so launch, restore replay, relaunch, continue and added members all write the same file. Startup guidance_merge, profile managed_block projection and the conflict target all use it. - Teardown cleans only the selected file. The other file is never edited, moved or deleted; blocks already written to CLAUDE.md stay until the user removes them, as the reference docs explain. - Export and the bundle rig.yaml rewrite round-trip the field. Reported and proposed by @hvpaiva. * docs(daemon): say accurately which paths deliver the Claude managed-block file Handover launches the successor directly and writes no guidance, so the startNode and migration comments no longer list it among the delivery paths. Comment-only change. * release: prepare 0.5.16 Set the version to 0.5.16 and finalize the changelog and release notes for the Claude instruction-file option (#25), the advisory portability report (#56) and the README update (#54). * docs: keep uncommitted edits safe when removing old Claude blocks Recommend deleting only the managed sections by hand, and warn that git restore CLAUDE.md discards every unstaged change to the file. Say that portability findings never fail the check while operational errors still do. --------- Co-authored-by: OpenRig contributors <noreply@openrig.dev>
This commit is contained in:
co-authored by
OpenRig contributors
parent
bee173902a
commit
5c54703035
@@ -8,6 +8,24 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.5.16]
|
||||
|
||||
- Let a rig write Claude Code's managed instruction blocks to `CLAUDE.local.md`
|
||||
instead of a tracked `CLAUDE.md`, with `managed_blocks: { claude-code: CLAUDE.local.md }`.
|
||||
The default stays `CLAUDE.md`, and Codex stays on `AGENTS.md`. Blocks already
|
||||
written to `CLAUDE.md` are not moved; remove them by hand after switching.
|
||||
Thanks to [@hvpaiva](https://github.com/hvpaiva) for reporting and proposing
|
||||
this ([#25](https://github.com/mvschwarz/openrig/issues/25)).
|
||||
- Add an advisory portability report for pull requests. It lists added lines
|
||||
that contain machine-, network- or account-specific values. Findings never
|
||||
fail the check; operational errors, such as a git failure, still do.
|
||||
- Show badges and a short demo of agents working in the README.
|
||||
|
||||
Includes [#54](https://github.com/mvschwarz/openrig/pull/54) and
|
||||
[#56](https://github.com/mvschwarz/openrig/pull/56) by
|
||||
[@mvschwarz](https://github.com/mvschwarz). The Node support range and SQLite
|
||||
version are unchanged.
|
||||
|
||||
## [0.5.15]
|
||||
|
||||
- Recognize Codex through shell and Node launchers during startup and recovery,
|
||||
|
||||
@@ -175,6 +175,7 @@ edges:
|
||||
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
|
||||
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
|
||||
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute). Absent leaves the default floor. A member may set its own `permission_policy`, which takes precedence over the rig-level one. See "Attaching a permission policy" below. |
|
||||
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex members always use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
|
||||
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
|
||||
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
|
||||
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
|
||||
@@ -205,6 +206,36 @@ application and inspection. In particular, `builtin:yolo` selects Codex's
|
||||
`danger-full-access` sandbox without forcing its approval policy, and replaces
|
||||
any `codex_config_profile` argument. See [practical permission choices](getting-started.md#opt-in-permissive-operation).
|
||||
|
||||
### Choosing the Claude instruction file
|
||||
|
||||
OpenRig writes its instructions for Claude Code members into managed blocks in
|
||||
the member's working directory. By default the file is `CLAUDE.md`. If your
|
||||
repository tracks `CLAUDE.md`, write the blocks to `CLAUDE.local.md` instead:
|
||||
|
||||
```yaml
|
||||
managed_blocks:
|
||||
claude-code: CLAUDE.local.md
|
||||
```
|
||||
|
||||
Claude Code loads `CLAUDE.local.md` from the working directory as well. By
|
||||
convention the file is kept out of git, for example with a `.gitignore` entry.
|
||||
|
||||
- Accepted values are `CLAUDE.md` and `CLAUDE.local.md`. Any other value or
|
||||
runtime key is rejected before a member launches.
|
||||
- The setting applies to launch, restore, relaunch, handover, adding members,
|
||||
and export. `rig down` removes OpenRig's blocks from the selected file only.
|
||||
- OpenRig never edits, moves or deletes blocks in the other file.
|
||||
|
||||
A rig that already wrote blocks into `CLAUDE.md` keeps them there after you
|
||||
switch. Until you remove them, `CLAUDE.md` stays modified and Claude Code loads
|
||||
both copies. Delete each `<!-- BEGIN OpenRig MANAGED BLOCK: … -->` …
|
||||
`<!-- END OpenRig MANAGED BLOCK: … -->` section by hand and keep the rest of the
|
||||
file. If `CLAUDE.md` has no other uncommitted edits you need to keep, you can
|
||||
instead run `git restore CLAUDE.md`; that command discards every unstaged change
|
||||
to the file, not only OpenRig's blocks. Running `rig down` on a rig that still uses
|
||||
the default is not a substitute: it strips every OpenRig block from that
|
||||
directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
|
||||
---
|
||||
|
||||
## Pod
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# OpenRig v0.5.16 — choose the Claude instruction file
|
||||
|
||||
Changes for version **0.5.16**. Publication status and date are recorded on the
|
||||
[GitHub release](https://github.com/mvschwarz/openrig/releases/tag/v0.5.16) and npm.
|
||||
|
||||
OpenRig 0.5.16 lets a rig keep its managed Claude Code instructions out of a
|
||||
tracked `CLAUDE.md`, and adds an advisory portability report for pull requests.
|
||||
|
||||
## Keep a tracked CLAUDE.md unchanged
|
||||
|
||||
OpenRig writes its instructions for Claude Code members into managed blocks in
|
||||
each member's working directory. By default that file is `CLAUDE.md`. If your
|
||||
repository tracks `CLAUDE.md`, select `CLAUDE.local.md` in the rig spec:
|
||||
|
||||
```yaml
|
||||
managed_blocks:
|
||||
claude-code: CLAUDE.local.md
|
||||
```
|
||||
|
||||
Claude Code loads `CLAUDE.local.md` from the working directory as well; keep it
|
||||
out of git, for example with a `.gitignore` entry. The selection applies when
|
||||
members launch, restore, relaunch or are added, and it is kept by `rig export`
|
||||
and rig bundles. `rig down` removes OpenRig's blocks only from the selected file.
|
||||
OpenRig never edits, moves or deletes blocks in the other file.
|
||||
|
||||
Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`;
|
||||
any other key or value is rejected before a member launches. Without the field,
|
||||
behavior is unchanged. Codex members always use `AGENTS.md`.
|
||||
|
||||
**Switching an existing rig:** blocks OpenRig already wrote into `CLAUDE.md`
|
||||
stay there. Until you remove them, the file stays modified and Claude Code loads
|
||||
both copies. Delete each `<!-- BEGIN OpenRig MANAGED BLOCK: … -->` …
|
||||
`<!-- END OpenRig MANAGED BLOCK: … -->` section by hand and keep the rest of the
|
||||
file. If `CLAUDE.md` has no other uncommitted edits you need to keep, you can
|
||||
instead run `git restore CLAUDE.md`; that command discards every unstaged change
|
||||
to the file, not only OpenRig's blocks. See
|
||||
[the RigSpec reference](../reference/rig-spec.md#choosing-the-claude-instruction-file).
|
||||
|
||||
## Portability report for pull requests
|
||||
|
||||
A new pull-request workflow lists added lines that contain values tied to one
|
||||
machine, network or account: credentials, home and temporary paths, network
|
||||
addresses and email addresses. Suspected credentials are withheld from the
|
||||
report. Findings never fail the check; the author decides what to change. It is
|
||||
pattern-based and does not guarantee that nothing private remains. Run it
|
||||
locally with:
|
||||
|
||||
```sh
|
||||
node scripts/portability-report.mjs
|
||||
```
|
||||
|
||||
## Contributors and included pull requests
|
||||
|
||||
Thanks to [@hvpaiva](https://github.com/hvpaiva) for reporting and proposing the
|
||||
Claude instruction-file option in [#25](https://github.com/mvschwarz/openrig/issues/25),
|
||||
and to [@mvschwarz](https://github.com/mvschwarz) for the README badges and demo
|
||||
([#54](https://github.com/mvschwarz/openrig/pull/54)) and the portability report
|
||||
([#56](https://github.com/mvschwarz/openrig/pull/56)).
|
||||
|
||||
The Node support range and SQLite version are unchanged. The macOS arm64 with
|
||||
Node 24 SQLite installation limitation noted for 0.5.15 still applies; use
|
||||
Node 22 on macOS arm64.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7414,7 +7414,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"bundleDependencies": [
|
||||
"@openrig/daemon"
|
||||
],
|
||||
@@ -7452,7 +7452,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7493,7 +7493,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { mergeManagedBlock } from "../domain/managed-blocks.js";
|
||||
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery } from "../domain/claude-activity-hooks.js";
|
||||
import { observeClaudePermission } from "../domain/permission-drift.js";
|
||||
@@ -114,7 +114,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
try {
|
||||
const didProject = this.projectEntry(entry, binding.cwd);
|
||||
const didProject = this.projectEntry(entry, binding.cwd, binding.claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE);
|
||||
if (didProject) {
|
||||
projected.push(entry.effectiveId);
|
||||
} else {
|
||||
@@ -174,7 +174,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
switch (hint) {
|
||||
case "guidance_merge": {
|
||||
const targetPath = nodePath.join(binding.cwd, "CLAUDE.md");
|
||||
const targetPath = nodePath.join(binding.cwd, binding.claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE);
|
||||
const merged = this.mergeGuidance(targetPath, file.path, content);
|
||||
if (!merged) continue; // rig-role skip: do not count as delivered
|
||||
break;
|
||||
@@ -421,13 +421,13 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: "Claude resume failed: timed out waiting for Claude to become active" };
|
||||
}
|
||||
|
||||
private projectEntry(entry: ProjectionEntry, cwd: string): boolean {
|
||||
private projectEntry(entry: ProjectionEntry, cwd: string, managedBlockFile: ClaudeManagedBlockFile): boolean {
|
||||
if (entry.category === "runtime_resource" && this.applyRuntimeResource(entry, cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
|
||||
const targetPath = nodePath.join(cwd, "CLAUDE.md");
|
||||
const targetPath = nodePath.join(cwd, managedBlockFile);
|
||||
const content = this.fs.readFile(entry.absolutePath);
|
||||
return this.mergeGuidance(targetPath, entry.effectiveId, content);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The canonical ordered migration list (001 → 084). SINGLE SOURCE: the daemon boot path
|
||||
// The canonical ordered migration list (001 → 085). SINGLE SOURCE: the daemon boot path
|
||||
// (startup.ts) and any test/tool that needs a schema-faithful DB both migrate from THIS array,
|
||||
// so a reader DB is never seeded from a stale hand-copied subset (the perf-fixture-migration-parity
|
||||
// trap). Append new migrations to the END, in order.
|
||||
@@ -86,10 +86,11 @@ import { humanNotificationIntentSchema } from "./migrations/081_human_notificati
|
||||
import { archiveIdentityProvenanceSchema } from "./migrations/082_archive_identity_provenance.js";
|
||||
import { reviewReadIndexesSchema } from "./migrations/083_review_read_indexes.js";
|
||||
import { inventoryEventIndexesSchema } from "./migrations/084_inventory_event_indexes.js";
|
||||
import { rigClaudeManagedBlockFileSchema } from "./migrations/085_rig_claude_managed_block_file.js";
|
||||
import { scopedOperatingPostureSchema } from "./migrations/080_scoped_operating_posture.js";
|
||||
import type { Migration } from "./migrate.js";
|
||||
|
||||
/** Ordered 001→084 (068 enforcer decisions, dropped forward by 071; 069/070 W3 launch truth). */
|
||||
/** Ordered 001→085 (068 enforcer decisions, dropped forward by 071; 069/070 W3 launch truth). */
|
||||
export const ALL_MIGRATIONS: Migration[] = [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
@@ -175,4 +176,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
archiveIdentityProvenanceSchema,
|
||||
reviewReadIndexesSchema,
|
||||
inventoryEventIndexesSchema,
|
||||
rigClaudeManagedBlockFileSchema,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* #25 — rigs.claude_managed_block_file column.
|
||||
*
|
||||
* Holds the rig-level `managed_blocks.claude-code` selection (`CLAUDE.md` or
|
||||
* `CLAUDE.local.md`), written by RigRepository.setRigClaudeManagedBlockFile at
|
||||
* instantiate time. Startup delivery, teardown and export read it back, so
|
||||
* launch, restore replay, relaunch and added members write the same file.
|
||||
* Handover writes no guidance; the successor reads the existing file.
|
||||
* NULL = the CLAUDE.md default. Mirrors migration 056 (rigs.permission_policy).
|
||||
*/
|
||||
export const rigClaudeManagedBlockFileSchema: Migration = {
|
||||
name: "085_rig_claude_managed_block_file.sql",
|
||||
sql: `
|
||||
ALTER TABLE rigs ADD COLUMN claude_managed_block_file TEXT;
|
||||
`,
|
||||
};
|
||||
@@ -7,6 +7,13 @@ import nodePath from "node:path";
|
||||
export const MANAGED_BLOCK_START = (id: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`;
|
||||
export const MANAGED_BLOCK_END = (id: string) => `<!-- END OpenRig MANAGED BLOCK: ${id} -->`;
|
||||
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
// #25 — the file a rig's Claude Code seats receive managed blocks in
|
||||
// (`managed_blocks: { claude-code: <file> }`). This is the set supported in
|
||||
// this release, not every file Claude Code can load.
|
||||
export const CLAUDE_MANAGED_BLOCK_FILES = ["CLAUDE.md", "CLAUDE.local.md"] as const;
|
||||
export type ClaudeManagedBlockFile = (typeof CLAUDE_MANAGED_BLOCK_FILES)[number];
|
||||
export const DEFAULT_CLAUDE_MANAGED_BLOCK_FILE: ClaudeManagedBlockFile = "CLAUDE.md";
|
||||
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
export interface ManagedBlockMergeFsOps {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { classifyResourceProjection } from "./conflict-detector.js";
|
||||
import type { ResolvedNodeConfig, QualifiedResource, ResolvedResources } from "./profile-resolver.js";
|
||||
import type { ResourceCollision } from "./agent-resolver.js";
|
||||
import type { ResolvedStartupFile } from "./runtime-adapter.js";
|
||||
import { DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "./managed-blocks.js";
|
||||
|
||||
// -- Types --
|
||||
|
||||
@@ -292,6 +293,7 @@ export function claudeConflictTargetPath(
|
||||
effectiveId: string,
|
||||
cwd: string,
|
||||
sourcePath?: string,
|
||||
managedBlockFile: ClaudeManagedBlockFile = DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
|
||||
): string | null {
|
||||
switch (category) {
|
||||
case "skill":
|
||||
@@ -299,7 +301,7 @@ export function claudeConflictTargetPath(
|
||||
case "subagent":
|
||||
return sourcePath ? nodePath.join(cwd, ".claude", "agents", nodePath.basename(sourcePath)) : null;
|
||||
case "guidance":
|
||||
return nodePath.join(cwd, "CLAUDE.md");
|
||||
return nodePath.join(cwd, managedBlockFile);
|
||||
default:
|
||||
return null; // plugin / runtime_resource: merged or dir-shaped — deferred
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveActiveOccupantRow } from "./active-occupant.js";
|
||||
import { resolve } from "node:path";
|
||||
import { ulid } from "ulid";
|
||||
import { deriveComposeProjectName } from "./compose-project-name.js";
|
||||
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
|
||||
import type {
|
||||
Rig,
|
||||
Node,
|
||||
@@ -205,6 +206,22 @@ export class RigRepository {
|
||||
return row?.permission_policy ?? null;
|
||||
}
|
||||
|
||||
/** #25 — persist the rig's selected Claude managed-block file (migration 085), or null for
|
||||
* the CLAUDE.md default. Mirrors setRigPermissionPolicy. */
|
||||
setRigClaudeManagedBlockFile(rigId: string, file: ClaudeManagedBlockFile | null): void {
|
||||
if (!this.hasRigColumn("claude_managed_block_file")) return;
|
||||
this.db.prepare("UPDATE rigs SET claude_managed_block_file = ?, updated_at = ? WHERE id = ?")
|
||||
.run(file ?? null, new Date().toISOString(), rigId);
|
||||
}
|
||||
|
||||
/** #25 — the rig's selected Claude managed-block file, or null when it uses the default. */
|
||||
getRigClaudeManagedBlockFile(rigId: string): ClaudeManagedBlockFile | null {
|
||||
if (!this.hasRigColumn("claude_managed_block_file")) return null;
|
||||
const row = this.db.prepare("SELECT claude_managed_block_file FROM rigs WHERE id = ?")
|
||||
.get(rigId) as { claude_managed_block_file: ClaudeManagedBlockFile | null } | undefined;
|
||||
return row?.claude_managed_block_file ?? null;
|
||||
}
|
||||
|
||||
/** Seam B Guard-F1 — persist the RIG-level resolved attachment provenance (migration 058).
|
||||
* declaringDir = the ORIGINAL declaring RigSpec dir; consumers must never re-resolve the
|
||||
* raw relative ref against an unrelated operation root. No-op on pre-058 fixture DBs. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RigNotFoundError } from "./errors.js";
|
||||
import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
|
||||
import { removeManagedBlocksFromFile, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE } from "./managed-blocks.js";
|
||||
import { stopTranscriptRotation } from "./transcript-rotation.js";
|
||||
|
||||
export interface TeardownResult {
|
||||
@@ -124,7 +124,7 @@ export class RigTeardownOrchestrator {
|
||||
if (killResult.ok || (killResult as { code?: string }).code === "session_not_found") {
|
||||
// Success or already gone — update DB atomically
|
||||
this.atomicNodeCleanup(session);
|
||||
this.cleanupManagedGuidanceFileForNode(session.runtime, session.cwd);
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, session.runtime, session.cwd);
|
||||
result.sessionsKilled++;
|
||||
} else {
|
||||
// Real kill failure — don't update this node
|
||||
@@ -206,16 +206,17 @@ export class RigTeardownOrchestrator {
|
||||
WHERE rig_id = ?
|
||||
`).all(rigId) as Array<{ runtime: string | null; cwd: string | null }>;
|
||||
for (const row of rows) {
|
||||
this.cleanupManagedGuidanceFileForNode(row.runtime, row.cwd);
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, row.runtime, row.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupManagedGuidanceFileForNode(runtime: string | null, cwd: string | null): void {
|
||||
private cleanupManagedGuidanceFileForNode(rigId: string, runtime: string | null, cwd: string | null): void {
|
||||
if (!runtime || !cwd) {
|
||||
return;
|
||||
}
|
||||
// #25: clean only the rig's selected Claude file; the other file is never touched.
|
||||
const targetPath = runtime === "claude-code"
|
||||
? nodePath.join(cwd, "CLAUDE.md")
|
||||
? nodePath.join(cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId) ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
|
||||
: runtime === "codex"
|
||||
? nodePath.join(cwd, "AGENTS.md")
|
||||
: null;
|
||||
|
||||
@@ -102,6 +102,7 @@ export class RigSpecCodec {
|
||||
if (spec.cultureFile) doc["culture_file"] = spec.cultureFile;
|
||||
// OPR.0.4.8.3 Seam B: rig-level permission_policy ref round-trips through serialization.
|
||||
if (spec.permissionPolicy) doc["permission_policy"] = spec.permissionPolicy;
|
||||
if (spec.managedBlocks) doc["managed_blocks"] = { ...spec.managedBlocks };
|
||||
if (spec.docs && spec.docs.length > 0) doc["docs"] = spec.docs.map((d) => ({ path: d.path }));
|
||||
if (spec.startup) doc["startup"] = serializeStartupBlock(spec.startup);
|
||||
if (spec.services) doc["services"] = serializeServices(spec.services);
|
||||
|
||||
@@ -214,11 +214,14 @@ export class RigSpecExporter {
|
||||
// from the spec re-emit) — explicit repository read, emitted only when set.
|
||||
const rigPermissionPolicy = this.rigRepo.getRigPermissionPolicy(rigId);
|
||||
const workspace = this.rigRepo.getRigWorkspace(rigId);
|
||||
// #25: the selected Claude managed-block file is also a rig-row field.
|
||||
const claudeManagedBlockFile = this.rigRepo.getRigClaudeManagedBlockFile(rigId);
|
||||
|
||||
return {
|
||||
version: "0.2",
|
||||
name: rig.rig.name,
|
||||
...(rigPermissionPolicy ? { permissionPolicy: rigPermissionPolicy } : {}),
|
||||
...(claudeManagedBlockFile ? { managedBlocks: { "claude-code": claudeManagedBlockFile } } : {}),
|
||||
...(workspace ? { workspace } : {}),
|
||||
pods: podSpecs,
|
||||
edges: crossPodEdges,
|
||||
|
||||
@@ -642,6 +642,10 @@ export class PodRigInstantiator {
|
||||
if (rigSpec.workspace) {
|
||||
this.deps.rigRepo.setRigWorkspace(materializedRigId, rigSpec.workspace);
|
||||
}
|
||||
// #25: the Claude managed-block destination is rig-row state (both persist sites).
|
||||
if (rigSpec.managedBlocks?.["claude-code"]) {
|
||||
this.deps.rigRepo.setRigClaudeManagedBlockFile(materializedRigId, rigSpec.managedBlocks["claude-code"]);
|
||||
}
|
||||
|
||||
// OPR.0.4.8.3 Seam B: persist the rig-level permission_policy REF (raw, like role) —
|
||||
// this is ONE of TWO rig-persist sites (materializeValidatedSpec + instantiate); missing
|
||||
@@ -1264,6 +1268,10 @@ export class PodRigInstantiator {
|
||||
if (rigSpec.workspace) {
|
||||
this.deps.rigRepo.setRigWorkspace(rigId, rigSpec.workspace);
|
||||
}
|
||||
// #25: the second rig-persist site (see materializeValidatedSpec).
|
||||
if (rigSpec.managedBlocks?.["claude-code"]) {
|
||||
this.deps.rigRepo.setRigClaudeManagedBlockFile(rigId, rigSpec.managedBlocks["claude-code"]);
|
||||
}
|
||||
// OPR.0.4.8.3 Seam B: the SECOND rig-persist site (bootstrap instantiate path) —
|
||||
// both sites must write or the rig ref silently drops on one instantiate path.
|
||||
// Guard-F1: resolved rig attachment persists here too (declaringDir = rigRoot).
|
||||
@@ -1943,7 +1951,12 @@ export class PodRigInstantiator {
|
||||
// P17 (finding A2): the conflict detector's resolver, UNINJECTED since the
|
||||
// 4.8 restack dropped the warnings-site threading — without it every entry
|
||||
// classified safe_projection and divergent targets overwrote silently.
|
||||
resolveTargetPath: claudeConflictTargetPath,
|
||||
// #25: a Claude seat's guidance conflict target is the rig's selected file (rig row,
|
||||
// the same source startNode binds for the write).
|
||||
resolveTargetPath: (category, effectiveId, cwd, sourcePath) => claudeConflictTargetPath(
|
||||
category, effectiveId, cwd, sourcePath,
|
||||
input.member.runtime === "claude-code" ? this.deps.rigRepo.getRigClaudeManagedBlockFile(input.rigId) ?? undefined : undefined,
|
||||
),
|
||||
lastHashLookup: (targetPath) => projectionManifest.lastHash(targetPath),
|
||||
});
|
||||
if (!planResult.ok) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import { WORKSPACE_KINDS } from "./types.js";
|
||||
import { validateSafePath } from "./path-safety.js";
|
||||
import { CLAUDE_MANAGED_BLOCK_FILES } from "./managed-blocks.js";
|
||||
import { canonicalCompactionStrategy, canonicalContinuityMechanic } from "./agent-manifest.js";
|
||||
import { aliasModelPinAdvisory } from "./spec-validation-advisory.js";
|
||||
import { validatePermissionPolicyRef } from "./permission-policy/policy-ref.js";
|
||||
@@ -44,7 +45,7 @@ const VALID_WAIT_TARGET_CONDITIONS = new Set(["healthy"]);
|
||||
const VALID_WORKSPACE_KINDS = new Set<string>(WORKSPACE_KINDS as readonly string[]);
|
||||
|
||||
const RIG_KEYS = new Set([
|
||||
"version", "name", "summary", "culture_file", "permission_policy", "docs",
|
||||
"version", "name", "summary", "culture_file", "permission_policy", "managed_blocks", "docs",
|
||||
"startup", "services", "workspace", "pods", "edges",
|
||||
]);
|
||||
const POD_KEYS = new Set(["id", "label", "summary", "continuity_policy", "startup", "members", "edges"]);
|
||||
@@ -59,6 +60,21 @@ const EDGE_KEYS = new Set(["kind", "from", "to"]);
|
||||
* unknown structural key before that literal can make accepted input vanish.
|
||||
* This stays deliberately small: one check over the four topology object
|
||||
* levels, not a second schema framework. */
|
||||
function validateManagedBlocks(raw: unknown): string[] {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return ['managed_blocks: must be a mapping such as { claude-code: CLAUDE.local.md }'];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (key !== "claude-code") {
|
||||
errors.push(`managed_blocks.${key}: unsupported runtime "${key}"; only "claude-code" is configurable`);
|
||||
} else if (!(CLAUDE_MANAGED_BLOCK_FILES as readonly unknown[]).includes(value)) {
|
||||
errors.push(`managed_blocks.claude-code: must be one of ${CLAUDE_MANAGED_BLOCK_FILES.join(", ")} (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function rejectUnknownTopologyKeys(
|
||||
raw: unknown,
|
||||
allowed: ReadonlySet<string>,
|
||||
@@ -156,6 +172,12 @@ export class RigSpecSchema {
|
||||
if (refErr) errors.push(refErr);
|
||||
}
|
||||
|
||||
// #25: optional per-runtime managed-block destination. Only claude-code is
|
||||
// configurable in this release; Codex stays on AGENTS.md.
|
||||
if (obj["managed_blocks"] !== undefined) {
|
||||
errors.push(...validateManagedBlocks(obj["managed_blocks"]));
|
||||
}
|
||||
|
||||
// pods: required array
|
||||
if (!obj["pods"] || !Array.isArray(obj["pods"])) {
|
||||
errors.push("pods: required non-empty array");
|
||||
@@ -223,6 +245,7 @@ export class RigSpecSchema {
|
||||
summary: raw["summary"] as string | undefined,
|
||||
cultureFile: raw["culture_file"] as string | undefined,
|
||||
permissionPolicy: raw["permission_policy"] as string | undefined,
|
||||
managedBlocks: raw["managed_blocks"] as RigSpec["managedBlocks"],
|
||||
docs,
|
||||
startup: raw["startup"] ? normalizeStartupBlock(raw["startup"]) : undefined,
|
||||
services: raw["services"] ? normalizeServicesBlock(raw["services"], raw["name"] as string) : undefined,
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface NodeBinding extends Binding {
|
||||
* restore). Absent = no policy attached → the env-driven floor/YOLO decision stands.
|
||||
* Present = authoritative for this seat (overrides the env read in BOTH directions). */
|
||||
launchPosture?: "floor" | "full_bypass";
|
||||
/** #25: the rig's `managed_blocks.claude-code` file. Absent = CLAUDE.md. Only the Claude adapter reads it. */
|
||||
claudeManagedBlockFile?: import("./managed-blocks.js").ClaudeManagedBlockFile;
|
||||
}
|
||||
|
||||
// -- Resolved startup file with source-root provenance --
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ProjectionPlan } from "./projection-planner.js";
|
||||
import { issueStartupChallenge } from "./startup-proof.js";
|
||||
import { resolveStartupProof } from "./startup-resolver.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { RigRepository } from "./rig-repository.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
|
||||
// -- Types --
|
||||
@@ -124,6 +125,12 @@ export class StartupOrchestrator {
|
||||
private readFile: (path: string) => string;
|
||||
|
||||
async startNode(input: StartupInput): Promise<StartupResult> {
|
||||
// #25: launch, restore replay, relaunch, continue and added members deliver
|
||||
// guidance through here, so the rig's managed-block destination is bound once
|
||||
// for the adapter. Handover does not come here: the successor launches directly
|
||||
// and reads the file already written in its cwd.
|
||||
const claudeManagedBlockFile = new RigRepository(this.db).getRigClaudeManagedBlockFile(input.rigId);
|
||||
if (claudeManagedBlockFile) input = { ...input, binding: { ...input.binding, claudeManagedBlockFile } };
|
||||
const errors: string[] = [];
|
||||
let continuityOutcome: "resumed" | "fresh" | "forked" | "rebuilt" = input.resumeToken
|
||||
? "resumed"
|
||||
|
||||
@@ -1167,6 +1167,8 @@ export interface RigSpec {
|
||||
/** OPR.0.4.8.3 Seam B: optional rig-level permission_policy REF (builtin:<name> or a
|
||||
* spec-relative custom path). Absent = the floor. A per-member ref overrides this. */
|
||||
permissionPolicy?: string;
|
||||
/** #25: per-runtime managed-block destination. Absent = CLAUDE.md. */
|
||||
managedBlocks?: { "claude-code"?: import("./managed-blocks.js").ClaudeManagedBlockFile };
|
||||
docs?: RigSpecDoc[];
|
||||
startup?: StartupBlock;
|
||||
services?: RigServicesSpec;
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { migrationsForFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import { RigSpecExporter } from "../src/domain/rigspec-exporter.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { claudeConflictTargetPath } from "../src/domain/projection-planner.js";
|
||||
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import type { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
// #25 — a rig can send Claude Code's OpenRig-managed blocks to CLAUDE.local.md
|
||||
// instead of the (often git-tracked) CLAUDE.md. These drive the real
|
||||
// instantiate/restore/expand/teardown paths with the real Claude adapter
|
||||
// writing into a temporary cwd.
|
||||
|
||||
const BEGIN = "<!-- BEGIN OpenRig MANAGED BLOCK:";
|
||||
const LOCAL = "managed_blocks:\n claude-code: CLAUDE.local.md";
|
||||
// A tracked CLAUDE.md that already carries blocks from an earlier default-target run.
|
||||
const OLD_BLOCKS_CLAUDE_MD = [
|
||||
"# Project rules",
|
||||
"",
|
||||
"Keep this file short.",
|
||||
"",
|
||||
"<!-- BEGIN OpenRig MANAGED BLOCK: openrig-start.md -->",
|
||||
"old managed text",
|
||||
"<!-- END OpenRig MANAGED BLOCK: openrig-start.md -->",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function mockTmux(): TmuxAdapter {
|
||||
return {
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
getPaneCommand: vi.fn(async () => "claude"),
|
||||
capturePaneContent: vi.fn(async () => "Claude Code\n>"),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => []),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function realFs(home: string): ClaudeAdapterFsOps {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
|
||||
copyFile: (s, d) => fs.copyFileSync(s, d),
|
||||
listFiles: (dir) => (fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? fs.readdirSync(dir) : []),
|
||||
homedir: home,
|
||||
};
|
||||
}
|
||||
|
||||
function noopAdapter(runtime: string): RuntimeAdapter {
|
||||
return {
|
||||
runtime,
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function rigYaml(managedBlocksYaml: string, cwd: string, name = "issue25-rig"): string {
|
||||
return [
|
||||
`version: "0.2"`,
|
||||
`name: ${name}`,
|
||||
managedBlocksYaml,
|
||||
`pods:`,
|
||||
` - id: dev`,
|
||||
` label: Dev`,
|
||||
` members:`,
|
||||
` - id: impl`,
|
||||
` agent_ref: "local:agents/impl"`,
|
||||
` profile: default`,
|
||||
` runtime: claude-code`,
|
||||
` cwd: "${cwd}"`,
|
||||
` edges: []`,
|
||||
`edges: []`,
|
||||
].filter(Boolean).join("\n") + "\n";
|
||||
}
|
||||
|
||||
function fixture(managedBlocksYaml: string, opts?: { claudeMd?: string; claudeLocalMd?: string }) {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "or-issue25-"));
|
||||
tmpDirs.push(root);
|
||||
const rigRoot = nodePath.join(root, "rig");
|
||||
const cwd = nodePath.join(root, "repo");
|
||||
const home = nodePath.join(root, "home");
|
||||
const dbFile = nodePath.join(root, "openrig.sqlite");
|
||||
fs.mkdirSync(nodePath.join(rigRoot, "agents", "impl"), { recursive: true });
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(rigRoot, "agents", "impl", "agent.yaml"),
|
||||
`name: impl\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []\n`);
|
||||
if (opts?.claudeMd !== undefined) fs.writeFileSync(nodePath.join(cwd, "CLAUDE.md"), opts.claudeMd);
|
||||
if (opts?.claudeLocalMd !== undefined) fs.writeFileSync(nodePath.join(cwd, "CLAUDE.local.md"), opts.claudeLocalMd);
|
||||
|
||||
const db = createDb(dbFile);
|
||||
migrate(db, migrationsForFullTestDb);
|
||||
const services = wire(db, home);
|
||||
const read = (name: string, dir = cwd) => {
|
||||
const p = nodePath.join(dir, name);
|
||||
return fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : null;
|
||||
};
|
||||
return { ...services, db, dbFile, root, rigRoot, cwd, home, yaml: rigYaml(managedBlocksYaml, cwd), read };
|
||||
}
|
||||
|
||||
function wire(db: ReturnType<typeof createDb>, home: string) {
|
||||
const rigRepo = new RigRepository(db);
|
||||
const podRepo = new PodRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const tmux = mockTmux();
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const claude = new ClaudeCodeAdapter({ tmux, fsOps: realFs(home), sleep: async () => {} });
|
||||
const resolverFs: AgentResolverFsOps = {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
};
|
||||
const inst = new PodRigInstantiator({
|
||||
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
|
||||
fsOps: resolverFs, tmuxAdapter: tmux,
|
||||
adapters: { "claude-code": claude, codex: noopAdapter("codex"), terminal: noopAdapter("terminal") },
|
||||
} as never);
|
||||
return { rigRepo, podRepo, sessionRegistry, eventBus, tmux, nodeLauncher, startupOrchestrator, claude, inst };
|
||||
}
|
||||
|
||||
async function launched(f: ReturnType<typeof fixture>): Promise<string> {
|
||||
const result = await f.inst.instantiate(f.yaml, f.rigRoot);
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
return (result as { ok: true; result: { rigId: string } }).result.rigId;
|
||||
}
|
||||
|
||||
describe("#25 journey — rig YAML selects the Claude managed-block destination", () => {
|
||||
it("managed_blocks: { claude-code: CLAUDE.local.md } writes the blocks to CLAUDE.local.md and never touches CLAUDE.md", async () => {
|
||||
const tracked = "# Project rules\n\nKeep this file short.\n";
|
||||
const f = fixture(LOCAL, { claudeMd: tracked });
|
||||
await launched(f);
|
||||
expect(f.read("CLAUDE.local.md")).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.md")).toBe(tracked);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("absent managed_blocks keeps today's destination: CLAUDE.md", async () => {
|
||||
const f = fixture("");
|
||||
await launched(f);
|
||||
expect(f.read("CLAUDE.md")).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.local.md")).toBeNull();
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("managed_blocks: { claude-code: CLAUDE.md } is the explicit default", async () => {
|
||||
const f = fixture("managed_blocks:\n claude-code: CLAUDE.md");
|
||||
await launched(f);
|
||||
expect(f.read("CLAUDE.md")).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.local.md")).toBeNull();
|
||||
f.db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#25 schema — accepted keys and values, rejected before launch", () => {
|
||||
const base = (managedBlocks: unknown) => ({
|
||||
version: "0.2", name: "r",
|
||||
...(managedBlocks === undefined ? {} : { managed_blocks: managedBlocks }),
|
||||
pods: [{ id: "dev", label: "Dev", members: [{ id: "impl", agent_ref: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." }], edges: [] }],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
it("accepts both supported destinations and absence", () => {
|
||||
expect(RigSpecSchema.validate(base(undefined)).errors).toEqual([]);
|
||||
expect(RigSpecSchema.validate(base({ "claude-code": "CLAUDE.md" })).errors).toEqual([]);
|
||||
expect(RigSpecSchema.validate(base({ "claude-code": "CLAUDE.local.md" })).errors).toEqual([]);
|
||||
expect(RigSpecSchema.validate(base({})).errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsupported value, naming both supported files", () => {
|
||||
for (const bad of ["AGENTS.md", "docs/CLAUDE.md", "../CLAUDE.local.md", "", 7, null]) {
|
||||
const errors = RigSpecSchema.validate(base({ "claude-code": bad })).errors;
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("managed_blocks.claude-code: must be one of CLAUDE.md, CLAUDE.local.md");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects any other runtime key, naming the supported key (Codex stays on AGENTS.md)", () => {
|
||||
for (const key of ["codex", "pi", "terminal", "claude"]) {
|
||||
const errors = RigSpecSchema.validate(base({ [key]: "CLAUDE.local.md" })).errors;
|
||||
expect(errors).toEqual([`managed_blocks.${key}: unsupported runtime "${key}"; only "claude-code" is configurable`]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-mapping value", () => {
|
||||
for (const bad of ["CLAUDE.local.md", ["CLAUDE.local.md"], null]) {
|
||||
expect(RigSpecSchema.validate(base(bad)).errors).toEqual(["managed_blocks: must be a mapping such as { claude-code: CLAUDE.local.md }"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("an invalid value is refused by instantiate before any seat launches or file is written", async () => {
|
||||
const f = fixture("managed_blocks:\n claude-code: AGENTS.md");
|
||||
const result = await f.inst.instantiate(f.yaml, f.rigRoot);
|
||||
expect(result).toMatchObject({ ok: false, code: "validation_failed" });
|
||||
expect(f.tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(f.cwd)).toEqual([]);
|
||||
f.db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#25 selected-file semantics — preservation, idempotence, the other file", () => {
|
||||
it("keeps user text in CLAUDE.local.md and leaves a CLAUDE.md with old blocks byte-identical", async () => {
|
||||
const userLocal = "# My local notes\n\nprefer short answers\n";
|
||||
const f = fixture(LOCAL, { claudeMd: OLD_BLOCKS_CLAUDE_MD, claudeLocalMd: userLocal });
|
||||
await launched(f);
|
||||
const local = f.read("CLAUDE.local.md")!;
|
||||
expect(local.startsWith(userLocal.trimEnd())).toBe(true);
|
||||
expect(local).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("repeated projection into CLAUDE.local.md is idempotent", async () => {
|
||||
const f = fixture(LOCAL, { claudeLocalMd: "user line\n" });
|
||||
const rigId = await launched(f);
|
||||
const first = f.read("CLAUDE.local.md");
|
||||
const node = f.rigRepo.getRig(rigId)!.nodes[0]!;
|
||||
const ctx = f.db.prepare("SELECT projection_entries_json, resolved_files_json FROM node_startup_context WHERE node_id = ?")
|
||||
.get(node.id) as { projection_entries_json: string; resolved_files_json: string };
|
||||
const binding = { cwd: f.cwd } as NodeBinding; // no destination on the binding: the rig row decides
|
||||
const files = JSON.parse(ctx.resolved_files_json).filter((file: { deliveryHint: string }) => file.deliveryHint === "guidance_merge");
|
||||
await f.claude.deliverStartup(files, { ...binding, claudeManagedBlockFile: "CLAUDE.local.md" });
|
||||
await f.claude.deliverStartup(files, { ...binding, claudeManagedBlockFile: "CLAUDE.local.md" });
|
||||
const again = f.read("CLAUDE.local.md")!;
|
||||
// Blocks are replaced in place, never duplicated. mergeManagedBlock's trailing
|
||||
// blank-line growth on re-merge predates #25 and is file-independent (parity below).
|
||||
const count = (text: string) => text.split(BEGIN).length - 1;
|
||||
expect(count(again)).toBe(count(first!));
|
||||
expect(again.trimEnd()).toBe(first!.trimEnd());
|
||||
|
||||
fs.writeFileSync(nodePath.join(f.cwd, "CLAUDE.md"), "user line\n");
|
||||
for (let i = 0; i < 3; i++) await f.claude.deliverStartup(files, binding);
|
||||
expect(f.read("CLAUDE.md")).toBe(again);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("profile managed_block projection uses the selected file too", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "or-issue25-proj-"));
|
||||
tmpDirs.push(root);
|
||||
const src = nodePath.join(root, "guide.md");
|
||||
fs.writeFileSync(src, "profile guidance body");
|
||||
const claude = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: realFs(root), sleep: async () => {} });
|
||||
const plan = {
|
||||
runtime: "claude-code", cwd: root, conflicts: [], noOps: [], diagnostics: [], startup: { files: [], actions: [] },
|
||||
entries: [{ category: "guidance" as const, effectiveId: "guide.md", sourceSpec: "impl", sourcePath: root, resourcePath: "guide.md", absolutePath: src, classification: "safe_projection" as const, mergeStrategy: "managed_block" as const }],
|
||||
};
|
||||
const result = await claude.project(plan, { cwd: root, claudeManagedBlockFile: "CLAUDE.local.md" } as NodeBinding);
|
||||
expect(result.projected).toContain("guide.md");
|
||||
expect(fs.readFileSync(nodePath.join(root, "CLAUDE.local.md"), "utf-8")).toContain("profile guidance body");
|
||||
expect(fs.existsSync(nodePath.join(root, "CLAUDE.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("the conflict target follows the selection; the default is unchanged", () => {
|
||||
expect(claudeConflictTargetPath("guidance", "g", "/cwd")).toBe("/cwd/CLAUDE.md");
|
||||
expect(claudeConflictTargetPath("guidance", "g", "/cwd", undefined, "CLAUDE.local.md")).toBe("/cwd/CLAUDE.local.md");
|
||||
expect(claudeConflictTargetPath("skill", "s", "/cwd", undefined, "CLAUDE.local.md")).toBe("/cwd/.claude/skills/s/SKILL.md");
|
||||
});
|
||||
|
||||
it("the Codex adapter ignores the Claude selection and stays on AGENTS.md", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "or-issue25-codex-"));
|
||||
tmpDirs.push(root);
|
||||
const src = nodePath.join(root, "culture.md");
|
||||
fs.writeFileSync(src, "codex guidance");
|
||||
const codex = new CodexRuntimeAdapter({ tmux: mockTmux(), fsOps: realFs(root) as never, sleep: async () => {} } as never);
|
||||
await codex.deliverStartup(
|
||||
[{ path: "culture.md", absolutePath: src, ownerRoot: root, deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start"] }],
|
||||
{ cwd: root, claudeManagedBlockFile: "CLAUDE.local.md" } as NodeBinding,
|
||||
);
|
||||
expect(fs.readFileSync(nodePath.join(root, "AGENTS.md"), "utf-8")).toContain("codex guidance");
|
||||
expect(fs.existsSync(nodePath.join(root, "CLAUDE.local.md"))).toBe(false);
|
||||
expect(fs.existsSync(nodePath.join(root, "CLAUDE.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#25 carriage — the selection holds across the lifecycle", () => {
|
||||
// Restore after a daemon restart: reopened DB, real pod-aware RestoreOrchestrator.
|
||||
// `withResumeToken: false` forces a fresh-primed relaunch, which replays startup;
|
||||
// an exact native resume replays nothing by design (D6a containment).
|
||||
async function restoreAfterRestart(withResumeToken: boolean) {
|
||||
const f = fixture(LOCAL, { claudeMd: OLD_BLOCKS_CLAUDE_MD });
|
||||
const rigId = await launched(f);
|
||||
const node = f.rigRepo.getRig(rigId)!.nodes[0]!;
|
||||
const session = f.sessionRegistry.getSessionsForRig(rigId).find((s) => s.nodeId === node.id)!;
|
||||
f.sessionRegistry.updateStatus(session.id, "running");
|
||||
if (!withResumeToken) f.db.prepare("UPDATE sessions SET resume_type = NULL, resume_token = NULL WHERE node_id = ?").run(node.id);
|
||||
const snapshotRepo = new SnapshotRepository(f.db);
|
||||
const checkpointStore = new CheckpointStore(f.db);
|
||||
const snap = new SnapshotCapture({ db: f.db, rigRepo: f.rigRepo, sessionRegistry: f.sessionRegistry, eventBus: f.eventBus, snapshotRepo, checkpointStore })
|
||||
.captureSnapshot(rigId, "manual");
|
||||
f.sessionRegistry.updateStatus(session.id, "exited"); // the rig is down before restore
|
||||
f.db.close();
|
||||
fs.rmSync(nodePath.join(f.cwd, "CLAUDE.local.md"));
|
||||
|
||||
const db2 = createDb(f.dbFile);
|
||||
const s2 = wire(db2, f.home);
|
||||
const snapshotRepo2 = new SnapshotRepository(db2);
|
||||
const checkpointStore2 = new CheckpointStore(db2);
|
||||
const orch = new RestoreOrchestrator({
|
||||
db: db2, rigRepo: s2.rigRepo, sessionRegistry: s2.sessionRegistry, eventBus: s2.eventBus,
|
||||
snapshotRepo: snapshotRepo2, checkpointStore: checkpointStore2,
|
||||
snapshotCapture: new SnapshotCapture({ db: db2, rigRepo: s2.rigRepo, sessionRegistry: s2.sessionRegistry, eventBus: s2.eventBus, snapshotRepo: snapshotRepo2, checkpointStore: checkpointStore2 }),
|
||||
nodeLauncher: s2.nodeLauncher, tmuxAdapter: s2.tmux,
|
||||
claudeResume: { canResume: vi.fn(() => false), resume: vi.fn() } as unknown as ClaudeResumeAdapter,
|
||||
codexResume: { canResume: vi.fn(() => false), resume: vi.fn() } as unknown as CodexResumeAdapter,
|
||||
});
|
||||
const restored = await orch.restore(snap.id, { adapters: { "claude-code": s2.claude }, ...(withResumeToken ? {} : { freshLogicalIds: ["dev.impl"] }) } as never);
|
||||
expect(restored, JSON.stringify(restored)).toMatchObject({ ok: true });
|
||||
db2.close();
|
||||
return { f, restored: restored as { ok: true; result: { nodes: Array<{ status: string }> } } };
|
||||
}
|
||||
|
||||
it("restore with a fresh-primed relaunch replays the blocks into CLAUDE.local.md only", { timeout: 30000 }, async () => {
|
||||
const { f, restored } = await restoreAfterRestart(false);
|
||||
expect(restored.result.nodes[0]!.status, JSON.stringify(restored)).not.toBe("failed");
|
||||
expect(f.read("CLAUDE.local.md")).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
});
|
||||
|
||||
it("restore by exact native resume writes neither file (containment unchanged)", { timeout: 30000 }, async () => {
|
||||
const { f } = await restoreAfterRestart(true);
|
||||
expect(f.read("CLAUDE.local.md")).toBeNull();
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
});
|
||||
|
||||
it("relaunch/continue replay of the persisted startup context binds the rig's selection even when the caller's binding omits it", async () => {
|
||||
const f = fixture(LOCAL, { claudeMd: OLD_BLOCKS_CLAUDE_MD });
|
||||
const rigId = await launched(f);
|
||||
fs.rmSync(nodePath.join(f.cwd, "CLAUDE.local.md"));
|
||||
const node = f.rigRepo.getRig(rigId)!.nodes[0]!;
|
||||
const session = f.sessionRegistry.getSessionsForRig(rigId).find((s) => s.nodeId === node.id)!;
|
||||
const ctx = f.db.prepare("SELECT projection_entries_json, resolved_files_json, startup_actions_json FROM node_startup_context WHERE node_id = ?")
|
||||
.get(node.id) as { projection_entries_json: string; resolved_files_json: string; startup_actions_json: string };
|
||||
// Same startNode inputs seat-lifecycle-service builds for launchFresh/continueFreshStartup.
|
||||
const result = await f.startupOrchestrator.startNode({
|
||||
rigId, nodeId: node.id, sessionId: session.id,
|
||||
binding: { cwd: f.cwd, tmuxSession: session.sessionName } as NodeBinding,
|
||||
adapter: f.claude,
|
||||
plan: { runtime: "claude-code", cwd: f.cwd, entries: JSON.parse(ctx.projection_entries_json), startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] },
|
||||
resolvedStartupFiles: JSON.parse(ctx.resolved_files_json),
|
||||
startupActions: JSON.parse(ctx.startup_actions_json),
|
||||
isRestore: false, sessionName: session.sessionName, skipHarnessLaunch: true,
|
||||
});
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
expect(f.read("CLAUDE.local.md")).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("expand/add: a member added to the running rig receives CLAUDE.local.md", async () => {
|
||||
const f = fixture(LOCAL);
|
||||
const rigId = await launched(f);
|
||||
const cwd2 = nodePath.join(f.root, "repo2");
|
||||
fs.mkdirSync(cwd2);
|
||||
fs.writeFileSync(nodePath.join(cwd2, "CLAUDE.md"), OLD_BLOCKS_CLAUDE_MD);
|
||||
const outcome = await f.inst.addMemberToPod(rigId, "dev",
|
||||
{ id: "helper", agent_ref: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: cwd2 }, f.rigRoot);
|
||||
expect(outcome.ok, JSON.stringify(outcome)).toBe(true);
|
||||
expect(f.read("CLAUDE.local.md", cwd2)).toContain(BEGIN);
|
||||
expect(f.read("CLAUDE.md", cwd2)).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("export → YAML → import round trip keeps the selection (and the default exports nothing)", async () => {
|
||||
const f = fixture(LOCAL);
|
||||
const rigId = await launched(f);
|
||||
const exported = new RigSpecExporter({ rigRepo: f.rigRepo, sessionRegistry: f.sessionRegistry, podRepo: f.podRepo }).exportRig(rigId);
|
||||
const yaml = RigSpecCodec.serialize(exported as never);
|
||||
expect(yaml).toContain("managed_blocks:\n claude-code: CLAUDE.local.md");
|
||||
const reparsed = RigSpecSchema.normalize(RigSpecCodec.parse(yaml) as Record<string, unknown>);
|
||||
expect(reparsed.managedBlocks).toEqual({ "claude-code": "CLAUDE.local.md" });
|
||||
|
||||
const g = fixture("");
|
||||
const defaultRig = await launched(g);
|
||||
const defaultYaml = RigSpecCodec.serialize(new RigSpecExporter({ rigRepo: g.rigRepo, sessionRegistry: g.sessionRegistry, podRepo: g.podRepo }).exportRig(defaultRig) as never);
|
||||
expect(defaultYaml).not.toContain("managed_blocks");
|
||||
f.db.close();
|
||||
g.db.close();
|
||||
});
|
||||
|
||||
it("bundle rig.yaml rewrite (normalize → serialize) keeps the selection", () => {
|
||||
const f = fixture(LOCAL);
|
||||
const raw = RigSpecCodec.parse(f.yaml) as Record<string, unknown>;
|
||||
const rewritten = RigSpecCodec.serialize(RigSpecSchema.normalize(raw));
|
||||
expect(RigSpecCodec.parse(rewritten)).toMatchObject({ managed_blocks: { "claude-code": "CLAUDE.local.md" } });
|
||||
f.db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#25 teardown — cleans the selected file only", () => {
|
||||
function teardown(f: ReturnType<typeof fixture>) {
|
||||
return new RigTeardownOrchestrator({
|
||||
db: f.db, rigRepo: f.rigRepo, sessionRegistry: f.sessionRegistry, tmuxAdapter: f.tmux, eventBus: f.eventBus,
|
||||
snapshotCapture: { db: f.db, captureSnapshot: vi.fn(() => ({ id: "snap" })) } as never,
|
||||
});
|
||||
}
|
||||
|
||||
it("strips blocks from CLAUDE.local.md, keeps its user text, and leaves CLAUDE.md with old blocks byte-identical", async () => {
|
||||
const f = fixture(LOCAL, { claudeMd: OLD_BLOCKS_CLAUDE_MD, claudeLocalMd: "my local line\n" });
|
||||
const rigId = await launched(f);
|
||||
for (const s of f.sessionRegistry.getSessionsForRig(rigId)) f.sessionRegistry.updateStatus(s.id, "running");
|
||||
await teardown(f).teardown(rigId);
|
||||
expect(f.read("CLAUDE.local.md")).toBe("my local line\n");
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("removes a CLAUDE.local.md that held only managed blocks", async () => {
|
||||
const f = fixture(LOCAL, { claudeMd: OLD_BLOCKS_CLAUDE_MD });
|
||||
const rigId = await launched(f);
|
||||
await teardown(f).teardown(rigId);
|
||||
expect(f.read("CLAUDE.local.md")).toBeNull();
|
||||
expect(f.read("CLAUDE.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
|
||||
it("default rig teardown still cleans CLAUDE.md and never creates or touches CLAUDE.local.md", async () => {
|
||||
const f = fixture("", { claudeLocalMd: OLD_BLOCKS_CLAUDE_MD });
|
||||
const rigId = await launched(f);
|
||||
expect(f.read("CLAUDE.md")).toContain(BEGIN);
|
||||
await teardown(f).teardown(rigId);
|
||||
expect(f.read("CLAUDE.md")).toBeNull();
|
||||
expect(f.read("CLAUDE.local.md")).toBe(OLD_BLOCKS_CLAUDE_MD);
|
||||
f.db.close();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import { scopedOperatingPostureSchema } from "../../src/db/migrations/080_scoped
|
||||
import { humanNotificationIntentSchema } from "../../src/db/migrations/081_human_notification_intent.js";
|
||||
import { reviewReadIndexesSchema } from "../../src/db/migrations/083_review_read_indexes.js";
|
||||
import { inventoryEventIndexesSchema } from "../../src/db/migrations/084_inventory_event_indexes.js";
|
||||
import { rigClaudeManagedBlockFileSchema } from "../../src/db/migrations/085_rig_claude_managed_block_file.js";
|
||||
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
|
||||
import { rigArchiveSchema } from "../../src/db/migrations/042_rig_archive.js";
|
||||
import { resumeProvenanceSchema } from "../../src/db/migrations/043_resume_provenance.js";
|
||||
@@ -114,7 +115,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];
|
||||
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];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -388,8 +388,11 @@ describe("P17 — conflicts surface LOUDLY (never a silent overwrite)", () => {
|
||||
it("WIRING PIN (the P16 class): the production planProjection call injects the resolver and threads conflict warnings", () => {
|
||||
const fsMod = require("node:fs") as typeof import("node:fs");
|
||||
const src = fsMod.readFileSync(new URL("../src/domain/rigspec-instantiator.ts", import.meta.url), "utf8");
|
||||
const callBlock = /planProjection\(\{[\s\S]{0,600}?\}\);/.exec(src)?.[0] ?? "";
|
||||
expect(callBlock, "planProjection call must inject resolveTargetPath").toContain("resolveTargetPath: claudeConflictTargetPath");
|
||||
const callBlock = /planProjection\(\{[\s\S]{0,1000}?\}\);/.exec(src)?.[0] ?? "";
|
||||
// #25: the resolver may be wrapped to pass the rig's managed-block file.
|
||||
expect(callBlock, "planProjection call must inject resolveTargetPath").toMatch(
|
||||
/resolveTargetPath: (claudeConflictTargetPath\b|\([^)]*\) => claudeConflictTargetPath\()/,
|
||||
);
|
||||
expect(src, "conflict warnings must be threaded to the warnings surface").toContain("projectionConflictWarnings(planResult.plan)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.5.15",
|
||||
"version": "0.5.16",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user