mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c36321c2f | ||
|
|
107b948263 | ||
|
|
6b1e454a42 |
No files matched your search
@@ -405,6 +405,11 @@ 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.
|
||||
|
||||
@@ -137,6 +137,7 @@ 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;
|
||||
@@ -204,6 +205,11 @@ 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>
|
||||
|
||||
@@ -207,6 +207,20 @@ 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({
|
||||
@@ -383,6 +397,14 @@ 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"]));
|
||||
|
||||
@@ -643,6 +643,9 @@ 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -19,6 +20,8 @@ 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 {
|
||||
@@ -52,10 +55,19 @@ 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));
|
||||
|
||||
@@ -121,6 +133,7 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +183,14 @@ 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;
|
||||
|
||||
@@ -178,34 +199,17 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
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 {
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
// 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
|
||||
|
||||
@@ -45,7 +45,9 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
verify(installId: string): VerificationResult {
|
||||
/** `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 {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -76,6 +78,16 @@ 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,7 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, 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). */
|
||||
@@ -92,6 +93,8 @@ 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` };
|
||||
@@ -103,10 +106,36 @@ 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 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. */
|
||||
* 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. */
|
||||
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[];
|
||||
@@ -115,15 +144,19 @@ 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;
|
||||
merge();
|
||||
return true;
|
||||
return merge();
|
||||
}
|
||||
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) return false;
|
||||
merge();
|
||||
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;
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,15 +41,95 @@ 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,
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
): boolean {
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -57,49 +137,39 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
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))
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
/** 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)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -136,19 +206,47 @@ 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.
|
||||
* - `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.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "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 }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
|
||||
| { 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);
|
||||
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,25 +17,21 @@ 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, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, pruneKeptManagedBlock, 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;
|
||||
@@ -97,15 +93,10 @@ 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 later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
/** 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. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
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));
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -199,14 +190,13 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
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";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -228,11 +218,13 @@ 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`). */
|
||||
* 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`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
|
||||
/** #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. */
|
||||
@@ -261,7 +253,8 @@ 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 is left as it is, as before. Any other text in the file is left exactly as it is,
|
||||
* 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,
|
||||
* 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 {
|
||||
@@ -279,17 +272,27 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (!records || item.unresolved) continue;
|
||||
if (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);
|
||||
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 });
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -313,6 +316,9 @@ 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`);
|
||||
@@ -321,7 +327,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = installVerifier.verify(result.installId);
|
||||
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
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,6 +281,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
code: "verification_failed",
|
||||
installId: result.installId,
|
||||
verification,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
conflicts: result.conflicts,
|
||||
verification,
|
||||
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1387,6 +1387,38 @@ 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
|
||||
|
||||
@@ -234,6 +234,48 @@ 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();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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"]);
|
||||
});
|
||||
});
|
||||
@@ -322,6 +322,32 @@ 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, {
|
||||
|
||||
Reference in new issue
Block a user