Skip to content
Foundry
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Was this page helpful?