@figs-so/cli 0.1.16 → 0.2.1

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 +10 -6
  2. package/SPEC.md +95 -16
  3. package/figs.mjs +621 -23
  4. package/package.json +4 -1
package/README.md CHANGED
@@ -7,11 +7,10 @@ Every agent you run (Claude Code, Codex, Cursor) publishes what it owns, what it
7
7
  needs from a person — into one shared view your whole team can see.
8
8
 
9
9
  > **The open standard for how AI employees report to humans.** The `.figs` format is that standard (this
10
- > repo). The hosted app at **[app.figs.so](https://app.figs.so)** is the easiest place to read it; you can
11
- > also self-host.
10
+ > repo). The hosted app at **[app.figs.so](https://app.figs.so)** is where you read it.
12
11
 
13
12
  [![CLI on npm](https://img.shields.io/npm/v/%40figs-so%2Fcli?label=%40figs-so%2Fcli)](https://www.npmjs.com/package/@figs-so/cli)
14
-  ·  License: **MIT** (this repo — protocol + CLI)  ·  The app: **AGPL-3.0**
13
+  ·  License: **MIT** (this repo — protocol + CLI)  ·  The app: **hosted** (closed source)
15
14
 
16
15
  ---
17
16
 
@@ -41,7 +40,9 @@ npx @figs-so/cli@latest push # publish → it appears in you
41
40
  ```
42
41
 
43
42
  That's it — your agent now shows up at **[app.figs.so](https://app.figs.so)**. No instrumentation, no
44
- SDK in your agent's code. From there you decide, deliberately, how much of its real work to surface.
43
+ SDK in your agent's code. From there you decide, deliberately, how much of its real work to surface
44
+ and day to day the agent records itself in one stroke per event: `figs report` (a run) ·
45
+ `figs ask` (needs a human) · `figs resolve` (close an ask). Each pushes itself.
45
46
 
46
47
  ## How it works
47
48
 
@@ -72,8 +73,11 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
72
73
  | `figs login` / `logout` | device-flow browser approve / remove local token |
73
74
  | `figs workspaces [--json]` | list your workspaces (create one in the web app) |
74
75
  | `figs init [--workspace <slug>]` | generate identity + write `.figs/` (omit the flag: uses your only workspace, else lists them) |
75
- | `figs doctor` | validate `.figs/` against the contract before pushing |
76
- | `figs push` | one-way publish of `.figs/` |
76
+ | **`figs report --result "…"`** | record a run — stamps id + timestamp, auto-captures the session trace, `--attach`es artifacts, pushes itself (`--resolves <ask-id>` closes an ask in the same stroke) |
77
+ | **`figs ask <type> --title "…"`** | raise a self-contained ask (`blocked` · `needs-decision` · `sign-off` · `fyi`) — options/details/attachments, pushed so a human sees it |
78
+ | **`figs resolve <ask-id>`** | close an ask — `--chosen` verbatim-checked against its options, `--withdrawn` for the un-ask |
79
+ | `figs push` | the bare transport — the verbs call it automatically; type it yourself after hand-edits or `--no-push` |
80
+ | `figs doctor` | validate `.figs/` against the spec without pushing — the conformance check for hand-authored or non-CLI setups |
77
81
  | `figs status [--json]` | login / workspace / agent state |
78
82
  | `figs help [<command>]` | usage (`-h`/`--help` on any command; `-v` for version) |
79
83
 
package/SPEC.md CHANGED
@@ -54,7 +54,6 @@ the CLI attaches it on push. Everything else is optional and rendered when prese
54
54
  | `id` | UUID | ✓ | Identity. **Supplied from `config.json#agentId` by the CLI on push — not written in this file.** |
55
55
  | `name` | string | ✓ | Display name. |
56
56
  | `key` | string | | Display slug; derived from `name` if absent. |
57
- | `type` | `"agent"` \| `"human"` | | Default `"agent"`. |
58
57
  | `avatar` | `{ seed: string }` | | Seed for the generated avatar. |
59
58
  | `role` | string | | Short title, e.g. "Reconciliation Officer". |
60
59
  | `status` | string | | Free-text lifecycle, e.g. `in_dev`, `active`. |
@@ -92,8 +91,27 @@ One JSON object per line (JSON Lines). Each is something the agent did.
92
91
  | `unit` | string | | The `Unit.id` this run is about. |
93
92
  | `period` | string | | |
94
93
  | `result` | string | | One-line outcome. |
95
- | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. |
96
- | `artifact` | string | | File name under `artifacts/` to attach. |
94
+ | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — a run is a complete fact when reported; nothing "closes" a run. |
95
+ | `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). |
96
+ | `resolves` | string | | The ask `id` this run executes/closes (the agent did the answered/approved thing and is reporting back — see [§6.1](#61-lifecycle--two-ledgers-split-by-author)). |
97
+ | `session` | `Session` | | Where/how this ran (see [§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
98
+
99
+ ### 5.1 `Session` — runtime metadata (optional)
100
+
101
+ An optional, **self-reported** block describing the runtime session that produced a run (or raised an
102
+ ask — see §6). Every field is optional — fill what your runtime exposes, omit the rest. This is
103
+ *transparency, not attestation*: the values come from the runtime's own records — `figs report`
104
+ captures them automatically; hand-authors copy what their runtime exposes. Cryptographic provenance
105
+ remains [reserved](#reserved-not-in-v1).
106
+
107
+ | Field | Type | Meaning |
108
+ |---|---|---|
109
+ | `runtime` | string | What ran it, e.g. `claude-code`, `codex`, `claude-managed-agents`. |
110
+ | `model` | string | Model id, e.g. `claude-fable-5`. |
111
+ | `sessionId` | string | The runtime's own session identifier. |
112
+ | `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
113
+ | `commit` | string | The agent repo's HEAD at run time; append `+dirty` when the working tree had uncommitted changes, e.g. `1b68668+dirty`. |
114
+ | `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. |
97
115
 
98
116
  ## 6. `asks.jsonl` — handoffs to a human
99
117
 
@@ -103,20 +121,57 @@ primitive** — the agent reached the edge of its autonomy.
103
121
  | Field | Type | Req | Meaning |
104
122
  |---|---|:--:|---|
105
123
  | `id` | string | ✓ | Stable id (upsert key). |
106
- | `type` | enum | ✓ | `blocked` \| `needs-decision` \| `sign-off` \| `fyi`. `fyi` is a non-blocking heads-up (no decision needed). (`confirm-assumption` still validates but is **deprecated** — use `needs-decision` or `fyi`.) |
107
- | `status` | `"open"` \| `"resolved"` | | Default `"open"`. |
124
+ | `type` | enum | ✓ | `blocked` \| `needs-decision` \| `sign-off` \| `fyi`. `fyi` is a non-blocking heads-up (no decision needed). |
125
+ | `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. |
126
+ | `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. |
108
127
  | `title` | string | ✓ | The ask, in one line. |
109
128
  | `unit` | string | | The `Unit.id` this concerns. |
129
+ | `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). |
110
130
  | `found` | string | | What the agent found / why it's stuck. |
111
131
  | `need` | string | | What it needs from the human. |
112
- | `options` | string[] | | Candidate resolutions. |
132
+ | `options` | string[] | | Candidate resolutions — **short, stable, quotable** strings: a future answer references one *verbatim* (see [§6.2](#62-resolution--how-an-ask-closed)). |
113
133
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
114
134
  | `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
135
+ | `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": … }`. |
115
136
  | `ts` | string (ISO-8601 w/ offset) | | |
137
+ | `session` | `Session` | | The session that raised this ask (same shape as [§5.1](#51-session--runtime-metadata-optional)). |
138
+
139
+ ### 6.1 Lifecycle — two ledgers, split by author
140
+
141
+ An ask is the **anchor of a thread whose two halves are owned by different parties**:
142
+
143
+ - **The agent's ledger** is `asks.jsonl` — only the agent writes here. Records **fold by `id`**
144
+ (field-level merge: later lines layer over earlier ones), so the close is an *append*, not an edit:
145
+
146
+ ```jsonc
147
+ { "id": "acme-bridge", "status": "resolved",
148
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)" } }
149
+ ```
116
150
 
117
- > In v1, an ask is **one-way**: it announces that a human is needed. Resolution happens in the agent's own
118
- > workflow (the agent sets `status: "resolved"` on a later push). Answers flowing *back* through Figs are
119
- > [reserved for a future version](#reserved-not-in-v1).
151
+ Appending keeps the local file crash-safe, concurrency-safe (multiple runners), and an honest
152
+ self-audit trail; the folded record the reader stores is one complete ask.
153
+ - **The human's ledger** is server-side — claims, answers, and verdicts born in the reader's UI.
154
+ These are [reserved](#reserved-not-in-v1) in v1 and **never appear in `asks.jsonl`**: nobody
155
+ writes into the other side's record; the two ledgers cross-reference by id.
156
+
157
+ The full state machine: `open` → *(answered/verdict — human, server-side)* →
158
+ `resolved` | `withdrawn` *(agent, in `asks.jsonl`)* — plus the one human-side close:
159
+ **`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
160
+ later resolution append folds onto it without reopening). Today resolution otherwise happens
161
+ in the agent's own workflow; answers flowing back through the reader are arriving incrementally.
162
+
163
+ ### 6.2 `Resolution` — how an ask closed
164
+
165
+ | Field | Type | Meaning |
166
+ |---|---|---|
167
+ | `note` | string | The agent's one-line account of the close. |
168
+ | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
169
+ | `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (verifiable, future) · answered out-of-band (self-reported) · the blocker cleared on its own. |
170
+ | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
171
+ | `answer` | string | **Reserved** — the Figs answer-event id the agent acted on, once answer-down ships. |
172
+
173
+ All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
174
+ normalize it to the object form.
120
175
 
121
176
  ## 7. `artifacts/` — rendered files
122
177
 
@@ -147,9 +202,22 @@ unchanged file is skipped on publish).
147
202
  The server upserts the agent by `id` and runs/asks by `id`; it never deletes. An agent **self-registers**
148
203
  on first push — there is no "create agent" step.
149
204
 
205
+ **A push never re-homes an agent.** The workspace an agent is registered to is authoritative
206
+ server-side: a payload whose `workspaceId` differs from it is rejected with HTTP `409` and body
207
+ `{ "error", "code": "agent_moved", "workspaceId"? }`. The `error` text states the fix; `workspaceId`
208
+ (the agent's current home) is included only when the pushing token has access to that workspace.
209
+ Moving an agent between workspaces is a reader-side management act, outside this contract — the agent
210
+ recovers by setting `config.json#workspaceId` to the workspace named in the error and pushing again
211
+ (each runner self-heals on its own next push; nothing propagates through the repo).
212
+
213
+ Because every push is authenticated, the receiver knows which account performed it and **may stamp each
214
+ newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
215
+ *credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
216
+ account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
217
+
150
218
  ## 9. Validation & versioning
151
219
 
152
- - A `.figs/` folder can be validated against this contract before publishing (`figs doctor` →
220
+ - A `.figs/` folder can be validated against this spec before publishing (`figs doctor` →
153
221
  `POST {endpoint}/api/validate`). The shapes are the source of truth; readers reject malformed payloads.
154
222
  - **`figs-spec` is integer-versioned.** v1 is the current version. **Additive/optional** fields keep the
155
223
  version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
@@ -161,8 +229,14 @@ on first push — there is no "create agent" step.
161
229
 
162
230
  Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
163
231
 
164
- - **Two-way / answer-down.** A human answer or sign-off flowing *back* to the agent through Figs (vs. the
165
- agent resolving in its own workflow). v1 is report-only.
232
+ - **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
233
+ through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
234
+ so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
235
+ `answer { by, ts, chosen?, text? }` where
236
+ `chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
237
+ for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
238
+ gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
239
+ and `directive` (human-initiated) are named-reserved.
166
240
  - **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
167
241
  v1 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
168
242
  - **Per-record visibility / scoping.** v1 publishes to a workspace where all members can read everything.
@@ -178,7 +252,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
178
252
  // .figs/agent.json (no `id` here — `figs init` puts it in config.json; the CLI attaches it on push)
179
253
  {
180
254
  "name": "Reconciliation",
181
- "type": "agent",
182
255
  "role": "Reconciliation Officer",
183
256
  "status": "in_dev",
184
257
  "avatar": { "seed": "Reconciliation" },
@@ -205,16 +278,22 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
205
278
 
206
279
  ```jsonc
207
280
  // .figs/runs.jsonl (one object per line)
208
- { "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" }
281
+ { "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",
282
+ "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
283
+ "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
284
+ "tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
209
285
  ```
210
286
 
211
287
  ```jsonc
212
- // .figs/asks.jsonl (one object per line)
213
- { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "needs-decision", "status": "open", "unit": "acme",
288
+ // .figs/asks.jsonl (one object per line; records fold by id — the close is an append)
289
+ { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "needs-decision", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
214
290
  "title": "No bridge rule for prefixed invoice numbers",
215
291
  "found": "~180 rows can't be matched safely; guessing risks false matches.",
216
292
  "need": "Confirm the bridge rule for prefixed invoice numbers.",
217
293
  "options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
218
294
  "details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
219
295
  "refs": [ { "label": "Acme report", "artifact": "acme-2025-11.html" } ] }
296
+ { "id": "acme-bridge", "status": "resolved",
297
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)",
298
+ "note": "confirmed in terminal — applied from 2025-11 onward" } }
220
299
  ```
package/figs.mjs CHANGED
@@ -9,11 +9,21 @@
9
9
  * figs workspaces list the user's workspaces [--json]
10
10
  * figs init --workspace <slug-or-id> [--endpoint <url>]
11
11
  * create .figs/config.json + GUIDE.md (generates a stable agent id)
12
- * figs doctor validate .figs/ against the contract before pushing
12
+ * figs report --result "…" record a run (stamps id/ts/session, --attach files, auto-push)
13
+ * figs ask <type> --title "…" raise an ask (self-contained: options/details/attachments, auto-push)
14
+ * figs resolve <ask-id> close an ask (verbatim-checks --chosen, auto-push)
15
+ * figs doctor validate .figs/ against the spec before pushing
13
16
  * figs push one-way push the .figs/ spine to the ingest endpoint
14
17
  * figs version print the CLI version (and check for updates)
15
18
  * figs help [<command>] usage; `-h`/`--help` on any command, `-v` for version
16
19
  *
20
+ * The writing verbs (report/ask/resolve) are sugar over the same files — they
21
+ * stamp ids + real-clock timestamps, validate on write with teaching errors,
22
+ * copy attachments into artifacts/, then invoke the same push as `figs push`
23
+ * (auto-push IS push — one transport, many entry points; --no-push to batch).
24
+ * Hand-writing the JSONL + bare `push` remains fully supported — files are the
25
+ * protocol; the verbs are conveniences.
26
+ *
17
27
  * Designed to be driven by an agent: non-interactive, clear output, `--json`
18
28
  * on read commands, `-h`/`--help`/`help` everywhere, and errors that say what to
19
29
  * do next. Bare `figs` prints help; unknown commands AND unknown flags exit
@@ -27,17 +37,20 @@
27
37
  */
28
38
 
29
39
  import {
40
+ appendFileSync,
30
41
  chmodSync,
31
42
  existsSync,
32
43
  mkdirSync,
44
+ readdirSync,
33
45
  readFileSync,
34
46
  rmSync,
47
+ statSync,
35
48
  writeFileSync,
36
49
  } from "node:fs"
37
50
  import { homedir } from "node:os"
38
- import { basename, join } from "node:path"
51
+ import { basename, extname, join } from "node:path"
39
52
  import { randomUUID } from "node:crypto"
40
- import { spawn } from "node:child_process"
53
+ import { execSync, spawn } from "node:child_process"
41
54
 
42
55
  // Single source of truth for the version: package.json (shipped alongside this
43
56
  // file in the published package). One edit keeps `figs version`, the floor
@@ -96,12 +109,72 @@ const COMMANDS = {
96
109
  ],
97
110
  eg: "figs init --workspace acme-corp",
98
111
  },
99
- doctor: { args: "", flags: ["--json"], desc: "validate .figs/ against the live contract before pushing" },
112
+ report: {
113
+ args: "--result <text> [options]",
114
+ flags: [
115
+ "--result", "--id", "--unit", "--period", "--status", "--attach",
116
+ "--resolves", "--chosen", "--by", "--note", "--no-push",
117
+ ],
118
+ desc: "record a run — writes one line to runs.jsonl, stamps id/ts/session, pushes",
119
+ more: [
120
+ "You supply the content; the CLI does the bookkeeping (id, real-clock ts, session",
121
+ "trace from your runtime's own records, validation, artifact copy, push).",
122
+ "--attach <file> (repeatable) copies the file into artifacts/ and links it.",
123
+ "--resolves <ask-id> also closes that ask (with optional --chosen/--by/--note).",
124
+ "--id only for deliberate stable ids (re-running the same job updates the same run).",
125
+ "--no-push writes locally only; `figs push` publishes later.",
126
+ "Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
127
+ ],
128
+ eg: 'figs report --result "88% matched · 31 flagged" --attach ./acme-2025-11.html',
129
+ },
130
+ ask: {
131
+ args: "<type> --title <text> [options]",
132
+ flags: [
133
+ "--id", "--title", "--need", "--found", "--option", "--detail", "--attach",
134
+ "--to", "--unit", "--run", "--stdin", "--no-push",
135
+ ],
136
+ desc: "raise an ask — one self-contained line in asks.jsonl, pushed so a human sees it",
137
+ more: [
138
+ "<type> is one of: blocked · needs-decision · sign-off · fyi.",
139
+ "Make it self-contained — a future session with zero context (or another human)",
140
+ "must be able to act from this record alone: --found (what you saw), --need (what",
141
+ "you need), --option (repeatable; short, stable, quotable — answers cite one",
142
+ "verbatim), --detail \"Label=Value\" (repeatable), --attach <file> (repeatable;",
143
+ "for sign-offs attach the exact content for review + a brief: what to do once",
144
+ "approved and what it requires).",
145
+ "--run <run-id> links the run this came out of — explicit id only (other",
146
+ "sessions may report concurrently; `figs report` prints the id it wrote).",
147
+ "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
148
+ ],
149
+ eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run recon-2026-06',
150
+ },
151
+ resolve: {
152
+ args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn|--rejected]",
153
+ flags: ["--chosen", "--by", "--note", "--withdrawn", "--rejected", "--no-push"],
154
+ desc: "close an ask — appends the resolution fold line and pushes",
155
+ more: [
156
+ "--chosen must quote one of the ask's options[] verbatim (checked).",
157
+ "Three closes, by who ended it: resolved (default — the need was met) ·",
158
+ "--withdrawn (YOU retracted it; nobody acted) · --rejected (a HUMAN declined",
159
+ "it — record their out-of-band no; rejected is terminal, re-raising = a new ask).",
160
+ "Use `figs report --resolves <ask-id>` instead when a run did the work.",
161
+ ],
162
+ eg: 'figs resolve acme-bridge --chosen "Strip the alpha prefix" --by "Sarah (accounting)"',
163
+ },
164
+ doctor: {
165
+ args: "",
166
+ flags: ["--json"],
167
+ desc: "validate .figs/ against the spec without pushing — the conformance check for hand-authored or non-CLI setups",
168
+ },
100
169
  push: {
101
170
  args: "",
102
171
  flags: [],
103
172
  desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
104
- more: ["Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected."],
173
+ more: [
174
+ "Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected.",
175
+ "The writing verbs (report/ask/resolve) call this automatically — you only need it",
176
+ "after hand-editing files, after --no-push, or to retry a failed auto-push.",
177
+ ],
105
178
  eg: "figs push",
106
179
  },
107
180
  version: { args: "", flags: [], desc: "print the CLI version and check for updates" },
@@ -137,6 +210,121 @@ function flag(name) {
137
210
  }
138
211
  return undefined
139
212
  }
213
+ /** All values of a repeatable flag, in order. */
214
+ function flagAll(name) {
215
+ const args = process.argv.slice(2)
216
+ const out = []
217
+ for (let i = 0; i < args.length; i++) {
218
+ if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1])
219
+ else if (args[i].startsWith(`${name}=`)) out.push(args[i].slice(name.length + 1))
220
+ }
221
+ return out
222
+ }
223
+ /** Boolean flag — present or not (takes no value). */
224
+ function hasFlag(name) {
225
+ return process.argv.slice(2).includes(name)
226
+ }
227
+ /** First non-flag token after the command (e.g. the ask type, the ask id). */
228
+ function positional() {
229
+ const args = process.argv.slice(3)
230
+ for (let i = 0; i < args.length; i++) {
231
+ if (args[i].startsWith("-")) {
232
+ // skip `--name value` pairs (but not `--name=value` or booleans)
233
+ if (!args[i].includes("=") && !BOOLEAN_FLAGS.has(args[i])) i++
234
+ continue
235
+ }
236
+ return args[i]
237
+ }
238
+ return undefined
239
+ }
240
+ const BOOLEAN_FLAGS = new Set([
241
+ "--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "-h", "--help",
242
+ ])
243
+
244
+ /** ISO-8601 with the machine's real UTC offset (never the agent's guess). */
245
+ function nowIso() {
246
+ const d = new Date()
247
+ const pad = (n, w = 2) => String(Math.abs(n)).padStart(w, "0")
248
+ const off = -d.getTimezoneOffset()
249
+ const sign = off >= 0 ? "+" : "-"
250
+ return (
251
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
252
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
253
+ (off === 0 ? "Z" : `${sign}${pad(Math.trunc(off / 60))}:${pad(off % 60)}`)
254
+ )
255
+ }
256
+ /** Generated unique id — stable/content-derived ids only via explicit --id. */
257
+ function genId(prefix) {
258
+ return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
259
+ }
260
+
261
+ // ---------- local validation (the spec's common mistakes, caught on write) ----
262
+ // The server's schema stays the source of truth; these catch what hand-authors
263
+ // and flag typos get wrong, with errors that teach the fix.
264
+ const ASK_TYPES = ["blocked", "needs-decision", "sign-off", "fyi"]
265
+ const RUN_STATUSES = ["ok", "warn", "fail"]
266
+ const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
267
+ const TO_VALUES = ["manager", "builder"]
268
+ const ARTIFACT_EXTS = new Set([
269
+ ".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
270
+ ])
271
+ const ARTIFACT_MAX = 3 * 1024 * 1024
272
+
273
+ /** "signoff" → `did you mean "sign-off"?` — normalized nearest match. */
274
+ function didYouMean(value, allowed) {
275
+ const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, "")
276
+ const hit = allowed.find((a) => norm(a) === norm(value))
277
+ return hit ? ` — did you mean "${hit}"?` : ` (valid: ${allowed.join(" · ")})`
278
+ }
279
+ function checkEnum(issues, obj, field, allowed, label) {
280
+ const v = obj[field]
281
+ if (v !== undefined && !allowed.includes(v)) {
282
+ issues.push(`${label}.${field}: "${v}" isn't valid${didYouMean(v, allowed)}`)
283
+ }
284
+ }
285
+ /** Validate one folded run record → array of issue strings. */
286
+ function validateRun(r) {
287
+ const issues = []
288
+ const label = `run "${r.id ?? "?"}"`
289
+ if (!r.id || typeof r.id !== "string") issues.push(`${label}: missing required "id"`)
290
+ if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
291
+ checkEnum(issues, r, "status", RUN_STATUSES, label)
292
+ if (r.artifact !== undefined && typeof r.artifact !== "string") {
293
+ issues.push(`${label}.artifact: must be a single file name (use "artifacts" for a list)`)
294
+ }
295
+ if (r.artifacts !== undefined && (!Array.isArray(r.artifacts) || r.artifacts.some((a) => typeof a !== "string"))) {
296
+ issues.push(`${label}.artifacts: must be an array of file names`)
297
+ }
298
+ return issues
299
+ }
300
+ /** Validate one folded ask record → array of issue strings. */
301
+ function validateAsk(a) {
302
+ const issues = []
303
+ const label = `ask "${a.id ?? "?"}"`
304
+ if (!a.id || typeof a.id !== "string") issues.push(`${label}: missing required "id"`)
305
+ if (!a.type) {
306
+ issues.push(
307
+ `${label}: missing required "type" — was it raised on another machine? (closing it from here needs the full record; cross-machine fetch is coming)`,
308
+ )
309
+ } else checkEnum(issues, a, "type", ASK_TYPES, label)
310
+ if (!a.title) issues.push(`${label}: missing required "title"`)
311
+ checkEnum(issues, a, "status", ASK_STATUSES, label)
312
+ checkEnum(issues, a, "to", TO_VALUES, label)
313
+ if (a.options !== undefined && (!Array.isArray(a.options) || a.options.some((o) => typeof o !== "string"))) {
314
+ issues.push(`${label}.options: must be an array of short, quotable strings`)
315
+ }
316
+ if (a.details !== undefined && (!Array.isArray(a.details) || a.details.some((d) => !d || typeof d.l !== "string"))) {
317
+ issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
318
+ }
319
+ if (a.refs !== undefined && (!Array.isArray(a.refs) || a.refs.some((r) => !r || typeof r.label !== "string"))) {
320
+ issues.push(`${label}.refs: must be [{ "label": "…", "artifact": "<file in artifacts/>" }]`)
321
+ }
322
+ return issues
323
+ }
324
+ /** Validate the whole local outbox (folded) — returns all issues. */
325
+ function validateOutbox(runs, asks) {
326
+ return [...runs.flatMap(validateRun), ...asks.flatMap(validateAsk)]
327
+ }
140
328
  function getToken() {
141
329
  return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
142
330
  }
@@ -287,6 +475,9 @@ else if (COMMANDS[cmd]) {
287
475
  else if (cmd === "status") await status()
288
476
  else if (cmd === "workspaces") await workspaces()
289
477
  else if (cmd === "init") await init()
478
+ else if (cmd === "report") await reportCmd()
479
+ else if (cmd === "ask") await askCmd()
480
+ else if (cmd === "resolve") await resolveCmd()
290
481
  else if (cmd === "doctor") await doctor()
291
482
  else if (cmd === "push") await push()
292
483
  } else {
@@ -732,7 +923,377 @@ async function init() {
732
923
  console.log(` Full guide: ${endpoint}/llms.txt`)
733
924
  }
734
925
 
735
- /** Validate the local .figs/ payload against the contract — no write. */
926
+ // ====================== the writing verbs ===================================
927
+ // report / ask / resolve — sugar over the same files (hand-writing stays
928
+ // first-class). The agent supplies content; the CLI stamps id + real-clock ts,
929
+ // captures the session trace, validates with teaching errors, copies
930
+ // attachments, then invokes the same push as `figs push`.
931
+
932
+ function requireFigs() {
933
+ if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
934
+ const config = readJson(join(repoDir, "config.json"), {})
935
+ if (!config.workspaceId || !config.agentId) {
936
+ die("config missing workspaceId/agentId — run `figs init`")
937
+ }
938
+ }
939
+ function appendJsonl(name, obj) {
940
+ appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
941
+ }
942
+ /** Print a record without its (noisy) session block. */
943
+ function summarize(obj) {
944
+ const { session, ...rest } = obj
945
+ return JSON.stringify(rest) + (session ? " (+ session trace)" : "")
946
+ }
947
+
948
+ /** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
949
+ function attachFiles(paths) {
950
+ const names = []
951
+ for (const p of paths) {
952
+ if (!existsSync(p)) die(`--attach: no such file: ${p}`)
953
+ const ext = extname(p).toLowerCase()
954
+ if (!ARTIFACT_EXTS.has(ext)) {
955
+ die(`--attach: unsupported type "${ext || p}" — supported: ${[...ARTIFACT_EXTS].join(" ")}`)
956
+ }
957
+ const bytes = readFileSync(p)
958
+ if (bytes.length > ARTIFACT_MAX) {
959
+ die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the 3 MB cap; compress or split it`)
960
+ }
961
+ const name = basename(p)
962
+ const dest = join(repoDir, "artifacts", name)
963
+ if (existsSync(dest) && !readFileSync(dest).equals(bytes)) {
964
+ die(
965
+ `--attach: artifacts/${name} already exists with different content — artifacts are immutable once published; use a new name (e.g. ${name.slice(0, -ext.length)}-v2${ext}) and reference that`,
966
+ )
967
+ }
968
+ mkdirSync(join(repoDir, "artifacts"), { recursive: true })
969
+ writeFileSync(dest, bytes)
970
+ names.push(name)
971
+ }
972
+ return names
973
+ }
974
+
975
+ // ---------- session auto-capture --------------------------------------------
976
+ // The trace comes from the runtime's own records, never from the model's
977
+ // memory. Best-effort by design: any failure → the optional block is omitted;
978
+ // a report is never blocked on trace capture.
979
+ function captureSession() {
980
+ try {
981
+ const candidates = [findClaudeTranscript(), findCodexTranscript()].filter(Boolean)
982
+ if (!candidates.length) return undefined
983
+ // The transcript being written *now* is the newest one, whichever runtime
984
+ // owns it. Anything older than a day is a leftover, not this session.
985
+ const best = candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
986
+ if (Date.now() - best.mtimeMs > 86400000) return undefined
987
+ const session = best.parse(best.path)
988
+ if (!session) return undefined
989
+ const commit = captureCommit()
990
+ if (commit) session.commit = commit
991
+ return session
992
+ } catch {
993
+ return undefined
994
+ }
995
+ }
996
+ function newestFile(dir, filter) {
997
+ if (!existsSync(dir)) return null
998
+ let best = null
999
+ for (const f of readdirSync(dir)) {
1000
+ if (!filter(f)) continue
1001
+ const p = join(dir, f)
1002
+ let st
1003
+ try {
1004
+ st = statSync(p)
1005
+ } catch {
1006
+ continue
1007
+ }
1008
+ if (!st.isFile()) continue
1009
+ if (!best || st.mtimeMs > best.mtimeMs) best = { path: p, name: f, mtimeMs: st.mtimeMs }
1010
+ }
1011
+ return best
1012
+ }
1013
+ function findClaudeTranscript() {
1014
+ const dir = join(homedir(), ".claude", "projects", process.cwd().replace(/[\\/:]/g, "-"))
1015
+ const f = newestFile(dir, (n) => n.endsWith(".jsonl"))
1016
+ return f ? { ...f, parse: parseClaudeTranscript } : null
1017
+ }
1018
+ function parseClaudeTranscript(path) {
1019
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1020
+ let model, startedAt
1021
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1022
+ if (!line.trim()) continue
1023
+ let e
1024
+ try {
1025
+ e = JSON.parse(line)
1026
+ } catch {
1027
+ continue
1028
+ }
1029
+ if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1030
+ const m = e.message
1031
+ if (!m?.usage || m.model === "<synthetic>") continue
1032
+ if (typeof m.model === "string") model = m.model
1033
+ tokens.input += m.usage.input_tokens ?? 0
1034
+ tokens.output += m.usage.output_tokens ?? 0
1035
+ tokens.cacheRead += m.usage.cache_read_input_tokens ?? 0
1036
+ tokens.cacheWrite += m.usage.cache_creation_input_tokens ?? 0
1037
+ }
1038
+ const out = { runtime: "claude-code", sessionId: basename(path, ".jsonl") }
1039
+ if (model) out.model = model
1040
+ if (startedAt) out.startedAt = startedAt
1041
+ if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) out.tokens = tokens
1042
+ return out
1043
+ }
1044
+ function findCodexTranscript() {
1045
+ const root = join(homedir(), ".codex", "sessions")
1046
+ if (!existsSync(root)) return null
1047
+ const desc = (dir) => {
1048
+ try {
1049
+ return readdirSync(dir).sort().reverse()
1050
+ } catch {
1051
+ return []
1052
+ }
1053
+ }
1054
+ for (const y of desc(root)) {
1055
+ for (const m of desc(join(root, y))) {
1056
+ for (const d of desc(join(root, y, m))) {
1057
+ const f = newestFile(join(root, y, m, d), (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"))
1058
+ if (f) return { ...f, parse: parseCodexTranscript }
1059
+ }
1060
+ }
1061
+ }
1062
+ return null
1063
+ }
1064
+ function parseCodexTranscript(path) {
1065
+ let model, usage, startedAt
1066
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1067
+ if (!line.trim()) continue
1068
+ let e
1069
+ try {
1070
+ e = JSON.parse(line)
1071
+ } catch {
1072
+ continue
1073
+ }
1074
+ if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1075
+ const p = e.payload ?? e
1076
+ if (!model && typeof p?.model === "string") model = p.model
1077
+ const u = p?.info?.total_token_usage ?? p?.total_token_usage
1078
+ if (u) usage = u
1079
+ }
1080
+ const out = { runtime: "codex" }
1081
+ const uuid = basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
1082
+ if (uuid) out.sessionId = uuid[0]
1083
+ if (model) out.model = model
1084
+ if (startedAt) out.startedAt = startedAt
1085
+ if (usage) {
1086
+ out.tokens = {
1087
+ input: usage.input_tokens ?? 0,
1088
+ output: usage.output_tokens ?? 0,
1089
+ cacheRead: usage.cached_input_tokens ?? 0,
1090
+ }
1091
+ }
1092
+ return out
1093
+ }
1094
+ function captureCommit() {
1095
+ try {
1096
+ const opts = { stdio: ["ignore", "pipe", "ignore"] }
1097
+ const sha = execSync("git rev-parse --short HEAD", opts).toString().trim()
1098
+ if (!sha) return undefined
1099
+ const dirty = execSync("git status --porcelain", opts).toString().trim()
1100
+ return dirty ? `${sha}+dirty` : sha
1101
+ } catch {
1102
+ return undefined
1103
+ }
1104
+ }
1105
+
1106
+ // ---------- the resolution fold (shared by `resolve` and `report --resolves`) -
1107
+ function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
1108
+ if (withdrawn && rejected) {
1109
+ die("--withdrawn and --rejected are different closes: withdrawn = YOU retracted the ask; rejected = a HUMAN declined it. Pick the one that's true.")
1110
+ }
1111
+ if ((withdrawn || rejected) && chosen) {
1112
+ die(`--chosen marks the need as met — it can't combine with ${withdrawn ? "--withdrawn" : "--rejected"} (use --note for the account)`)
1113
+ }
1114
+ const asks = foldById(readJsonl("asks.jsonl"))
1115
+ const ask = asks.find((a) => a.id === askId)
1116
+ const warnings = []
1117
+ if (!ask) {
1118
+ warnings.push(
1119
+ `ask "${askId}" isn't in the local asks.jsonl (raised on another machine, or pruned) — recording the close anyway; the server folds it onto the full record`,
1120
+ )
1121
+ } else if (chosen && ask.options?.length && !ask.options.includes(chosen)) {
1122
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1123
+ const near = ask.options.find((o) => norm(o) === norm(chosen))
1124
+ if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
1125
+ die(
1126
+ `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1127
+ ask.options.map((o) => ` · ${o}`).join("\n") +
1128
+ "\n (quote one verbatim, or use --note for a free-text account)",
1129
+ )
1130
+ }
1131
+ const resolution = {}
1132
+ if (chosen) resolution.chosen = chosen
1133
+ if (by) resolution.by = by
1134
+ if (note) resolution.note = note
1135
+ // `via` says where the unblock came from; out-of-band human answers are
1136
+ // "human". A rejection is inherently a human's call; otherwise set it only
1137
+ // when there's evidence of one (a chosen/by), never on withdrawn (nobody
1138
+ // acted — that's the point).
1139
+ if (rejected || (!withdrawn && (chosen || by))) resolution.via = "human"
1140
+ const line = {
1141
+ id: askId,
1142
+ status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
1143
+ }
1144
+ if (Object.keys(resolution).length) line.resolution = resolution
1145
+ return { line, warnings }
1146
+ }
1147
+
1148
+ /** The verbs' shared final step — same transport as `figs push`. */
1149
+ async function autoPush() {
1150
+ if (hasFlag("--no-push")) {
1151
+ console.log("figs: saved locally (--no-push) — `figs push` publishes it")
1152
+ return
1153
+ }
1154
+ if (!(await doPush())) {
1155
+ console.warn("figs: ! saved locally — the push failed (see above); fix and run `figs push`")
1156
+ process.exitCode = 1
1157
+ }
1158
+ }
1159
+
1160
+ async function reportCmd() {
1161
+ requireFigs()
1162
+ const result = flag("--result")
1163
+ if (!result) {
1164
+ die('report needs --result "<one-line outcome>" — e.g. figs report --result "88% matched · 31 flagged"')
1165
+ }
1166
+ const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
1167
+ const unit = flag("--unit")
1168
+ if (unit) run.unit = unit
1169
+ const period = flag("--period")
1170
+ if (period) run.period = period
1171
+ const status = flag("--status")
1172
+ if (status) run.status = status
1173
+ const attached = attachFiles(flagAll("--attach"))
1174
+ if (attached.length === 1) run.artifact = attached[0]
1175
+ else if (attached.length > 1) run.artifacts = attached
1176
+ const resolves = flag("--resolves")
1177
+ let resolution = null
1178
+ if (resolves) {
1179
+ run.resolves = resolves
1180
+ resolution = buildResolution(resolves, {
1181
+ chosen: flag("--chosen"),
1182
+ by: flag("--by"),
1183
+ note: flag("--note"),
1184
+ })
1185
+ }
1186
+ const session = captureSession()
1187
+ if (session) run.session = session
1188
+
1189
+ const issues = validateRun(run)
1190
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1191
+ appendJsonl("runs.jsonl", run)
1192
+ console.log(`figs: ✓ run recorded — ${summarize(run)}`)
1193
+ if (resolution) {
1194
+ for (const w of resolution.warnings) console.warn(`figs: ! ${w}`)
1195
+ appendJsonl("asks.jsonl", resolution.line)
1196
+ console.log(`figs: ✓ ask ${resolves} ${resolution.line.status}`)
1197
+ }
1198
+ await autoPush()
1199
+ }
1200
+
1201
+ async function askCmd() {
1202
+ requireFigs()
1203
+ let base = {}
1204
+ if (hasFlag("--stdin")) {
1205
+ let raw = ""
1206
+ try {
1207
+ raw = readFileSync(0, "utf8")
1208
+ } catch {
1209
+ /* no stdin */
1210
+ }
1211
+ if (!raw.trim()) die("--stdin given but nothing arrived on stdin — pipe a JSON object")
1212
+ try {
1213
+ base = JSON.parse(raw)
1214
+ } catch (e) {
1215
+ die(`--stdin: invalid JSON: ${e.message}`)
1216
+ }
1217
+ if (!base || typeof base !== "object" || Array.isArray(base)) {
1218
+ die("--stdin must be a single JSON object (one ask)")
1219
+ }
1220
+ }
1221
+ const type = positional() ?? base.type
1222
+ if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title "…"`)
1223
+ const ask = { ...base, id: flag("--id") ?? base.id ?? genId("ask"), ts: nowIso(), type }
1224
+ if (!ask.status) ask.status = "open"
1225
+ const title = flag("--title") ?? base.title
1226
+ if (!title) die('ask needs --title "<the ask, in one line>"')
1227
+ ask.title = title
1228
+ for (const [f, k] of [["--need", "need"], ["--found", "found"], ["--unit", "unit"], ["--to", "to"]]) {
1229
+ const v = flag(f)
1230
+ if (v) ask[k] = v
1231
+ }
1232
+ const options = flagAll("--option")
1233
+ if (options.length) ask.options = options
1234
+ for (const o of ask.options ?? []) {
1235
+ if (o.length > 80) {
1236
+ console.warn(
1237
+ `figs: ! option "${o.slice(0, 40)}…" is long — options should be short, stable, quotable (an answer cites one verbatim)`,
1238
+ )
1239
+ }
1240
+ }
1241
+ const details = flagAll("--detail").map((d) => {
1242
+ const i = d.indexOf("=")
1243
+ if (i < 1) die(`--detail must be "Label=Value", got "${d}"`)
1244
+ return { l: d.slice(0, i), v: d.slice(i + 1) }
1245
+ })
1246
+ if (details.length) ask.details = [...(base.details ?? []), ...details]
1247
+ const runRef = flag("--run")
1248
+ if (runRef === "last") {
1249
+ // Deliberately unsupported: concurrent sessions of the same agent report
1250
+ // runs in parallel — "the latest run" may be someone else's. Explicit only.
1251
+ die('--run takes the explicit run id (no "last" — another session may have reported since); `figs report` prints the id of what it wrote')
1252
+ }
1253
+ if (runRef) ask.run = runRef
1254
+ const attached = attachFiles(flagAll("--attach"))
1255
+ if (attached.length) {
1256
+ ask.refs = [...(base.refs ?? []), ...attached.map((n) => ({ label: n, artifact: n }))]
1257
+ }
1258
+ if (ask.type === "sign-off" && !ask.refs?.length) {
1259
+ console.warn(
1260
+ "figs: ! tip: a sign-off reviews best with attachments — the exact content to approve, plus a brief (what to do once approved + what it requires). Add --attach <file>",
1261
+ )
1262
+ }
1263
+ const session = captureSession()
1264
+ if (session) ask.session = session
1265
+
1266
+ const issues = validateAsk(ask)
1267
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1268
+ appendJsonl("asks.jsonl", ask)
1269
+ console.log(`figs: ✓ ask raised — ${summarize(ask)}`)
1270
+ if (!ask.to) {
1271
+ console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
1272
+ }
1273
+ await autoPush()
1274
+ console.log(
1275
+ "figs: answers arrive out-of-band for now (`figs inbox` is coming) — your human replies in the app thread or directly to you",
1276
+ )
1277
+ }
1278
+
1279
+ async function resolveCmd() {
1280
+ requireFigs()
1281
+ const askId = positional()
1282
+ if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
1283
+ const { line, warnings } = buildResolution(askId, {
1284
+ chosen: flag("--chosen"),
1285
+ by: flag("--by"),
1286
+ note: flag("--note"),
1287
+ withdrawn: hasFlag("--withdrawn"),
1288
+ rejected: hasFlag("--rejected"),
1289
+ })
1290
+ for (const w of warnings) console.warn(`figs: ! ${w}`)
1291
+ appendJsonl("asks.jsonl", line)
1292
+ console.log(`figs: ✓ ask ${askId} ${line.status} — ${JSON.stringify(line)}`)
1293
+ await autoPush()
1294
+ }
1295
+
1296
+ /** Validate the local .figs/ payload against the spec — no write, no push. */
736
1297
  async function doctor() {
737
1298
  // Local checks first (no token/network needed) — fail fast and offline.
738
1299
  if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
@@ -754,6 +1315,18 @@ async function doctor() {
754
1315
  process.exit(1)
755
1316
  }
756
1317
 
1318
+ // Same local checks the write-path runs — catches hand-authored mistakes
1319
+ // offline, before the server round-trip.
1320
+ const localIssues = validateOutbox(
1321
+ foldById(readJsonl("runs.jsonl")),
1322
+ foldById(readJsonl("asks.jsonl")),
1323
+ )
1324
+ if (localIssues.length) {
1325
+ console.log("figs: ✗ local validation issues:")
1326
+ for (const i of localIssues) console.log(` ${i}`)
1327
+ process.exit(1)
1328
+ }
1329
+
757
1330
  if (!getToken()) die("not logged in — run `figs login`")
758
1331
  const r = await api("POST", "/api/validate", {
759
1332
  workspaceId: config.workspaceId,
@@ -780,26 +1353,51 @@ async function doctor() {
780
1353
  process.exit(1)
781
1354
  }
782
1355
 
1356
+ /** `figs push` — the bare transport; exits non-zero on any failure. */
783
1357
  async function push() {
784
- const token = process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
785
- if (!token) die("not logged in — run `figs login` (or set FIGS_TOKEN)")
786
- await checkVersion({ hardFail: true })
787
- if (!existsSync(repoDir)) {
788
- die("no .figs/ here run `figs init` first")
1358
+ if (!(await doPush())) process.exit(1)
1359
+ }
1360
+
1361
+ /**
1362
+ * The one transport: spine /api/ingest, artifacts → /api/artifacts/upload.
1363
+ * `figs push` is a thin wrapper; the writing verbs call this after their local
1364
+ * append (auto-push IS push — one transport, many entry points). Runs the same
1365
+ * local checks as `figs doctor` first, so a malformed hand-written line never
1366
+ * reaches the server as a confusing 4xx. Prints its own errors and returns
1367
+ * false on failure — callers decide whether that's fatal.
1368
+ */
1369
+ async function doPush() {
1370
+ const fail = (msg) => {
1371
+ console.error(`figs: ✗ push: ${msg}`)
1372
+ return false
789
1373
  }
1374
+ const token = getToken()
1375
+ if (!token) return fail("not logged in — run `figs login` (or set FIGS_TOKEN)")
1376
+ await checkVersion({ hardFail: true })
1377
+ if (!existsSync(repoDir)) return fail("no .figs/ here — run `figs init` first")
790
1378
  const config = readJson(join(repoDir, "config.json"), {})
791
1379
  if (!config.workspaceId || !config.agentId) {
792
- die("config missing workspaceId/agentId — run `figs init`")
1380
+ return fail("config missing workspaceId/agentId — run `figs init`")
793
1381
  }
794
1382
  const endpoint =
795
1383
  process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
796
1384
 
797
1385
  const agentJson = readJson(join(repoDir, "agent.json"), null)
798
- if (!agentJson) die("missing .figs/agent.json")
1386
+ if (!agentJson) return fail("missing .figs/agent.json")
799
1387
  const agent = { ...agentJson, id: config.agentId }
800
1388
  const runs = foldById(readJsonl("runs.jsonl"))
801
1389
  const asks = foldById(readJsonl("asks.jsonl"))
802
1390
 
1391
+ // Local pre-flight — fail fast, offline, with teaching errors.
1392
+ const placeholders = findPlaceholders(agentJson)
1393
+ if (placeholders.length) {
1394
+ return fail(
1395
+ `agent.json still has template placeholders (${placeholders.map((p) => p.path).join(", ")}) — fill them in; \`figs doctor\` lists them`,
1396
+ )
1397
+ }
1398
+ const issues = validateOutbox(runs, asks)
1399
+ if (issues.length) return fail(`local validation failed:\n ${issues.join("\n ")}`)
1400
+
803
1401
  const base = endpoint.replace(/\/+$/, "")
804
1402
  let res
805
1403
  try {
@@ -809,17 +1407,17 @@ async function push() {
809
1407
  body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
810
1408
  })
811
1409
  } catch (e) {
812
- die(`push failed — cannot reach ${base} (${netReason(e)})`)
1410
+ return fail(`cannot reach ${base} (${netReason(e)})`)
813
1411
  }
814
1412
  const text = await res.text()
815
- if (!res.ok) die(`push failed (${res.status}): ${text}`)
1413
+ if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`)
816
1414
  console.log(
817
1415
  `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
818
1416
  )
819
1417
  // The wow-moment link — relay this to your human so they can see the agent.
820
1418
  console.log(` view at ${base}/w/${config.workspaceId}`)
821
1419
 
822
- await pushArtifacts(base, token, config, runs, asks)
1420
+ return pushArtifacts(base, token, config, runs, asks)
823
1421
  }
824
1422
 
825
1423
  /**
@@ -836,10 +1434,9 @@ async function pushArtifacts(base, token, config, runs, asks) {
836
1434
  const refNames = (asks ?? []).flatMap((a) =>
837
1435
  (a.refs ?? []).map((r) => r.artifact),
838
1436
  )
839
- const names = [
840
- ...new Set([...runs.map((r) => r.artifact), ...refNames].filter(Boolean)),
841
- ]
842
- if (names.length === 0) return
1437
+ const runNames = runs.flatMap((r) => [r.artifact, ...(r.artifacts ?? [])])
1438
+ const names = [...new Set([...runNames, ...refNames].filter(Boolean))]
1439
+ if (names.length === 0) return true
843
1440
 
844
1441
  let uploaded = 0
845
1442
  let unchanged = 0
@@ -889,9 +1486,10 @@ async function pushArtifacts(base, token, config, runs, asks) {
889
1486
  (missing ? `, ${missing} missing` : "") +
890
1487
  (failed ? `, ${failed} failed` : ""),
891
1488
  )
892
- // The spine already landed; signal a non-zero exit so an agent's run loop can
893
- // catch that an artifact the manager needs to read did not publish.
894
- if (failed) process.exit(1)
1489
+ // The spine already landed; a false return lets the caller exit non-zero so
1490
+ // an agent's run loop can catch that an artifact the manager needs to read
1491
+ // did not publish.
1492
+ return failed === 0
895
1493
  }
896
1494
 
897
1495
  function readJsonl(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.1.16",
3
+ "version": "0.2.1",
4
4
  "description": "Figs CLI — publish your AI agent's state to Figs (figs.so). Run by the agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,9 @@
12
12
  "SPEC.md",
13
13
  "LICENSE"
14
14
  ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
15
18
  "engines": {
16
19
  "node": ">=18"
17
20
  },