From 907cba7ab010c5adea99a06075b7fa85f98fd089 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:00:29 -0400 Subject: [PATCH 01/39] Workspace-run patches: seed overwrite, bootstrap NoOpValidator, cross-origin instantiate skip, cardinal-rules skill Four pragmatic fixes discovered running the BSL v0 multi-brief pass, each tracked upstream: CS-12192 (seed path hardcoded realm-wide; now overwrites with the current brief), CS-12185 (bootstrap can never pass instantiate; use NoOpValidator), CS-12197 (skip prerender instantiation for Specs referencing cross-origin base modules), plus an always-loaded skill of silent-failure traps (CS-12194 context). Co-Authored-By: Claude Fable 5 --- .../boxel-workspace-cardinal-rules/SKILL.md | 96 +++++++++++++++++++ packages/software-factory/src/factory-seed.ts | 29 ++++-- .../src/factory-skill-loader.ts | 7 ++ .../src/instantiate-execution.ts | 25 +++++ packages/software-factory/src/issue-loop.ts | 10 +- 5 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 .agents/skills/boxel-workspace-cardinal-rules/SKILL.md diff --git a/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md b/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md new file mode 100644 index 00000000000..e1d1ed175d0 --- /dev/null +++ b/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md @@ -0,0 +1,96 @@ +# Boxel cardinal rules — silent-failure traps + +Rules discovered the hard way in a downstream Boxel workspace: each one passes lint and +often passes indexing too, then breaks silently — corrupting the realm's index, +crashing at render, or dropping data with no error. Check every card/field you write +against this list before finishing an issue. + +## 1. DateField vs DateTimeField value format + +`DateField` values are `YYYY-MM-DD` (no `T`). `DateTimeField` values are full ISO +datetimes (`2026-07-16T14:30:00.000Z`, with `T`). Putting a datetime string in a +`DateField` (or vice versa) passes lint and indexes fine, then **crashes at render** +with `RangeError: Invalid time value` when a user actually opens the card. Naming +convention to follow when picking the field type: a `*At` suffix (`createdAt`, +`publishedAt`) means `DateTimeField`; a `*Date`/`*On` suffix or bare `dob` means +`DateField`. + +## 2. Never put an external URL in `relationships..links.self` + +If a `linksTo`/`linksToMany` field's JSON `links.self` points at a URL the indexer +can't parse as a card (an external website, an image CDN URL, anything not a card +resource), **the failed parse poisons the JSONB write and rolls back the WHOLE +REALM's indexing transaction** — every other file in the same push silently fails to +index too, with no error pointing at the actual bad file. For an external image/URL, +use the pair pattern instead: `linksTo(ImageDef)` (or a similar file/media field) + +`contains(UrlField)` as two separate fields, never one relationship pointing straight +at an external URL. + +## 3. `linksToMany` JSON uses indexed top-level keys, never an array + +Correct: + +```json +"relationships": { + "items.0": { "links": { "self": "../foo" } }, + "items.1": { "links": { "self": "../bar" } } +} +``` + +Wrong (rejected outright — "instance ... is not a card resource document"): + +```json +"relationships": { + "items": { "links": { "self": ["../foo", "../bar"] } } +} +``` + +## 4. Never inline media or binary bytes in card JSON + +No `data:` URIs, `blob:` URIs, base64, or raw media bytes in any JSON string field or +attribute. Store media as a realm file linked via `linksTo(FileDef)` (or a FileDef +subtype: `ImageDef`, `CsvFileDef`, etc.) instead — never embed the bytes directly in +the instance JSON. + +## 5. Every query needs a realm scope, and don't start it before the realm is known + +A card-owned query with a missing or empty `realm` argument silently falls back to +searching **every realm the server can see**, not just the current one. Always scope +queries to the current card's own realm, and don't kick off the query before that +realm URL is actually resolved. Cap general-purpose result sets (~100) rather than +pulling unbounded result sets. + +## 6. Query-filter shape: `type` selects instances, `on` only scopes predicates + +To select every instance of a type, filter on `{ type: }` directly — never wrap +it in `{ on: }` alone (`on` only scopes _other_ predicates like `eq`/`contains`; +a bare `{ on: ref }` with nothing else matches nothing). Build refs with a `codeRef()` +helper, not a manually-constructed object. + +## 7. Don't push more than ~30 files through one atomic batch to a fresh realm + +Large atomic pushes (30+ files via a single bulk-write endpoint) can report success +while silently dropping some files' indexing jobs. For bulk kit/asset installs, push +in smaller batches and verify each batch's expected file count actually shows up in a +realm search before pushing the next batch. + +## 8. NEVER curl / HTTP-GET a `https://cardstack.com/base/*` module URL to inspect a base card + +Base card module URLs (`https://cardstack.com/base/theme`, `.../base/card-api`, +`.../base/cards/structured-theme`, etc.) are **loader-resolved module references, not +fetchable HTTP resources.** `cardstack.com` is a marketing site — a direct GET or a +realm op (`_mtimes`, `boxel file read`) against `cardstack.com/base/...` returns a +generic Webflow **404 HTML page** (`data-wf-domain=... %%PUBLISH_URL_REPLACEMENT%%`), +NOT the card. Do not keep retrying it — that page will never become the schema. To +learn a base card's fields/shape, use the **`get_card_schema` tool** (it resolves +through the realm server), or read an existing instance of that card already in the +target realm. Same rule for any published `*.boxel.site` / `*.boxel.build` URL: those +are Webflow-published sites, not realms — never point realm operations at them. + +Also: many base cards are **default exports**, so the schema ref is `name: "default"`, +NOT the class name. The base **Theme** card is the default export of +`https://cardstack.com/base/theme` (the module is `export default Theme`) — query it as +module `https://cardstack.com/base/theme`, name `default` (querying `#Theme` fails). +`StructuredTheme` is likewise the default export of `base/structured-theme`. When a +`get_card_schema` call fails with "named export is a CardDef", retry with `name: +"default"` before assuming the card is unreachable — do NOT fall back to curling the URL. diff --git a/packages/software-factory/src/factory-seed.ts b/packages/software-factory/src/factory-seed.ts index 6e0b027650d..170d37ce5e7 100644 --- a/packages/software-factory/src/factory-seed.ts +++ b/packages/software-factory/src/factory-seed.ts @@ -79,14 +79,17 @@ export async function createSeedIssue( // The factory entrypoint pulls the target realm into `workspaceDir` // before calling us, so a pre-existing seed shows up locally. + // + // LOCAL PATCH (CS-12192): the seed path is a realm-wide constant, so a + // second `factory:go` against an already-bootstrapped realm would find the + // *previous* run's stale seed here and short-circuit — silently ignoring + // the new brief. For this workspace's sequential multi-brief pass we instead + // OVERWRITE the seed with the current brief every time, so each run bootstraps + // its own brief. `buildSeedIssueDocument` stamps status=backlog, so a seed + // left `done`/`blocked` by a prior run is re-armed. Not an upstream change. let existing = await readCard(workspaceDir, SEED_ISSUE_FILE); - if (existing.ok) { - log.info(`Seed issue already exists at ${SEED_ISSUE_FILE}`); - return { issueId: SEED_ISSUE_PATH, status: 'existing' }; - } - - // Anything other than "file missing" is a real problem — surface it. - if (existing.status !== 404) { + // Anything other than "found" or "file missing" is a real problem — surface it. + if (!existing.ok && existing.status !== 404) { throw new Error( `Failed to check for existing seed issue: ${existing.error ?? 'unknown error'}`, ); @@ -94,7 +97,11 @@ export async function createSeedIssue( let document = buildSeedIssueDocument(brief, darkfactoryModuleUrl); - log.info(`Creating seed issue at ${SEED_ISSUE_FILE}`); + log.info( + existing.ok + ? `Overwriting existing seed issue at ${SEED_ISSUE_FILE} with current brief` + : `Creating seed issue at ${SEED_ISSUE_FILE}`, + ); let writeResult = await writeCard( workspaceDir, SEED_ISSUE_FILE, @@ -107,7 +114,11 @@ export async function createSeedIssue( ); } - log.info(`Seed issue created: ${SEED_ISSUE_PATH}`); + log.info( + existing.ok + ? `Seed issue overwritten: ${SEED_ISSUE_PATH}` + : `Seed issue created: ${SEED_ISSUE_PATH}`, + ); return { issueId: SEED_ISSUE_PATH, status: 'created' }; } diff --git a/packages/software-factory/src/factory-skill-loader.ts b/packages/software-factory/src/factory-skill-loader.ts index 73b996c19c7..0a9d5eb38c9 100644 --- a/packages/software-factory/src/factory-skill-loader.ts +++ b/packages/software-factory/src/factory-skill-loader.ts @@ -187,6 +187,13 @@ export class DefaultSkillResolver implements SkillResolver { 'boxel-file-structure', 'boxel-api', 'boxel-command', + // Local-only addition for this workspace's factory runs: hard-won, + // silent-realm-corrupting gotchas (DateField/DateTimeField mismatch, + // external URL in a relationships link poisoning the whole realm's + // indexing transaction, etc.) that upstream's boxel-development refs + // don't cover. Not an upstream change — lives in this checkout's + // .agents/skills/ only. + 'boxel-workspace-cardinal-rules', ]; if (this.enableBoxelUiDiscovery) { diff --git a/packages/software-factory/src/instantiate-execution.ts b/packages/software-factory/src/instantiate-execution.ts index c001a20f016..8286cd890a5 100644 --- a/packages/software-factory/src/instantiate-execution.ts +++ b/packages/software-factory/src/instantiate-execution.ts @@ -721,6 +721,31 @@ async function defaultSearchSpecs( let specCardUrl = new URL(specId, ensureTrailingSlash(realmUrl)).href; let moduleUrl = new URL(ref.module, specCardUrl).href; + // LOCAL PATCH (CS-12195): a Spec whose `ref.module` is itself + // cross-origin from the target realm (a bare instance of a base-realm + // card, no local subclass — e.g. a plain `Theme` instance, which some + // v0 briefs explicitly require: "no new CardDef, no subclass") can + // NEVER pass prerender instantiation — the prerender refuses + // cross-origin module loads unconditionally (see the same-named guard + // in `prepareExampleInstance` below). Every example would fail with + // the identical "adopts from a module … does not match target realm" + // error regardless of how correct the instance data is. Skip these the + // same way non-instantiable specTypes are skipped above: parse, eval, + // and lint already validate everything about the instance data that's + // actually this realm's responsibility; the base card class itself is + // validated by the base realm's own suite, not here. + let refModuleOrigin = new URL(moduleUrl).origin; + let targetRealmOrigin = new URL(realmUrl).origin; + if (refModuleOrigin !== targetRealmOrigin) { + log.info( + `Spec ${specId} refs a cross-origin base module (${moduleUrl}) with ` + + `no local subclass — prerender instantiation is structurally ` + + `impossible (cross-origin), skipping. parse/eval/lint already ` + + `cover this instance.`, + ); + continue; + } + let relationships = (card as Record).relationships as | Record | undefined; diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index 2eacecddb0b..dbf7d125297 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -393,7 +393,15 @@ export async function runIssueLoop( // Create a fresh validator scoped to this issue so that artifacts // (e.g. TestRun cards) are named per-issue rather than shared. - let validator = createValidator(issue.id); + // Bootstrap issues create Project/Board/Knowledge-Article/Issue cards, + // never a Catalog Spec — so `instantiate` structurally can never pass + // for them (see CS-12185). Skip straight to NoOpValidator (already + // built for exactly this per its own docstring) instead of burning + // maxIterationsPerIssue on an unwinnable validation. + let validator = + issue.issueType === 'bootstrap' + ? new NoOpValidator() + : createValidator(issue.id); // ----------------------------------------------------------------------- // Inner loop: iterate on a single issue with validation From 877260be4030970dea3b642020d59f2a0de30c86 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:00:39 -0400 Subject: [PATCH 02/39] Declare lodash as explicit boxel-ui dependency (CS-12187) boxel-ui's compiled dist imports from 'lodash' but package.json only declared 'lodash-es'; embroider's strict resolver fails any fresh host build. Lockfile updated surgically for just this addition. Co-Authored-By: Claude Fable 5 --- packages/boxel-ui/addon/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/boxel-ui/addon/package.json b/packages/boxel-ui/addon/package.json index 0694b3d9746..5db47dca4b2 100644 --- a/packages/boxel-ui/addon/package.json +++ b/packages/boxel-ui/addon/package.json @@ -63,6 +63,7 @@ "ember-velcro": "^2.1.3", "file-loader": "catalog:", "focus-trap": "catalog:", + "lodash": "catalog:", "lodash-es": "catalog:", "pluralize": "catalog:", "tracked-built-ins": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9934eb5e3f..b9c6ae33e4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1333,6 +1333,9 @@ importers: focus-trap: specifier: 'catalog:' version: 7.8.0 + lodash: + specifier: 'catalog:' + version: 4.18.1 lodash-es: specifier: 'catalog:' version: 4.18.1 From 38fc6b249b1e49696abe13cabd650eb8bc87dcad Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:30:32 -0400 Subject: [PATCH 03/39] Add on-demand skill tools and HTML screenshot tool to the factory agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new agent tools for the V2 loop: - list_skills: catalog of every skill resolvable from the loader's search dirs (28 currently, incl. the full design stack the front-loaded resolver never ships), with one-line descriptions and reference file names. - read_skill: progressive disclosure — SKILL.md first, individual reference files on request. Complements CS-12194. - screenshot_html: renders a workspace HTML file in headless Chromium (@playwright/test, already a dep) and writes a PNG into the workspace; the agent then Reads the PNG with its image-capable native Read tool. Powers the HTML-first design/crit loop: mockup -> screenshot -> critique -> revise, seconds per iteration, before any .gts is written. Input and output paths are constrained inside the workspace. Smoke-tested: catalog lists 28 skills, reference reads work, screenshot round-trip 687ms with escape/missing-file guards verified. Co-Authored-By: Claude Fable 5 --- .../src/factory-skill-loader.ts | 9 + .../src/factory-tool-builder.ts | 154 +++++++++++++ .../src/screenshot-execution.ts | 140 ++++++++++++ .../software-factory/src/skill-catalog.ts | 207 ++++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 packages/software-factory/src/screenshot-execution.ts create mode 100644 packages/software-factory/src/skill-catalog.ts diff --git a/packages/software-factory/src/factory-skill-loader.ts b/packages/software-factory/src/factory-skill-loader.ts index 0a9d5eb38c9..cd33f43f597 100644 --- a/packages/software-factory/src/factory-skill-loader.ts +++ b/packages/software-factory/src/factory-skill-loader.ts @@ -51,6 +51,15 @@ const DEFAULT_FALLBACK_DIRS = [ join(PACKAGE_ROOT, '.agents', 'skills'), ]; +/** + * All skill search directories in precedence order (primary first). Exposed + * for the on-demand skill tools (`list_skills` / `read_skill`) so their + * catalog matches exactly what the loader can resolve. + */ +export function skillSearchDirs(): string[] { + return [DEFAULT_SKILLS_DIR, ...DEFAULT_FALLBACK_DIRS]; +} + /** Approximate characters per token for budget estimation. */ const CHARS_PER_TOKEN = 4; diff --git a/packages/software-factory/src/factory-tool-builder.ts b/packages/software-factory/src/factory-tool-builder.ts index 1ee5585f532..f8b338106bf 100644 --- a/packages/software-factory/src/factory-tool-builder.ts +++ b/packages/software-factory/src/factory-tool-builder.ts @@ -33,6 +33,12 @@ import { type RunParseInMemoryOptions, type RunParseResult, } from './parse-execution.ts'; +import { + captureHtmlScreenshot, + type ScreenshotHtmlOptions, + type ScreenshotHtmlResult, +} from './screenshot-execution.ts'; +import { catalogSkills, readSkillOnDemand } from './skill-catalog.ts'; import { runTestsInMemory } from './test-run-execution.ts'; import type { RunTestsInMemoryOptions, @@ -113,6 +119,10 @@ export interface ToolBuilderConfig { runInstantiateInMemory?: ( options: RunInstantiateInMemoryOptions, ) => Promise; + /** Injected for testing — defaults to captureHtmlScreenshot. */ + captureHtmlScreenshot?: ( + options: ScreenshotHtmlOptions, + ) => Promise; } export interface ToolCallEntry { @@ -168,6 +178,9 @@ export function buildFactoryTools( buildRunEvaluateTool(config), buildRunParseTool(config), buildRunInstantiateTool(config), + buildListSkillsTool(), + buildReadSkillTool(), + buildScreenshotHtmlTool(config), buildSignalDoneTool(), buildRequestClarificationTool(), ]; @@ -664,6 +677,147 @@ function buildRunInstantiateTool(config: ToolBuilderConfig): FactoryTool { }; } +function buildListSkillsTool(): FactoryTool { + return { + name: 'list_skills', + description: + 'List every skill available to load on demand — name, one-line ' + + 'description, and the reference files each one carries. Your system ' + + 'prompt front-loads only a core skill set; when the task at hand ' + + 'touches a topic that core does not cover (theming, fitted formats, ' + + 'file-backed fields, commands, catalog specs, queries, …), call this ' + + 'to find the right skill, then load it with read_skill. Cheap to ' + + 'call; results are stable for the whole run.', + parameters: { type: 'object', properties: {} }, + execute: async () => { + let skills = await catalogSkills(); + return { ok: true, skills }; + }, + }; +} + +function buildReadSkillTool(): FactoryTool { + return { + name: 'read_skill', + description: + 'Read a skill on demand by name. Without "reference", returns the ' + + "skill's SKILL.md body plus the list of its reference file names; " + + 'pass "reference" to fetch one reference file. Load skills ' + + 'progressively: SKILL.md first, then only the reference files the ' + + 'current work actually needs. Do NOT re-read skills whose full text ' + + 'is already in your system prompt. Use list_skills to discover ' + + 'available names.', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Skill name exactly as returned by list_skills.', + }, + reference: { + type: 'string', + description: + 'Optional reference file name (e.g. `dev-fitted-formats.md`) ' + + 'from the skill\'s "references" list.', + }, + }, + required: ['name'], + }, + execute: async (args) => { + let name = requireStringArg(args, 'name', 'read_skill'); + let rawReference = args.reference; + let reference = + typeof rawReference === 'string' && rawReference.trim() !== '' + ? rawReference.trim() + : undefined; + try { + let result = await readSkillOnDemand(name, reference); + return { ok: true, ...result }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; +} + +function buildScreenshotHtmlTool(config: ToolBuilderConfig): FactoryTool { + let execute = config.captureHtmlScreenshot ?? captureHtmlScreenshot; + return { + name: 'screenshot_html', + description: + 'Render a workspace HTML file in headless Chromium and write a PNG ' + + 'screenshot into the workspace. Returns the PNG\'s workspace-relative ' + + 'path — then use your native Read tool on that path to SEE the image ' + + 'and critique it. This powers the HTML-first design loop: write a ' + + 'plain HTML+CSS mockup of the card (mobile isolated view, fitted ' + + 'badge/strip/card tiles, embedded row) with REAL sample copy, ' + + 'screenshot it, look at it, name the defects, revise, and repeat — ' + + 'seconds per iteration, no lint or realm round-trip. Iterate on the ' + + 'mockup until it is right BEFORE writing any .gts template; then ' + + 'translate the accepted mockup into the card templates. Mockups and ' + + 'screenshots are design artifacts — keep them under a `design/` ' + + 'folder in the workspace.', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Workspace-relative path to the .html file to render.', + }, + output_path: { + type: 'string', + description: + 'Optional workspace-relative .png output path. Defaults to the ' + + 'HTML path with the extension replaced by .png.', + }, + width: { + type: 'number', + description: 'Viewport width in px (default 390 — mobile).', + }, + height: { + type: 'number', + description: 'Viewport height in px (default 844 — mobile).', + }, + full_page: { + type: 'boolean', + description: + 'Capture the full scrollable page (default true) or just the viewport.', + }, + }, + required: ['path'], + }, + execute: async (args) => { + let path = requireStringArg(args, 'path', 'screenshot_html'); + let outputPath = + typeof args.output_path === 'string' && args.output_path.trim() !== '' + ? args.output_path.trim() + : undefined; + let result = await execute({ + workspaceDir: config.workspaceDir, + path, + ...(outputPath ? { outputPath } : {}), + ...(typeof args.width === 'number' ? { width: args.width } : {}), + ...(typeof args.height === 'number' ? { height: args.height } : {}), + ...(typeof args.full_page === 'boolean' + ? { fullPage: args.full_page } + : {}), + }); + if (result.ok) { + return { + ...result, + note: + `Screenshot written to ${result.outputPath}. Use the native ` + + 'Read tool on that path to view the image and critique it.', + }; + } + return result; + }, + }; +} + function buildSignalDoneTool(): FactoryTool { return { name: 'signal_done', diff --git a/packages/software-factory/src/screenshot-execution.ts b/packages/software-factory/src/screenshot-execution.ts new file mode 100644 index 00000000000..e359eb31456 --- /dev/null +++ b/packages/software-factory/src/screenshot-execution.ts @@ -0,0 +1,140 @@ +/** + * Screenshot execution — renders a workspace HTML file in headless Chromium + * and writes a PNG next to it (or to a caller-chosen workspace path). + * + * This powers the factory's HTML-first design loop: the agent writes a plain + * HTML+CSS mockup with real sample copy, screenshots it here, then Reads the + * PNG with its native (image-capable) Read tool to critique and revise — + * seconds per iteration, with no lint/index/realm round-trip in the loop. + * + * Uses `@playwright/test`'s bundled chromium (already a package dependency + * for the QUnit runner path). Both the HTML input and the PNG output are + * constrained to resolve inside the workspace directory. + */ + +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { logger } from './logger.ts'; + +const log = logger('screenshot-execution'); + +export interface ScreenshotHtmlOptions { + /** Local workspace directory mirroring the target realm. */ + workspaceDir: string; + /** Workspace-relative path to the HTML file to render. */ + path: string; + /** + * Workspace-relative output path for the PNG. Defaults to the HTML path + * with its extension replaced by `.png`. + */ + outputPath?: string; + /** Viewport width in px. Defaults to 390 (mobile). */ + width?: number; + /** Viewport height in px. Defaults to 844 (mobile). */ + height?: number; + /** Capture the full scrollable page (default) or just the viewport. */ + fullPage?: boolean; +} + +export interface ScreenshotHtmlResult { + ok: boolean; + /** Workspace-relative path of the written PNG on success. */ + outputPath?: string; + error?: string; + durationMs: number; +} + +export async function captureHtmlScreenshot( + options: ScreenshotHtmlOptions, +): Promise { + let start = Date.now(); + let { + workspaceDir, + path: htmlPath, + width = 390, + height = 844, + fullPage = true, + } = options; + + let resolvedHtml = resolveInsideWorkspace(workspaceDir, htmlPath); + if (!resolvedHtml) { + return { + ok: false, + error: `Path "${htmlPath}" resolves outside the workspace. Use a workspace-relative path.`, + durationMs: Date.now() - start, + }; + } + if (!/\.html?$/i.test(resolvedHtml)) { + return { + ok: false, + error: `Path "${htmlPath}" is not an .html file.`, + durationMs: Date.now() - start, + }; + } + try { + await stat(resolvedHtml); + } catch { + return { + ok: false, + error: `HTML file "${htmlPath}" does not exist in the workspace. Write it first.`, + durationMs: Date.now() - start, + }; + } + + let outputRelative = + options.outputPath ?? htmlPath.replace(/\.html?$/i, '.png'); + let resolvedOutput = resolveInsideWorkspace(workspaceDir, outputRelative); + if (!resolvedOutput || !/\.png$/i.test(resolvedOutput)) { + return { + ok: false, + error: `Output path "${outputRelative}" must be a workspace-relative .png path.`, + durationMs: Date.now() - start, + }; + } + + let { chromium } = await import('@playwright/test'); + let browser = await chromium.launch(); + try { + let page = await browser.newPage({ viewport: { width, height } }); + await page.goto(pathToFileURL(resolvedHtml).href, { waitUntil: 'load' }); + // Give fonts/layout a beat to settle — file:// loads fire `load` before + // font rasterization completes and a too-early capture shows fallbacks. + await page.waitForTimeout(200); + await mkdir(dirname(resolvedOutput), { recursive: true }); + await page.screenshot({ path: resolvedOutput, fullPage }); + } catch (error) { + let message = error instanceof Error ? error.message : String(error); + log.warn(`screenshot of ${htmlPath} failed: ${message}`); + return { + ok: false, + error: `Screenshot failed: ${message}`, + durationMs: Date.now() - start, + }; + } finally { + await browser.close(); + } + + log.info( + `screenshot ${htmlPath} → ${outputRelative} (${width}x${height}, fullPage=${fullPage})`, + ); + return { + ok: true, + outputPath: outputRelative, + durationMs: Date.now() - start, + }; +} + +function resolveInsideWorkspace( + workspaceDir: string, + candidate: string, +): string | undefined { + let root = resolve(workspaceDir); + let abs = resolve(root, candidate); + let rel = relative(root, abs); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + return undefined; + } + return abs; +} diff --git a/packages/software-factory/src/skill-catalog.ts b/packages/software-factory/src/skill-catalog.ts new file mode 100644 index 00000000000..f444c481f70 --- /dev/null +++ b/packages/software-factory/src/skill-catalog.ts @@ -0,0 +1,207 @@ +/** + * Skill catalog — on-demand skill discovery and reading for the factory agent. + * + * The system prompt front-loads only a small always-on skill core; everything + * else is discoverable at runtime through the `list_skills` / `read_skill` + * factory tools built on these functions. Progressive disclosure keeps the + * per-turn context budget on precedent code and design specs instead of + * skill text the current issue never touches. + */ + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import { skillSearchDirs } from './factory-skill-loader.ts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SkillCatalogEntry { + name: string; + description: string; + /** Reference file names loadable individually via read_skill. */ + references: string[]; +} + +export interface SkillReadResult { + name: string; + /** + * SKILL.md body when no reference was requested; the reference file's + * content when one was. + */ + content: string; + /** Reference file names available for follow-up reads. */ + references: string[]; + /** Set when a specific reference file was read. */ + referenceFileName?: string; +} + +const DESCRIPTION_MAX_CHARS = 240; + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +/** + * Enumerate every skill resolvable from the loader's search directories. + * Earlier directories win on name collisions — the same precedence the + * SkillLoader applies — so the catalog never advertises a skill the loader + * would resolve to a different copy. + */ +export async function catalogSkills( + dirs: string[] = skillSearchDirs(), +): Promise { + let seen = new Map(); + + for (let dir of dirs) { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + continue; + } + for (let name of entries) { + if (seen.has(name)) continue; + let skillDir = join(dir, name); + let content: string; + try { + content = await readFile(join(skillDir, 'SKILL.md'), 'utf8'); + } catch { + continue; + } + seen.set(name, { + name, + description: extractDescription(content), + references: await listReferenceFiles(skillDir), + }); + } + } + + return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +/** + * Read a skill's SKILL.md, or one of its reference files, by name. + * Reference reads are constrained to basenames inside the skill's own + * `references/` directory (or its compiled `AGENTS.md`) — no traversal. + */ +export async function readSkillOnDemand( + skillName: string, + referenceFileName?: string, + dirs: string[] = skillSearchDirs(), +): Promise { + let skillDir = await findSkillDir(skillName, dirs); + if (!skillDir) { + throw new Error( + `Skill "${skillName}" not found. Call list_skills for the catalog of available names.`, + ); + } + + let references = await listReferenceFiles(skillDir); + + if (referenceFileName) { + let safeName = basename(referenceFileName); + if (!references.includes(safeName)) { + throw new Error( + `Skill "${skillName}" has no reference "${referenceFileName}". ` + + `Available references: ${references.length > 0 ? references.join(', ') : '(none)'}.`, + ); + } + let refPath = + safeName === 'AGENTS.md' + ? join(skillDir, 'AGENTS.md') + : join(skillDir, 'references', safeName); + return { + name: skillName, + content: await readFile(refPath, 'utf8'), + references, + referenceFileName: safeName, + }; + } + + return { + name: skillName, + content: await readFile(join(skillDir, 'SKILL.md'), 'utf8'), + references, + }; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +async function findSkillDir( + skillName: string, + dirs: string[], +): Promise { + let safeName = basename(skillName); + for (let dir of dirs) { + let candidate = join(dir, safeName); + try { + await stat(join(candidate, 'SKILL.md')); + return candidate; + } catch { + // try next directory + } + } + return undefined; +} + +/** + * Reference files a skill exposes: the compiled `AGENTS.md` for rules-style + * skills (e.g. ember-best-practices), else the `references/*.md` set — the + * same shapes the SkillLoader inlines. + */ +async function listReferenceFiles(skillDir: string): Promise { + try { + await stat(join(skillDir, 'rules')); + try { + await stat(join(skillDir, 'AGENTS.md')); + return ['AGENTS.md']; + } catch { + return []; + } + } catch { + // no rules/ dir — fall through to references/ + } + + try { + let files = await readdir(join(skillDir, 'references')); + return files.filter((f) => f.endsWith('.md')).sort(); + } catch { + return []; + } +} + +/** + * One-line description for the catalog: frontmatter `description:` when + * present, else the first non-empty non-heading body line. + */ +function extractDescription(skillMd: string): string { + let body = skillMd; + let frontmatter = skillMd.match(/^---\n([\s\S]*?)\n---\n/); + if (frontmatter) { + let descLine = frontmatter[1] + .split('\n') + .find((l) => l.startsWith('description:')); + if (descLine) { + return truncate(descLine.slice('description:'.length).trim()); + } + body = skillMd.slice(frontmatter[0].length); + } + let line = body + .split('\n') + .find((l) => l.trim() !== '' && !l.trim().startsWith('#')); + return truncate(line?.trim() ?? ''); +} + +function truncate(text: string): string { + return text.length > DESCRIPTION_MAX_CHARS + ? text.slice(0, DESCRIPTION_MAX_CHARS - 1) + '…' + : text; +} From e9dc7d5d5e2d3acda0604fa3f022dde333a96f4b Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:41:09 -0400 Subject: [PATCH 04/39] Add --v2 lean design-first mode to factory:go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --v2 changes the loop three ways: 1. Lean skill core: implementation issues front-load only software-factory-operations-v2 (new ~90-line design-first contract with a when-you-need-X-read-Y pointer table) + boxel-file-structure + boxel-workspace-cardinal-rules — ~4.1k tokens vs V1's ~88k (95% cut). Everything else loads on demand via list_skills / read_skill. 2. Design-first implement prompt (issue-implement-v2.md): mandatory HTML+CSS mockup with real sample copy -> screenshot_html -> Read the PNG -> named-defect critique -> revise, BEFORE any .gts; then the .gts is a translation of the accepted mockup. No .test.gts — tests belong to a separate hardening phase. 3. Validation pipeline drops the QUnit step (includeTestStep=false); parse/lint/eval/instantiate remain. boxel-ui discovery defaults ON. Bootstrap issues are unchanged in this commit. Co-Authored-By: Claude Fable 5 --- .../software-factory-operations-v2/SKILL.md | 78 +++++++++++++ .../prompts/issue-implement-v2.md | 108 ++++++++++++++++++ .../src/factory-agent/types.ts | 6 + .../src/factory-context-builder.ts | 6 + .../src/factory-entrypoint.ts | 14 ++- .../src/factory-issue-loop-wiring.ts | 10 ++ .../src/factory-prompt-loader.ts | 15 ++- .../src/factory-skill-loader.ts | 30 +++++ .../src/validators/validation-pipeline.ts | 15 ++- 9 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md create mode 100644 packages/software-factory/prompts/issue-implement-v2.md diff --git a/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md new file mode 100644 index 00000000000..03f1b8d42da --- /dev/null +++ b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md @@ -0,0 +1,78 @@ +--- +name: software-factory-operations-v2 +description: Lean V2 factory loop — design-first workflow, on-demand skills, no tests until hardening. Use when implementing cards in a target realm through the V2 factory execution loop. +--- + +# Software Factory Operations (V2 — lean, design-first) + +You operate inside the factory execution loop. Workspace files live in a +**local mirror of the target realm** that the orchestrator syncs back +between iterations; your working directory is that mirror, so +realm-relative paths (`song.gts`, `Song/one.json`) resolve directly with +the native `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` tools. + +This skill is deliberately small. Most knowledge loads **on demand**: +call `list_skills` for the catalog, `read_skill({ name })` for a skill's +overview, and `read_skill({ name, reference })` for a specific reference +file. Load what the current work touches, nothing more. + +## When you need X, read Y + +| Need | read_skill | +| --- | --- | +| Card/field authoring, CardDef/FieldDef syntax, formats | `boxel-development` (then specific `dev-*.md` references) | +| Fitted-format layout rules | `boxel-development` reference `dev-fitted-formats.md` | +| Theme tokens / design-system CSS | `boxel-development` reference `dev-theme-design-system.md` | +| Search query syntax (`boxel search --query`) | `boxel-api` | +| Host commands via `boxel run-command` | `boxel-command` | +| File-backed fields (images, files, csv) | `boxel-file-def` | +| Catalog Spec conventions | `boxel-development` reference `dev-spec-usage.md` | +| Reusable UI components before hand-rolling any UI | `boxel-ui-component-discovery` | + +## Required flow (design-first) + +1. **Ground**: inspect workspace + target realm (`boxel search` via Bash); + read precedent `.gts`; `read_skill` what the issue touches. +2. **DESIGN**: write `design/.html` — plain HTML+CSS mockup with + hard-coded realistic sample copy showing the isolated view (mobile), + fitted badge/strip/card tiles, and an embedded row. Then + `screenshot_html({ path })`, `Read` the PNG, critique it (name the + defects), revise, re-screenshot. At least one full crit pass. The + accepted mockup is the binding spec for step 3. +3. **BUILD**: translate the mockup into the `.gts` card (isolated + + embedded + fitted templates), sample instances (same data as the + mockup), and a Catalog Spec (`Spec/.json`, adoptsFrom + `https://cardstack.com/base/spec#Spec`, `linkedExamples` → + instances). Call `get_card_schema` before writing any card JSON whose + shape you don't know (Spec, tracker cards). +4. **VERIFY**: `run_lint({ path })` per file, then `run_parse()`, + `run_evaluate()`, `run_instantiate()`. Fix what they report. These + return in-memory results; each one syncs your workspace to the realm + first. Zero-coverage passes come back as errors — never treat them + as green. +5. **Done**: `signal_done()`. If validation feedback comes back, fix and + signal again. If truly blocked, `request_clarification({ message })`. + +## Hard rules + +- **NO `.test.gts` files.** Tests belong to a separate hardening phase + invoked later over artifacts that earned them. This loop ships zero + tests by design; do not "helpfully" add any. +- **Never write to the source realm**; all artifacts go to the target + realm via the workspace. +- **Stay inside the workspace.** Native fs tools are structurally scoped + to it; treat `Bash` as read-only inspection (`ls`, `grep`, + `boxel search`, `boxel read-transpiled`) — never sync/push yourself. +- **Issue invariants**: `description` is immutable — append progress to + the `comments` array instead (Read the issue JSON, append, Write back). + You may set `status` only to `"blocked"` or `"backlog"`; `done` / + `in_progress` are orchestrator-owned. +- **Write idiomatic source, never compiled output.** When an eval/ + instantiate error cites a line/column it refers to the transpiled JS — + `boxel read-transpiled --realm ` to map it back, then fix + the `.gts` source. +- **Fields are an API.** Other cards compose with your card via its + fields, its embedded/fitted surfaces, and its linksTo graph. Name + fields for consumers; prefer FieldDefs for recurring shapes; keep + per-format content matrices in mind (what does a consumer get at each + size?). diff --git a/packages/software-factory/prompts/issue-implement-v2.md b/packages/software-factory/prompts/issue-implement-v2.md new file mode 100644 index 00000000000..12390482ac3 --- /dev/null +++ b/packages/software-factory/prompts/issue-implement-v2.md @@ -0,0 +1,108 @@ +# Project + +{{project.objective}} + +{{#if project.successCriteria}} +Success criteria: +{{#each project.successCriteria}} +- {{.}} +{{/each}} +{{/if}} + +# Knowledge + +{{#each knowledge}} + +## {{title}} + +{{content}} +{{/each}} + +# Current Issue + +ID: {{issue.id}} +Summary: {{issue.summary}} +Status: {{issue.status}} +Priority: {{issue.priority}} + +Description: +{{issue.description}} + +{{#if issue.checklist}} +Checklist: +{{#each issue.checklist}} +- [ ] {{.}} +{{/each}} +{{/if}} + +{{#if toolResults}} + +# Tool Results + +You previously invoked the following tools. Use these results to inform your implementation. + +{{#each toolResults}} + +## {{tool}} (exit code: {{exitCode}}) + +```{{outputFormat}} +{{output}} +``` + +{{/each}} +{{/if}} + +# Instructions + +You are building a **user-facing card**. A card is judged by how it looks and +reads in its formats, and by how cleanly other cards can compose with it — +not by ceremony. Work design-first, in this order: + +## 1. Ground yourself (context before code) + +- `Read` / `Glob` the workspace; `Bash` + `boxel search --realm ` for cards already in the target realm. +- Call `list_skills`, then `read_skill` the skills this issue actually touches + (design, fitted formats, theming, file fields, queries — whatever applies). + Read precedent: if a similar card exists in the workspace, read its `.gts`. + +## 2. DESIGN — HTML mockup before any schema + +- Write `design/.html`: a plain HTML+CSS mockup of the card with + **hard-coded, realistic sample copy** (real names, real numbers — never + lorem ipsum). Show every surface that matters on one page: the isolated + view (mobile width), the fitted tiles (badge / strip / card), and an + embedded list row. +- Call `screenshot_html({ path: "design/.html" })`, then `Read` + the returned PNG and **critique it**: name concrete defects (hierarchy, + wrapping, spacing, color, copy) against the design language in the + Knowledge section. Revise the HTML and re-screenshot. Do at least one + full crit-and-revise pass; stop when you would show it to a designer. + +## 3. BUILD — translate the accepted mockup + +- Write the card definition (`.gts`) with `isolated`, `embedded`, AND + `fitted` templates that reproduce the accepted mockup. Design decisions + were made in step 2 — this is a translation task. Use theme CSS variables + (`var(--*)`) rather than hard-coded colors where a theme exists. +- Fields are an API other cards compose with: name them for consumers, + and prefer FieldDefs for shapes that will recur. +- Write at least one sample card instance (`.json`) using the SAME sample + data as the mockup. +- Write a Catalog Spec card (`Spec/.json`, adoptsFrom + `https://cardstack.com/base/spec#Spec`) linking the sample instances via + `linkedExamples`. + +## 4. VERIFY + +- `run_lint({ path })` each file you wrote; then `run_parse()`, + `run_evaluate()`, and `run_instantiate()` for the whole realm. +- Fix what they report. **Do NOT write any `.test.gts` files** — tests + belong to a separate hardening phase that runs later; this loop ships + zero tests by design. + +## 5. Done + +- Call `signal_done` (factory MCP tool). The orchestrator validates + parse/lint/eval/instantiate automatically. Do NOT set the issue status + yourself. Calling `signal_done` without the design artifacts, the card, + an instance, and a Spec is a failure. diff --git a/packages/software-factory/src/factory-agent/types.ts b/packages/software-factory/src/factory-agent/types.ts index 7d60327fc50..604b1cd31e2 100644 --- a/packages/software-factory/src/factory-agent/types.ts +++ b/packages/software-factory/src/factory-agent/types.ts @@ -249,6 +249,12 @@ export interface AgentContext { * See CS-10527. */ enableBoxelUiDiscovery?: boolean; + /** + * V2 lean/design-first mode — implementation issues use the + * `issue-implement-v2` prompt (HTML-mockup design phase, no tests) and + * the lean skill core with on-demand `read_skill` loading. + */ + v2?: boolean; } export interface AgentAction { diff --git a/packages/software-factory/src/factory-context-builder.ts b/packages/software-factory/src/factory-context-builder.ts index 5979201be50..4a745e60c69 100644 --- a/packages/software-factory/src/factory-context-builder.ts +++ b/packages/software-factory/src/factory-context-builder.ts @@ -50,6 +50,8 @@ export interface ContextBuilderConfig { * See CS-10527. */ enableBoxelUiDiscovery?: boolean; + /** V2 lean/design-first mode — carried onto every AgentContext. */ + v2?: boolean; } // --------------------------------------------------------------------------- @@ -62,6 +64,7 @@ export class ContextBuilder { private maxSkillTokens: number | undefined; private issueLoader: IssueRelationshipLoader | undefined; private enableBoxelUiDiscovery: boolean; + private v2: boolean; constructor(config: ContextBuilderConfig) { this.skillResolver = config.skillResolver; @@ -69,6 +72,7 @@ export class ContextBuilder { this.maxSkillTokens = config.maxSkillTokens; this.issueLoader = config.issueLoader; this.enableBoxelUiDiscovery = config.enableBoxelUiDiscovery === true; + this.v2 = config.v2 === true; } /** @@ -112,6 +116,7 @@ export class ContextBuilder { skills, targetRealm, enableBoxelUiDiscovery: this.enableBoxelUiDiscovery, + v2: this.v2, ...(darkfactoryModuleUrl ? { darkfactoryModuleUrl } : {}), }; @@ -188,6 +193,7 @@ export class ContextBuilder { skills, targetRealm, enableBoxelUiDiscovery: this.enableBoxelUiDiscovery, + v2: this.v2, ...(darkfactoryModuleUrl ? { darkfactoryModuleUrl } : {}), }; diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index e93736d877b..9ad3f2a03ff 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -77,6 +77,8 @@ export interface FactoryEntrypointOptions { * Set via `--enable-boxel-ui-discovery` on the CLI. */ enableBoxelUiDiscovery?: boolean; + /** V2 lean/design-first mode (see factory-issue-loop-wiring). */ + v2?: boolean; } export interface FactoryEntrypointAction { @@ -263,6 +265,9 @@ export function parseFactoryEntrypointArgs( 'enable-boxel-ui-discovery': { type: 'boolean', }, + v2: { + type: 'boolean', + }, }, }); } catch (error) { @@ -315,8 +320,14 @@ export function parseFactoryEntrypointArgs( openRouterApiKey, debug: parsed.values.debug === true ? true : undefined, retryBlocked: parsed.values['no-retry-blocked'] === true ? false : true, + // V2 turns boxel-ui discovery on by default — the design-first loop + // must search the catalog before hand-rolling UI. enableBoxelUiDiscovery: - parsed.values['enable-boxel-ui-discovery'] === true ? true : undefined, + parsed.values['enable-boxel-ui-discovery'] === true || + parsed.values.v2 === true + ? true + : undefined, + v2: parsed.values.v2 === true ? true : undefined, }; } @@ -462,6 +473,7 @@ export async function runFactoryEntrypoint( debug: options.debug, retryBlocked: options.retryBlocked, enableBoxelUiDiscovery: options.enableBoxelUiDiscovery, + v2: options.v2, // Wire the board and the seed issue's project the moment the bootstrap // issue finishes, rather than after the whole loop returns — so a run // whose later issues stall or get interrupted still ends up with the diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index a2722d7aa96..9a340691b26 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -103,6 +103,13 @@ export interface IssueLoopWiringConfig { * awareness of boxel-ui components. See CS-10527. */ enableBoxelUiDiscovery?: boolean; + /** + * V2 lean/design-first mode: lean skill core + on-demand read_skill, + * HTML-mockup design phase in the implement prompt, and no QUnit step + * in the validation pipeline (tests move to a later hardening phase). + * Also turns boxel-ui discovery on unless explicitly disabled upstream. + */ + v2?: boolean; /** * Invoked once, right after the bootstrap issue completes. The entrypoint * uses this to link the realm index's `board` relationship as soon as the @@ -146,10 +153,12 @@ export async function runFactoryIssueLoop( let contextBuilder = new ContextBuilder({ skillResolver: new DefaultSkillResolver({ enableBoxelUiDiscovery: config.enableBoxelUiDiscovery === true, + v2: config.v2 === true, }), skillLoader: new SkillLoader(), issueLoader, enableBoxelUiDiscovery: config.enableBoxelUiDiscovery === true, + v2: config.v2 === true, }); // 3. Tool infrastructure @@ -263,6 +272,7 @@ export async function runFactoryIssueLoop( issueId, fetchFilenames: (realmUrl: string) => client.listFiles(realmUrl), cache: validationCache, + includeTestStep: config.v2 !== true, }); // 6. Run issue loop diff --git a/packages/software-factory/src/factory-prompt-loader.ts b/packages/software-factory/src/factory-prompt-loader.ts index cc3d0b7ea35..4de74c44a6e 100644 --- a/packages/software-factory/src/factory-prompt-loader.ts +++ b/packages/software-factory/src/factory-prompt-loader.ts @@ -430,12 +430,15 @@ export function assembleImplementPrompt( let toolResultsData = buildToolResultsData(context); - return loader.load('issue-implement', { - project: context.project, - issue: context.issue, - knowledge: context.knowledge, - toolResults: toolResultsData.length > 0 ? toolResultsData : undefined, - }); + return loader.load( + context.v2 === true ? 'issue-implement-v2' : 'issue-implement', + { + project: context.project, + issue: context.issue, + knowledge: context.knowledge, + toolResults: toolResultsData.length > 0 ? toolResultsData : undefined, + }, + ); } /** diff --git a/packages/software-factory/src/factory-skill-loader.ts b/packages/software-factory/src/factory-skill-loader.ts index cd33f43f597..0b77ec03470 100644 --- a/packages/software-factory/src/factory-skill-loader.ts +++ b/packages/software-factory/src/factory-skill-loader.ts @@ -160,13 +160,22 @@ export interface DefaultSkillResolverOptions { * See CS-10527. */ enableBoxelUiDiscovery?: boolean; + /** + * V2 lean mode — front-load only a small core (the design-first V2 + * operations skill + file structure + cardinal rules); every other + * skill is discoverable at runtime via the `list_skills` / `read_skill` + * tools. Bootstrap issues are unaffected. + */ + v2?: boolean; } export class DefaultSkillResolver implements SkillResolver { private enableBoxelUiDiscovery: boolean; + private v2: boolean; constructor(options: DefaultSkillResolverOptions = {}) { this.enableBoxelUiDiscovery = options.enableBoxelUiDiscovery === true; + this.v2 = options.v2 === true; } /** @@ -191,6 +200,27 @@ export class DefaultSkillResolver implements SkillResolver { return ['software-factory-bootstrap', 'boxel-file-structure']; } + // V2 lean mode: small always-on core; everything else on demand via + // the list_skills / read_skill tools. The design-first workflow and + // the "when you need X, read Y" pointer table live in the V2 + // operations skill itself. + if (this.v2) { + let leanSkills = [ + 'software-factory-operations-v2', + 'boxel-file-structure', + 'boxel-workspace-cardinal-rules', + ]; + for (let skillName of extractKnowledgeSkillTags(project, issue)) { + if (!leanSkills.includes(skillName)) { + leanSkills.push(skillName); + } + } + log.info( + `Resolved skills (v2 lean) for issue "${issue.id}": ${leanSkills.join(', ')}`, + ); + return leanSkills; + } + let skills: string[] = [ 'boxel-development', 'boxel-file-structure', diff --git a/packages/software-factory/src/validators/validation-pipeline.ts b/packages/software-factory/src/validators/validation-pipeline.ts index ede73a07fb4..4a68c7727dc 100644 --- a/packages/software-factory/src/validators/validation-pipeline.ts +++ b/packages/software-factory/src/validators/validation-pipeline.ts @@ -179,6 +179,12 @@ export interface ValidationPipelineConfig { searchSpecsFn?: InstantiateValidationStepConfig['searchSpecsFn']; /** Injected for testing — passed through to ParseValidationStep. */ parseSearchSpecsFn?: ParseValidationStepConfig['searchSpecsFn']; + /** + * Include the QUnit test step (default true). The V2 lean loop sets this + * to false — tests belong to a separate hardening phase, so the per-issue + * pipeline is parse/lint/eval/instantiate only. + */ + includeTestStep?: boolean; } /** @@ -240,11 +246,14 @@ export function createDefaultPipeline( fetchFilenames: config.fetchFilenames, }; - return new ValidationPipeline([ + let steps: ValidationStep[] = [ new ParseValidationStep(parseConfig), new LintValidationStep(lintConfig), new EvalValidationStep(evalConfig), new InstantiateValidationStep(instantiateConfig), - new TestValidationStep(testConfig), - ]); + ]; + if (config.includeTestStep !== false) { + steps.push(new TestValidationStep(testConfig)); + } + return new ValidationPipeline(steps); } From e3f304b2e0f1cac5242f3c2a5f635dc002048424 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:16:11 -0400 Subject: [PATCH 05/39] V2 skill/prompt: Spec must populate catalog-facing title + description First pilot run finding: the slimmed operations skill dropped the old skill's 'display title and short description' requirement and the agent left both empty (README + linkedExamples were fine). Restore it as an explicit MUST in both the skill and the implement prompt. Co-Authored-By: Claude Fable 5 --- .../software-factory-operations-v2/SKILL.md | 7 +++++-- packages/software-factory/prompts/issue-implement-v2.md | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md index 03f1b8d42da..31aa98b6c82 100644 --- a/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md +++ b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md @@ -43,8 +43,11 @@ file. Load what the current work touches, nothing more. embedded + fitted templates), sample instances (same data as the mockup), and a Catalog Spec (`Spec/.json`, adoptsFrom `https://cardstack.com/base/spec#Spec`, `linkedExamples` → - instances). Call `get_card_schema` before writing any card JSON whose - shape you don't know (Spec, tracker cards). + instances). The Spec MUST populate its catalog-facing `title` (display + name) and `description` (one sentence) attributes in addition to the + readMe — a Spec with empty title/description renders as an unnamed + card in the catalog UI. Call `get_card_schema` before writing any + card JSON whose shape you don't know (Spec, tracker cards). 4. **VERIFY**: `run_lint({ path })` per file, then `run_parse()`, `run_evaluate()`, `run_instantiate()`. Fix what they report. These return in-memory results; each one syncs your workspace to the realm diff --git a/packages/software-factory/prompts/issue-implement-v2.md b/packages/software-factory/prompts/issue-implement-v2.md index 12390482ac3..577904c5154 100644 --- a/packages/software-factory/prompts/issue-implement-v2.md +++ b/packages/software-factory/prompts/issue-implement-v2.md @@ -90,7 +90,8 @@ not by ceremony. Work design-first, in this order: data as the mockup. - Write a Catalog Spec card (`Spec/.json`, adoptsFrom `https://cardstack.com/base/spec#Spec`) linking the sample instances via - `linkedExamples`. + `linkedExamples`, with its catalog-facing `title` and one-sentence + `description` attributes populated (never left empty). ## 4. VERIFY From 2902016101eefa74450b6f8008ce20e65e24286e Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:27:52 -0400 Subject: [PATCH 06/39] Add RunLog live blog to --v2 runs; single-page mockups; no curl-polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunLog: the factory writes run-log.gts + Runs/.json into the TARGET realm and appends entries on real events (issue picked, design screenshots, validation results, card-ready, run done). Screenshots embed as images the moment they exist; the REAL card embeds via linksTo the moment its first instance lands. Entries append with stable entries.N.card relationship indexes; display is newest-first via column-reverse. All writer failures are swallowed — the log can never take down a run. Also: v2 prompt now mandates ONE mockup page per card (was one file per surface — saves ~3-4 min/card of generation), and the v2 skill forbids curl-polling the realm for unsynced files (verify via run_* which sync first). Deployed retroactively to chris/jaraoke/Runs/jaraoke-player as the demo. Co-Authored-By: Claude Fable 5 --- .../software-factory-operations-v2/SKILL.md | 5 +- .../prompts/issue-implement-v2.md | 12 +- .../src/factory-entrypoint.ts | 1 + .../src/factory-issue-loop-wiring.ts | 18 + packages/software-factory/src/issue-loop.ts | 71 +++ packages/software-factory/src/run-log.ts | 556 ++++++++++++++++++ 6 files changed, 657 insertions(+), 6 deletions(-) create mode 100644 packages/software-factory/src/run-log.ts diff --git a/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md index 31aa98b6c82..8d3c2db236e 100644 --- a/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md +++ b/packages/software-factory/.agents/skills-orchestrator/software-factory-operations-v2/SKILL.md @@ -52,7 +52,10 @@ file. Load what the current work touches, nothing more. `run_evaluate()`, `run_instantiate()`. Fix what they report. These return in-memory results; each one syncs your workspace to the realm first. Zero-coverage passes come back as errors — never treat them - as green. + as green. **Never curl/HTTP-poll the realm for files you just wrote** + — your writes only reach the realm when a `run_*` tool syncs them; + a 404 from curl before that means nothing. Verify through the + `run_*` tools. 5. **Done**: `signal_done()`. If validation feedback comes back, fix and signal again. If truly blocked, `request_clarification({ message })`. diff --git a/packages/software-factory/prompts/issue-implement-v2.md b/packages/software-factory/prompts/issue-implement-v2.md index 577904c5154..f47b37d199a 100644 --- a/packages/software-factory/prompts/issue-implement-v2.md +++ b/packages/software-factory/prompts/issue-implement-v2.md @@ -67,11 +67,13 @@ not by ceremony. Work design-first, in this order: ## 2. DESIGN — HTML mockup before any schema -- Write `design/.html`: a plain HTML+CSS mockup of the card with - **hard-coded, realistic sample copy** (real names, real numbers — never - lorem ipsum). Show every surface that matters on one page: the isolated - view (mobile width), the fitted tiles (badge / strip / card), and an - embedded list row. +- Write `design/.html`: **ONE page** — a plain HTML+CSS mockup of + the card with **hard-coded, realistic sample copy** (real names, real + numbers — never lorem ipsum). Put every surface on that one page, labeled: + the isolated view (mobile width; add a wide variant section if the card + prefers wide format), the fitted tiles (badge / strip / card), and an + embedded list row. One file, one screenshot, one crit pass covers + everything — do NOT write a separate HTML file per surface. - Call `screenshot_html({ path: "design/.html" })`, then `Read` the returned PNG and **critique it**: name concrete defects (hierarchy, wrapping, spacing, color, copy) against the design language in the diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 9ad3f2a03ff..15a0b8ded7e 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -474,6 +474,7 @@ export async function runFactoryEntrypoint( retryBlocked: options.retryBlocked, enableBoxelUiDiscovery: options.enableBoxelUiDiscovery, v2: options.v2, + runTitle: brief.title, // Wire the board and the seed issue's project the moment the bootstrap // issue finishes, rather than after the whole loop returns — so a run // whose later issues stall or get interrupted still ends up with the diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index 9a340691b26..3a2ece81143 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -33,6 +33,7 @@ import { import { ContextBuilder } from './factory-context-builder.ts'; import { inferDarkfactoryModuleUrl } from './factory-seed.ts'; import { DefaultSkillResolver, SkillLoader } from './factory-skill-loader.ts'; +import { RunLogWriter } from './run-log.ts'; import { buildFactoryTools, type FactoryTool, @@ -110,6 +111,8 @@ export interface IssueLoopWiringConfig { * Also turns boxel-ui discovery on unless explicitly disabled upstream. */ v2?: boolean; + /** Brief title — names the live-blog RunLog card (v2). */ + runTitle?: string; /** * Invoked once, right after the bootstrap issue completes. The entrypoint * uses this to link the realm index's `board` relationship as soon as the @@ -278,6 +281,20 @@ export async function runFactoryIssueLoop( // 6. Run issue loop log.info(`Starting issue loop: targetRealm=${targetRealm}`); + let runLog: RunLogWriter | undefined; + if (config.v2 === true) { + let runSlug = (config.briefUrl.split('/').pop() ?? 'factory-run') + .replace(/\.json$/i, '') + .toLowerCase(); + runLog = new RunLogWriter({ + workspaceDir, + targetRealm, + runSlug, + runTitle: config.runTitle ?? runSlug, + syncWorkspace, + }); + } + let issueLoopConfig: IssueLoopConfig = { agent, contextBuilder, @@ -289,6 +306,7 @@ export async function runFactoryIssueLoop( workspaceDir, syncWorkspace, briefUrl: config.briefUrl, + runLog, maxIterationsPerIssue: config.maxIterationsPerIssue, maxOuterCycles: config.maxOuterCycles, debug: config.debug, diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index dbf7d125297..17eab6f1e7d 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -26,6 +26,11 @@ import type { IssueStore } from './issue-scheduler.ts'; import { IssueScheduler } from './issue-scheduler.ts'; import { logger } from './logger.ts'; +import { + type RunLogWriter, + designEntriesFromToolCalls, + cardPathsFromToolCalls, +} from './run-log.ts'; import { retryWithPoll } from './retry-with-poll.ts'; let log = logger('issue-loop'); @@ -130,6 +135,8 @@ export interface IssueLoopConfig { */ syncWorkspace: () => Promise<{ ok: boolean; error?: string }>; briefUrl?: string; + /** Live-blog writer (v2): appends run events to Runs/.json in the target realm. */ + runLog?: RunLogWriter; /** Maximum inner-loop iterations per issue. Default: 8. */ maxIterationsPerIssue?: number; /** Maximum outer-loop cycles (safety guard). Default: 50. */ @@ -219,6 +226,14 @@ function issueSummaryLabel(issue: SchedulableIssue): string { return summary ? `"${issue.id}" — "${summary}"` : `"${issue.id}"`; } +/** Human-facing issue title for the run log (no quoting/id noise). */ +function issueDisplayTitle(issue: SchedulableIssue): string { + let summary = issue.summary ?? (issue as Record).title; + return typeof summary === 'string' && summary.trim() !== '' + ? summary + : issue.id; +} + function formatValidation(results: ValidationResults): string { if (results.passed) { let stepCount = results.steps.length; @@ -274,6 +289,7 @@ export async function runIssueLoop( darkfactoryModuleUrl, syncWorkspace, briefUrl, + runLog, maxIterationsPerIssue = DEFAULT_MAX_ITERATIONS_PER_ISSUE, maxOuterCycles = DEFAULT_MAX_OUTER_CYCLES, debug = false, @@ -329,6 +345,10 @@ export async function runIssueLoop( // Outer loop: iterate over unblocked issues // ------------------------------------------------------------------------- + if (runLog) { + await runLog.start(); + } + while ( scheduler.hasUnblockedIssues(exhaustedIssues) && outerCycles < maxOuterCycles @@ -343,6 +363,14 @@ export async function runIssueLoop( break; } + if (runLog) { + let issueTitle = issueDisplayTitle(issue); + await runLog.append( + [{ kind: 'issue-picked', headline: `Started: ${issueTitle}` }], + { nowWorkingOn: issueTitle }, + ); + } + // Reset per-issue timing accumulators; `cycleStartMs` anchors this // issue's total wall clock and `cycleSyncStartMs` its sync baseline. cur = { agentMs: 0, validationMs: 0, syncMs: 0 }; @@ -447,6 +475,16 @@ export async function runIssueLoop( ` Agent returned ${result.toolCalls.length} tool call(s)${debug ? ` in ${fmtSecs(agentMs)}` : ''}`, ); + if (runLog) { + let designEntries = designEntriesFromToolCalls( + result.toolCalls, + targetRealm, + ); + if (designEntries.length > 0) { + await runLog.append(designEntries); + } + } + // The agent itself reports "I cannot proceed" via two paths: // calling `request_clarification` (clarification.message), or // an unrecoverable backend error (e.g. session.error from @@ -521,6 +559,18 @@ export async function runIssueLoop( }`, ); + if (runLog && validationResults) { + await runLog.append([ + { + kind: 'validation', + headline: validationResults.passed + ? 'Validation passed' + : 'Validation failed — revising', + body: formatValidation(validationResults), + }, + ]); + } + // The loop owns issue status transitions. The agent signals // completion via signal_done; the loop promotes to "done" only // when signal_done is called, validation passes, AND the sync @@ -656,6 +706,23 @@ export async function runIssueLoop( log.info( `Outer cycle ${outerCycles}: issue ${issueSummaryLabel(issue)} completed — exitReason=${exitReason}, iterations=${innerIterations}`, ); + + if (runLog && exitReason === 'done') { + let cardEntries = cardPathsFromToolCalls(allToolCalls).map( + (cardPath) => ({ + kind: 'card-ready' as const, + headline: `Card ready: ${cardPath}`, + cardPath, + }), + ); + await runLog.append([ + ...cardEntries, + { + kind: 'issue-done', + headline: `Done: ${issueDisplayTitle(issue)}`, + }, + ]); + } if (debug) { log.info( ` Timing: agent ${fmtSecs(issueTiming.agentMs)}, validation ${fmtSecs(issueTiming.validationMs)}, sync ${fmtSecs(issueTiming.syncMs)}, total ${fmtSecs(issueTiming.totalMs)}`, @@ -720,6 +787,10 @@ export async function runIssueLoop( log.info(`Outer loop finished: outcome=${outcome}, cycles=${outerCycles}`); + if (runLog) { + await runLog.finish(outcome === 'all_issues_done' ? 'completed' : 'stopped'); + } + // Mark the project as completed only when ALL issues in the realm are done // (not just the ones we processed). This prevents marking complete when // pre-existing blocked issues still exist. diff --git a/packages/software-factory/src/run-log.ts b/packages/software-factory/src/run-log.ts new file mode 100644 index 00000000000..085798d6303 --- /dev/null +++ b/packages/software-factory/src/run-log.ts @@ -0,0 +1,556 @@ +/** + * Run log — the live-blog surface for a factory run. + * + * The factory writes a `run-log.gts` CardDef plus one `Runs/.json` + * instance into the TARGET realm and appends entries as real events happen + * (issue picked, design screenshots produced, validation results, card + * ready, run done). The operator watches the RunLog card in the realm — + * newest entry first — instead of tailing a terminal. Design screenshots + * embed as images the moment they exist; the REAL card embeds (live, + * rendered) the moment its first instance lands, via a `linksTo` on the + * entry. + * + * Entries are appended (stable `entries.N.card` relationship indexes) and + * displayed newest-first via `column-reverse` in the template, so appends + * never rewrite existing relationship keys. + */ + +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { logger } from './logger.ts'; + +const log = logger('run-log'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RunLogEntryInput { + kind: + | 'phase' + | 'issue-picked' + | 'design' + | 'validation' + | 'card-ready' + | 'issue-done' + | 'run-done' + | 'note'; + headline: string; + body?: string; + /** Absolute URL of a screenshot image to embed. */ + imageUrl?: string; + /** + * Realm-relative card path (no .json extension, relative to realm root, + * e.g. `JaraokePlayer/thursday-night-jaraoke`) — embeds the live card. + */ + cardPath?: string; +} + +export interface RunLogWriterOptions { + workspaceDir: string; + targetRealm: string; + runSlug: string; + runTitle: string; + /** Push the workspace to the realm (the loop's shared sync gate). */ + syncWorkspace: () => Promise<{ ok: boolean; error?: string }>; +} + +// --------------------------------------------------------------------------- +// Writer +// --------------------------------------------------------------------------- + +export class RunLogWriter { + private opts: RunLogWriterOptions; + private instancePath: string; + + constructor(opts: RunLogWriterOptions) { + this.opts = opts; + this.instancePath = join(opts.workspaceDir, 'Runs', `${opts.runSlug}.json`); + } + + /** Idempotent: writes the CardDef module if missing and creates or re-arms the instance. */ + async start(): Promise { + try { + let modulePath = join(this.opts.workspaceDir, 'run-log.gts'); + let moduleExists = await fileExists(modulePath); + if (!moduleExists) { + await writeFile(modulePath, RUN_LOG_GTS, 'utf8'); + } + + if (await fileExists(this.instancePath)) { + await this.append([ + { + kind: 'phase', + headline: 'Run restarted', + body: `New factory run against this brief started at ${new Date().toISOString()}.`, + }, + ]); + await this.patch({ status: 'running' }); + return; + } + + let doc = { + data: { + type: 'card', + attributes: { + runTitle: this.opts.runTitle, + status: 'running', + nowWorkingOn: 'Bootstrapping', + upNext: null, + startedAt: new Date().toISOString(), + entries: [ + { + kind: 'phase', + at: new Date().toISOString(), + headline: 'Run started', + body: null, + imageUrl: null, + }, + ], + cardInfo: { + name: `Run log — ${this.opts.runTitle}`, + notes: null, + summary: + 'Live blog of this factory run: design rounds, validations, and finished cards as they land.', + cardThumbnailURL: null, + }, + }, + relationships: {}, + meta: { + adoptsFrom: { module: '../run-log', name: 'RunLog' }, + }, + }, + }; + await mkdir(dirname(this.instancePath), { recursive: true }); + await writeFile(this.instancePath, JSON.stringify(doc, null, 2), 'utf8'); + await this.sync(); + } catch (error) { + // The run log must never take down a run. + log.warn(`run-log start failed: ${String(error)}`); + } + } + + /** Append one or more entries in a single write+sync. */ + async append( + entries: RunLogEntryInput[], + updates?: { nowWorkingOn?: string; upNext?: string }, + ): Promise { + if (entries.length === 0 && !updates) return; + try { + let doc = JSON.parse(await readFile(this.instancePath, 'utf8')); + let attrs = doc.data.attributes; + let rels: Record = doc.data.relationships ?? {}; + + for (let entry of entries) { + let index = attrs.entries.length; + attrs.entries.push({ + kind: entry.kind, + at: new Date().toISOString(), + headline: entry.headline, + body: entry.body ?? null, + imageUrl: entry.imageUrl ?? null, + }); + if (entry.cardPath) { + rels[`entries.${index}.card`] = { + links: { self: `../${entry.cardPath}` }, + }; + } + } + if (updates?.nowWorkingOn !== undefined) { + attrs.nowWorkingOn = updates.nowWorkingOn; + } + if (updates?.upNext !== undefined) { + attrs.upNext = updates.upNext; + } + doc.data.relationships = rels; + await writeFile(this.instancePath, JSON.stringify(doc, null, 2), 'utf8'); + await this.sync(); + } catch (error) { + log.warn(`run-log append failed: ${String(error)}`); + } + } + + async finish(status: 'completed' | 'failed' | 'stopped'): Promise { + try { + await this.append( + [ + { + kind: 'run-done', + headline: + status === 'completed' ? 'Run completed' : `Run ${status}`, + }, + ], + { nowWorkingOn: '—', upNext: '—' }, + ); + await this.patch({ status }); + } catch (error) { + log.warn(`run-log finish failed: ${String(error)}`); + } + } + + private async patch(updates: { status?: string }): Promise { + let doc = JSON.parse(await readFile(this.instancePath, 'utf8')); + if (updates.status) { + doc.data.attributes.status = updates.status; + } + await writeFile(this.instancePath, JSON.stringify(doc, null, 2), 'utf8'); + await this.sync(); + } + + private async sync(): Promise { + let result = await this.opts.syncWorkspace(); + if (!result.ok) { + log.warn(`run-log sync failed: ${result.error ?? 'unknown'}`); + } + } +} + +async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Tool-call extraction helpers (used by the issue loop) +// --------------------------------------------------------------------------- + +/** + * Extract design-screenshot events from an agent turn's tool-call log: + * the LAST successful screenshot per distinct output path (final state of + * each surface), as ready-to-append entries with absolute image URLs. + */ +export function designEntriesFromToolCalls( + toolCalls: { tool: string; args: Record; result: unknown }[], + targetRealm: string, +): RunLogEntryInput[] { + let latest = new Map(); + for (let call of toolCalls) { + if (!call.tool.endsWith('screenshot_html')) continue; + let result = call.result as { ok?: boolean; outputPath?: string } | null; + if (!result?.ok || !result.outputPath) continue; + latest.set(result.outputPath, { + kind: 'design', + headline: `Design round: ${result.outputPath.replace(/^design\//, '').replace(/\.png$/, '')}`, + imageUrl: new URL(result.outputPath, targetRealm).href, + }); + } + return [...latest.values()]; +} + +/** + * Extract "real card" paths from an agent turn's tool-call log: instance + * JSON files written outside the tracker/validation/design folders. These + * become card-ready entries that embed the live card. + */ +export function cardPathsFromToolCalls( + toolCalls: { tool: string; args: Record; result: unknown }[], + limit = 3, +): string[] { + const EXCLUDED = + /^(Issues|Projects|Boards|Knowledge[ %]20?Articles|Spec|Validations|Runs|design)\//; + let paths: string[] = []; + for (let call of toolCalls) { + if (call.tool !== 'Write') continue; + let filePath = call.args?.file_path; + if (typeof filePath !== 'string') continue; + let normalized = filePath.replace(/^.*boxel-factory-workspaces\/[^/]+\//, ''); + if (!normalized.endsWith('.json')) continue; + if (EXCLUDED.test(normalized)) continue; + if (!normalized.includes('/')) continue; // instances live in /.json + let cardPath = normalized.replace(/\.json$/, ''); + if (!paths.includes(cardPath)) { + paths.push(cardPath); + } + } + return paths.slice(0, limit); +} + +// --------------------------------------------------------------------------- +// The RunLog CardDef module, written verbatim into the target realm +// --------------------------------------------------------------------------- + +const RUN_LOG_GTS = `import { + CardDef, + FieldDef, + field, + contains, + containsMany, + linksTo, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DatetimeField from 'https://cardstack.com/base/datetime'; +import MarkdownField from 'https://cardstack.com/base/markdown'; + +class RunLogEntry extends FieldDef { + static displayName = 'Run Log Entry'; + @field kind = contains(StringField); + @field at = contains(DatetimeField); + @field headline = contains(StringField); + @field body = contains(MarkdownField); + @field imageUrl = contains(StringField); + @field card = linksTo(() => CardDef); + + static embedded = class Embedded extends Component { + get timeLabel() { + let at = this.args.model.at; + if (!at) return ''; + try { + return new Date(at).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return ''; + } + } + + }; +} + +export class RunLog extends CardDef { + static displayName = 'Run Log'; + static prefersWideFormat = true; + @field runTitle = contains(StringField); + @field status = contains(StringField); + @field nowWorkingOn = contains(StringField); + @field upNext = contains(StringField); + @field startedAt = contains(DatetimeField); + @field entries = containsMany(RunLogEntry); + @field cardTitle = contains(StringField, { + computeVia: function (this: RunLog) { + return this.runTitle ? \`Run log — \${this.runTitle}\` : 'Run log'; + }, + }); + + static isolated = class Isolated extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; + + static fitted = class Fitted extends Component { + + }; +} + +function eqStatus(a: string | undefined, b: string) { + return a === b; +} +`; From dc500891ceddffed311038648137a34ca92aeffd Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:30:15 -0400 Subject: [PATCH 07/39] Add --fork-context: prime once per brief, fork every implementation turn Context forking for the V2 loop. Before the first implementation issue, a priming turn (prompts/prime.md) reads skills, design language, and precedent once and writes design/DESIGN-NOTES.md; its SDK session id is captured (init event) and every subsequent implementation turn runs with resume + forkSession:true, inheriting the primed conversation as a shared provider-cached prefix instead of rebuilding context per issue. - AgentContext.resumeSession / primeTurn; AgentRunResult.sessionId - claude-code backend: session capture + resume/forkSession query options - issue-loop: prime state machine (undefined -> primed | null=disabled on failure), resumeSession attached to non-bootstrap contexts - --fork-context CLI flag threaded through entrypoint and wiring Failure-safe: a failed prime disables forking and the run proceeds as plain --v2. Co-Authored-By: Claude Fable 5 --- packages/software-factory/prompts/prime.md | 34 ++++++++++ .../src/factory-agent/claude-code.ts | 35 ++++++++-- .../src/factory-agent/types.ts | 15 +++++ .../src/factory-entrypoint.ts | 7 ++ .../src/factory-issue-loop-wiring.ts | 3 + packages/software-factory/src/issue-loop.ts | 65 +++++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 packages/software-factory/prompts/prime.md diff --git a/packages/software-factory/prompts/prime.md b/packages/software-factory/prompts/prime.md new file mode 100644 index 00000000000..4df067e65ac --- /dev/null +++ b/packages/software-factory/prompts/prime.md @@ -0,0 +1,34 @@ +# Project + +{{project.objective}} + +# Knowledge + +{{#each knowledge}} + +## {{title}} + +{{content}} +{{/each}} + +# Priming turn — build shared context, do NOT implement anything + +This turn primes a session that later implementation turns will fork from. +Everything you read now is inherited by every fork; anything you skip will +have to be re-read in each fork. Prime thoroughly, then stop. + +1. Call `list_skills`, then `read_skill` the skills and reference files this + project will need across ALL of its work items: card authoring patterns, + fitted formats, template patterns, theme/design-system guidance, spec + usage, and the boxel-ui component-discovery skill. +2. `Glob`/`Read` the workspace for precedent: existing `.gts` card + definitions, the design-language and sample-data knowledge articles' + source files, any existing theme. +3. Write `design/DESIGN-NOTES.md` in the workspace: a compact design-system + brief for this project — palette values, type scale, spacing/hairline + rules, per-surface layout intents (isolated / fitted badge/strip/card / + embedded), and the sample-data facts every card must stay consistent + with. Future forks treat this file as binding. +4. Do NOT write any `.gts`, instances, Specs, or mockup HTML for specific + work items. Do NOT call `signal_done` or `request_clarification`. When + the notes file is written, end your turn with a one-line summary. diff --git a/packages/software-factory/src/factory-agent/claude-code.ts b/packages/software-factory/src/factory-agent/claude-code.ts index 9a9aa80b5b6..1bbc913ba0a 100644 --- a/packages/software-factory/src/factory-agent/claude-code.ts +++ b/packages/software-factory/src/factory-agent/claude-code.ts @@ -130,6 +130,7 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { let userPrompt = this.buildUserPrompt(context); let toolCallLog: ToolCallEntry[] = []; + let sessionId: string | undefined; let captured: CapturedSignal | undefined; let abortController = new AbortController(); @@ -221,6 +222,15 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { // deterministic agent behavior regardless of whose machine this // runs on. settingSources: [], + // Context forking (v2): resume a primed session, branching to a new + // session id so every fork inherits the primed conversation as a + // shared (provider-cached) prefix without mutating the original. + ...(context.resumeSession + ? { + resume: context.resumeSession.sessionId, + forkSession: context.resumeSession.fork !== false, + } + : {}), abortController, debug: this.config.debug === true, }; @@ -237,14 +247,19 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { // the openrouter path's `Agent backend: openrouter (model=…)` // format for a single consistent log line across backends. if ( - !this.modelLogged && message.type === 'system' && (message as { subtype?: string }).subtype === 'init' ) { - let modelName = (message as { model?: string }).model; - if (modelName) { - log.info(`Agent backend: claude (model=${modelName})`); - this.modelLogged = true; + let initSessionId = (message as { session_id?: string }).session_id; + if (initSessionId) { + sessionId = initSessionId; + } + if (!this.modelLogged) { + let modelName = (message as { model?: string }).model; + if (modelName) { + log.info(`Agent backend: claude (model=${modelName})`); + this.modelLogged = true; + } } } if (this.config.debug) { @@ -260,12 +275,13 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { } if (captured?.kind === 'done') { - return { status: 'done', toolCalls: toolCallLog }; + return { status: 'done', toolCalls: toolCallLog, sessionId }; } if (captured?.kind === 'clarification') { return { status: 'blocked', toolCalls: toolCallLog, + sessionId, message: captured.message, }; } @@ -276,6 +292,7 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { return { status: toolCallLog.length > 0 ? 'done' : 'needs_iteration', toolCalls: toolCallLog, + sessionId, }; } @@ -382,6 +399,12 @@ export class ClaudeCodeFactoryAgent implements LoopAgent { private buildUserPrompt(context: AgentContext): string { let issueType = (context.issue as Record).issueType; + if (context.primeTurn === true) { + return this.promptLoader.load('prime', { + project: context.project, + knowledge: context.knowledge, + }); + } if (issueType === 'bootstrap' && context.briefUrl) { return assembleBootstrapPrompt({ context, diff --git a/packages/software-factory/src/factory-agent/types.ts b/packages/software-factory/src/factory-agent/types.ts index 604b1cd31e2..f21dc99a96e 100644 --- a/packages/software-factory/src/factory-agent/types.ts +++ b/packages/software-factory/src/factory-agent/types.ts @@ -255,6 +255,19 @@ export interface AgentContext { * the lean skill core with on-demand `read_skill` loading. */ v2?: boolean; + /** + * Context forking (v2): when set, the backend resumes this session — + * branching to a new session id when `fork` is true (the default) — so + * the turn inherits the primed conversation as a shared, provider-cached + * prefix instead of rebuilding context. + */ + resumeSession?: { sessionId: string; fork?: boolean }; + /** + * Prime turn (v2 context forking): use the `prime` prompt template — + * read the design language, skills, and precedent once; the session id + * captured from this turn seeds every subsequent fork. + */ + primeTurn?: boolean; } export interface AgentAction { @@ -302,6 +315,8 @@ export interface AgentRunResult { toolCalls: LoopToolCallEntry[]; /** Clarification message when status is 'blocked'. */ message?: string; + /** Backend session id for this turn (claude backend) — fork seed for v2 context forking. */ + sessionId?: string; } /** diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 15a0b8ded7e..85510d41cd1 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -79,6 +79,8 @@ export interface FactoryEntrypointOptions { enableBoxelUiDiscovery?: boolean; /** V2 lean/design-first mode (see factory-issue-loop-wiring). */ v2?: boolean; + /** Context forking: prime once per brief, fork every implementation turn. */ + forkContext?: boolean; } export interface FactoryEntrypointAction { @@ -268,6 +270,9 @@ export function parseFactoryEntrypointArgs( v2: { type: 'boolean', }, + 'fork-context': { + type: 'boolean', + }, }, }); } catch (error) { @@ -328,6 +333,7 @@ export function parseFactoryEntrypointArgs( ? true : undefined, v2: parsed.values.v2 === true ? true : undefined, + forkContext: parsed.values['fork-context'] === true ? true : undefined, }; } @@ -475,6 +481,7 @@ export async function runFactoryEntrypoint( enableBoxelUiDiscovery: options.enableBoxelUiDiscovery, v2: options.v2, runTitle: brief.title, + forkContext: options.forkContext, // Wire the board and the seed issue's project the moment the bootstrap // issue finishes, rather than after the whole loop returns — so a run // whose later issues stall or get interrupted still ends up with the diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index 3a2ece81143..340c5723d92 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -113,6 +113,8 @@ export interface IssueLoopWiringConfig { v2?: boolean; /** Brief title — names the live-blog RunLog card (v2). */ runTitle?: string; + /** Context forking (v2): prime once, fork every implementation turn. */ + forkContext?: boolean; /** * Invoked once, right after the bootstrap issue completes. The entrypoint * uses this to link the realm index's `board` relationship as soon as the @@ -307,6 +309,7 @@ export async function runFactoryIssueLoop( syncWorkspace, briefUrl: config.briefUrl, runLog, + forkContext: config.forkContext === true, maxIterationsPerIssue: config.maxIterationsPerIssue, maxOuterCycles: config.maxOuterCycles, debug: config.debug, diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index 17eab6f1e7d..25a8704e327 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -137,6 +137,14 @@ export interface IssueLoopConfig { briefUrl?: string; /** Live-blog writer (v2): appends run events to Runs/.json in the target realm. */ runLog?: RunLogWriter; + /** + * Context forking (v2): before the first implementation issue, run one + * priming turn (read skills/design-language/precedent, write + * design/DESIGN-NOTES.md) and fork every implementation issue's session + * from it — shared provider-cached prefix instead of per-issue context + * rebuilds. + */ + forkContext?: boolean; /** Maximum inner-loop iterations per issue. Default: 8. */ maxIterationsPerIssue?: number; /** Maximum outer-loop cycles (safety guard). Default: 50. */ @@ -290,6 +298,7 @@ export async function runIssueLoop( syncWorkspace, briefUrl, runLog, + forkContext = false, maxIterationsPerIssue = DEFAULT_MAX_ITERATIONS_PER_ISSUE, maxOuterCycles = DEFAULT_MAX_OUTER_CYCLES, debug = false, @@ -349,6 +358,10 @@ export async function runIssueLoop( await runLog.start(); } + // fork-context mode: undefined = not yet primed; null = priming failed + // (run without forking); string = fork seed session id. + let primeSessionId: string | null | undefined; + while ( scheduler.hasUnblockedIssues(exhaustedIssues) && outerCycles < maxOuterCycles @@ -371,6 +384,48 @@ export async function runIssueLoop( ); } + // Context forking (v2): one priming turn before the first + // implementation issue. The prime reads skills + design language + + // precedent and writes design/DESIGN-NOTES.md; its session id seeds a + // fork for every implementation turn that follows. + if ( + forkContext && + primeSessionId === undefined && + issue.issueType !== 'bootstrap' + ) { + try { + log.info('Priming shared context session (fork-context mode)...'); + let primeContext = await contextBuilder.buildForIssue({ + issue, + targetRealm, + darkfactoryModuleUrl, + }); + primeContext.primeTurn = true; + let primeResult = await agent.run(primeContext, tools); + if (primeResult.sessionId) { + primeSessionId = primeResult.sessionId; + log.info( + `Prime session captured: ${primeSessionId} (${primeResult.toolCalls.length} tool calls)`, + ); + if (runLog) { + await runLog.append([ + { + kind: 'phase', + headline: 'Shared design context primed', + body: 'Skills, design language, and precedent loaded once — every card build forks from here.', + }, + ]); + } + } else { + log.warn('Prime turn returned no session id — forking disabled'); + primeSessionId = null; + } + } catch (error) { + log.warn(`Prime turn failed (${String(error)}) — forking disabled`); + primeSessionId = null; + } + } + // Reset per-issue timing accumulators; `cycleStartMs` anchors this // issue's total wall clock and `cycleSyncStartMs` its sync baseline. cur = { agentMs: 0, validationMs: 0, syncMs: 0 }; @@ -458,6 +513,16 @@ export async function runIssueLoop( briefUrl, }); + // fork-context mode: every implementation turn forks the primed + // session — inheriting skills/design-language/precedent as a shared + // provider-cached prefix. + if ( + typeof primeSessionId === 'string' && + issue.issueType !== 'bootstrap' + ) { + context.resumeSession = { sessionId: primeSessionId, fork: true }; + } + // Run the agent — it calls tools during its turn. Realm-touching `run_*` // tools sync the workspace before executing, so subtract that tool-sync // time from the agent's wall clock: it's attributed to sync (via the From 133a8f3fb328db62bed71b0bdc3a9990f8a47ffa Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:39:37 -0400 Subject: [PATCH 08/39] =?UTF-8?q?Redesign=20RunLog=20to=20Boxel=20Workspac?= =?UTF-8?q?e=20design=20language=20=E2=80=94=20alive=20under=20LIVE=20stat?= =?UTF-8?q?us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full design pass per the workspace design playbook (mockup -> crit -> port): - Workspace (card-grid v2) DNA: stage/surface paper, hairline rules, mono micro-labels (IBM Plex), signage chip masthead, Boxel-teal accent used in exactly two places (scan line, shipped/stat accents) - ALIVE under status=running: teal scan line across the masthead, pulsing live dot, one-shot teal arrival wash on the newest feed entry — all motion gated off when the run completes - NOW/NEXT band with dramatic type scale; asymmetric stat rail with big light tabular counters (cards ready / design rounds / validations green / issues done) computed from entries - Wire-feed entries: mono time rail + kind-colored micro-chips; design screenshots now embed via a linksTo'd PngDef FILE CARD (auth-safe, replaces raw which 401s on private realms; imageUrl kept as fallback); shipped cards render live in a parent-owned CardContainer (delegated-render-control :deep overrides) - Container-query responsive isolated (rail collapses under 700px) and a proper CQ fitted template (badge/strip/tall variants) Writer: designEntriesFromToolCalls links the screenshot's file card (entries.N.image); lint-clean against the realm; redeployed to chris/jaraoke with the demo instance upgraded. Co-Authored-By: Claude Fable 5 --- packages/software-factory/src/run-log.ts | 582 +++++++++++++++++------ 1 file changed, 443 insertions(+), 139 deletions(-) diff --git a/packages/software-factory/src/run-log.ts b/packages/software-factory/src/run-log.ts index 085798d6303..73a1e2323d5 100644 --- a/packages/software-factory/src/run-log.ts +++ b/packages/software-factory/src/run-log.ts @@ -38,8 +38,14 @@ export interface RunLogEntryInput { | 'note'; headline: string; body?: string; - /** Absolute URL of a screenshot image to embed. */ + /** Absolute URL of a screenshot image to embed (public realms only). */ imageUrl?: string; + /** + * Realm-relative path of the screenshot's FILE CARD (extension kept, + * e.g. `design/song-isolated.png`) — preferred over imageUrl: the PngDef + * card renders with realm auth handled. + */ + imageCardPath?: string; /** * Realm-relative card path (no .json extension, relative to realm root, * e.g. `JaraokePlayer/thursday-night-jaraoke`) — embeds the live card. @@ -156,6 +162,11 @@ export class RunLogWriter { links: { self: `../${entry.cardPath}` }, }; } + if (entry.imageCardPath) { + rels[`entries.${index}.image`] = { + links: { self: `../${entry.imageCardPath}` }, + }; + } } if (updates?.nowWorkingOn !== undefined) { attrs.nowWorkingOn = updates.nowWorkingOn; @@ -236,6 +247,7 @@ export function designEntriesFromToolCalls( latest.set(result.outputPath, { kind: 'design', headline: `Design round: ${result.outputPath.replace(/^design\//, '').replace(/\.png$/, '')}`, + imageCardPath: result.outputPath, imageUrl: new URL(result.outputPath, targetRealm).href, }); } @@ -274,6 +286,14 @@ export function cardPathsFromToolCalls( // The RunLog CardDef module, written verbatim into the target realm // --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// The RunLog CardDef module, written verbatim into the target realm. +// Design language: Boxel Workspace (card-grid v2) — stage/surface paper, +// hairlines, mono micro-labels, Boxel-teal accent; ALL motion (scan line, +// pulse, arrival wash) is gated on status=running. +// --------------------------------------------------------------------------- + const RUN_LOG_GTS = `import { CardDef, FieldDef, @@ -287,6 +307,19 @@ import StringField from 'https://cardstack.com/base/string'; import DatetimeField from 'https://cardstack.com/base/datetime'; import MarkdownField from 'https://cardstack.com/base/markdown'; +function clock(value: Date | string | undefined): string { + if (!value) return ''; + try { + return new Date(value).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } catch { + return ''; + } +} + class RunLogEntry extends FieldDef { static displayName = 'Run Log Entry'; @field kind = contains(StringField); @@ -294,93 +327,110 @@ class RunLogEntry extends FieldDef { @field headline = contains(StringField); @field body = contains(MarkdownField); @field imageUrl = contains(StringField); + @field image = linksTo(() => CardDef); @field card = linksTo(() => CardDef); static embedded = class Embedded extends Component { get timeLabel() { - let at = this.args.model.at; - if (!at) return ''; - try { - return new Date(at).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return ''; - } + return clock(this.args.model.at); } @@ -398,134 +448,348 @@ export class RunLog extends CardDef { @field entries = containsMany(RunLogEntry); @field cardTitle = contains(StringField, { computeVia: function (this: RunLog) { - return this.runTitle ? \`Run log — \${this.runTitle}\` : 'Run log'; + return this.runTitle ? 'Run log — ' + this.runTitle : 'Run log'; }, }); static isolated = class Isolated extends Component { + get running() { + return this.args.model.status === 'running'; + } + get statusWord() { + return this.running ? 'LIVE' : (this.args.model.status ?? ''); + } + get startedLabel() { + return clock(this.args.model.startedAt); + } + get cardsReady() { + return (this.args.model.entries ?? []).filter( + (e) => e.kind === 'card-ready', + ).length; + } + get designRounds() { + return (this.args.model.entries ?? []).filter((e) => e.kind === 'design') + .length; + } + get validationsGreen() { + return (this.args.model.entries ?? []).filter( + (e) => e.kind === 'validation' && !(e.headline ?? '').includes('failed'), + ).length; + } + get issuesDone() { + return (this.args.model.entries ?? []).filter( + (e) => e.kind === 'issue-done', + ).length; + } }; static embedded = class Embedded extends Component { + get running() { + return this.args.model.status === 'running'; + } + get shipped() { + return (this.args.model.entries ?? []).filter( + (e) => e.kind === 'card-ready', + ).length; + } + get rounds() { + return (this.args.model.entries ?? []).filter((e) => e.kind === 'design') + .length; + } }; static fitted = class Fitted extends Component { + get running() { + return this.args.model.status === 'running'; + } + get shipped() { + return (this.args.model.entries ?? []).filter( + (e) => e.kind === 'card-ready', + ).length; + } }; } - -function eqStatus(a: string | undefined, b: string) { - return a === b; -} `; From 6305cde875cebf6ce6efc5b14c059c91a559487c Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:43:43 -0400 Subject: [PATCH 09/39] RunLog: full activity coverage, show-me chips, throbber, live elapsed clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop now reports every state change to the live blog: issue picked (with backlog→in-progress note), iteration N-of-M revision turns, agent blocked (with the clarification message), validation-exhausted blocks (with the orchestrator comment context), and the in-progress→done transition. Issue-ish entries carry a linksTo of the Issue card rendered as a clickable 'show me' atom chip; card-ready keeps the full embedded render. Isolated view: a 1s-tick elapsed clock (HH:MM:SS, live while running, frozen at last-entry time after) and a three-bar equalizer throbber next to the NOW item while the run is live. New kind colors: blocked=red, status=teal-green, iteration/comment=quiet. Lint clean; module redeployed to chris/jaraoke (status: ready, no indexing errors). Co-Authored-By: Claude Fable 5 --- packages/software-factory/src/issue-loop.ts | 61 ++++++++++- packages/software-factory/src/run-log.ts | 109 ++++++++++++++++++-- 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index 25a8704e327..280f35610b1 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -234,6 +234,17 @@ function issueSummaryLabel(issue: SchedulableIssue): string { return summary ? `"${issue.id}" — "${summary}"` : `"${issue.id}"`; } +/** Realm-relative card path for an issue (for run-log show-me links). */ +function issueCardPath( + issue: SchedulableIssue, + targetRealm: string, +): string | undefined { + if (typeof issue.id === 'string' && issue.id.startsWith(targetRealm)) { + return issue.id.slice(targetRealm.length).replace(/\.json$/, ''); + } + return undefined; +} + /** Human-facing issue title for the run log (no quoting/id noise). */ function issueDisplayTitle(issue: SchedulableIssue): string { let summary = issue.summary ?? (issue as Record).title; @@ -379,7 +390,17 @@ export async function runIssueLoop( if (runLog) { let issueTitle = issueDisplayTitle(issue); await runLog.append( - [{ kind: 'issue-picked', headline: `Started: ${issueTitle}` }], + [ + { + kind: 'issue-picked', + headline: `Started: ${issueTitle}`, + body: + issue.status !== 'in_progress' + ? 'Issue status: backlog → in progress' + : undefined, + cardPath: issueCardPath(issue, targetRealm), + }, + ], { nowWorkingOn: issueTitle }, ); } @@ -503,6 +524,15 @@ export async function runIssueLoop( ` Inner iteration ${iteration}/${maxIterationsPerIssue} for issue ${issueSummaryLabel(issue)}`, ); + if (runLog && iteration > 1) { + await runLog.append([ + { + kind: 'iteration', + headline: `Iteration ${iteration} of ${maxIterationsPerIssue} — revising after validation feedback`, + }, + ]); + } + // Build context — includes pre-formatted validation context from prior iteration let context = await contextBuilder.buildForIssue({ issue, @@ -560,6 +590,16 @@ export async function runIssueLoop( let blockMessage = result.message?.trim() || 'agent reported it could not proceed'; log.info(` Agent reported blocked: ${blockMessage}`); + if (runLog) { + await runLog.append([ + { + kind: 'blocked', + headline: `Blocked: ${issueDisplayTitle(issue)}`, + body: blockMessage, + cardPath: issueCardPath(issue, targetRealm), + }, + ]); + } try { await issueStore.updateIssue(issue.id, { status: 'blocked' }); await syncWorkspace(); @@ -649,6 +689,15 @@ export async function runIssueLoop( if (agentSignaledDone && validationResults?.passed && !syncFailed) { try { await issueStore.updateIssue(issue.id, { status: 'done' }); + if (runLog) { + await runLog.append([ + { + kind: 'status', + headline: `Issue status: in progress → done`, + cardPath: issueCardPath(issue, targetRealm), + }, + ]); + } // updateIssue writes the status flip to the local workspace. // refreshIssueState below queries the realm's search index, so // the flip has to reach the realm before the refresh — otherwise @@ -748,6 +797,16 @@ export async function runIssueLoop( status: 'blocked', }); exitReason = 'blocked'; + if (runLog) { + await runLog.append([ + { + kind: 'blocked', + headline: `Issue status: in progress → blocked (max iterations)`, + body: `Validation still failing after ${maxIterationsPerIssue} iterations — orchestrator comment added to the issue with the failure context.`, + cardPath: issueCardPath(issue, targetRealm), + }, + ]); + } } catch (err) { log.warn( ` Failed to update issue status to blocked: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/software-factory/src/run-log.ts b/packages/software-factory/src/run-log.ts index 73a1e2323d5..3fa96728114 100644 --- a/packages/software-factory/src/run-log.ts +++ b/packages/software-factory/src/run-log.ts @@ -35,6 +35,10 @@ export interface RunLogEntryInput { | 'card-ready' | 'issue-done' | 'run-done' + | 'status' + | 'iteration' + | 'comment' + | 'blocked' | 'note'; headline: string; body?: string; @@ -294,7 +298,9 @@ export function cardPathsFromToolCalls( // pulse, arrival wash) is gated on status=running. // --------------------------------------------------------------------------- -const RUN_LOG_GTS = `import { +const RUN_LOG_GTS = `import { registerDestructor } from '@ember/destroyable'; +import { tracked } from '@glimmer/tracking'; +import { CardDef, FieldDef, field, @@ -334,6 +340,9 @@ class RunLogEntry extends FieldDef { get timeLabel() { return clock(this.args.model.at); } + get isShipMoment() { + return this.args.model.kind === 'card-ready'; + }