From 42238059ddbf4acb4dccabd6ee1a179c2bfbbc91 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 21:03:45 -0400 Subject: [PATCH 1/3] feat(ui): allow campaign routes in the widget fetch allowlist GET /amicode/campaigns and /amicode/campaign join FETCH_ROUTES so the upcoming Campaign Inspector digest widget can fetch them. Exact-match property preserved and tested: /amicode/campaign-x, /amicode/campaignx, and /amicode/campaigns-extra still fail the prefix-ride check. --- .../overlay/packages/ui/src/amicode/widget-allowlist.ts | 2 ++ .../overlay/packages/ui/src/amicode/widget-schema.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/widget-allowlist.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/widget-allowlist.ts index efbdd9b4..1af86caf 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/widget-allowlist.ts +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/widget-allowlist.ts @@ -7,6 +7,8 @@ const FETCH_ROUTES = [ "/amicode/profile", "/amicode/problems", "/amicode/problem", + "/amicode/campaigns", + "/amicode/campaign", "/amicode/run-status", "/amicode/run-series", "/amicode/run-cards", diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.test.ts index 9ca9c550..ae8438d5 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.test.ts +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/widget-schema.test.ts @@ -95,9 +95,16 @@ describe("allowFetch", () => { expect(allowFetch("/amicode/run-series?run=r1&lab=default")).toBe(true) expect(allowFetch("/amicode/dashboard")).toBe(true) }) + test("campaign routes pass (list + drill-down with query)", () => { + expect(allowFetch("/amicode/campaigns")).toBe(true) + expect(allowFetch("/amicode/campaign?slug=session-20260830-skill-health")).toBe(true) + }) test("prefix rides and foreign paths rejected", () => { expect(allowFetch("/amicode/problem-x")).toBe(false) expect(allowFetch("/amicode/problemx?slug=s")).toBe(false) + expect(allowFetch("/amicode/campaign-x")).toBe(false) + expect(allowFetch("/amicode/campaignx?slug=s")).toBe(false) + expect(allowFetch("/amicode/campaigns-extra")).toBe(false) expect(allowFetch("/session")).toBe(false) expect(allowFetch("http://evil.example/amicode/profile")).toBe(false) expect(allowFetch(42)).toBe(false) From 4abb63f0c337799ac40f944727a6343ba0acb13b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 21:25:50 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(service):=20amicode=20service=20slice?= =?UTF-8?q?=20=E2=80=94=20campaign=20routes=20over=20the=20session=20ledge?= =?UTF-8?q?rs=20(#658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Campaign Inspector's data path: two read-only routes over the personal vault's sessions/ dir plus the ledger-section parser that feeds them. - campaign_ledger.ts — mechanical projection of the stable nine-section grammar: split on the '## N.' headers (the '## §N' variant and the out-of-order '## 10.' thread included), §2's verdict table parsed to rows (header kept, separator dropped), §3/§4/§5 as structured text, §8 bounded to the last 10 table rows (last 40 lines when non-table), §9 as compaction. The §9-straddle corruption (loop rows appended after §9's header, verified in the wild) is recovered: §8's table extends to EOF, no rows lost to the compaction bucket. node-builtins only, per the sibling rule; frontmatter parsed regex-lite like stack_state.ts's parseMarker. - GET /amicode/campaigns — newest-first list (slug, date, campaign, status, type, one-line objective) from the personal vault via the vault family's mount resolution. Empty/missing dir → empty list; frontmatter-less or malformed ledgers degrade to null fields + the filename date; unreadable entries (directories-as-files included) are skipped. Never a 500. - GET /amicode/campaign?slug=… — one ledger's structured sections (verdicts/active_work/blocked/next_queue/loop_log_tail/compaction/ sections_found + file_date for staleness honesty). Unknown slug → the problems.ts not_found BODY convention; the slug guard doubles as the session-*.md glob and bars traversal. The vault-browser's fail-closed loopback law rides along (personal mounts pass by default). - Fixtures: trimmed REAL ledgers — the clean strumento-twins one and the §9-straddled hrl-8dot one — plus synthetic edges (missing §4/§5, empty dir, frontmatter-less, malformed frontmatter, non-table §8). AC map: list route + parser = AC1, detail route = AC2, straddle recovery = AC3, degradation = AC4 (allowlist = separate ui commit). --- .../src/amicode_service/campaign_ledger.ts | 355 ++++++++++++++++++ .../extension/src/amicode_service/index.ts | 15 + .../extension/test/campaign_ledger.test.ts | 330 ++++++++++++++++ .../extension/test/campaign_routes.test.ts | 91 +++++ ...20260820-hrl-8dot-spin-mintime.straddle.md | 57 +++ ...0260830-strumento-twins-bringup.trimmed.md | 67 ++++ 6 files changed, 915 insertions(+) create mode 100644 packages/extension/src/amicode_service/campaign_ledger.ts create mode 100644 packages/extension/test/campaign_ledger.test.ts create mode 100644 packages/extension/test/campaign_routes.test.ts create mode 100644 packages/extension/test/fixtures/campaign/session-20260820-hrl-8dot-spin-mintime.straddle.md create mode 100644 packages/extension/test/fixtures/campaign/session-20260830-strumento-twins-bringup.trimmed.md diff --git a/packages/extension/src/amicode_service/campaign_ledger.ts b/packages/extension/src/amicode_service/campaign_ledger.ts new file mode 100644 index 00000000..a06c886c --- /dev/null +++ b/packages/extension/src/amicode_service/campaign_ledger.ts @@ -0,0 +1,355 @@ +// AMICODE (issue #658): the campaign-ledger section parser + the campaign +// route bodies (GET /amicode/campaigns, GET /amicode/campaign). +// +// Ground truth is the personal vault's sessions/ dir: session ledgers written +// by live agents in a stable nine-section grammar (§1 objective/directives, +// §2 verdict table, §3 active work, §4 blocked, §5 next queue, §6 checkout +// topology, §7 gotchas, §8 loop log, §9 compaction). The parser is a +// MECHANICAL projection of that grammar — split on the `## N.` numbered +// headers, parse §2/§8's markdown tables — no agent authoring, no LLM. +// +// node: builtins only (fs/path/os) — the amicode_service sibling rule +// (vaults.ts / problems.ts neighborhood; no YAML/markdown dependency: the +// frontmatter is key: value scalars parsed regex-lite, like stack_state.ts's +// parseMarker). Same never-reject discipline as the rest of the service: +// body-builders return JSON strings and collapse every failure into the +// route's one success shape. +import { existsSync, readdirSync, readFileSync } from "node:fs" +import path from "node:path" +import { listMounts } from "./vaults" +import { browseAllowed, mountBrowseRefusal } from "./vault_browser" + +// ── Grammar ─────────────────────────────────────────────────────────────────── + +/** A parsed section table row: trimmed cell values, leading/trailing pipes + * dropped, `| --- |` separator rows excluded. */ +export type TableRow = string[] + +export interface ParsedLedger { + /** Raw `key: value` scalars from the `---` frontmatter block (quotes + * stripped; list values kept as their raw `[a, b]` text). Empty when the + * file has no (or an unterminated) frontmatter block. */ + frontmatter: Record + /** §1 body, trimmed ("" when §1 absent). The list route renders its first + * non-empty line; the drill-down gets the whole section. */ + objective: string + /** §2's markdown-table rows (verdict table / hypothesis ledger — the title + * varies across ledgers, the table is the constant). Header row first when + * the table has one; separator rows never included. */ + verdicts: TableRow[] + /** §3 body, trimmed. */ + activeWork: string + /** §4 body, trimmed ("" when the section is missing). */ + blocked: string + /** §5 body, trimmed ("" when the section is missing). */ + nextQueue: string + /** §8's loop log, bounded to the last WINDOW_LOOP_LOG_ROWS table rows (or, + * for a non-table §8, the last WINDOW_LOOP_LOG_LINES non-empty lines) — + * append-only logs grow without limit and the digest needs a window, not + * the archive. Includes any §9-straddled rows (see below). */ + loopLogTail: string + /** §9's compaction-log body, trimmed — the non-table part when loop rows + * straddled the header. */ + compaction: string + /** Every `## N.` section number found, ascending. Ledgers in the wild + * carry 0–10 sections (a `## §N` variant exists; one real ledger has a + * `## 10.` parallel thread before §9; others renumber the grammar). */ + sectionsFound: number[] +} + +// The tail-window bound (documented contract): last 10 table rows, or the +// last 40 non-empty lines when §8 isn't a table. 10 loop rows ≈ the last few +// work days of a campaign at the observed cadence; 40 plain lines ≈ the same +// volume for the early ledgers that logged loops as bullets. +export const WINDOW_LOOP_LOG_ROWS = 10 +export const WINDOW_LOOP_LOG_LINES = 40 + +// ── Frontmatter (regex-lite — no YAML dependency in this neighborhood) ─────── + +/** Split a leading `---` frontmatter block off the text. An unterminated + * block (no closing `---`) is NOT frontmatter — the whole text is body. + * Malformed lines (no colon) are skipped; values are trimmed with one level + * of wrapping double quotes stripped (the grammar's scalar convention). */ +export function splitFrontmatter(text: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {} + if (!text.startsWith("---")) return { frontmatter, body: text } + const lines = text.split("\n") + let close = -1 + for (let i = 1; i < lines.length && i <= 64; i++) { + if (lines[i]?.trim() === "---") { + close = i + break + } + } + if (close === -1) return { frontmatter, body: text } + for (const line of lines.slice(1, close)) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/) + if (!m) continue + const value = m[2]!.trim().replace(/^"(.*)"$/, "$1") + frontmatter[m[1]!] = value + } + return { frontmatter, body: lines.slice(close + 1).join("\n") } +} + +// ── Section splitting ───────────────────────────────────────────────────────── + +/** The numbered-header grammar: `## N. Title` canonically, with a `## §N + * Title` variant in the wild. The number is the identity — section TITLES + * drift across ledgers ("Verdict table" vs "Hypothesis ledger", a §6 that is + * the loop log) — so matching keys on the title would mis-file content. */ +const SECTION_HEADER = /^##\s+§?\s*(\d+)\s*[.):\s]/ + +/** Split the body into `section number → body` per the `## N.` headers. + * Duplicate numbers (never observed, append-only grammar) merge by append. + * Unnumbered `##` lines stay inside the section they follow. */ +export function splitSections(body: string): { sections: Map; sectionsFound: number[] } { + const sections = new Map() + let current: number | null = null + for (const line of body.split("\n")) { + const m = line.match(SECTION_HEADER) + if (m) { + current = Number(m[1]) + continue + } + if (current !== null) { + const prev = sections.get(current) + sections.set(current, prev === undefined ? line : `${prev}\n${line}`) + } + } + const sectionsFound = [...sections.keys()].sort((a, b) => a - b) + return { sections, sectionsFound } +} + +const trimOrEmpty = (text: string | undefined): string => (text ?? "").trim() + +// ── Markdown tables ─────────────────────────────────────────────────────────── + +const isTableLine = (line: string): boolean => /^\s*\|.*\|\s*$/.test(line) +const isSeparatorRow = (line: string): boolean => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line) + +/** Trimmed cell values of one table line: split on `|`, drop the empty + * leading/trailing cells the boundary pipes leave behind. */ +function cells(line: string): string[] { + const parts = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|") + return parts.map((c) => c.trim()) +} + +/** All table rows in a text block, in order, separator rows excluded. */ +export function parseTableRows(text: string): TableRow[] { + return text + .split("\n") + .filter((l) => isTableLine(l) && !isSeparatorRow(l)) + .map(cells) +} + +// ── The §9 straddle (append-at-EOF corruption, verified in the wild) ───────── +// +// Some ledgers have §8 loop-log rows appended AFTER the §9 header — the +// appender walked to EOF and the §9 section was already there, so every row +// it appended landed under §9. Grammar-law: §8's table extends to EOF when +// table rows follow §9's header. Mechanic: §9's table lines migrate to §8; +// §9 keeps its non-table content (the "(append-only: …)" template line). + +function recoverStraddledLoopLog(sections: Map): void { + const s9 = sections.get(9) + if (s9 === undefined || !sections.has(8)) return + const lines = s9.split("\n") + const tableLines = lines.filter(isTableLine) + if (tableLines.length === 0) return + const kept = lines.filter((l) => !isTableLine(l)) + sections.set(9, kept.join("\n")) + sections.set(8, `${sections.get(8)}\n${tableLines.join("\n")}`) +} + +// ── parseLedger ─────────────────────────────────────────────────────────────── + +export function parseLedger(text: string): ParsedLedger { + const { frontmatter, body } = splitFrontmatter(text) + const { sections, sectionsFound } = splitSections(body) + recoverStraddledLoopLog(sections) + + // Loop-log tail: the bounded window. Table §8 (the canonical grammar) → + // last WINDOW_LOOP_LOG_ROWS rows; non-table §8 (early ledgers logged loops + // as bullets) → last WINDOW_LOOP_LOG_LINES non-empty lines. + const loopBody = sections.get(8) ?? "" + const loopRows = parseTableRows(loopBody) + const loopLogTail = + loopRows.length > 0 + ? loopRows + .slice(-WINDOW_LOOP_LOG_ROWS) + .map((row) => `| ${row.join(" | ")} |`) + .join("\n") + : loopBody + .split("\n") + .filter((l) => l.trim() !== "") + .slice(-WINDOW_LOOP_LOG_LINES) + .join("\n") + + return { + frontmatter, + objective: trimOrEmpty(sections.get(1)), + verdicts: parseTableRows(sections.get(2) ?? ""), + activeWork: trimOrEmpty(sections.get(3)), + blocked: trimOrEmpty(sections.get(4)), + nextQueue: trimOrEmpty(sections.get(5)), + loopLogTail, + compaction: trimOrEmpty(sections.get(9)), + sectionsFound, + } +} + +// ── Personal-vault sessions dir (the mount-resolution seam) ────────────────── + +/** The personal vault's sessions/ directory, or undefined when no personal + * mount is attached. Reuses the vault family's mount resolution (kind-rank + * ordering; first kind === "personal") — never a hardcoded path. */ +export function personalSessionsDir(root?: string): string | undefined { + const mount = listMounts(root).find((m) => m.kind === "personal") + return mount ? path.join(mount.dir, "sessions") : undefined +} + +// ── Route bodies ────────────────────────────────────────────────────────────── +// Wire shapes (snake_case, mirroring problems.ts): the parser module's +// camelCase stays in-process; one convention lives at each layer. +// +// campaigns: { ok, campaigns: [{slug, date, campaign, status, type, +// objective}], error } +// campaign: { ok, campaign: {…list fields…, verdicts, active_work, +// blocked, next_queue, loop_log_tail, compaction, +// sections_found, file_date}, error } +// +// Degradation law (issue #658 AC): an empty/missing sessions dir is an empty +// list; a malformed or missing frontmatter degrades to null fields + the +// filename date; a file that cannot be read is skipped. Never a 500. + +const err = (code: string, detail: string): string => JSON.stringify({ ok: false, error: `${code}: ${detail}` }) + +/** §1's first non-empty line — the one-line objective the list renders. + * Leading list-bullet markers stripped; capped so one pathological line + * can't bloat the digest. */ +function objectiveLine(objective: string): string { + const first = objective.split("\n").find((l) => l.trim() !== "") ?? "" + return first.trim().replace(/^[-*]\s+/, "").slice(0, 240) +} + +/** The YYYYMMDD date embedded in a `session-YYYYMMDD-…` filename, or null. */ +function fileDate(slug: string): string | null { + const m = slug.match(/^session-(\d{4})(\d{2})(\d{2})/) + if (!m) return null + return `${m[1]}-${m[2]}-${m[3]}` +} + +/** One list entry per `session-*.md` in the dir, newest first (date desc — + * frontmatter date, falling back to the filename's embedded date — ties + * broken by slug desc). Unreadable files are skipped; frontmatter-less or + * malformed ones appear with null fields and the filename date. */ +export function campaignsBody(sessionsDir: string | undefined): string { + if (!sessionsDir || !existsSync(sessionsDir)) return JSON.stringify({ ok: true, campaigns: [], error: null }) + const campaigns: Record[] = [] + for (const name of readdirSync(sessionsDir).sort()) { + if (!name.startsWith("session-") || !name.endsWith(".md")) continue + const slug = name.slice(0, -3) + let text: string + try { + text = readFileSync(path.join(sessionsDir, name), "utf8") + } catch { + continue // one unreadable file must not kill the list + } + const parsed = parseLedger(text) + campaigns.push({ + slug, + date: parsed.frontmatter.date ?? fileDate(slug), + campaign: parsed.frontmatter.campaign ?? parsed.frontmatter.label ?? null, + status: parsed.frontmatter.status ?? null, + type: parsed.frontmatter.type ?? null, + objective: objectiveLine(parsed.objective), + }) + } + campaigns.sort((a, b) => { + const da = String(a.date ?? ""), db = String(b.date ?? "") + if (da !== db) return da < db ? 1 : -1 // desc; "" (no date at all) sorts last + return String(a.slug) < String(b.slug) ? 1 : -1 + }) + return JSON.stringify({ ok: true, campaigns, error: null }) +} + +/** Slugs are `session--` file stems: letters, digits, dash, + * underscore — nothing that can traverse (`/`, `\`, `..`) and nothing that + * reaches a non-ledger file (the guard doubles as the `session-*.md` glob). */ +const SLUG_OK = /^session-[A-Za-z0-9_-]+$/ + +/** One ledger, parsed per the grammar. Unknown slug → `not_found:` + * (the problems.ts 404-shape convention: an ok:false BODY, not an HTTP 404 — + * consumers parse one schema per route). */ +export function campaignBody(sessionsDir: string | undefined, slug: string | undefined): string { + if (!slug || slug.trim() === "") return err("bad_request", "missing slug") + if (!SLUG_OK.test(slug)) return err(`not_found:${slug}`, "no such session ledger") + const file = path.join(sessionsDir ?? "", `${slug}.md`) + let text: string + try { + text = readFileSync(file, "utf8") + } catch { + return err(`not_found:${slug}`, "no such session ledger") + } + const parsed = parseLedger(text) + return JSON.stringify({ + ok: true, + campaign: { + slug, + date: parsed.frontmatter.date ?? fileDate(slug), + campaign: parsed.frontmatter.campaign ?? parsed.frontmatter.label ?? null, + status: parsed.frontmatter.status ?? null, + type: parsed.frontmatter.type ?? null, + objective: parsed.objective, + verdicts: parsed.verdicts, + active_work: parsed.activeWork, + blocked: parsed.blocked, + next_queue: parsed.nextQueue, + loop_log_tail: parsed.loopLogTail, + compaction: parsed.compaction, + sections_found: parsed.sectionsFound, + file_date: fileDate(slug), + }, + error: null, + }) +} + +// ── Cached entrypoints for the routes (never reject; body is a JSON string) ── +// Same shape as problems.ts's cached(): the route binds these; body-builders +// stay injectable for tests. 10 s TTL — ledgers move at loop boundaries, not +// milliseconds, and the digest polls. + +const caches = new Map() +function cached(key: string, build: () => string): string { + const hit = caches.get(key) + if (hit && Date.now() - hit.at < 10_000) return hit.body + let body: string + try { + body = build() + } catch (e) { + body = err("bad_output", String(e)) + } + caches.set(key, { at: Date.now(), body }) + return body +} + +/** The vault-browser's fail-closed law rides along: session ledgers are + * personal-vault content, so a non-loopback server refuses these routes + * exactly as /amicode/vault-file does (AMICO_VAULT_BROWSER overrides apply), + * and a personal mount whose marker opts out (browse = false) serves nothing. + * On the loopback service (the only kind that exists today) both pass and + * the routes behave identically to the ungated case. */ +function gateRefusal(): string | undefined { + if (!browseAllowed()) + return err("forbidden", "vault browsing serves loopback servers only (set AMICO_VAULT_BROWSER=1 to override)") + const mount = listMounts().find((m) => m.kind === "personal") + if (!mount) return undefined // no personal vault → the builders' empty shapes, not a refusal + return mountBrowseRefusal(mount.id, mount.dir) +} + +export function campaignsResponse(): string { + return cached("campaigns", () => gateRefusal() ?? campaignsBody(personalSessionsDir())) +} +export function campaignResponse(slug: string | undefined): string { + return cached(`campaign:${slug ?? "@none"}`, () => gateRefusal() ?? campaignBody(personalSessionsDir(), slug)) +} diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index b2e9aa9b..f103661c 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -27,6 +27,7 @@ import { runSeriesResponse, runStatusResponse, } from "./problems"; +import { campaignResponse, campaignsResponse } from "./campaign_ledger"; import { libraryBody, saveLibraryFile } from "./library"; import { widgetsResponse, widgetCodeResponse, forkWidgetResponse, loadRegistry } from "./widgets"; import { dashboardResponse, saveDashboardResponse } from "./dashboard"; @@ -122,6 +123,19 @@ export function registerLibraryRoutes(server: AmicodeServiceServer): AmicodeServ return server; } +// Campaign routes (issue #658): read-only projections of the personal vault's +// session ledgers — the Campaign Inspector's data path. Same family pattern +// as the problem routes: one success shape per route, slug rides the query. +export function registerCampaignRoutes(server: AmicodeServiceServer): AmicodeServiceServer { + server.add("GET", "/amicode/campaigns", () => ({ body: campaignsResponse() })); + + server.add("GET", "/amicode/campaign", ({ url }) => ({ + body: campaignResponse(url.searchParams.get("slug") ?? undefined), + })); + + return server; +} + export function registerWidgetRoutes(server: AmicodeServiceServer): AmicodeServiceServer { server.add("GET", "/amicode/widgets", () => ({ body: widgetsResponse() })); @@ -201,6 +215,7 @@ export function createAmicodeService(opts: { password?: string } = {}): AmicodeS registerProfileRoutes(server); registerVaultRoutes(server); registerProblemRoutes(server); + registerCampaignRoutes(server); registerLibraryRoutes(server); registerWidgetRoutes(server); registerProjectRoutes(server); diff --git a/packages/extension/test/campaign_ledger.test.ts b/packages/extension/test/campaign_ledger.test.ts new file mode 100644 index 00000000..3e65a459 --- /dev/null +++ b/packages/extension/test/campaign_ledger.test.ts @@ -0,0 +1,330 @@ +// Tests for the campaign-ledger section parser + the campaign routes +// (GET /amicode/campaigns, GET /amicode/campaign — issue #658). +// +// campaign_ledger.ts uses node: builtins only (fs/path) — the amicode_service +// sibling rule (stack_state.ts neighborhood). Fixtures are TRIMMED REAL +// ledgers from the personal vault's sessions/ dir (fixtures/campaign/): +// a clean 9-section one and the §9-straddled one (loop-log rows appended +// after the §9 header — append-at-EOF straddle, verified in the wild). +// Route-level tests point the personal vault at a fresh temp dir via +// AMICO_VAULTS_ROOT so nothing touches the real ~/.amico. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { campaignBody, campaignsBody, parseLedger } from "../src/amicode_service/campaign_ledger"; + +const fixture = (name: string): string => + fileURLToPath(new URL(`./fixtures/campaign/${name}`, import.meta.url)); + +describe("parseLedger — clean fixture (real trimmed strumento-twins ledger)", () => { + const text = fs.readFileSync(fixture("session-20260830-strumento-twins-bringup.trimmed.md"), "utf8"); + const parsed = parseLedger(text); + + it("parses the frontmatter scalars (quotes stripped, arrays kept raw)", () => { + expect(parsed.frontmatter.type).toBe("session-ledger"); + expect(parsed.frontmatter.date).toBe("2026-08-30"); + expect(parsed.frontmatter.campaign).toBe("strumento-twins-bringup"); + expect(parsed.frontmatter.status).toBe("ACTIVE"); + }); + + it("finds the nine canonical sections", () => { + expect(parsed.sectionsFound).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it("carries the §1 objective (full section body, trimmed)", () => { + expect(parsed.objective).toContain("make **Strumento.jl the substrate**"); + expect(parsed.objective).not.toContain("## 2."); + }); + + it("parses §2's verdict table rows (header kept, separator dropped)", () => { + expect(parsed.verdicts).toHaveLength(3); // header + S1 + S2 + expect(parsed.verdicts[0]).toEqual(["slice", "repo", "content", "status"]); + expect(parsed.verdicts[1]![0]).toBe("S1"); + expect(parsed.verdicts[2]![0]).toBe("S2"); + expect(parsed.verdicts[1]!.join(" ")).toContain("PR #17 squash-merged @ 3e94ae4"); + }); + + it("carries §3/§4/§5 as structured text", () => { + expect(parsed.activeWork).toContain("L1-impl-14"); + expect(parsed.blocked).toContain("**S2** blocked by S1 merged"); + expect(parsed.nextQueue).toContain("casts **IN FLIGHT**"); + expect(parsed.blocked).not.toContain("## 5."); + }); + + it("carries §9's compaction state", () => { + expect(parsed.compaction).toContain("(none — append one row per compaction"); + }); +}); + +describe("parseLedger — loop-log tail window (documented bound: last 10 table rows)", () => { + it("bounds a long §8 table to the last 10 rows, order preserved", () => { + const rows = Array.from({ length: 14 }, (_, i) => `| ${i} | 2026-08-30 | unit ${i} | done | artifacts |`).join("\n"); + const text = `# L\n\n## 8. Loop log\n\n| loop | date | unit | verdict | artifacts |\n|---|---|---|---|---|\n${rows}\n`; + const parsed = parseLedger(text); + const tailRows = parsed.loopLogTail.split("\n"); + expect(tailRows).toHaveLength(10); + expect(tailRows[0]).toContain("| 4 |"); // first kept row = row 4 of 0..13 + expect(tailRows[9]).toContain("| 13 |"); // the newest row survives + expect(parsed.loopLogTail).not.toContain("unit 3 "); + }); + + it("falls back to the last 40 non-empty lines when §8 is not a table", () => { + const lines = Array.from({ length: 50 }, (_, i) => `- loop ${i} note`).join("\n"); + const text = `# L\n\n## 8. Loop log\n\n${lines}\n`; + const parsed = parseLedger(text); + const tailLines = parsed.loopLogTail.split("\n"); + expect(tailLines).toHaveLength(40); + expect(tailLines[0]).toContain("loop 10"); + expect(tailLines[39]).toContain("loop 49"); + }); +}); + +describe("parseLedger — §9 straddle (real trimmed hrl-8dot ledger; loop rows appended after §9)", () => { + const text = fs.readFileSync(fixture("session-20260820-hrl-8dot-spin-mintime.straddle.md"), "utf8"); + const parsed = parseLedger(text); + + it("keeps the §2 hypothesis table (title variant 'Hypothesis ledger')", () => { + expect(parsed.verdicts).toHaveLength(2); // header + H2 + expect(parsed.verdicts[1]![0]).toBe("H2"); + expect(parsed.verdicts[1]!.join(" ")).toContain("REFUTED (inverted)"); + }); + + it("recovers the straddled loop rows into §8's log — none lost to compaction", () => { + // §8 proper had header + 2 rows; 2 more data rows were appended after §9's + // header at EOF. The window keeps all 5 (bound is 10). + const tailRows = parsed.loopLogTail.split("\n"); + expect(tailRows).toHaveLength(5); + expect(tailRows[0]).toContain("| date | H# |"); // §8's own header row first + // The straddled rows are the pass-3 amendment and the fast-calibration rows. + expect(parsed.loopLogTail).toContain("pass-3 amendment"); + expect(parsed.loopLogTail).toContain("spec-20260820-hrl-spin-cz-fast-calibration"); + // …and the newest straddled row is the tail's last row (chronology kept). + expect(tailRows[4]).toContain("spec-20260820-hrl-spin-cz-fast-calibration"); + }); + + it("leaves §9's own (non-table) compaction content in compaction", () => { + expect(parsed.compaction).toContain("(append-only: timestamp, auto/manual"); + expect(parsed.compaction).not.toContain("pass-3 amendment"); + expect(parsed.compaction).not.toContain("|"); + }); +}); + +describe("campaignsBody — GET /amicode/campaigns (list, newest first)", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-campaigns-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const writeLedger = (name: string, text: string): void => { + fs.mkdirSync(path.join(tmp, "sessions"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "sessions", name), text); + }; + + it("lists session-*.md files newest-first with frontmatter + §1 objective line", () => { + writeLedger( + "session-20260820-hrl.md", + fs.readFileSync(fixture("session-20260820-hrl-8dot-spin-mintime.straddle.md"), "utf8"), + ); + writeLedger( + "session-20260830-twins.md", + fs.readFileSync(fixture("session-20260830-strumento-twins-bringup.trimmed.md"), "utf8"), + ); + const body = JSON.parse(campaignsBody(path.join(tmp, "sessions"))); + expect(body.ok).toBe(true); + expect(body.error).toBeNull(); + expect(body.campaigns.map((c: any) => c.slug)).toEqual(["session-20260830-twins", "session-20260820-hrl"]); + const twins = body.campaigns[0]; + expect(twins).toMatchObject({ + slug: "session-20260830-twins", + date: "2026-08-30", + campaign: "strumento-twins-bringup", + status: "ACTIVE", + type: "session-ledger", + }); + expect(twins.objective).toContain("Execute the 2026-08-30 plan-of-record"); + // the hrl ledger is type: session with a label, not a campaign name + expect(body.campaigns[1]).toMatchObject({ date: "2026-08-20", status: "active", campaign: "hrl-8dot-spin-mintime" }); + }); + + it("degrades on a frontmatter-less ledger (nulls, date falls back to the filename) and skips non-ledger files", () => { + writeLedger( + "session-20260830-skill-health.md", + "# Session ledger — skill health\n\n## 1. Objective & standing directives\n\n- User directives (2026-08-29 night): test everything.\n", + ); + writeLedger("CHECKOUTS.md", "# Checkouts\n"); + writeLedger("notes.md", "not a ledger"); + const body = JSON.parse(campaignsBody(path.join(tmp, "sessions"))); + expect(body.ok).toBe(true); + expect(body.campaigns).toHaveLength(1); + expect(body.campaigns[0]).toMatchObject({ + slug: "session-20260830-skill-health", + date: "2026-08-30", // filename fallback — the file has no frontmatter + campaign: null, + status: null, + type: null, + }); + expect(body.campaigns[0].objective).toContain("User directives (2026-08-29 night)"); + }); + + it("degrades on malformed frontmatter (unterminated block) — degraded entry, not an error", () => { + writeLedger( + "session-20260827-broken.md", + "---\ntype: session-ledger\ndate: 2026-08-27\n# no closing fence\n\n## 1. Objective\n\nShip it.\n", + ); + const body = JSON.parse(campaignsBody(path.join(tmp, "sessions"))); + expect(body.ok).toBe(true); + expect(body.campaigns).toHaveLength(1); + expect(body.campaigns[0].type).toBeNull(); + }); + + it("returns an empty list for an empty sessions dir and for a missing dir — never an error shape", () => { + fs.mkdirSync(path.join(tmp, "sessions"), { recursive: true }); + expect(JSON.parse(campaignsBody(path.join(tmp, "sessions")))).toEqual({ ok: true, campaigns: [], error: null }); + expect(JSON.parse(campaignsBody(path.join(tmp, "nope")))).toEqual({ ok: true, campaigns: [], error: null }); + expect(JSON.parse(campaignsBody(undefined))).toEqual({ ok: true, campaigns: [], error: null }); + }); +}); + +describe("campaignBody — GET /amicode/campaign?slug=… (one ledger, structured sections)", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-campaign-")); + fs.mkdirSync(path.join(tmp, "sessions"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "sessions", "session-20260830-twins.md"), + fs.readFileSync(fixture("session-20260830-strumento-twins-bringup.trimmed.md"), "utf8"), + ); + fs.writeFileSync( + path.join(tmp, "sessions", "session-20260820-hrl.md"), + fs.readFileSync(fixture("session-20260820-hrl-8dot-spin-mintime.straddle.md"), "utf8"), + ); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns the ledger's structured sections (wire snake_case)", () => { + const body = JSON.parse(campaignBody(path.join(tmp, "sessions"), "session-20260830-twins")); + expect(body.ok).toBe(true); + expect(body.error).toBeNull(); + const c = body.campaign; + expect(c.slug).toBe("session-20260830-twins"); + expect(c.date).toBe("2026-08-30"); + expect(c.campaign).toBe("strumento-twins-bringup"); + expect(c.status).toBe("ACTIVE"); + expect(c.type).toBe("session-ledger"); + expect(c.objective).toContain("make **Strumento.jl the substrate**"); + expect(c.verdicts).toHaveLength(3); + expect(c.verdicts[0]).toEqual(["slice", "repo", "content", "status"]); + expect(c.active_work).toContain("L1-impl-14"); + expect(c.blocked).toContain("**S2** blocked by S1 merged"); + expect(c.next_queue).toContain("casts **IN FLIGHT**"); + expect(c.loop_log_tail.split("\n")).toHaveLength(3); // header + loop 0 + loop 1 + expect(c.compaction).toContain("(none — append one row per compaction"); + expect(c.sections_found).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(c.file_date).toBe("2026-08-30"); + }); + + it("serves the straddled ledger with §8 extended to EOF (loop rows intact)", () => { + const body = JSON.parse(campaignBody(path.join(tmp, "sessions"), "session-20260820-hrl")); + const c = body.campaign; + expect(c.loop_log_tail.split("\n")).toHaveLength(5); + expect(c.loop_log_tail).toContain("pass-3 amendment"); + expect(c.compaction).not.toContain("|"); + expect(c.date).toBe("2026-08-20"); + expect(c.campaign).toBe("hrl-8dot-spin-mintime"); // label fallback + }); + + it("404-shapes an unknown slug (ok:false not_found, HTTP-200 body — the problems.ts convention)", () => { + const body = JSON.parse(campaignBody(path.join(tmp, "sessions"), "session-19990101-nope")); + expect(body.ok).toBe(false); + expect(body.error).toContain("not_found:session-19990101-nope"); + expect(body.campaign).toBeUndefined(); + }); + + it("bad-requests a missing slug", () => { + const body = JSON.parse(campaignBody(path.join(tmp, "sessions"), undefined)); + expect(body.ok).toBe(false); + expect(body.error).toContain("bad_request"); + }); + + it("refuses a slug that would traverse out of the sessions dir", () => { + for (const evil of ["..%2F..%2Fvault", "session-../../secret", "..", "session-../x"]) { + const body = JSON.parse(campaignBody(path.join(tmp, "sessions"), evil)); + expect(body.ok).toBe(false); + expect(body.error).not.toContain("ok\":true"); + } + // the guarded read never escaped: no file outside sessions/ was consulted + expect(fs.existsSync(path.join(tmp, "secret"))).toBe(false); + }); + + it("degrades a missing dir to not_found for the asked slug", () => { + const body = JSON.parse(campaignBody(path.join(tmp, "nope"), "session-20260830-twins")); + expect(body.ok).toBe(false); + expect(body.error).toContain("not_found:"); + }); +}); + +describe("degradation — missing sections, junk in the dir, never a 500 (issue #658 AC)", () => { + it("missing §4/§5 degrade to empty strings; sectionsFound reflects what exists", () => { + const text = [ + "---", + "type: session-ledger", + "date: 2026-08-26", + "campaign: no-queue-ledger", + "status: ACTIVE", + "---", + "", + "# L", + "", + "## 1. Objective", + "", + "Do the thing.", + "", + "## 2. Verdict table", + "", + "| unit | status | evidence |", + "|---|---|---|", + "| U1 | DONE | ran it |", + "", + "## 8. Loop log", + "", + "| loop | verdict |", + "|---|---|", + "| 0 | started |", + "", + "## 9. Compaction log", + "", + "(empty)", + "", + ].join("\n"); + const parsed = parseLedger(text); + expect(parsed.blocked).toBe(""); + expect(parsed.nextQueue).toBe(""); + expect(parsed.activeWork).toBe(""); + expect(parsed.verdicts).toHaveLength(2); + expect(parsed.sectionsFound).toEqual([1, 2, 8, 9]); + expect(parsed.loopLogTail).toContain("| 0 | started |"); + }); + + it("a directory named like a ledger (or any unreadable entry) is skipped by the list", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-campaign-junk-")); + try { + const sessions = path.join(tmp, "sessions"); + fs.mkdirSync(path.join(sessions, "session-20260826-actually-a-dir.md"), { recursive: true }); + fs.writeFileSync(path.join(sessions, "session-20260825-fine.md"), "# L\n\n## 1. Objective\n\nFine.\n"); + const body = JSON.parse(campaignsBody(sessions)); + expect(body.ok).toBe(true); + expect(body.campaigns.map((c: any) => c.slug)).toEqual(["session-20260825-fine"]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/extension/test/campaign_routes.test.ts b/packages/extension/test/campaign_routes.test.ts new file mode 100644 index 00000000..83b35419 --- /dev/null +++ b/packages/extension/test/campaign_routes.test.ts @@ -0,0 +1,91 @@ +// Server-wiring test for the campaign routes (issue #658): the two GETs are +// registered on the extension-host service, served over HTTP with the same +// never-reject discipline as the problems/run-status family. The personal +// vault is a temp mount (AMICO_VAULTS_ROOT) with the trimmed REAL fixtures +// installed as session ledgers; nothing touches ~/.amico. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, copyFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createAmicodeService } from "../src/amicode_service"; + +const fixture = (name: string): string => + fileURLToPath(new URL(`./fixtures/campaign/${name}`, import.meta.url)); + +describe("amicode service — campaign routes over HTTP", () => { + let sandbox: string; + let savedEnv: string | undefined; + let service: ReturnType; + let base: string; + let auth: string; + + beforeAll(async () => { + sandbox = mkdtempSync(join(tmpdir(), "amicode-campaign-wiring-")); + mkdirSync(join(sandbox, "my-vault", "sessions"), { recursive: true }); + writeFileSync(join(sandbox, "my-vault", ".amico-vault.toml"), 'kind = "personal"\nname = "test"\n'); + copyFileSync( + fixture("session-20260830-strumento-twins-bringup.trimmed.md"), + join(sandbox, "my-vault", "sessions", "session-20260830-twins.md"), + ); + copyFileSync( + fixture("session-20260820-hrl-8dot-spin-mintime.straddle.md"), + join(sandbox, "my-vault", "sessions", "session-20260820-hrl.md"), + ); + savedEnv = process.env.AMICO_VAULTS_ROOT; + process.env.AMICO_VAULTS_ROOT = sandbox; + service = createAmicodeService({ password: "wiring-test-password" }); + const url = await service.start(); + base = url.toString().replace(/\/$/, ""); + auth = service.authHeader; + }); + + afterAll(async () => { + await service.stop(); + if (savedEnv === undefined) delete process.env.AMICO_VAULTS_ROOT; + else process.env.AMICO_VAULTS_ROOT = savedEnv; + rmSync(sandbox, { recursive: true, force: true }); + }); + + it("GET /amicode/campaigns serves the parsed list (newest first) from the personal vault", async () => { + const r = await fetch(`${base}/amicode/campaigns`, { headers: { Authorization: auth } }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.ok).toBe(true); + expect(body.campaigns.map((c: any) => c.slug)).toEqual(["session-20260830-twins", "session-20260820-hrl"]); + expect(body.campaigns[0]).toMatchObject({ campaign: "strumento-twins-bringup", status: "ACTIVE" }); + }); + + it("GET /amicode/campaign?slug=… serves the structured sections", async () => { + const r = await fetch(`${base}/amicode/campaign?slug=session-20260820-hrl`, { headers: { Authorization: auth } }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.ok).toBe(true); + expect(body.campaign.verdicts[1]![0]).toBe("H2"); + expect(body.campaign.loop_log_tail).toContain("pass-3 amendment"); // straddle recovered + expect(body.campaign.compaction).not.toContain("|"); + }); + + it("an unknown slug is an ok:false not_found BODY, not an HTTP 404", async () => { + const r = await fetch(`${base}/amicode/campaign?slug=session-19990101-nope`, { headers: { Authorization: auth } }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.ok).toBe(false); + expect(body.error).toContain("not_found:session-19990101-nope"); + }); + + it("the route table stays exact-match: /amicode/campaign-x is no route", async () => { + const r = await fetch(`${base}/amicode/campaign-x`, { headers: { Authorization: auth } }); + expect(r.status).toBe(404); + }); + + it("a corrupt sessions dir (directory-as-file) still serves 200s — never a 500 (issue #658 AC)", async () => { + mkdirSync(join(sandbox, "v", "sessions", "session-20260824-dir.md"), { recursive: true }); + const r1 = await fetch(`${base}/amicode/campaigns`, { headers: { Authorization: auth } }); + expect(r1.status).toBe(200); + expect((await r1.json()).ok).toBe(true); + const r2 = await fetch(`${base}/amicode/campaign?slug=session-20260824-dir`, { headers: { Authorization: auth } }); + expect(r2.status).toBe(200); + expect((await r2.json()).ok).toBe(false); // not_found body, never a 500 + }); +}); diff --git a/packages/extension/test/fixtures/campaign/session-20260820-hrl-8dot-spin-mintime.straddle.md b/packages/extension/test/fixtures/campaign/session-20260820-hrl-8dot-spin-mintime.straddle.md new file mode 100644 index 00000000..6da7d55f --- /dev/null +++ b/packages/extension/test/fixtures/campaign/session-20260820-hrl-8dot-spin-mintime.straddle.md @@ -0,0 +1,57 @@ +--- +type: session +date: 2026-08-20 +label: "hrl-8dot-spin-mintime" +status: active +tags: [session, autoresearch, spin-qubit, hrl, silicon, min-time, calibration, exchange] +--- + +# Session ledger — HRL 8-dot spin: minimum-time gates + calibration + +## 1. Objective & standing directives + +- **Objective**: autoresearch campaign on a model of the **HRL 8-dot silicon spin chip** + (linear Si/SiGe quantum-dot array): **minimum-time gates** — CZ via exchange first + (validated demo family), then the interesting extensions on the 8-dot geometry + (spectator-dot crosstalk, CNOT via exchange+EDSR, possibly EDSR single-qubit) — and + **closed-loop calibration of the min-time pulses** under quasi-static charge noise / + parameter drift (QILC-style, simulated — no device I/O is wired in this build). + +## 2. Hypothesis ledger + +| H# | hypothesis | verdict | evidence | +|---|---|---|---| +| H2 | Min-time compression charges a robustness tax under quasi-static charge noise (≥ 0.5 pp at σ_δ = 0.05δ, σ_J/J = 5%); adjoint buys back at most half | **REFUTED (inverted)** — σ_survive = NONE for all 4 pulses (F_mean < 0.99 at every σ ≥ 0.005 — the cliff is below the grid); at every σ the compressed pulses are ≥ the nominal (S4 inversion CONFIRMED at M=50, all six σ, both cells). The tax premise is dead in this family; the striking finding is absolute fragility: uncalibrated F_mean ≈ 0.73-0.75 even at 0.5% drift | calib/sweep/*.toml + note | + +## 3. Active work + +- **Experimenter cast COMPLETE (EXHAUSTED, budget 10/10)** — task + `ses_fdf5215daffePV0XPMM97O8XQC` (three segment-returns, truncated twice; work products + audited from disk each time). + +## 4. Blocked & reasons + +- **Cloud lane DOWN** — amicode #423: `harmoniqsapis.com` NXDOMAIN (verified live this + session, day 3+). NO LONGER BLOCKS H1 (fleet routing chosen at the launch gate) but + still blocks any cloud-shaped work and the parked cat-cavity campaign. Human lever: + registrar/DNS fix. + +## 5. Next queue + +1. **Altissimo CPU bring-up on erlich (user directive, 2026-08-26)**: patch + Piccolissimo worktree (one-line n_ineq fix, §7) → 0-unit smoke on the P2B ensemble + problem → P2B contingency re-solve Altissimo-primary (1 committed unit). This also + discriminates optimizer-limited vs formulation-limited on HV (§2). + +## 8. Loop log + +| date | H# | spec_id | review | experimenter | gates | notes | +|---|---|---|---|---|---|---| +| 2026-08-20 | — | — | — | — | — | Kickoff: probes (vault mounts, catalog — no spin incumbents; STRATEGY P11 adjacent; cloud lane NXDOMAIN verified live; `cx-gate-spin` prior failure read). Ledger created; hypothesizer cast. | +| 2026-08-20 | H1 | spec-20260820-hrl-spin-cz-mintime-scaling | approved-mechanical ×2 (fleet-routed amendment, design hash 2765bace433844d2…) | EXPERIMENTER cast in flight | none yet (no compute spent) | Launch gate: user chose fleet-CPU routing (erlich, Altissimo primary) + demo-family params. Spec amended + re-approved. CHECKOUTS row claimed. Mechanism: SSH dispatch, not amico-run (recorded in spec LAUNCH MECHANISM invariant). | + +## 9. Compaction log + +*(append-only: timestamp, auto/manual, messages dropped, summary audit result)* +| 2026-08-20 | H1 | spec-20260820 pass-3 amendment (approved-mechanical, 0 findings) | same | EXPERIMENTER pass 3 COMPLETE (ses_fdf5215daffePV0XQC, 12/12 budget) | parent gates: 5/5 lower-edge probes rollout-verified (max \|Δ\| 1.8e-6 at B@70); pass3_summary.toml verified; note PASS 3 section grep-verified present (experimenter honest this round) | H1 DECIDED (three-way split, §2). Sub-20-ns CZ is the campaign's first bankable result (banking = human-only). Analyzer skipped again (pass/fail structure; parent gates sufficient). Loop pivots to H4/H2 per §5. | +| 2026-08-20 | H4′+H2′ | spec-20260820-hrl-spin-cz-fast-calibration | mechanical clean + MANUAL CRITIC (BLOCK → 3 blockings discharged → approved; one session/three lenses, deviation recorded) | EXPERIMENTER cast in flight (ses_fdf5215daffePV0XQC) | none yet | Experiment 2: calibration of the fast CZ. Budget 22. Standing authorization active. | diff --git a/packages/extension/test/fixtures/campaign/session-20260830-strumento-twins-bringup.trimmed.md b/packages/extension/test/fixtures/campaign/session-20260830-strumento-twins-bringup.trimmed.md new file mode 100644 index 00000000..05c82515 --- /dev/null +++ b/packages/extension/test/fixtures/campaign/session-20260830-strumento-twins-bringup.trimmed.md @@ -0,0 +1,67 @@ +--- +type: session-ledger +schema_version: "1" +campaign: strumento-twins-bringup +date: 2026-08-30 +status: ACTIVE +previous: session-20260829-calibration-stack-codesign.md (parallel, ACTIVE) +tags: [session, autodev, strumento-jl, twins, bringup, intonato, sosia, spira] +--- + +# Ledger — Strumento.jl twins + bring-up substrate + +## 1. Objective & standing directives + +- Execute the 2026-08-30 plan-of-record (user-approved across the plan-mode session): + make **Strumento.jl the substrate** — soc registry (real/mock/twin), digital twins, + device bring-up — while **execution stays on Python strumento + QICK, untouched**. +- Division of labor (user, load-bearing): **Strumento.jl calibrates the device; + Intonato/issimo calibrates the pulse given the device.** Python strumento executes + both on hardware. Julia procedures drive Python experiments through the seam; the + twin serves the same D14 wire so rehearsal = production with a registry-id change. + +## 2. Verdict table (slices) + +| slice | repo | content | status | +|---|---|---|---| +| S1 | Strumento.jl #14 | Drop the Intonato dependency — standalone substrate: Piccolo direct dep, MockSoc rollout refactor (drop Intonato's SimulatedExperiment), delete backend.jl/experiment.jl/integration_test.jl (relocated by S2), exports pruned, v0.2.0, register in General | **DONE** — PR #17 squash-merged @ 3e94ae4 (director suite re-run 31/31; CI green 10m17s); v0.2.0 registration posted (commitcomment-198283928) — General PR pending | +| S2 | Intonato.jl #31 | Absorb the seam: StrumentoBackend + StrumentoExperiment + integration tests move in, dep Strumento ≥ 0.2, `using Intonato` reexports the full stack; fold the stale IntonatoQICK docstring fix | **IN FLIGHT** — unblocked (v0.2.0 REGISTERED, General#166615 merged 2026-08-31T00:27Z); cast L7-impl-31, worktree /tmp/wt-intonato-s2 @ `31-absorb-seam` | + +## 3. Active work — cast receipts + +| cast | role | target | result | +|---|---|---|---| +| L1-impl-14 | implementer | Strumento.jl #14, /tmp/wt-strumj-s1 @ `14-standalone-substrate` | **DONE** — 2 commits (88c9d6f golden pin first, 5e34a3f inversion); golden values held bit-exact (no tolerance widened); director re-ran suite 31/31 + hermetic Intonato-free load; PR #17 CI green → squash-merged @ 3e94ae4; #14 auto-closed | + +## 4. Blocked & reasons + +- **S2** blocked by S1 merged AND Strumento v0.2.0 through General auto-merge + (Intonato CI must resolve the dep; JuliaRegistrator → General PR → auto-merge is + hours of latency — the watch item). + +## 5. Next queue + +1. ~~File S1–S5 issues~~ — **DONE**: Strumento.jl #14/#15/#16, Intonato.jl #31, spira #2 (afk-labeled; afk/hitl created where missing). +2. S1 (#14) + S5 (spira #2) casts **IN FLIGHT** (parallel, disjoint repos). + +## 6. Checkout topology + +- Mirrored in `sessions/CHECKOUTS.md`; claim rows added at dispatch. + +## 7. Gotchas & methodology + +- **Julia using-scope shadowing** (Strumento.jl #12 find): a method defined on a + `using`-imported name mints a fresh local function — contract functions must be + explicitly `import`ed. The seam move (S2) MUST carry the function-object identity + test to its new home. + +## 8. Loop log + +| loop | date | unit | verdict | artifacts | +|---|---|---|---|---| +| 0 | 2026-08-30 | kickoff: director-core bound, dev gate pack read, parallel ledgers read (calibration-stack-codesign ACTIVE, Brad freeze noted), repo+registry recon done, plan-of-record confirmed against disk state | ledger created | this file | +| 1 | 2026-08-30 | decompose: issues #14/#31/#15/#16/spira#2 filed via write-an-issue (the plan-approved set), labels ensured, worktrees /tmp/wt-strumj-s1 + /tmp/wt-spira-s5 cut from main, CHECKOUTS claimed; dispatching S1 ∥ S5 in parallel | **DONE** — both slices merged: Strumento PR #17 @ 3e94ae4 (director-gated: suite re-run 31/31, hermetic load check, CI green), spira PR #3 @ 6d39ce7 (director note-read gate); v0.2.0 registration posted | issues, PRs #17/#3, this file | + +## 9. Compaction log + +- (none — append one row per compaction; re-read this file first) From 10c3779f0f3664c4fbe6c7028387e7d0dd4fedeb Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 31 Aug 2026 01:05:04 -0400 Subject: [PATCH 3/3] fix(app-bundle): manifest hashes for the campaign-route allowlist + its test (drift gate green) The overlay is extractor-managed; the campaign-route slice's two overlay edits (widget-allowlist FETCH_ROUTES + its exact-match test) needed their manifest sha256 entries re-recorded. Precedent: #639's manifest hash updates. Local drift gate: PASS (558 files). --- packages/app-bundle/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 01d77914..a91e604d 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1339,7 +1339,7 @@ "packages/ui/src/amicode/vaults.ts": "6250b08a8d6493bd42c5407bfcb581a300757b273a587247e3666ddbe25c00af", "packages/ui/src/amicode/wave-geometry.test.ts": "3a89a89310b8c9ce15b402ced6dec83d691bc4ccf50448f2a89e4c0ff1c80cc7", "packages/ui/src/amicode/wave-geometry.ts": "79fcd8ce75e280f6de3fae108ae5db4b992e28a8826735a33dbbbebfa68c8f24", - "packages/ui/src/amicode/widget-allowlist.ts": "53c6554561fc694756cac154e1db5520e806dfe6dfb2ffa41ee77bafd18536fe", + "packages/ui/src/amicode/widget-allowlist.ts": "7f982150123113c248ef315b5bec39c85c0b7969de94855de39cc67f74d15938", "packages/ui/src/amicode/widget-bridge.test.ts": "67ef016e90aef247c16b5dea1362a549f36a5ec44faa5457357b4646d57ff110", "packages/ui/src/amicode/widget-bridge.ts": "91f5949af16ae3d6c3e5acf3e6853518b9cf7cec19a2534c3636e30270d1e536", "packages/ui/src/amicode/widget-config-form.tsx": "b62f99deed3abebf14e17788218e9583d277e759f01721d9c7ec9b1377b3c04d", @@ -1348,7 +1348,7 @@ "packages/ui/src/amicode/widget-preview-card.tsx": "ed022fda6f0e4838d8d8e98680ac6b77979fa8e0987becac1c0d8ad35bf22ecd", "packages/ui/src/amicode/widget-preview.test.ts": "3ba95ada8fa9ce742a3246c52e1b1f5b4df1a9e0d8ad453f41e4468fec3b7e54", "packages/ui/src/amicode/widget-preview.ts": "21fa605139b96f45675b11a73fa0e8e974be3444feed0f024b8003e96e44927b", - "packages/ui/src/amicode/widget-schema.test.ts": "b24049aeeda3fd2a922b5ce6601d99df9c8d3aa44b3e69dc8510a13120c7353a", + "packages/ui/src/amicode/widget-schema.test.ts": "fd6c1ab0c5e56c2d7eeae605d67a8de8b250aaa6553beea2f80a742c4a1d4ecb", "packages/ui/src/amicode/widget-schema.ts": "e8a0efa3c86ccfae34d2a6feead87b556a2030c1a4e1bbd4b7c5839e116376a6", "packages/ui/src/amicode/widget-tokens.test.ts": "426064638b322a421f794bedf5dbf41ad75fc30b3da6e2d02dcf6ed2f85d5f45", "packages/ui/src/amicode/widget-tokens.ts": "397edcfa18882b2b6b6abef0a8783d72323f793e974d0bd440ca240a10b2f737",