@figs-so/cli 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +66 -39
  2. package/SPEC.md +214 -173
  3. package/figs.mjs +1085 -579
  4. package/package.json +1 -1
package/SPEC.md CHANGED
@@ -1,66 +1,78 @@
1
- # The `.figs` Protocol — `figs-spec v1`
1
+ # The `.figs` Protocol — `figs-spec v2`
2
2
 
3
- > **Status:** v1 — minimal and stable. This spec defines the `.figs/` folder an AI agent writes and how
4
- > it is published. It is deliberately small: it describes *reporting* (agent → human), which is all v1
5
- > covers. Two-way (answers/sign-off flowing back to the agent) is **reserved for a future version** see
6
- > [Reserved](#reserved-not-in-v1). Licensed **MIT** — implement it in anything.
3
+ > **Status:** v2. This spec defines the `.figs/` folder an AI agent writes, how it is published, and
4
+ > how a human's replies come back. It is deliberately small. **Account-optional:** the protocol and the
5
+ > local tooling are fully usable with no account and no network; a reader/remote is strictly additive.
6
+ > Licensed **MIT** — implement it in anything.
7
+ >
8
+ > *v2 (from v1): the human-reply ledger (`messages.jsonl`) is now part of the format and the loop is
9
+ > two-way; ask types narrowed to `question`/`sign-off`; one unified `attachments[]`; `config.json`'s
10
+ > destination fields are optional (local mode); wire auth is `Authorization: Bearer`. See each section.*
7
11
 
8
12
  ## 1. Design principles
9
13
 
10
- - **One-way.** An agent *publishes* its state. A Figs reader is a **read-only mirror** — it never writes
11
- back into the agent or its repo.
12
- - **Local-first.** The agent owns a `.figs/` folder on disk. Publishing is an explicit act (`push`), not a
13
- live connection.
14
- - **Upsert-only.** Publishing inserts or updates records by their `id`; it **never deletes** remote rows.
15
- The remote is a durable record; the local folder is a transient outbox.
16
- - **Two content modes, no display language.** Everything is either *structured state* (JSON/JSONL we
17
- describe below, rendered by fixed components) or a *rendered artifact* (a file shown in a sandboxed
18
- viewer). There is no layout/templating DSL.
19
- - **Self-describing identity.** An agent generates its own UUID once; that UUID *is* its identity. The same
20
- agent (a repo) may be run by many people; their pushes aggregate under that one identity.
14
+ - **Local-first, account-optional.** The agent owns a `.figs/` folder on disk and is fully operational
15
+ with no account and no network — record work, raise asks, recover across sessions, validate. Publishing
16
+ to a reader is an explicit, optional act (`push`), not a live connection.
17
+ - **Agent ledgers flow one way (up); the human ledger is the one two-way file.** An agent *publishes*
18
+ its own records (`runs.jsonl`, `asks.jsonl`); a reader never writes them back. A human's replies live
19
+ in `messages.jsonl` the single file that also syncs *down* (§6, §8). Nobody writes into the other
20
+ side's ledger.
21
+ - **One agent = one repo = one machine** (the topology rule). The agent ledgers are the source of truth
22
+ on that machine; a reader is an aggregation mirror for humans, never an authority over the files.
23
+ Running one agent from several machines at once is unsupported (commit the outbox and manage the merge
24
+ yourself, at your own risk); the fleet-wide cross-machine view is the reader's job.
25
+ - **Upsert-only, never destructive.** Publishing inserts or updates records by their `id`; it never
26
+ deletes. A push may not walk a record backwards (a stale close/settle never reopens — §8).
27
+ - **Two content modes, no display language.** Everything is either *structured state* (the JSON/JSONL
28
+ below, rendered by fixed components) or an *attachment* (a file shown in a sandboxed viewer or offered
29
+ for download). There is no layout/templating DSL.
30
+ - **Self-describing identity.** An agent generates its own UUID once; that UUID *is* its identity. The
31
+ same agent (a repo) may be run by many people; their pushes aggregate under that one identity.
21
32
 
22
33
  ## 2. Folder layout
23
34
 
24
35
  ```
25
36
  .figs/
26
- ├── config.json # identity + destination (committed, non-secret)
37
+ ├── config.json # identity (+ destination once linked); committed, non-secret
27
38
  ├── agent.json # the charter — who this agent is (committed)
28
39
  ├── CONTRACT.md # agent-authored: what this agent surfaces / holds back (committed)
29
40
  ├── GUIDE.md # orientation breadcrumb, written by the CLI (committed)
30
- ├── runs.jsonl # activity log, one JSON object per line (outbox; gitignored)
31
- ├── asks.jsonl # things needing a human, one per line (outbox; gitignored)
32
- └── artifacts/ # files referenced by runs/asks (outbox; gitignored)
41
+ ├── runs.jsonl # activity log one job per line (machine-local outbox; gitignored)
42
+ ├── asks.jsonl # handoffs to a human one ask per line (machine-local outbox; gitignored)
43
+ ├── messages.jsonl # the human's replies one event per line (machine-local; gitignored)
44
+ └── artifacts/ # files attached to any moment (machine-local; gitignored)
33
45
  ```
34
46
 
35
- **Commit** `config.json` + `agent.json` + `CONTRACT.md` + `GUIDE.md`. The activity files
36
- (`runs.jsonl`, `asks.jsonl`, `artifacts/`) are a transient outbox and are typically gitignored.
47
+ **Commit** `config.json` + `agent.json` + `CONTRACT.md` + `GUIDE.md`. The journal
48
+ (`runs.jsonl`, `asks.jsonl`, `messages.jsonl`, `artifacts/`) is a **machine-local** outbox records
49
+ live on this machine; once linked + pushed, the reader is the durable record humans see.
37
50
 
38
- **`CONTRACT.md` + `GUIDE.md` are companion conventions, not wire format** — they are never
39
- pushed. `CONTRACT.md` is the standing agreement between the agent and its user about what gets
40
- surfaced; `GUIDE.md` is an orientation stub the reference CLI writes (and never clobbers).
41
- Implementations may add files like these; readers must ignore files this spec doesn't name.
51
+ **`CONTRACT.md` + `GUIDE.md` are companion conventions, not wire format** — never pushed.
52
+ `CONTRACT.md` is the standing agreement between agent and user about what gets surfaced; `GUIDE.md`
53
+ is an orientation stub the reference CLI writes (and never clobbers). Implementations may add files
54
+ like these; **readers must ignore files this spec doesn't name.**
42
55
 
43
- **The membership rule what belongs in `.figs/`:** everything in the folder is *Figs-facing* —
44
- protocol metadata (`config.json`), the published record (the charter, the outbox), or a
45
- convention *about* publishing (`CONTRACT.md`, `GUIDE.md`). An agent's private working state
46
- memory, self-checks, scratch notes — lives outside `.figs/`, elsewhere in the repo. If a file's
47
- only reader is the agent itself, it does not belong here.
56
+ **The membership rule:** everything in `.figs/` is *Figs-facing* — protocol metadata, the published
57
+ record, or a convention *about* publishing. An agent's private working state (memory, scratch notes)
58
+ lives elsewhere in the repo. If a file's only reader is the agent itself, it does not belong here.
48
59
 
49
- ## 3. `config.json` — identity + destination
60
+ ## 3. `config.json` — identity (+ destination)
50
61
 
51
- Non-secret. Pins one shared identity so many runners' pushes aggregate.
62
+ Non-secret. In **local mode** it is just `{ "agentId": "…" }`. `figs link` adds the destination
63
+ (`endpoint` + `workspaceId`) when the agent connects to a reader.
52
64
 
53
- | Field | Type | Notes |
54
- |---|---|---|
55
- | `endpoint` | string (URL) | Where to publish (default `https://app.figs.so`). |
56
- | `workspaceId` | UUID | The workspace this agent belongs to. |
57
- | `agentId` | UUID | The agent's identity, generated once by `figs init`. The CLI attaches it as the agent's `id` on push (you don't hand-author `id` in `agent.json`). |
65
+ | Field | Type | Req | Notes |
66
+ |---|---|:--:|---|
67
+ | `agentId` | UUID | | The agent's identity, minted once by `figs init`. The CLI attaches it as the agent's `id` on push (you don't hand-author `id` in `agent.json`). |
68
+ | `endpoint` | string (URL) | | Where to publish (default `https://app.figs.so`). Written by `figs link`. |
69
+ | `workspaceId` | UUID | | The workspace this agent belongs to. Written by `figs link`. **Its presence is what "linked" means** absent = local mode. |
58
70
 
59
71
  ## 4. `agent.json` — the charter
60
72
 
61
73
  The agent's self-description. Authoring this and publishing makes the agent *appear*. The only field you
62
- author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json` and
63
- the CLI attaches it on push. Everything else is optional and rendered when present.
74
+ author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json`
75
+ and the CLI attaches it on push. Everything else is optional and rendered when present.
64
76
 
65
77
  | Field | Type | Req | Meaning |
66
78
  |---|---|:--:|---|
@@ -76,7 +88,7 @@ the CLI attaches it on push. Everything else is optional and rendered when prese
76
88
  | `mandate` | string | | One-paragraph statement of what it's responsible for. |
77
89
  | `steps` | string[] | | **Ordered** procedure (numbered render). For pipeline-shaped agents. |
78
90
  | `responsibilities` | string[] | | **Unordered** areas of work (bulleted render). For broad/mission agents. |
79
- | `properties` | `{ k, v }[]` | | Freeform catch-all for facts with no dedicated field. Keep keys short, values single-line. Don't duplicate first-class fields. |
91
+ | `properties` | `{ k, v }[]` | | Freeform catch-all for facts with no dedicated field. Keep keys short, values single-line. |
80
92
  | `units` | `Unit[]` | | The instances/things the agent operates on (see below). |
81
93
 
82
94
  Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipeline vs. a set of work areas.
@@ -95,186 +107,216 @@ Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipel
95
107
 
96
108
  ## 5. `runs.jsonl` — activity
97
109
 
98
- One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's
99
- *manager* would recognize ("recon — Acme — November"), under a **stable, meaningful id**
100
- (`recon-acme-2026-11`); the runs list reads as the job list. Records **fold by `id`** (same
101
- merge as asks): re-reporting a job's id layers progress onto its row (`status` evolves
102
- blocked-ish `warn` `ok`) sittings/sessions are agent plumbing and never mint records.
103
- Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
104
-
105
- A job is either **in flight** or **settled** (`state`, below). A **checkpoint**
106
- (`figs checkpoint`) folds progress onto the job's id and marks it in-flight the record
107
- survives the session working it, so a crash mid-job leaves a visible, recoverable stub
108
- instead of nothing. A **report** files the outcome and settles it; a report with no prior
109
- checkpoint is simply a job **born settled** (the single-sitting case). Nothing *external*
110
+ One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's *manager*
111
+ would recognize ("recon — Acme — November"), under a **stable, meaningful id** (`recon-acme-2026-11`);
112
+ the runs list reads as the job list. Records **fold by `id`**: re-reporting a job's id layers progress
113
+ onto its row (`status` evolves `warn` `ok`) sittings/sessions are agent plumbing and never mint
114
+ records. Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
115
+
116
+ A job is either **in flight** or **settled** (`state`). A **checkpoint** (`figs checkpoint`) folds
117
+ progress onto the job's id and marks it in-flight the record survives the session working it, so a
118
+ crash mid-job leaves a visible, recoverable stub. A **report** files the outcome and settles it; a
119
+ report with no prior checkpoint is a job **born settled** (the single-sitting case). Nothing *external*
110
120
  ever closes a run — only the agent's own report settles its job.
111
121
 
112
122
  | Field | Type | Req | Meaning |
113
123
  |---|---|:--:|---|
114
124
  | `id` | string | ✓ | Stable id (upsert key). |
115
- | `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran, e.g. `2026-05-28T23:41:26Z`. |
125
+ | `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran. Machine-stamped by the CLI, never typed. |
116
126
  | `unit` | string | | The `Unit.id` this run is about. |
117
127
  | `period` | string | | |
118
- | `result` | string | | The job's current one-line state — where it stands while in flight; the outcome once settled. |
119
- | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — what the work looks like right now (a stuck job is `warn`); whether the job is *done* is `state`, the orthogonal dimension. |
120
- | `state` | `"in-flight"` \| `"settled"` | | Default `"settled"`. **Lifecycle, verb-stamped** — `figs checkpoint` stamps `in-flight`, `figs report` stamps `settled`; agents never hand-pick it. An in-flight job whose agent died stays visibly in flight — that's the point: the next session finds it in `figs inbox` and finishes or settles it. |
121
- | `artifacts` | string[] | | File names under `artifacts/` to attach. Singular `artifact` (string) remains valid shorthand for one readers normalize to the array (same pattern as `resolution`'s bare-string shorthand). |
122
- | `session` | `Session` | | Where/how this ran (see [§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
128
+ | `result` | string | | The job's current one-line state while in flight; its outcome once settled. |
129
+ | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — what the work looks like now (a stuck job is `warn`); whether it's *done* is `state`. |
130
+ | `state` | `"in-flight"` \| `"settled"` | | Default `"settled"`. **Lifecycle, verb-stamped** — `checkpoint` in-flight, `report` settled. An in-flight job whose agent died stays in flight: the next session finds it in `figs inbox` and finishes or settles it. |
131
+ | `attachments` | string[] | | File names under `artifacts/` produced at this moment (§7). Attachments belong to their line, not the folded record. |
132
+ | `session` | `Session` | | Where/how this ran ([§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
123
133
 
124
134
  ### 5.1 `Session` — runtime metadata (optional)
125
135
 
126
136
  An optional, **self-reported** block describing the runtime session that produced a run (or raised an
127
- ask see §6). Every field is optional fill what your runtime exposes, omit the rest. This is
128
- *transparency, not attestation*: the values come from the runtime's own records — hand-authored, or
129
- written by integrations that can copy provable values at work-time (the CLI never infers them).
130
- Cryptographic provenance remains [reserved](#reserved-not-in-v1).
137
+ ask, or a message). Every field is optional. This is *transparency, not attestation*: the values come
138
+ from the runtime's own records — hand-authored, or written by integrations that copy provable values at
139
+ work-time (the CLI never infers them). Cryptographic provenance remains [reserved](#reserved-not-in-v2).
131
140
 
132
141
  | Field | Type | Meaning |
133
142
  |---|---|---|
134
- | `runtime` | string | What ran it, e.g. `claude-code`, `codex`, `claude-managed-agents`. |
143
+ | `runtime` | string | What ran it, e.g. `claude-code`, `codex`. |
135
144
  | `model` | string | Model id, e.g. `claude-fable-5`. |
136
145
  | `sessionId` | string | The runtime's own session identifier. |
137
146
  | `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
138
- | `commit` | string | The agent repo's HEAD at run time; append `+dirty` when the working tree had uncommitted changes, e.g. `1b68668+dirty`. |
139
- | `trigger` | string | What set this sitting in motion — one self-reported line, e.g. `monthly close cron`, `inbox: answer on acme-bridge`, `Wayne, in chat`. A *fresh* sitting on a job states it (stamped from `--trigger` on `figs checkpoint`/`report`); records continuing the same session omit it. The one mechanically verified trigger stays `resolution.answer` ([§6.2](#62-resolution--how-an-ask-closed)). |
140
- | `tokens` | `{ input?, output?, cacheRead?, cacheWrite? }` (numbers) | **Session totals at report time** — cumulative for the whole session, *not* per-job. Approximate by design (an interactive session may include unrelated chat). Readers may derive per-run deltas between consecutive runs sharing a `sessionId`. Include cache figures when available — in agentic sessions they often dominate real cost. |
147
+ | `commit` | string | The repo's HEAD at run time; append `+dirty` when the tree had uncommitted changes. |
148
+ | `trigger` | string | What set this sitting in motion — one self-reported line (`monthly close cron`, `inbox: answer on acme-bridge`, `Wayne, in chat`). A *fresh* sitting states it; continuations omit it. |
149
+ | `tokens` | `{ input?, output?, cacheRead?, cacheWrite? }` | **Session totals at report time** — cumulative for the whole session, not per-job. Approximate by design. |
141
150
 
142
151
  ## 6. `asks.jsonl` — handoffs to a human
143
152
 
144
- One JSON object per line. Each is something the agent needs a person to resolve. **This is the handoff
145
- primitive** — the agent reached the edge of its autonomy.
153
+ One JSON object per line. Each is something the agent needs a person to resolve the agent reached the
154
+ edge of its autonomy.
146
155
 
147
156
  | Field | Type | Req | Meaning |
148
157
  |---|---|:--:|---|
149
158
  | `id` | string | ✓ | Stable id (upsert key). |
150
- | `type` | enum | ✓ | `needs-decision` \| `sign-off` \| `fyi` — **the type is the answer contract**: *needs-decision* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request changes / reject), *fyi* wants nothing (a for-the-record note; readers never count it as needing a human). `blocked` was **folded into `needs-decision`** (2026-06, pre-launch in-place edit): a stuck job is the *run's* `status`, not an ask type. |
151
- | `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **asker** retracted it — no longer needed, nobody acted) \| `"rejected"` (the **answerer** declined it — a human said no; usually born in the reader's UI, but the agent may record an out-of-band rejection too). Three closes, three authors-of-the-ending. **Rejected is terminal** on this id — readers keep it sticky; re-raising is a new ask. |
152
- | `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder` — e.g. self-edit/logic-change flags). Absent = unaddressed; readers may guess from `type` but must present it as a guess. |
159
+ | `type` | enum | ✓ | `question` \| `sign-off` — **the type is the answer contract**: *question* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request-changes / reject). (`needs-decision` was renamed `question`; `fyi` was retired a for-the-record note is a settled report, not an ask; `blocked` is the run's `status`, not an ask type.) |
160
+ | `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **agent** retracted it; nobody acted) \| `"rejected"` (a human declined it). **Rejected is terminal** on this id — re-raising is a new ask. |
161
+ | `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder`). Absent = unaddressed. |
153
162
  | `title` | string | ✓ | The ask, in one line. |
154
163
  | `unit` | string | | The `Unit.id` this concerns. |
155
- | `run` | string | | The run `id` this ask was raised during (the work that surfaced it). **Optional** — asks also arise outside runs (a self-found issue, expired credentials). |
164
+ | `run` | string | | The run `id` this ask was raised during. **Optional** — asks also arise outside runs. |
156
165
  | `found` | string | | What the agent found / why it's stuck. |
157
166
  | `need` | string | | What it needs from the human. |
158
- | `options` | string[] | | Candidate resolutions — **short, stable, quotable** strings: an answer references one *verbatim* (see [§6.2](#62-resolution--how-an-ask-closed)). On a **sign-off** they are **answer paths** — qualified verdicts the human's verdict can cite verbatim alongside approve/request-changes (e.g. `"Approved — file the 15 ready charges"`). |
159
- | `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these stated steps, in order** (e.g. `"Post the 8 journal entries to SAP"`, `"Email the filing to Acme"`); flag anything irreversible in the step itself. This is the agent's **declared intent, not a bound plan** — readers present it as the agent's claim. Invalid on other types: a *needs-decision* has no approval; there, the chosen option carries the next step. |
167
+ | `options` | string[] | | Candidate answers — **short, stable, quotable** strings: a reply cites one *verbatim* (§6.2). On a **sign-off** they are qualified-verdict paths (e.g. `"Approved — file the 15 ready charges"`). |
168
+ | `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these steps, in order**; flag anything irreversible in the step. The agent's declared intent, not a bound plan. Invalid on a `question`. |
160
169
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
161
- | `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
170
+ | `attachments` | string[] | | File names under `artifacts/` attached to this ask (the exact content to review — §7). |
162
171
  | `resolution` | string \| `Resolution` | | The agent's account of the close ([§6.2](#62-resolution--how-an-ask-closed)). A bare string is shorthand for `{ "note": … }`. |
163
- | `ts` | string (ISO-8601 w/ offset) | | |
164
- | `session` | `Session` | | The session that raised this ask (same shape as [§5.1](#51-session--runtime-metadata-optional)). |
165
-
166
- ### 6.1 Lifecycle — two ledgers, split by author
172
+ | `ts` | string (ISO-8601 w/ offset) | | Machine-stamped. |
173
+ | `session` | `Session` | | The session that raised this ask. |
167
174
 
168
- An ask is the **anchor of a thread whose two halves are owned by different parties**:
175
+ ### 6.1 Lifecycle two ledgers, two directions
169
176
 
170
- - **The agent's ledger** is `asks.jsonl` only the agent writes here. Records **fold by `id`**
171
- (field-level merge: later lines layer over earlier ones), so the close is an *append*, not an edit:
177
+ An ask anchors a thread whose two halves are owned by different parties:
172
178
 
173
- ```jsonc
174
- { "id": "acme-bridge", "status": "resolved",
175
- "resolution": { "chosen": "Strip the alpha prefix", "via": "human",
176
- "by": "Sarah (accounting)", "ts": "2026-06-01T09:12:00Z" } }
177
- ```
178
-
179
- Appending keeps the local file crash-safe, concurrency-safe (multiple runners), and an honest
179
+ - **The agent's ledger** is `asks.jsonl` — only the agent writes here, one-way **up**. Records **fold
180
+ by `id`** (field-level merge; later lines layer over earlier), so the close is an *append*, not an
181
+ edit. Appending keeps the file crash-safe, concurrency-safe (same-machine sessions), and an honest
180
182
  self-audit trail; the folded record the reader stores is one complete ask.
181
- - **The human's ledger** is server-sideclaims, answers, and verdicts born in the reader's UI.
182
- These are [reserved](#reserved-not-in-v1) in v1 and **never appear in `asks.jsonl`**: nobody
183
- writes into the other side's record; the two ledgers cross-reference by id.
183
+ - **The human's ledger** is `messages.jsonl` (§6.3) replies (answers/verdicts). It is the one file
184
+ that flows **down** too: a reply made in the reader's UI syncs into `messages.jsonl`; a reply given
185
+ out-of-band (e.g. in chat) is transcribed there by the agent (`figs answer`). Either way, nobody
186
+ writes the other side's ledger.
184
187
 
185
- The full state machine: `open` → *(answered/verdicthuman, server-side)* →
186
- `resolved` | `withdrawn` *(agent, in `asks.jsonl`)* plus the one human-side close:
187
- **`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
188
- later resolution append folds onto it without reopening). Today resolution otherwise happens
189
- in the agent's own workflow; answers flowing back through the reader are arriving incrementally.
188
+ State machine: `open` → *(a reply arrives `messages.jsonl`)* → the agent **closes** it
189
+ (`resolved` | `withdrawn` | `rejected`, derived from the reply; §6.2). `rejected` is terminal.
190
190
 
191
191
  ### 6.2 `Resolution` — how an ask closed
192
192
 
193
+ The close is derived from the newest reply on the ask and cites it.
194
+
193
195
  | Field | Type | Meaning |
194
196
  |---|---|---|
195
197
  | `note` | string | The agent's one-line account of the close. |
196
- | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
197
- | `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (verified see `answer`) · answered out-of-band (self-reported) · the blocker cleared on its own. |
198
- | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
199
- | `answer` | string | The Figs answer-event id the agent acted on — written by `figs resolve` when the answer came through the inbox (attribution by mechanism, never typed). The cited event may be an answer **or a qualified verdict** (a verdict carrying `chosen`). |
200
- | `ts` | string (ISO-8601 w/ offset) | When the agent closed it — **machine-stamped by `figs resolve`, never typed**. The agent's claim of the execution-adjacent moment, same self-reported grade as the record `ts`; readers stamp their own receipt at ingest and surface both only when they diverge. Lives *inside* `resolution` so the fold can't collide with the record's raise `ts`. |
198
+ | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]` (copied from the cited reply). |
199
+ | `run` | string | The job the reply set in motion (mirror of `ask.run`) so a reader can navigate answer work outcome. |
200
+ | `via` | `"figs"` \| `"human"` \| `"self"` | How it closed: `figs` = derived from a reply on file, citing it (`answer`) · `human` = an out-of-band reply with no event cited · `self` = the blocker cleared on its own. |
201
+ | `answer` | string | The `messages.jsonl` event id the close acted on — written by `figs close` (attribution by mechanism, never typed). **Trust derives from that event's mint origin** (§6.3), not from this field. |
202
+ | `ts` | string (ISO-8601 w/ offset) | When the agent closed it — **machine-stamped, never typed**. Lives *inside* `resolution` so the fold can't collide with the ask's raise `ts`. |
201
203
 
202
- All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
203
- normalize it to the object form.
204
+ All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }`.
204
205
 
205
- ## 7. `artifacts/`rendered files
206
+ ### 6.3 `messages.jsonl`the human-reply ledger *(new in v2)*
206
207
 
207
- Files referenced by a run's `artifact` or an ask's `refs[].artifact`. Each is content-addressed (an
208
- unchanged file is skipped on publish).
208
+ One JSON object per line. Each is a **human's reply** to an ask. Messages are **events, not records**:
209
+ immutable, ids minted once, they **accumulate** (no fold) — an ask can carry answer → changes-requested
210
+ → approved, and every one survives. A correction is a *new* message.
209
211
 
210
- - **Supported kinds** (by extension): `html`, `markdown` (`.md`), `text` (`.txt`), `json`, and `image`
211
- (`.png` `.jpg` `.gif` `.webp` `.svg`).
212
- - **Size:** keep each file **≤ ~3 MB** (compress images client-side if needed).
213
- - Artifacts are shown in a **sandboxed iframe** by the reader; an artifact cannot reach the host app.
212
+ | Field | Type | Req | Meaning |
213
+ |---|---|:--:|---|
214
+ | `id` | string | | Event id, minted once by whoever creates the message (the reader, or the CLI for a transcription). Never hand-authored, never re-minted. |
215
+ | `kind` | `"answer"` \| `"verdict"` | | An answer (to a question) or a verdict (on a sign-off). |
216
+ | `ask` | string | ✓* | The ask id this replies to. *Optional only for reserved human-initiated kinds (`note`/`directive`), which may anchor to a run or stand alone. |
217
+ | `by` | string | ✓ | Who said it (the human). |
218
+ | `ts` | string (ISO-8601 w/ offset) | ✓ | Machine-stamped (server clock for reader-minted, CLI clock for transcribed). |
219
+ | `source` | `"app"` \| `"chat"` \| … | | **Where the reply arrived** (display metadata, *not* trust) — `app` = in the reader, `chat` = transcribed by the agent. Extensible (`slack`, `email`, …). |
220
+ | `chosen` | string | | The option cited, **verbatim** from the ask's `options[]`. |
221
+ | `text` | string | | Free-text reply. |
222
+ | `verdict` | `"approved"` \| `"changes-requested"` \| `"rejected"` | | On a `verdict` message. |
223
+
224
+ **The trust rule (normative):** a reader derives the *verified* grade from **mint origin** — a message
225
+ the reader minted itself is attested; a message that arrived via push (transcribed by an agent) is
226
+ self-reported, **whatever its `source` says.** `source` is display metadata; never trust input.
227
+
228
+ `messages.jsonl` is part of the wire (pushed up, §8) and the one file that syncs **down**. It is not
229
+ folded; readers and the CLI dedupe by event `id`.
230
+
231
+ ## 7. `artifacts/` — attachments
232
+
233
+ Files attached to a moment via `attachments[]` on a run, ask, or close line. An attachment belongs to
234
+ the line that produced it (a checkpoint draft on its checkpoint, the deliverable on its report, proof
235
+ on its close) — **not** to the folded record, so an intermediate is never lost. Each file is
236
+ content-addressed (an unchanged file is skipped on publish; a re-attach of the same name with different
237
+ bytes is rejected — use a new name).
238
+
239
+ - **Renderable** (shown inline in a sandboxed viewer): `html`, `md`, `txt`, `json`, images
240
+ (`.png .jpg .jpeg .gif .webp .svg`).
241
+ - **Download-only** (offered as a download, never rendered — lower risk, nothing executes):
242
+ `.csv .pdf .xlsx .xls .docx`. Extensible.
243
+ - **Size:** keep each file **≤ ~10 MB**.
244
+ - Attachments are produced locally and **do not sync down** (a reference missing on a fresh clone is
245
+ shown as "view it in the app", not downloaded).
214
246
 
215
247
  ## 8. Publishing (the wire contract)
216
248
 
217
- `push` sends two things, authenticated by a per-user token in the `x-figs-token` header:
249
+ Authenticated by a per-user token in the **`Authorization: Bearer <token>`** header. (The reference CLI
250
+ also sends the legacy `x-figs-token` through the v1→v2 transition; readers may accept both until the
251
+ minimum CLI version requires Bearer.)
252
+
253
+ **Up — `push`** sends:
218
254
 
219
255
  1. **The spine** → `POST {endpoint}/api/ingest`, body:
220
256
  ```jsonc
221
257
  {
222
- "workspaceId": "<uuid>", // from config.json
223
- "agent": { /* agent.json */ },
224
- "runs": [ /* runs.jsonl */ ], // optional
225
- "asks": [ /* asks.jsonl */ ] // optional
258
+ "workspaceId": "<uuid>", // from config.json
259
+ "agent": { /* agent.json */ },
260
+ "runs": [ /* runs.jsonl */ ], // optional
261
+ "asks": [ /* asks.jsonl */ ], // optional
262
+ "messages": [ /* messages.jsonl */ ], // optional — transcribed replies the reader lacks
263
+ "confirmRename": true // optional — `figs push --rename`: confirm a real name change
226
264
  }
227
265
  ```
228
- 2. **Each referenced artifact** → `POST {endpoint}/api/artifacts/upload`, content base64-encoded (so
229
- binaries survive), hash server-verified.
266
+ 2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
267
+
268
+ The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it never
269
+ deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
270
+ server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
271
+ and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
272
+ agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
273
+ `{ "error", "code": "agent_moved", "workspaceId"? }`; the agent recovers by setting
274
+ `config.json#workspaceId` to the named workspace and pushing again. **A push never silently
275
+ re-identifies an agent:** a `name` differing from the one registered for that `agentId` is the
276
+ fingerprint of a copied folder and is rejected `409` `{ "error", "code": "agent_renamed" }` — the
277
+ agent rotates identity (`rm -rf .figs && figs init`) for a genuinely new agent, or sets
278
+ `confirmRename` (`figs push --rename`) once to confirm a real rename. `name` is the signal because a
279
+ copy-to-make-another always renames while role/mandate evolve legitimately; the check is `name` only.
280
+
281
+ **Down — the reply sync.** Delivery is **agent-pulled**, never pushed into the repo: a reader exposes a
282
+ read returning **this agent's human messages** (answers/verdicts), which the CLI merges into
283
+ `messages.jsonl` (append-if-id-absent). It must return the agent's complete open surface or flag
284
+ truncation — a silently partial sync is forbidden. *(Only this — the human reply ledger — syncs down;
285
+ agent ledgers and attachments never do. The exact endpoint is finalized alongside the reader.)*
286
+
287
+ Because every push is authenticated, the receiver may stamp each newly created row with the pushing
288
+ identity ("pushed by") — it attributes the *credential*, not necessarily the human at the keyboard.
289
+ Agents never author this field.
230
290
 
231
- The server upserts the agent by `id` and runs/asks by `id`; it never deletes. An agent **self-registers**
232
- on first push — there is no "create agent" step.
291
+ ## 9. Validation & versioning
233
292
 
234
- **A push never re-homes an agent.** The workspace an agent is registered to is authoritative
235
- server-side: a payload whose `workspaceId` differs from it is rejected with HTTP `409` and body
236
- `{ "error", "code": "agent_moved", "workspaceId"? }`. The `error` text states the fix; `workspaceId`
237
- (the agent's current home) is included only when the pushing token has access to that workspace.
238
- Moving an agent between workspaces is a reader-side management act, outside this contract — the agent
239
- recovers by setting `config.json#workspaceId` to the workspace named in the error and pushing again
240
- (each runner self-heals on its own next push; nothing propagates through the repo).
293
+ - **Local validation is normative for conformance** and runs account-free (`figs doctor`). A reader's
294
+ `POST {endpoint}/api/validate` is an additive second opinion, not the gate.
295
+ - **`figs-spec` is integer-versioned.** v2 is current. **Additive/optional** fields keep the version
296
+ number; the number bumps only on a **breaking** change. (Implementations report support via
297
+ `GET {endpoint}/api/version`.) v1 v2 bumped because two-way reply flow and `messages.jsonl` are not
298
+ additive to v1's one-way promise.
299
+ - The spec stays intentionally minimal extensions arrive as additive optional fields until a breaking
300
+ change is unavoidable.
241
301
 
242
- Because every push is authenticated, the receiver knows which account performed it and **may stamp each
243
- newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
244
- *credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
245
- account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
302
+ ## Reserved (not in v2)
246
303
 
247
- ## 9. Validation & versioning
304
+ Named here so implementers don't repurpose these concepts:
248
305
 
249
- - A `.figs/` folder can be validated against this spec before publishing (`figs doctor`
250
- `POST {endpoint}/api/validate`). The shapes are the source of truth; readers reject malformed payloads.
251
- - **`figs-spec` is integer-versioned.** v1 is the current version. **Additive/optional** fields keep the
252
- version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
253
- change. (Implementations report support via `GET {endpoint}/api/version`.)
254
- - v1 is intentionally minimal — it defines the smallest useful surface so we don't freeze the wrong
255
- abstractions early. Extensions arrive as additive optional fields until a breaking change is unavoidable.
256
-
257
- ## Reserved (not in v1)
258
-
259
- Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
260
-
261
- - **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
262
- through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
263
- so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
264
- `answer { by, ts, chosen?, text? }` where
265
- `chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
266
- for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
267
- gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
268
- and `directive` (human-initiated) are named-reserved.
306
+ - **Human-initiated messages `note` / `directive`.** A human starting a thread ("also send the email
307
+ to X") rather than replying to an ask. The channel exists (`messages.jsonl` + the down-sync); these
308
+ kinds and their anchoring (to a run, or standalone) are named-reserved.
269
309
  - **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
270
- v1 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
271
- - **Per-record visibility / scoping.** v1 publishes to a workspace where all members can read everything.
310
+ v2 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
311
+ - **Per-record visibility / scoping.** v2 publishes to a workspace where all members read everything.
312
+ - **Sync cursors / pagination.** The down-sync returns the agent's open surface whole (or flags
313
+ truncation); cursors arrive when scale demands.
272
314
 
273
315
  ## 10. A complete example
274
316
 
275
317
  ```jsonc
276
- // .figs/config.json
277
- { "endpoint": "https://app.figs.so", "workspaceId": "…uuid…", "agentId": "…uuid…" }
318
+ // .figs/config.json (linked; local mode would be just { "agentId": "…" })
319
+ { "agentId": "…uuid…", "endpoint": "https://app.figs.so", "workspaceId": "…uuid…" }
278
320
  ```
279
321
 
280
322
  ```jsonc
@@ -283,7 +325,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
283
325
  "name": "Reconciliation",
284
326
  "role": "Reconciliation Officer",
285
327
  "status": "in_dev",
286
- "avatar": { "seed": "Reconciliation" },
287
328
  "org": { "department": "Finance Ops" },
288
329
  "runtime": "Claude Code",
289
330
  "cadence": "Monthly",
@@ -294,10 +335,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
294
335
  "Classify every key — matched / needs-review / our-side-only / customer-only — with a 'why'.",
295
336
  "Surface discrepancies. Never write back to the source."
296
337
  ],
297
- "properties": [
298
- { "k": "Data sources", "v": "Stripe · NetSuite" },
299
- { "k": "Escalation", "v": "#finance-ops" }
300
- ],
301
338
  "units": [
302
339
  { "id": "acme", "name": "Acme Corp", "status": "88% matched · 31 keys flagged", "period": "2025-11",
303
340
  "stats": [ { "l": "Matched", "v": "2,161 keys" }, { "l": "Needs review", "v": "31 keys" } ] }
@@ -306,25 +343,29 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
306
343
  ```
307
344
 
308
345
  ```jsonc
309
- // .figs/runs.jsonl (one object per line; records fold by id — the checkpoint opened the job, the report settled it)
346
+ // .figs/runs.jsonl (records fold by id — the checkpoint opened the job, the report settled it)
310
347
  { "id": "acme-2025-11", "ts": "2026-05-28T23:05:40Z", "unit": "acme", "period": "2025-11", "state": "in-flight", "result": "Statements pulled — matching now",
311
- "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
312
- { "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "state": "settled", "artifact": "acme-2025-11.html",
313
- "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
314
- "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
315
- "tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
348
+ "attachments": ["acme-wip.csv"], "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
349
+ { "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "state": "settled",
350
+ "attachments": ["acme-2025-11.html"], "session": { "runtime": "claude-code", "model": "claude-fable-5" } }
316
351
  ```
317
352
 
318
353
  ```jsonc
319
- // .figs/asks.jsonl (one object per line; records fold by id — the close is an append)
320
- { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "needs-decision", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
354
+ // .figs/asks.jsonl (records fold by id — the close is an append, derived from the reply and citing it)
355
+ { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "question", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
321
356
  "title": "No bridge rule for prefixed invoice numbers",
322
357
  "found": "~180 rows can't be matched safely; guessing risks false matches.",
323
358
  "need": "Confirm the bridge rule for prefixed invoice numbers.",
324
359
  "options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
325
360
  "details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
326
- "refs": [ { "label": "Acme report", "artifact": "acme-2025-11.html" } ] }
361
+ "attachments": [ "acme-2025-11.html" ] }
327
362
  { "id": "acme-bridge", "status": "resolved",
328
- "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)",
329
- "note": "confirmed in terminal — applied from 2025-11 onward" } }
363
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "figs", "answer": "msg-7f3a", "by": "Sarah (accounting)",
364
+ "run": "acme-bridge-fix-2025-11", "ts": "2026-06-01T09:12:00Z" } }
365
+ ```
366
+
367
+ ```jsonc
368
+ // .figs/messages.jsonl (the human's replies — events, not folded; deduped by id)
369
+ { "id": "msg-7f3a", "kind": "answer", "ask": "acme-bridge", "by": "Sarah (accounting)", "ts": "2026-06-01T09:10:00Z",
370
+ "source": "app", "chosen": "Strip the alpha prefix" }
330
371
  ```