Keep a work log
Use one artifact per issue to retain intent, evidence, decisions, and handoffs.
The issue is the unit of work across runs. Give it one artifact named by repository and issue number. Each stage writes a new version; each run has its own ID in the history. Repeating a run does not create a new issue log.
| File | Purpose |
|---|---|
record.json |
Current issue snapshot, plan, candidate, review, decision, and event history. |
chronicle.md |
The same history in reading order, with timestamps and run IDs. |
issue.md |
The captured issue body. |
attempts/<number>/tests.json |
Command exit codes, stdout, and stderr for that attempt. |
The work log records the original request, plan, each implementation explanation and commit, failed checks, review findings, decisions, and on-call handoffs. Agent conversations can continue, but each reviewer also receives the written record. It need not reconstruct the task from memory.
Define the record
record.ts owns the stored format. requireRecord(issue) loads it;
recordEvent(...) appends an event and writes both the JSON record and
readable chronicle. Later steps use these functions instead of writing the
artifact files themselves.
Create .foundry/marbles/_factory/record.ts. Set repository and
baseBranch to match your checkout:
Complete record.ts
import { artifact } from "@foundry.sh/marbles";
import { z } from "zod";
export const repository = "OWNER/REPOSITORY";
export const baseBranch = "main";
export const maxAttempts = 3;
export const issueInput = z.object({ issue: z.number().int().positive() });
const sessionRef = z.object({
id: z.string(),
model: z.string().optional(),
provider: z.string().optional(),
});
export const issueSchema = z.object({
body: z.string(),
number: z.number(),
state: z.enum(["OPEN", "CLOSED"]),
title: z.string(),
updatedAt: z.string(),
url: z.string(),
});
export const planSchema = z.object({
approach: z.array(z.string()).min(1),
checks: z.array(z.string()).min(1),
questions: z.array(z.string()),
});
export const reviewSchema = z.object({
blockers: z.array(z.string()),
criteria: z.array(
z.object({ criterion: z.string(), evidence: z.string(), met: z.boolean() })
),
notes: z.array(z.string()),
});
export const recordSchema = z.object({
acceptance: z.array(z.string()),
attempts: z.number().int().nonnegative(),
base: z.string(),
change: z
.object({ branch: z.string(), revision: z.string(), summary: z.string() })
.optional(),
decision: z
.object({ approved: z.boolean(), note: z.string(), revision: z.string() })
.optional(),
events: z.array(
z.object({
at: z.string(),
detail: z.string(),
runId: z.string(),
stage: z.string(),
})
),
issue: issueSchema,
plan: planSchema.optional(),
pullRequest: z
.object({ number: z.number(), revision: z.string(), url: z.string() })
.optional(),
reviewer: sessionRef.optional(),
status: z.enum([
"triaged",
"queued",
"planned",
"implementing",
"implemented",
"needs-changes",
"verified",
"approved",
"declined",
"pr-open",
"merged",
"closed",
"blocked",
]),
verification: z
.object({
review: reviewSchema,
revision: z.string(),
testsPassed: z.boolean(),
})
.optional(),
});
export type WorkRecord = z.infer<typeof recordSchema>;
export const workInput = z.object({ work: recordSchema });
export const journal = (issue: number) =>
artifact({
name: `Factory ${repository}#${issue}`,
type: "application/json",
});
export async function readRecord(
issue: number
): Promise<WorkRecord | undefined> {
const version = await journal(issue).latest();
const text = await version?.text("record.json");
return text ? recordSchema.parse(JSON.parse(text)) : undefined;
}
export async function requireRecord(issue: number): Promise<WorkRecord> {
const record = await readRecord(issue);
if (!record) {
throw new Error("No work log exists. Run factory intake first.");
}
return record;
}
export function chronicle(record: WorkRecord): string {
return [
`# ${repository}#${record.issue.number}: ${record.issue.title}`,
record.issue.url,
`Status: ${record.status}`,
`Original base: ${record.base}`,
"## Acceptance criteria",
...record.acceptance.map((item) => `- ${item}`),
"## History",
...record.events.map(
(event) =>
`### ${event.at} ${event.stage}\n\nRun: ${event.runId}\n\n${event.detail}`
),
].join("\n\n");
}
export async function recordEvent(
record: WorkRecord,
runId: string,
stage: string,
detail: string,
files: Record<string, string> = {}
): Promise<WorkRecord> {
const next = recordSchema.parse({
...record,
events: [
...record.events,
{
at: new Date().toISOString(),
detail,
runId,
stage,
},
],
});
await journal(record.issue.number).write(
{
...files,
"chronicle.md": chronicle(next),
"record.json": JSON.stringify(next, null, 2),
},
{ replace: false }
);
return next;
}
export function newRecord(
issue: z.infer<typeof issueSchema>,
base: string
): WorkRecord {
return {
acceptance: [],
attempts: 0,
base,
events: [],
issue,
status: "triaged",
};
}
Artifacts
keeps the versions. replace: false retains earlier evidence files when a
stage adds new ones. Each version contains the full event history so far.
This is a work log, not a transactional queue. Concurrent writers can
replace one another’s changes; serialize work for the same issue.
These artifact writes do not announce every event in the Feed. Later steps
use report.result(...) for a readable status or on-call handoff, while the
issue artifact keeps the full record. See
Artifacts and the Feed
for when to store evidence, post a message, or do both.
Capture the issue
Create .foundry/marbles/_factory/intake.ts:
Complete intake.ts
import { step } from "@foundry.sh/marbles";
import {
baseBranch,
issueInput,
issueSchema,
newRecord,
readRecord,
recordEvent,
recordSchema,
repository,
} from "./record";
const acceptanceSection =
/^## Acceptance criteria\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m;
const checkbox = /^- \[[ xX]\] (.+)$/gm;
export const intake = step("factory-intake")
.input(issueInput)
.output(recordSchema)
.do(async ({ input, exec, run }) => {
const existing = await readRecord(input.issue);
if (existing && existing.status !== "triaged") {
const needsNewIntake =
existing.status === "blocked" && existing.attempts === 0;
if (!needsNewIntake) {
if (["blocked", "implementing"].includes(existing.status)) {
throw new Error(
"Inspect the partial attempt before continuing this issue."
);
}
return existing;
}
}
const localRepo = (
await exec([
"gh",
"repo",
"view",
"--json",
"nameWithOwner",
"--jq",
".nameWithOwner",
])
).stdout.trim();
if (localRepo.toLowerCase() !== repository.toLowerCase()) {
throw new Error("The checkout does not match the configured repository.");
}
const result = await exec([
"gh",
"issue",
"view",
String(input.issue),
"--repo",
repository,
"--json",
"number,title,body,url,updatedAt,state",
]);
const issue = issueSchema.parse(JSON.parse(result.stdout));
if (issue.state !== "OPEN") {
throw new Error("Choose an open issue.");
}
const section = acceptanceSection.exec(issue.body)?.[1] ?? "";
const acceptance = [...section.matchAll(checkbox)].map(
(match) => match[1] ?? ""
);
if (!acceptance.length) {
throw new Error(
"Add checkboxes under ## Acceptance criteria to the issue."
);
}
await exec(["git", "fetch", "origin"]);
const base = (
await exec([
"git",
"rev-parse",
"--verify",
`origin/${baseBranch}^{commit}`,
])
).stdout.trim();
const record = {
...(existing ?? newRecord(issue, base)),
acceptance,
base,
issue,
status: "queued" as const,
};
return recordEvent(
record,
run.id,
"intake",
`Captured issue ${issue.updatedAt} at ${base}.`,
{
"issue.md": issue.body,
}
);
});
This step reads the issue, fetches origin, and pins the starting commit.
Fetching updates remote refs. It does not switch branches or merge into
your checkout. Later stages use the recorded SHA.
Create .foundry/marbles/factory.ts:
import { workflow } from "@foundry.sh/marbles";
import { intake } from "./_factory/intake";
import { issueInput } from "./_factory/record";
workflow("factory")
.input(issueInput)
.do(({ input }) => intake({}, input));
Run it with an open issue that has acceptance criteria:
marbles list
marbles roll factory --input '{"issue":42}'
Inspect the returned issue, base SHA, and intake event. Artifact versions
live in the artifact store, which
is separate from the temporary Git worktrees used later.
Rerunning factory reads the existing record. Finished stages return their
recorded result, so you can extend the walkthrough using the same issue.
A planning failure before implementation can be retried after updating the
issue. For a candidate that needs changes, use the revision chapter instead.
The log records completed writes and captured failures. A process killed between a Git or GitHub operation and its log write can leave external work ahead of the record. Reconcile the branch or pull request before continuing. Durability explains that boundary.
Next: Plan the work.