Skip to content

Doc-sync applies a doc change twice when the turn updated and committed the doc through Bash #2471

Open

Description

@jackmaninov

Version

LifeOS 8.761.0 · attestation: not run

What is broken

The doc-sync pass in hooks/lib/micro-integrity.ts can apply a doc change the turn already made, so the doc ends up changed twice.

Pass 2 leaves out docs the turn edited with Edit or Write (touched.editedThisTurn). It doesn't leave out a doc the turn updated through Bash (sed, a heredoc, a script) and committed. That doc stays eligible, so it goes to the model next to the code diff from before the turn. The model sees code that has moved past the doc it was given, makes the same update again, and doc-sync commits it.

We hit this twice in one session. A turn added one series to a catalog and updated the count in the skill's docs itself, through Bash, in the same commit. Doc-sync then raised the count again in a follow-up commit, so the docs claimed one series more than the catalog held. In a scratch repo with the model stubbed out, the same turn with the doc edited through the Edit tool isn't affected, which is why this is easy to miss: it only shows up when docs are edited from the shell.

The turn's own commits already appear in touched.changes, so a doc in them can be skipped the same way an edit-tool doc is. Docs the turn didn't touch stay eligible, so drift the turn caused still gets fixed.

Where (file:line)

hooks/lib/micro-integrity.ts:416

Repro on a clean tree

# from the install root, with the attached test file in hooks/lib/:
bun test ./hooks/lib/micro-integrity.test.ts
# 8.761.0: 2 pass, 1 fail
#   (fail) doc-sync skips docs the turn committed > a doc the turn updated through Bash and committed is not sent to the model
# with the patch: 3 pass, 0 fail

Negative control

Probe: cd "$RELEASE_TREE" && bun test ./hooks/lib/micro-integrity.test.ts 2>&1 | grep -E "^\((pass|fail)\)|^ [0-9]+ (pass|fail)$" · exit 0 · captured 2026-10-30T09:09:55.147Z

Red looks like: 1 fail

(fail) doc-sync skips docs the turn committed > a doc the turn updated through Bash and committed is not sent to the model [49.35ms]
 2 pass
 1 fail

Suggested fix

Skip any doc in the turn's own commits as well as the ones it edited with an edit tool: build a set from touched.changes and add it to the eligible filter (four lines). The attached test builds two scratch git repos and stubs out the model. It covers three cases: a Bash-committed doc is not sent (fails on 8.761.0), an edit-tool doc is not sent (unchanged behaviour), and an untouched doc is still sent with its stale text (unchanged behaviour). We've run this as a local patch since 2026-10-28.

diff --git a/hooks/lib/micro-integrity.ts b/hooks/lib/micro-integrity.ts
index d126cd6..b8de88f 100644
--- a/hooks/lib/micro-integrity.ts
+++ b/hooks/lib/micro-integrity.ts
@@ -413,7 +413,10 @@ export async function microIntegrity(session: string, calls: ToolCall[], deps: R
 
     // Pass 2: one model call over the docs that name what changed.
     const renamedOnly = new Set(touched.changes.filter((c) => c.kind === "R").flatMap((c) => spellings({ repo: c.repo, rel: c.from! }, deps.roots)));
-    const eligible = [...hits].filter(([abs, found]) => !touched.editedThisTurn.has(abs) && [...found].some((n) => !renamedOnly.has(n)));
+    // A doc the turn committed is the turn's own update, even when Bash wrote it rather than an edit tool. Sent beside
+    // a diff from before the turn, the model would apply the same change a second time.
+    const changedThisTurn = new Set(touched.changes.map((c) => c.abs));
+    const eligible = [...hits].filter(([abs, found]) => !touched.editedThisTurn.has(abs) && !changedThisTurn.has(abs) && [...found].some((n) => !renamedOnly.has(n)));
     for (const [abs] of eligible.filter(([abs]) => dirty.has(abs))) skipped.push({ doc: docInfo(abs)?.label ?? abs, reason: "has uncommitted changes" });
     const ranked = eligible.filter(([abs]) => !dirty.has(abs))
       // Docs that name a changed file outrank docs that only list its folder.
diff --git a/hooks/lib/micro-integrity.test.ts b/hooks/lib/micro-integrity.test.ts
new file mode 100644
index 0000000..ac8208f
--- /dev/null
+++ b/hooks/lib/micro-integrity.test.ts
@@ -0,0 +1,75 @@
+/**
+ * micro-integrity.test.ts — a doc the turn already updated and committed is never sent to the doc-sync model,
+ * however it was written. Each case builds two scratch git repos (no network, no model: the infer stub records
+ * the prompt).
+ */
+import { describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+import { microIntegrity } from "./micro-integrity";
+
+const sh = (cwd: string, ...a: string[]): string => {
+  const r = Bun.spawnSync(a, { cwd, stdout: "pipe", stderr: "pipe" });
+  if (r.exitCode !== 0) throw new Error(`${a.join(" ")}: ${r.stderr}`);
+  return r.stdout.toString().trim();
+};
+const series = (n: number) => `export const S = [\n${Array.from({ length: n }, (_, i) => `  "s${i}",`).join("\n")}\n];\n`;
+const docText = (n: number) => `# X\n\n\`Tools/LiveCatalog.ts\` holds the ${n} series the report covers.\n`;
+
+/** A turn that adds one catalog series (Edit tool); `docBy` says how the doc was updated, or "none". */
+async function runTurn(docBy: "bash-committed" | "edit-tool" | "none"): Promise<{ prompt: string; considered: string[] }> {
+  const root = realpathSync(mkdtempSync(join(tmpdir(), "mi-test-")));
+  try {
+    const claude = join(root, "claude"), user = join(root, "user");
+    for (const d of [claude, user]) {
+      mkdirSync(d, { recursive: true });
+      sh(d, "git", "init", "-q"); sh(d, "git", "config", "user.email", "t@t"); sh(d, "git", "config", "user.name", "t");
+    }
+    writeFileSync(join(user, ".keep"), ""); sh(user, "git", "add", "."); sh(user, "git", "commit", "-qm", "init");
+    const cat = join(claude, "skills/X/Tools/LiveCatalog.ts"), doc = join(claude, "skills/X/SKILL.md");
+    mkdirSync(join(claude, "skills/X/Tools"), { recursive: true });
+    writeFileSync(cat, series(16)); writeFileSync(doc, docText(16));
+    sh(claude, "git", "add", "."); sh(claude, "git", "commit", "-qm", "base");
+
+    const t0 = Date.now();
+    writeFileSync(cat, series(17));
+    if (docBy !== "none") writeFileSync(doc, docText(17));
+    sh(claude, "git", "add", "."); sh(claude, "git", "commit", "-qm", "add a series");
+    const hash = sh(claude, "git", "rev-parse", "--short", "HEAD");
+    const calls = [
+      { id: "1", name: "Edit", input: { file_path: cat }, answered: true, ok: true, resultText: "ok", ts: t0 },
+      ...(docBy === "edit-tool" ? [{ id: "2", name: "Edit", input: { file_path: doc }, answered: true, ok: true, resultText: "ok", ts: t0 + 1 }] : []),
+      ...(docBy === "bash-committed" ? [{ id: "2", name: "Bash", input: { command: "sed -i 's/16/17/' skills/X/SKILL.md" }, answered: true, ok: true, resultText: "", ts: t0 + 1 }] : []),
+      { id: "3", name: "Bash", input: { command: "bun CommitOwn.ts" }, answered: true, ok: true, resultText: `{"ok":true,"commit":"${hash}"}`, ts: t0 + 2 },
+    ];
+    let prompt = "";
+    const res = await microIntegrity("t", calls as any, {
+      roots: { claude, user },
+      stateDir: join(root, "state"),
+      infer: async (_s, u) => { prompt = u; return { success: true, parsed: { edits: [] } }; },
+      commit: () => ({ ok: true }),
+    });
+    return { prompt, considered: res?.considered ?? [] };
+  } finally {
+    rmSync(root, { recursive: true, force: true });
+  }
+}
+
+describe("doc-sync skips docs the turn committed", () => {
+  test("a doc the turn updated through Bash and committed is not sent to the model", async () => {
+    const r = await runTurn("bash-committed");
+    expect(r.considered).not.toContain("skills/X/SKILL.md");
+    expect(r.prompt).not.toContain("### Doc: skills/X/SKILL.md");
+  });
+
+  test("a doc the turn updated with an edit tool is still not sent (upstream behaviour kept)", async () => {
+    expect((await runTurn("edit-tool")).considered).not.toContain("skills/X/SKILL.md");
+  });
+
+  test("a doc the turn left alone is still sent, so drift the turn caused still gets fixed", async () => {
+    const r = await runTurn("none");
+    expect(r.considered).toContain("skills/X/SKILL.md");
+    expect(r.prompt).toContain("holds the 16 series");
+  });
+});

Environment

  • OS: Pop!_OS 24.04 LTS (x64)
  • Harness: claude-code
  • bun: 1.3.14 · git: 2.56.0
  • Doctor broken: none

Activity

  1. mschroettle commented on Oct 30, 2026

    @mschroettle

    Note

    🤖 Paul (AI assistant) responding on behalf of Michael

    Same class, one more place it shows up: the code-sync start and finish checks.

    Both key on tool names. The LifeOSHooks start check only runs for Read and WRITE_TOOLS (Write, Edit, MultiEdit, NotebookEdit, LifeOSHooks.ts:81 and :1404), and IsaCodeSyncGate counts only those same tools (IsaCodeSyncGate.hook.ts:24). A governed file written from the shell passes both.

    Reproduced on 8.761.0 in a fresh interactive session, plugin armed: asked to append a blank line to a file governed by an ISA the session had not read. With the Edit tool the start check refused as designed ("LifeOS: start with the ISA. … governed by …/ISA.md, which this session has not read"). Without a tool named, the model used printf '\n' >> <file> and the write went through with no refusal.

    This is documented in ISASystem.md § Code sync ("a change written through Bash is caught only at ship time, where git sees every change"), so it is a known trade-off rather than a bug. The point for this issue: the ship-time net only exists where ReleaseRecord.ts runs (CfEnv deploys). In a repo that never deploys that way, shell writes to ISA-governed code are not caught at any stage. Whatever fix lands for doc-sync (reading the turn's own commits in touched.changes) could serve the finish gate the same way: a commit made by the turn that touches governed code is visible without parsing the shell command.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions