---
title: Keep a work log
description: 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:

<details>
<summary>Complete record.ts</summary>

```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",
  };
}
```

</details>

[Artifacts](https://github.com/Francois-Esquire/foundry/blob/main/packages/artifacts/README.md)
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](/marbles/foundation/artifacts-and-results#artifacts-and-the-feed)
for when to store evidence, post a message, or do both.

## Capture the issue

Create `.foundry/marbles/_factory/intake.ts`:

<details>
<summary>Complete intake.ts</summary>

```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,
      }
    );
  });
```

</details>

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`:

```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:

```sh
marbles list
marbles roll factory --input '{"issue":42}'
```

Inspect the returned issue, base SHA, and `intake` event. Artifact versions
live in the [artifact store](/marbles/reference/state#persisted-state), 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](/marbles/foundation/durability) explains that boundary.

Next: [Plan the work](/marbles/use-cases/software-factory/development/plan).
