@figs-so/cli 0.7.0 → 1.0.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 -37
  2. package/SPEC.md +214 -153
  3. package/figs.mjs +1166 -560
  4. package/package.json +1 -1
package/SPEC.md CHANGED
@@ -1,53 +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
- ├── runs.jsonl # activity log, one JSON object per line (outbox; gitignored)
29
- ├── asks.jsonl # things needing a human, one per line (outbox; gitignored)
30
- └── artifacts/ # files referenced by runs/asks (outbox; gitignored)
39
+ ├── CONTRACT.md # agent-authored: what this agent surfaces / holds back (committed)
40
+ ├── GUIDE.md # orientation breadcrumb, written by the CLI (committed)
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)
31
45
  ```
32
46
 
33
- **Commit** `config.json` + `agent.json` (identity + charter). The activity files (`runs.jsonl`,
34
- `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.
35
50
 
36
- ## 3. `config.json` — identity + destination
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.**
37
55
 
38
- Non-secret. Pins one shared identity so many runners' pushes aggregate.
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.
39
59
 
40
- | Field | Type | Notes |
41
- |---|---|---|
42
- | `endpoint` | string (URL) | Where to publish (default `https://app.figs.so`). |
43
- | `workspaceId` | UUID | The workspace this agent belongs to. |
44
- | `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`). |
60
+ ## 3. `config.json` identity (+ destination)
61
+
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.
64
+
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. |
45
70
 
46
71
  ## 4. `agent.json` — the charter
47
72
 
48
73
  The agent's self-description. Authoring this and publishing makes the agent *appear*. The only field you
49
- author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json` and
50
- 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.
51
76
 
52
77
  | Field | Type | Req | Meaning |
53
78
  |---|---|:--:|---|
@@ -63,7 +88,7 @@ the CLI attaches it on push. Everything else is optional and rendered when prese
63
88
  | `mandate` | string | | One-paragraph statement of what it's responsible for. |
64
89
  | `steps` | string[] | | **Ordered** procedure (numbered render). For pipeline-shaped agents. |
65
90
  | `responsibilities` | string[] | | **Unordered** areas of work (bulleted render). For broad/mission agents. |
66
- | `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. |
67
92
  | `units` | `Unit[]` | | The instances/things the agent operates on (see below). |
68
93
 
69
94
  Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipeline vs. a set of work areas.
@@ -82,175 +107,210 @@ Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipel
82
107
 
83
108
  ## 5. `runs.jsonl` — activity
84
109
 
85
- One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's
86
- *manager* would recognize ("recon — Acme — November"), under a **stable, meaningful id**
87
- (`recon-acme-2026-11`); the runs list reads as the job list. Records **fold by `id`** (same
88
- merge as asks): re-reporting a job's id layers progress onto its row (`status` evolves
89
- blocked-ish `warn` `ok`) sittings/sessions are agent plumbing and never mint records.
90
- Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
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*
120
+ ever closes a run — only the agent's own report settles its job.
91
121
 
92
122
  | Field | Type | Req | Meaning |
93
123
  |---|---|:--:|---|
94
124
  | `id` | string | ✓ | Stable id (upsert key). |
95
- | `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. |
96
126
  | `unit` | string | | The `Unit.id` this run is about. |
97
127
  | `period` | string | | |
98
- | `result` | string | | One-line outcome. |
99
- | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — a run is a complete fact when reported; nothing "closes" a run. |
100
- | `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). |
101
- | `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 moment7). 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. |
102
133
 
103
134
  ### 5.1 `Session` — runtime metadata (optional)
104
135
 
105
136
  An optional, **self-reported** block describing the runtime session that produced a run (or raised an
106
- ask see §6). Every field is optional fill what your runtime exposes, omit the rest. This is
107
- *transparency, not attestation*: the values come from the runtime's own records — `figs report`
108
- captures them automatically; hand-authors copy what their runtime exposes. Cryptographic provenance
109
- 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).
110
140
 
111
141
  | Field | Type | Meaning |
112
142
  |---|---|---|
113
- | `runtime` | string | What ran it, e.g. `claude-code`, `codex`, `claude-managed-agents`. |
143
+ | `runtime` | string | What ran it, e.g. `claude-code`, `codex`. |
114
144
  | `model` | string | Model id, e.g. `claude-fable-5`. |
115
145
  | `sessionId` | string | The runtime's own session identifier. |
116
146
  | `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
117
- | `commit` | string | The agent repo's HEAD at run time; append `+dirty` when the working tree had uncommitted changes, e.g. `1b68668+dirty`. |
118
- | `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 motionone 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. |
119
150
 
120
151
  ## 6. `asks.jsonl` — handoffs to a human
121
152
 
122
- One JSON object per line. Each is something the agent needs a person to resolve. **This is the handoff
123
- 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.
124
155
 
125
156
  | Field | Type | Req | Meaning |
126
157
  |---|---|:--:|---|
127
158
  | `id` | string | ✓ | Stable id (upsert key). |
128
- | `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. |
129
- | `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. |
130
- | `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. |
131
162
  | `title` | string | ✓ | The ask, in one line. |
132
163
  | `unit` | string | | The `Unit.id` this concerns. |
133
- | `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. |
134
165
  | `found` | string | | What the agent found / why it's stuck. |
135
166
  | `need` | string | | What it needs from the human. |
136
- | `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"`). |
137
- | `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`. |
138
169
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
139
- | `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). |
140
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": … }`. |
141
- | `ts` | string (ISO-8601 w/ offset) | | |
142
- | `session` | `Session` | | The session that raised this ask (same shape as [§5.1](#51-session--runtime-metadata-optional)). |
172
+ | `ts` | string (ISO-8601 w/ offset) | | Machine-stamped. |
173
+ | `session` | `Session` | | The session that raised this ask. |
143
174
 
144
- ### 6.1 Lifecycle — two ledgers, split by author
175
+ ### 6.1 Lifecycle — two ledgers, two directions
145
176
 
146
- An ask is the **anchor of a thread whose two halves are owned by different parties**:
177
+ An ask anchors a thread whose two halves are owned by different parties:
147
178
 
148
- - **The agent's ledger** is `asks.jsonl` — only the agent writes here. Records **fold by `id`**
149
- (field-level merge: later lines layer over earlier ones), so the close is an *append*, not an edit:
150
-
151
- ```jsonc
152
- { "id": "acme-bridge", "status": "resolved",
153
- "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)" } }
154
- ```
155
-
156
- 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
157
182
  self-audit trail; the folded record the reader stores is one complete ask.
158
- - **The human's ledger** is server-sideclaims, answers, and verdicts born in the reader's UI.
159
- These are [reserved](#reserved-not-in-v1) in v1 and **never appear in `asks.jsonl`**: nobody
160
- 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.
161
187
 
162
- The full state machine: `open` → *(answered/verdicthuman, server-side)* →
163
- `resolved` | `withdrawn` *(agent, in `asks.jsonl`)* plus the one human-side close:
164
- **`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
165
- later resolution append folds onto it without reopening). Today resolution otherwise happens
166
- 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.
167
190
 
168
191
  ### 6.2 `Resolution` — how an ask closed
169
192
 
193
+ The close is derived from the newest reply on the ask and cites it.
194
+
170
195
  | Field | Type | Meaning |
171
196
  |---|---|---|
172
197
  | `note` | string | The agent's one-line account of the close. |
173
- | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
174
- | `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. |
175
- | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
176
- | `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`). |
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`. |
177
203
 
178
- All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
179
- normalize it to the object form.
204
+ All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }`.
180
205
 
181
- ## 7. `artifacts/`rendered files
206
+ ### 6.3 `messages.jsonl`the human-reply ledger *(new in v2)*
182
207
 
183
- Files referenced by a run's `artifact` or an ask's `refs[].artifact`. Each is content-addressed (an
184
- 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.
185
211
 
186
- - **Supported kinds** (by extension): `html`, `markdown` (`.md`), `text` (`.txt`), `json`, and `image`
187
- (`.png` `.jpg` `.gif` `.webp` `.svg`).
188
- - **Size:** keep each file **≤ ~3 MB** (compress images client-side if needed).
189
- - 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).
190
246
 
191
247
  ## 8. Publishing (the wire contract)
192
248
 
193
- `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:
194
254
 
195
255
  1. **The spine** → `POST {endpoint}/api/ingest`, body:
196
256
  ```jsonc
197
257
  {
198
- "workspaceId": "<uuid>", // from config.json
199
- "agent": { /* agent.json */ },
200
- "runs": [ /* runs.jsonl */ ], // optional
201
- "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
202
263
  }
203
264
  ```
204
- 2. **Each referenced artifact** → `POST {endpoint}/api/artifacts/upload`, content base64-encoded (so
205
- binaries survive), hash server-verified.
265
+ 2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
266
+
267
+ The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it never
268
+ deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
269
+ server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
270
+ and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
271
+ agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
272
+ `{ "error", "code": "agent_moved", "workspaceId"? }`; the agent recovers by setting
273
+ `config.json#workspaceId` to the named workspace and pushing again.
274
+
275
+ **Down — the reply sync.** Delivery is **agent-pulled**, never pushed into the repo: a reader exposes a
276
+ read returning **this agent's human messages** (answers/verdicts), which the CLI merges into
277
+ `messages.jsonl` (append-if-id-absent). It must return the agent's complete open surface or flag
278
+ truncation — a silently partial sync is forbidden. *(Only this — the human reply ledger — syncs down;
279
+ agent ledgers and attachments never do. The exact endpoint is finalized alongside the reader.)*
280
+
281
+ Because every push is authenticated, the receiver may stamp each newly created row with the pushing
282
+ identity ("pushed by") — it attributes the *credential*, not necessarily the human at the keyboard.
283
+ Agents never author this field.
206
284
 
207
- The server upserts the agent by `id` and runs/asks by `id`; it never deletes. An agent **self-registers**
208
- on first push — there is no "create agent" step.
285
+ ## 9. Validation & versioning
209
286
 
210
- **A push never re-homes an agent.** The workspace an agent is registered to is authoritative
211
- server-side: a payload whose `workspaceId` differs from it is rejected with HTTP `409` and body
212
- `{ "error", "code": "agent_moved", "workspaceId"? }`. The `error` text states the fix; `workspaceId`
213
- (the agent's current home) is included only when the pushing token has access to that workspace.
214
- Moving an agent between workspaces is a reader-side management act, outside this contract — the agent
215
- recovers by setting `config.json#workspaceId` to the workspace named in the error and pushing again
216
- (each runner self-heals on its own next push; nothing propagates through the repo).
287
+ - **Local validation is normative for conformance** and runs account-free (`figs doctor`). A reader's
288
+ `POST {endpoint}/api/validate` is an additive second opinion, not the gate.
289
+ - **`figs-spec` is integer-versioned.** v2 is current. **Additive/optional** fields keep the version
290
+ number; the number bumps only on a **breaking** change. (Implementations report support via
291
+ `GET {endpoint}/api/version`.) v1 v2 bumped because two-way reply flow and `messages.jsonl` are not
292
+ additive to v1's one-way promise.
293
+ - The spec stays intentionally minimal extensions arrive as additive optional fields until a breaking
294
+ change is unavoidable.
217
295
 
218
- Because every push is authenticated, the receiver knows which account performed it and **may stamp each
219
- newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
220
- *credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
221
- account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
296
+ ## Reserved (not in v2)
222
297
 
223
- ## 9. Validation & versioning
298
+ Named here so implementers don't repurpose these concepts:
224
299
 
225
- - A `.figs/` folder can be validated against this spec before publishing (`figs doctor`
226
- `POST {endpoint}/api/validate`). The shapes are the source of truth; readers reject malformed payloads.
227
- - **`figs-spec` is integer-versioned.** v1 is the current version. **Additive/optional** fields keep the
228
- version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
229
- change. (Implementations report support via `GET {endpoint}/api/version`.)
230
- - v1 is intentionally minimal — it defines the smallest useful surface so we don't freeze the wrong
231
- abstractions early. Extensions arrive as additive optional fields until a breaking change is unavoidable.
232
-
233
- ## Reserved (not in v1)
234
-
235
- Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
236
-
237
- - **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
238
- through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
239
- so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
240
- `answer { by, ts, chosen?, text? }` where
241
- `chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
242
- for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
243
- gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
244
- and `directive` (human-initiated) are named-reserved.
300
+ - **Human-initiated messages `note` / `directive`.** A human starting a thread ("also send the email
301
+ to X") rather than replying to an ask. The channel exists (`messages.jsonl` + the down-sync); these
302
+ kinds and their anchoring (to a run, or standalone) are named-reserved.
245
303
  - **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
246
- v1 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
247
- - **Per-record visibility / scoping.** v1 publishes to a workspace where all members can read everything.
304
+ v2 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
305
+ - **Per-record visibility / scoping.** v2 publishes to a workspace where all members read everything.
306
+ - **Sync cursors / pagination.** The down-sync returns the agent's open surface whole (or flags
307
+ truncation); cursors arrive when scale demands.
248
308
 
249
309
  ## 10. A complete example
250
310
 
251
311
  ```jsonc
252
- // .figs/config.json
253
- { "endpoint": "https://app.figs.so", "workspaceId": "…uuid…", "agentId": "…uuid…" }
312
+ // .figs/config.json (linked; local mode would be just { "agentId": "…" })
313
+ { "agentId": "…uuid…", "endpoint": "https://app.figs.so", "workspaceId": "…uuid…" }
254
314
  ```
255
315
 
256
316
  ```jsonc
@@ -259,7 +319,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
259
319
  "name": "Reconciliation",
260
320
  "role": "Reconciliation Officer",
261
321
  "status": "in_dev",
262
- "avatar": { "seed": "Reconciliation" },
263
322
  "org": { "department": "Finance Ops" },
264
323
  "runtime": "Claude Code",
265
324
  "cadence": "Monthly",
@@ -270,10 +329,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
270
329
  "Classify every key — matched / needs-review / our-side-only / customer-only — with a 'why'.",
271
330
  "Surface discrepancies. Never write back to the source."
272
331
  ],
273
- "properties": [
274
- { "k": "Data sources", "v": "Stripe · NetSuite" },
275
- { "k": "Escalation", "v": "#finance-ops" }
276
- ],
277
332
  "units": [
278
333
  { "id": "acme", "name": "Acme Corp", "status": "88% matched · 31 keys flagged", "period": "2025-11",
279
334
  "stats": [ { "l": "Matched", "v": "2,161 keys" }, { "l": "Needs review", "v": "31 keys" } ] }
@@ -282,23 +337,29 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
282
337
  ```
283
338
 
284
339
  ```jsonc
285
- // .figs/runs.jsonl (one object per line)
286
- { "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "artifact": "acme-2025-11.html",
287
- "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
288
- "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
289
- "tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
340
+ // .figs/runs.jsonl (records fold by id — the checkpoint opened the job, the report settled it)
341
+ { "id": "acme-2025-11", "ts": "2026-05-28T23:05:40Z", "unit": "acme", "period": "2025-11", "state": "in-flight", "result": "Statements pulled — matching now",
342
+ "attachments": ["acme-wip.csv"], "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
343
+ { "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",
344
+ "attachments": ["acme-2025-11.html"], "session": { "runtime": "claude-code", "model": "claude-fable-5" } }
290
345
  ```
291
346
 
292
347
  ```jsonc
293
- // .figs/asks.jsonl (one object per line; records fold by id — the close is an append)
294
- { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "needs-decision", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
348
+ // .figs/asks.jsonl (records fold by id — the close is an append, derived from the reply and citing it)
349
+ { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "question", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
295
350
  "title": "No bridge rule for prefixed invoice numbers",
296
351
  "found": "~180 rows can't be matched safely; guessing risks false matches.",
297
352
  "need": "Confirm the bridge rule for prefixed invoice numbers.",
298
353
  "options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
299
354
  "details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
300
- "refs": [ { "label": "Acme report", "artifact": "acme-2025-11.html" } ] }
355
+ "attachments": [ "acme-2025-11.html" ] }
301
356
  { "id": "acme-bridge", "status": "resolved",
302
- "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)",
303
- "note": "confirmed in terminal — applied from 2025-11 onward" } }
357
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "figs", "answer": "msg-7f3a", "by": "Sarah (accounting)",
358
+ "run": "acme-bridge-fix-2025-11", "ts": "2026-06-01T09:12:00Z" } }
359
+ ```
360
+
361
+ ```jsonc
362
+ // .figs/messages.jsonl (the human's replies — events, not folded; deduped by id)
363
+ { "id": "msg-7f3a", "kind": "answer", "ask": "acme-bridge", "by": "Sarah (accounting)", "ts": "2026-06-01T09:10:00Z",
364
+ "source": "app", "chosen": "Strip the alpha prefix" }
304
365
  ```