@figs-so/cli 0.1.15 → 0.2.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 +5 -7
  2. package/SPEC.md +93 -16
  3. package/figs.mjs +620 -28
  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
 
@@ -34,9 +33,8 @@ better. Figs is the human-facing layer on top: the one place a whole team can se
34
33
  Run these from your agent's repo (or have the agent run them):
35
34
 
36
35
  ```bash
37
- npx @figs-so/cli@latest login # opens your browser to approve (the agent never sees a token)
38
- npx @figs-so/cli@latest workspaces # find your workspace slug
39
- npx @figs-so/cli@latest init --workspace <slug> # scaffolds .figs/ (identity + a charter template)
36
+ npx @figs-so/cli@latest login # opens your browser sign up & approve (the agent never sees a token)
37
+ npx @figs-so/cli@latest init # scaffolds .figs/ — uses your only workspace (--workspace <slug> to pick)
40
38
  # fill in .figs/agent.json — its name, mandate, what it owns (figs doctor flags any placeholders)
41
39
  npx @figs-so/cli@latest push # publish → it appears in your org chart
42
40
  ```
@@ -72,7 +70,7 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
72
70
  |---|---|
73
71
  | `figs login` / `logout` | device-flow browser approve / remove local token |
74
72
  | `figs workspaces [--json]` | list your workspaces (create one in the web app) |
75
- | `figs init --workspace <slug>` | generate identity + write `.figs/` |
73
+ | `figs init [--workspace <slug>]` | generate identity + write `.figs/` (omit the flag: uses your only workspace, else lists them) |
76
74
  | `figs doctor` | validate `.figs/` against the contract before pushing |
77
75
  | `figs push` | one-way publish of `.figs/` |
78
76
  | `figs status [--json]` | login / workspace / agent state |
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,55 @@ 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 agent un-asked — no longer needed, nobody acted). |
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` → *(claimed → answered — human, server-side, reserved)* →
158
+ `resolved` | `withdrawn` *(agent, in `asks.jsonl`)*. In v1 only the agent-owned transitions exist;
159
+ resolution happens in the agent's own workflow.
160
+
161
+ ### 6.2 `Resolution` — how an ask closed
162
+
163
+ | Field | Type | Meaning |
164
+ |---|---|---|
165
+ | `note` | string | The agent's one-line account of the close. |
166
+ | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
167
+ | `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. |
168
+ | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
169
+ | `answer` | string | **Reserved** — the Figs answer-event id the agent acted on, once answer-down ships. |
170
+
171
+ All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
172
+ normalize it to the object form.
120
173
 
121
174
  ## 7. `artifacts/` — rendered files
122
175
 
@@ -147,9 +200,22 @@ unchanged file is skipped on publish).
147
200
  The server upserts the agent by `id` and runs/asks by `id`; it never deletes. An agent **self-registers**
148
201
  on first push — there is no "create agent" step.
149
202
 
203
+ **A push never re-homes an agent.** The workspace an agent is registered to is authoritative
204
+ server-side: a payload whose `workspaceId` differs from it is rejected with HTTP `409` and body
205
+ `{ "error", "code": "agent_moved", "workspaceId"? }`. The `error` text states the fix; `workspaceId`
206
+ (the agent's current home) is included only when the pushing token has access to that workspace.
207
+ Moving an agent between workspaces is a reader-side management act, outside this contract — the agent
208
+ recovers by setting `config.json#workspaceId` to the workspace named in the error and pushing again
209
+ (each runner self-heals on its own next push; nothing propagates through the repo).
210
+
211
+ Because every push is authenticated, the receiver knows which account performed it and **may stamp each
212
+ newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
213
+ *credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
214
+ account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
215
+
150
216
  ## 9. Validation & versioning
151
217
 
152
- - A `.figs/` folder can be validated against this contract before publishing (`figs doctor` →
218
+ - A `.figs/` folder can be validated against this spec before publishing (`figs doctor` →
153
219
  `POST {endpoint}/api/validate`). The shapes are the source of truth; readers reject malformed payloads.
154
220
  - **`figs-spec` is integer-versioned.** v1 is the current version. **Additive/optional** fields keep the
155
221
  version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
@@ -161,8 +227,14 @@ on first push — there is no "create agent" step.
161
227
 
162
228
  Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
163
229
 
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.
230
+ - **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
231
+ through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
232
+ so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
233
+ `answer { by, ts, chosen?, text? }` where
234
+ `chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
235
+ for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
236
+ gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
237
+ and `directive` (human-initiated) are named-reserved.
166
238
  - **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
167
239
  v1 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
168
240
  - **Per-record visibility / scoping.** v1 publishes to a workspace where all members can read everything.
@@ -178,7 +250,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
178
250
  // .figs/agent.json (no `id` here — `figs init` puts it in config.json; the CLI attaches it on push)
179
251
  {
180
252
  "name": "Reconciliation",
181
- "type": "agent",
182
253
  "role": "Reconciliation Officer",
183
254
  "status": "in_dev",
184
255
  "avatar": { "seed": "Reconciliation" },
@@ -205,16 +276,22 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
205
276
 
206
277
  ```jsonc
207
278
  // .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" }
279
+ { "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",
280
+ "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
281
+ "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
282
+ "tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
209
283
  ```
210
284
 
211
285
  ```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",
286
+ // .figs/asks.jsonl (one object per line; records fold by id — the close is an append)
287
+ { "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "needs-decision", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
214
288
  "title": "No bridge rule for prefixed invoice numbers",
215
289
  "found": "~180 rows can't be matched safely; guessing risks false matches.",
216
290
  "need": "Confirm the bridge rule for prefixed invoice numbers.",
217
291
  "options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
218
292
  "details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
219
293
  "refs": [ { "label": "Acme report", "artifact": "acme-2025-11.html" } ] }
294
+ { "id": "acme-bridge", "status": "resolved",
295
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)",
296
+ "note": "confirmed in terminal — applied from 2025-11 onward" } }
220
297
  ```
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
@@ -91,17 +104,74 @@ const COMMANDS = {
91
104
  desc: "scaffold .figs/ here (identity + charter/contract/guide templates)",
92
105
  more: [
93
106
  "--workspace takes a slug (resolved to its UUID) or a raw UUID — get it from `figs workspaces`.",
94
- "Omit --workspace and (logged in) it lists your workspaces so you can re-run with the right one.",
107
+ "Omit --workspace and (logged in) it uses your only workspace, or lists them so you can re-run with one.",
95
108
  "Never clobbers: an existing agent.json / CONTRACT.md / GUIDE.md / outbox is left exactly as-is.",
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 <id|last> links the run this came out of (last = newest local run).",
146
+ "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
147
+ ],
148
+ eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run last',
149
+ },
150
+ resolve: {
151
+ args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn]",
152
+ flags: ["--chosen", "--by", "--note", "--withdrawn", "--no-push"],
153
+ desc: "close an ask — appends the resolution fold line and pushes",
154
+ more: [
155
+ "--chosen must quote one of the ask's options[] verbatim (checked).",
156
+ "--withdrawn = the ask is no longer needed, nobody acted (don't mark resolved).",
157
+ "Use `figs report --resolves <ask-id>` instead when a run did the work.",
158
+ ],
159
+ eg: 'figs resolve acme-bridge --chosen "Strip the alpha prefix" --by "Sarah (accounting)"',
160
+ },
161
+ doctor: {
162
+ args: "",
163
+ flags: ["--json"],
164
+ desc: "validate .figs/ against the spec without pushing — the conformance check for hand-authored or non-CLI setups",
165
+ },
100
166
  push: {
101
167
  args: "",
102
168
  flags: [],
103
169
  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."],
170
+ more: [
171
+ "Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected.",
172
+ "The writing verbs (report/ask/resolve) call this automatically — you only need it",
173
+ "after hand-editing files, after --no-push, or to retry a failed auto-push.",
174
+ ],
105
175
  eg: "figs push",
106
176
  },
107
177
  version: { args: "", flags: [], desc: "print the CLI version and check for updates" },
@@ -137,6 +207,119 @@ function flag(name) {
137
207
  }
138
208
  return undefined
139
209
  }
210
+ /** All values of a repeatable flag, in order. */
211
+ function flagAll(name) {
212
+ const args = process.argv.slice(2)
213
+ const out = []
214
+ for (let i = 0; i < args.length; i++) {
215
+ if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1])
216
+ else if (args[i].startsWith(`${name}=`)) out.push(args[i].slice(name.length + 1))
217
+ }
218
+ return out
219
+ }
220
+ /** Boolean flag — present or not (takes no value). */
221
+ function hasFlag(name) {
222
+ return process.argv.slice(2).includes(name)
223
+ }
224
+ /** First non-flag token after the command (e.g. the ask type, the ask id). */
225
+ function positional() {
226
+ const args = process.argv.slice(3)
227
+ for (let i = 0; i < args.length; i++) {
228
+ if (args[i].startsWith("-")) {
229
+ // skip `--name value` pairs (but not `--name=value` or booleans)
230
+ if (!args[i].includes("=") && !BOOLEAN_FLAGS.has(args[i])) i++
231
+ continue
232
+ }
233
+ return args[i]
234
+ }
235
+ return undefined
236
+ }
237
+ const BOOLEAN_FLAGS = new Set(["--no-push", "--stdin", "--withdrawn", "--json", "-h", "--help"])
238
+
239
+ /** ISO-8601 with the machine's real UTC offset (never the agent's guess). */
240
+ function nowIso() {
241
+ const d = new Date()
242
+ const pad = (n, w = 2) => String(Math.abs(n)).padStart(w, "0")
243
+ const off = -d.getTimezoneOffset()
244
+ const sign = off >= 0 ? "+" : "-"
245
+ return (
246
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
247
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
248
+ (off === 0 ? "Z" : `${sign}${pad(Math.trunc(off / 60))}:${pad(off % 60)}`)
249
+ )
250
+ }
251
+ /** Generated unique id — stable/content-derived ids only via explicit --id. */
252
+ function genId(prefix) {
253
+ return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
254
+ }
255
+
256
+ // ---------- local validation (the spec's common mistakes, caught on write) ----
257
+ // The server's schema stays the source of truth; these catch what hand-authors
258
+ // and flag typos get wrong, with errors that teach the fix.
259
+ const ASK_TYPES = ["blocked", "needs-decision", "sign-off", "fyi"]
260
+ const RUN_STATUSES = ["ok", "warn", "fail"]
261
+ const ASK_STATUSES = ["open", "resolved", "withdrawn"]
262
+ const TO_VALUES = ["manager", "builder"]
263
+ const ARTIFACT_EXTS = new Set([
264
+ ".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
265
+ ])
266
+ const ARTIFACT_MAX = 3 * 1024 * 1024
267
+
268
+ /** "signoff" → `did you mean "sign-off"?` — normalized nearest match. */
269
+ function didYouMean(value, allowed) {
270
+ const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, "")
271
+ const hit = allowed.find((a) => norm(a) === norm(value))
272
+ return hit ? ` — did you mean "${hit}"?` : ` (valid: ${allowed.join(" · ")})`
273
+ }
274
+ function checkEnum(issues, obj, field, allowed, label) {
275
+ const v = obj[field]
276
+ if (v !== undefined && !allowed.includes(v)) {
277
+ issues.push(`${label}.${field}: "${v}" isn't valid${didYouMean(v, allowed)}`)
278
+ }
279
+ }
280
+ /** Validate one folded run record → array of issue strings. */
281
+ function validateRun(r) {
282
+ const issues = []
283
+ const label = `run "${r.id ?? "?"}"`
284
+ if (!r.id || typeof r.id !== "string") issues.push(`${label}: missing required "id"`)
285
+ if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
286
+ checkEnum(issues, r, "status", RUN_STATUSES, label)
287
+ if (r.artifact !== undefined && typeof r.artifact !== "string") {
288
+ issues.push(`${label}.artifact: must be a single file name (use "artifacts" for a list)`)
289
+ }
290
+ if (r.artifacts !== undefined && (!Array.isArray(r.artifacts) || r.artifacts.some((a) => typeof a !== "string"))) {
291
+ issues.push(`${label}.artifacts: must be an array of file names`)
292
+ }
293
+ return issues
294
+ }
295
+ /** Validate one folded ask record → array of issue strings. */
296
+ function validateAsk(a) {
297
+ const issues = []
298
+ const label = `ask "${a.id ?? "?"}"`
299
+ if (!a.id || typeof a.id !== "string") issues.push(`${label}: missing required "id"`)
300
+ if (!a.type) {
301
+ issues.push(
302
+ `${label}: missing required "type" — was it raised on another machine? (closing it from here needs the full record; cross-machine fetch is coming)`,
303
+ )
304
+ } else checkEnum(issues, a, "type", ASK_TYPES, label)
305
+ if (!a.title) issues.push(`${label}: missing required "title"`)
306
+ checkEnum(issues, a, "status", ASK_STATUSES, label)
307
+ checkEnum(issues, a, "to", TO_VALUES, label)
308
+ if (a.options !== undefined && (!Array.isArray(a.options) || a.options.some((o) => typeof o !== "string"))) {
309
+ issues.push(`${label}.options: must be an array of short, quotable strings`)
310
+ }
311
+ if (a.details !== undefined && (!Array.isArray(a.details) || a.details.some((d) => !d || typeof d.l !== "string"))) {
312
+ issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
313
+ }
314
+ if (a.refs !== undefined && (!Array.isArray(a.refs) || a.refs.some((r) => !r || typeof r.label !== "string"))) {
315
+ issues.push(`${label}.refs: must be [{ "label": "…", "artifact": "<file in artifacts/>" }]`)
316
+ }
317
+ return issues
318
+ }
319
+ /** Validate the whole local outbox (folded) — returns all issues. */
320
+ function validateOutbox(runs, asks) {
321
+ return [...runs.flatMap(validateRun), ...asks.flatMap(validateAsk)]
322
+ }
140
323
  function getToken() {
141
324
  return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
142
325
  }
@@ -287,6 +470,9 @@ else if (COMMANDS[cmd]) {
287
470
  else if (cmd === "status") await status()
288
471
  else if (cmd === "workspaces") await workspaces()
289
472
  else if (cmd === "init") await init()
473
+ else if (cmd === "report") await reportCmd()
474
+ else if (cmd === "ask") await askCmd()
475
+ else if (cmd === "resolve") await resolveCmd()
290
476
  else if (cmd === "doctor") await doctor()
291
477
  else if (cmd === "push") await push()
292
478
  } else {
@@ -361,6 +547,7 @@ async function login(token) {
361
547
  if (status === "approved" && r.data.token) {
362
548
  saveToken(r.data.token)
363
549
  console.log("figs: ✓ authorized — token saved to ~/.figs/credentials.json")
550
+ console.log("figs: next — run `figs init` to scaffold .figs/ here")
364
551
  return
365
552
  }
366
553
  if (status === "denied") die("authorization denied")
@@ -614,9 +801,10 @@ function findPlaceholders(obj) {
614
801
  * Resolve which workspace this `.figs/` belongs to. `--workspace` is optional:
615
802
  * - given a UUID → use it as-is (no network).
616
803
  * - given a slug → resolve to its UUID via the API (needs auth).
617
- * - omitted → reuse the one already in config.json (idempotent re-init),
618
- * else list the user's workspaces and have them re-run with one
619
- * (the agent drives the choice we never silently pick).
804
+ * - omitted → reuse the one already in config.json (idempotent re-init);
805
+ * else use the user's only workspace (logged, so it's visible);
806
+ * else list them and have the agent re-run with one (when
807
+ * there's a real choice, the agent drives it — we never guess).
620
808
  * Returns the workspace UUID, or exits with an actionable message.
621
809
  */
622
810
  async function resolveWorkspaceId(workspaceArg, endpoint) {
@@ -639,7 +827,7 @@ async function resolveWorkspaceId(workspaceArg, endpoint) {
639
827
  const existing = readJson(join(repoDir, "config.json"), null)
640
828
  if (existing?.workspaceId) return existing.workspaceId
641
829
 
642
- // First-time init with no workspace named — list them so the agent can re-run.
830
+ // First-time init with no workspace named — use the only one, else list them.
643
831
  if (!getToken()) {
644
832
  die("which workspace? run `figs login` first so I can list them, then `figs init --workspace <slug>` (or pass a workspace UUID directly)")
645
833
  }
@@ -649,6 +837,10 @@ async function resolveWorkspaceId(workspaceArg, endpoint) {
649
837
  if (list.length === 0) {
650
838
  die(`no workspaces yet — create one at ${endpoint}, then re-run \`figs init --workspace <slug>\``)
651
839
  }
840
+ if (list.length === 1) {
841
+ console.log(`figs: using workspace ${list[0].slug} (${list[0].name})`)
842
+ return list[0].id
843
+ }
652
844
  console.log("figs: which workspace? re-run init with one of these:")
653
845
  for (const w of list) console.log(` figs init --workspace ${w.slug} (${w.name})`)
654
846
  process.exit(1)
@@ -726,7 +918,370 @@ async function init() {
726
918
  console.log(` Full guide: ${endpoint}/llms.txt`)
727
919
  }
728
920
 
729
- /** Validate the local .figs/ payload against the contract — no write. */
921
+ // ====================== the writing verbs ===================================
922
+ // report / ask / resolve — sugar over the same files (hand-writing stays
923
+ // first-class). The agent supplies content; the CLI stamps id + real-clock ts,
924
+ // captures the session trace, validates with teaching errors, copies
925
+ // attachments, then invokes the same push as `figs push`.
926
+
927
+ function requireFigs() {
928
+ if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
929
+ const config = readJson(join(repoDir, "config.json"), {})
930
+ if (!config.workspaceId || !config.agentId) {
931
+ die("config missing workspaceId/agentId — run `figs init`")
932
+ }
933
+ }
934
+ function appendJsonl(name, obj) {
935
+ appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
936
+ }
937
+ /** Print a record without its (noisy) session block. */
938
+ function summarize(obj) {
939
+ const { session, ...rest } = obj
940
+ return JSON.stringify(rest) + (session ? " (+ session trace)" : "")
941
+ }
942
+
943
+ /** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
944
+ function attachFiles(paths) {
945
+ const names = []
946
+ for (const p of paths) {
947
+ if (!existsSync(p)) die(`--attach: no such file: ${p}`)
948
+ const ext = extname(p).toLowerCase()
949
+ if (!ARTIFACT_EXTS.has(ext)) {
950
+ die(`--attach: unsupported type "${ext || p}" — supported: ${[...ARTIFACT_EXTS].join(" ")}`)
951
+ }
952
+ const bytes = readFileSync(p)
953
+ if (bytes.length > ARTIFACT_MAX) {
954
+ die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the 3 MB cap; compress or split it`)
955
+ }
956
+ const name = basename(p)
957
+ const dest = join(repoDir, "artifacts", name)
958
+ if (existsSync(dest) && !readFileSync(dest).equals(bytes)) {
959
+ die(
960
+ `--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`,
961
+ )
962
+ }
963
+ mkdirSync(join(repoDir, "artifacts"), { recursive: true })
964
+ writeFileSync(dest, bytes)
965
+ names.push(name)
966
+ }
967
+ return names
968
+ }
969
+
970
+ // ---------- session auto-capture --------------------------------------------
971
+ // The trace comes from the runtime's own records, never from the model's
972
+ // memory. Best-effort by design: any failure → the optional block is omitted;
973
+ // a report is never blocked on trace capture.
974
+ function captureSession() {
975
+ try {
976
+ const candidates = [findClaudeTranscript(), findCodexTranscript()].filter(Boolean)
977
+ if (!candidates.length) return undefined
978
+ // The transcript being written *now* is the newest one, whichever runtime
979
+ // owns it. Anything older than a day is a leftover, not this session.
980
+ const best = candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
981
+ if (Date.now() - best.mtimeMs > 86400000) return undefined
982
+ const session = best.parse(best.path)
983
+ if (!session) return undefined
984
+ const commit = captureCommit()
985
+ if (commit) session.commit = commit
986
+ return session
987
+ } catch {
988
+ return undefined
989
+ }
990
+ }
991
+ function newestFile(dir, filter) {
992
+ if (!existsSync(dir)) return null
993
+ let best = null
994
+ for (const f of readdirSync(dir)) {
995
+ if (!filter(f)) continue
996
+ const p = join(dir, f)
997
+ let st
998
+ try {
999
+ st = statSync(p)
1000
+ } catch {
1001
+ continue
1002
+ }
1003
+ if (!st.isFile()) continue
1004
+ if (!best || st.mtimeMs > best.mtimeMs) best = { path: p, name: f, mtimeMs: st.mtimeMs }
1005
+ }
1006
+ return best
1007
+ }
1008
+ function findClaudeTranscript() {
1009
+ const dir = join(homedir(), ".claude", "projects", process.cwd().replace(/[\\/:]/g, "-"))
1010
+ const f = newestFile(dir, (n) => n.endsWith(".jsonl"))
1011
+ return f ? { ...f, parse: parseClaudeTranscript } : null
1012
+ }
1013
+ function parseClaudeTranscript(path) {
1014
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1015
+ let model, startedAt
1016
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1017
+ if (!line.trim()) continue
1018
+ let e
1019
+ try {
1020
+ e = JSON.parse(line)
1021
+ } catch {
1022
+ continue
1023
+ }
1024
+ if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1025
+ const m = e.message
1026
+ if (!m?.usage || m.model === "<synthetic>") continue
1027
+ if (typeof m.model === "string") model = m.model
1028
+ tokens.input += m.usage.input_tokens ?? 0
1029
+ tokens.output += m.usage.output_tokens ?? 0
1030
+ tokens.cacheRead += m.usage.cache_read_input_tokens ?? 0
1031
+ tokens.cacheWrite += m.usage.cache_creation_input_tokens ?? 0
1032
+ }
1033
+ const out = { runtime: "claude-code", sessionId: basename(path, ".jsonl") }
1034
+ if (model) out.model = model
1035
+ if (startedAt) out.startedAt = startedAt
1036
+ if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) out.tokens = tokens
1037
+ return out
1038
+ }
1039
+ function findCodexTranscript() {
1040
+ const root = join(homedir(), ".codex", "sessions")
1041
+ if (!existsSync(root)) return null
1042
+ const desc = (dir) => {
1043
+ try {
1044
+ return readdirSync(dir).sort().reverse()
1045
+ } catch {
1046
+ return []
1047
+ }
1048
+ }
1049
+ for (const y of desc(root)) {
1050
+ for (const m of desc(join(root, y))) {
1051
+ for (const d of desc(join(root, y, m))) {
1052
+ const f = newestFile(join(root, y, m, d), (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"))
1053
+ if (f) return { ...f, parse: parseCodexTranscript }
1054
+ }
1055
+ }
1056
+ }
1057
+ return null
1058
+ }
1059
+ function parseCodexTranscript(path) {
1060
+ let model, usage, startedAt
1061
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1062
+ if (!line.trim()) continue
1063
+ let e
1064
+ try {
1065
+ e = JSON.parse(line)
1066
+ } catch {
1067
+ continue
1068
+ }
1069
+ if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1070
+ const p = e.payload ?? e
1071
+ if (!model && typeof p?.model === "string") model = p.model
1072
+ const u = p?.info?.total_token_usage ?? p?.total_token_usage
1073
+ if (u) usage = u
1074
+ }
1075
+ const out = { runtime: "codex" }
1076
+ 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)
1077
+ if (uuid) out.sessionId = uuid[0]
1078
+ if (model) out.model = model
1079
+ if (startedAt) out.startedAt = startedAt
1080
+ if (usage) {
1081
+ out.tokens = {
1082
+ input: usage.input_tokens ?? 0,
1083
+ output: usage.output_tokens ?? 0,
1084
+ cacheRead: usage.cached_input_tokens ?? 0,
1085
+ }
1086
+ }
1087
+ return out
1088
+ }
1089
+ function captureCommit() {
1090
+ try {
1091
+ const opts = { stdio: ["ignore", "pipe", "ignore"] }
1092
+ const sha = execSync("git rev-parse --short HEAD", opts).toString().trim()
1093
+ if (!sha) return undefined
1094
+ const dirty = execSync("git status --porcelain", opts).toString().trim()
1095
+ return dirty ? `${sha}+dirty` : sha
1096
+ } catch {
1097
+ return undefined
1098
+ }
1099
+ }
1100
+
1101
+ // ---------- the resolution fold (shared by `resolve` and `report --resolves`) -
1102
+ function buildResolution(askId, { chosen, by, note, withdrawn }) {
1103
+ if (withdrawn && chosen) {
1104
+ die("--withdrawn and --chosen are mutually exclusive (withdrawn = the ask is no longer needed, nobody acted)")
1105
+ }
1106
+ const asks = foldById(readJsonl("asks.jsonl"))
1107
+ const ask = asks.find((a) => a.id === askId)
1108
+ const warnings = []
1109
+ if (!ask) {
1110
+ warnings.push(
1111
+ `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`,
1112
+ )
1113
+ } else if (chosen && ask.options?.length && !ask.options.includes(chosen)) {
1114
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1115
+ const near = ask.options.find((o) => norm(o) === norm(chosen))
1116
+ if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
1117
+ die(
1118
+ `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1119
+ ask.options.map((o) => ` · ${o}`).join("\n") +
1120
+ "\n (quote one verbatim, or use --note for a free-text account)",
1121
+ )
1122
+ }
1123
+ const resolution = {}
1124
+ if (chosen) resolution.chosen = chosen
1125
+ if (by) resolution.by = by
1126
+ if (note) resolution.note = note
1127
+ // `via` says where the unblock came from; out-of-band human answers are
1128
+ // "human". Set it only when there's evidence of one (a chosen/by), never on
1129
+ // withdrawn (nobody acted — that's the point).
1130
+ if (!withdrawn && (chosen || by)) resolution.via = "human"
1131
+ const line = { id: askId, status: withdrawn ? "withdrawn" : "resolved" }
1132
+ if (Object.keys(resolution).length) line.resolution = resolution
1133
+ return { line, warnings }
1134
+ }
1135
+
1136
+ /** The verbs' shared final step — same transport as `figs push`. */
1137
+ async function autoPush() {
1138
+ if (hasFlag("--no-push")) {
1139
+ console.log("figs: saved locally (--no-push) — `figs push` publishes it")
1140
+ return
1141
+ }
1142
+ if (!(await doPush())) {
1143
+ console.warn("figs: ! saved locally — the push failed (see above); fix and run `figs push`")
1144
+ process.exitCode = 1
1145
+ }
1146
+ }
1147
+
1148
+ async function reportCmd() {
1149
+ requireFigs()
1150
+ const result = flag("--result")
1151
+ if (!result) {
1152
+ die('report needs --result "<one-line outcome>" — e.g. figs report --result "88% matched · 31 flagged"')
1153
+ }
1154
+ const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
1155
+ const unit = flag("--unit")
1156
+ if (unit) run.unit = unit
1157
+ const period = flag("--period")
1158
+ if (period) run.period = period
1159
+ const status = flag("--status")
1160
+ if (status) run.status = status
1161
+ const attached = attachFiles(flagAll("--attach"))
1162
+ if (attached.length === 1) run.artifact = attached[0]
1163
+ else if (attached.length > 1) run.artifacts = attached
1164
+ const resolves = flag("--resolves")
1165
+ let resolution = null
1166
+ if (resolves) {
1167
+ run.resolves = resolves
1168
+ resolution = buildResolution(resolves, {
1169
+ chosen: flag("--chosen"),
1170
+ by: flag("--by"),
1171
+ note: flag("--note"),
1172
+ })
1173
+ }
1174
+ const session = captureSession()
1175
+ if (session) run.session = session
1176
+
1177
+ const issues = validateRun(run)
1178
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1179
+ appendJsonl("runs.jsonl", run)
1180
+ console.log(`figs: ✓ run recorded — ${summarize(run)}`)
1181
+ if (resolution) {
1182
+ for (const w of resolution.warnings) console.warn(`figs: ! ${w}`)
1183
+ appendJsonl("asks.jsonl", resolution.line)
1184
+ console.log(`figs: ✓ ask ${resolves} ${resolution.line.status}`)
1185
+ }
1186
+ await autoPush()
1187
+ }
1188
+
1189
+ async function askCmd() {
1190
+ requireFigs()
1191
+ let base = {}
1192
+ if (hasFlag("--stdin")) {
1193
+ let raw = ""
1194
+ try {
1195
+ raw = readFileSync(0, "utf8")
1196
+ } catch {
1197
+ /* no stdin */
1198
+ }
1199
+ if (!raw.trim()) die("--stdin given but nothing arrived on stdin — pipe a JSON object")
1200
+ try {
1201
+ base = JSON.parse(raw)
1202
+ } catch (e) {
1203
+ die(`--stdin: invalid JSON: ${e.message}`)
1204
+ }
1205
+ if (!base || typeof base !== "object" || Array.isArray(base)) {
1206
+ die("--stdin must be a single JSON object (one ask)")
1207
+ }
1208
+ }
1209
+ const type = positional() ?? base.type
1210
+ if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title "…"`)
1211
+ const ask = { ...base, id: flag("--id") ?? base.id ?? genId("ask"), ts: nowIso(), type }
1212
+ if (!ask.status) ask.status = "open"
1213
+ const title = flag("--title") ?? base.title
1214
+ if (!title) die('ask needs --title "<the ask, in one line>"')
1215
+ ask.title = title
1216
+ for (const [f, k] of [["--need", "need"], ["--found", "found"], ["--unit", "unit"], ["--to", "to"]]) {
1217
+ const v = flag(f)
1218
+ if (v) ask[k] = v
1219
+ }
1220
+ const options = flagAll("--option")
1221
+ if (options.length) ask.options = options
1222
+ for (const o of ask.options ?? []) {
1223
+ if (o.length > 80) {
1224
+ console.warn(
1225
+ `figs: ! option "${o.slice(0, 40)}…" is long — options should be short, stable, quotable (an answer cites one verbatim)`,
1226
+ )
1227
+ }
1228
+ }
1229
+ const details = flagAll("--detail").map((d) => {
1230
+ const i = d.indexOf("=")
1231
+ if (i < 1) die(`--detail must be "Label=Value", got "${d}"`)
1232
+ return { l: d.slice(0, i), v: d.slice(i + 1) }
1233
+ })
1234
+ if (details.length) ask.details = [...(base.details ?? []), ...details]
1235
+ const runRef = flag("--run")
1236
+ if (runRef === "last") {
1237
+ const runs = foldById(readJsonl("runs.jsonl"))
1238
+ if (!runs.length) {
1239
+ die("--run last: no runs in the local runs.jsonl — `figs report` one first, or pass an explicit id")
1240
+ }
1241
+ ask.run = runs.reduce((a, b) => ((a.ts ?? "") > (b.ts ?? "") ? a : b)).id
1242
+ } else if (runRef) ask.run = runRef
1243
+ const attached = attachFiles(flagAll("--attach"))
1244
+ if (attached.length) {
1245
+ ask.refs = [...(base.refs ?? []), ...attached.map((n) => ({ label: n, artifact: n }))]
1246
+ }
1247
+ if (ask.type === "sign-off" && !ask.refs?.length) {
1248
+ console.warn(
1249
+ "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>",
1250
+ )
1251
+ }
1252
+ const session = captureSession()
1253
+ if (session) ask.session = session
1254
+
1255
+ const issues = validateAsk(ask)
1256
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1257
+ appendJsonl("asks.jsonl", ask)
1258
+ console.log(`figs: ✓ ask raised — ${summarize(ask)}`)
1259
+ if (!ask.to) {
1260
+ console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
1261
+ }
1262
+ await autoPush()
1263
+ console.log(
1264
+ "figs: answers arrive out-of-band for now (`figs inbox` is coming) — your human replies in the app thread or directly to you",
1265
+ )
1266
+ }
1267
+
1268
+ async function resolveCmd() {
1269
+ requireFigs()
1270
+ const askId = positional()
1271
+ if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
1272
+ const { line, warnings } = buildResolution(askId, {
1273
+ chosen: flag("--chosen"),
1274
+ by: flag("--by"),
1275
+ note: flag("--note"),
1276
+ withdrawn: hasFlag("--withdrawn"),
1277
+ })
1278
+ for (const w of warnings) console.warn(`figs: ! ${w}`)
1279
+ appendJsonl("asks.jsonl", line)
1280
+ console.log(`figs: ✓ ask ${askId} ${line.status} — ${JSON.stringify(line)}`)
1281
+ await autoPush()
1282
+ }
1283
+
1284
+ /** Validate the local .figs/ payload against the spec — no write, no push. */
730
1285
  async function doctor() {
731
1286
  // Local checks first (no token/network needed) — fail fast and offline.
732
1287
  if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
@@ -748,6 +1303,18 @@ async function doctor() {
748
1303
  process.exit(1)
749
1304
  }
750
1305
 
1306
+ // Same local checks the write-path runs — catches hand-authored mistakes
1307
+ // offline, before the server round-trip.
1308
+ const localIssues = validateOutbox(
1309
+ foldById(readJsonl("runs.jsonl")),
1310
+ foldById(readJsonl("asks.jsonl")),
1311
+ )
1312
+ if (localIssues.length) {
1313
+ console.log("figs: ✗ local validation issues:")
1314
+ for (const i of localIssues) console.log(` ${i}`)
1315
+ process.exit(1)
1316
+ }
1317
+
751
1318
  if (!getToken()) die("not logged in — run `figs login`")
752
1319
  const r = await api("POST", "/api/validate", {
753
1320
  workspaceId: config.workspaceId,
@@ -774,26 +1341,51 @@ async function doctor() {
774
1341
  process.exit(1)
775
1342
  }
776
1343
 
1344
+ /** `figs push` — the bare transport; exits non-zero on any failure. */
777
1345
  async function push() {
778
- const token = process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
779
- if (!token) die("not logged in — run `figs login` (or set FIGS_TOKEN)")
780
- await checkVersion({ hardFail: true })
781
- if (!existsSync(repoDir)) {
782
- die("no .figs/ here run `figs init` first")
1346
+ if (!(await doPush())) process.exit(1)
1347
+ }
1348
+
1349
+ /**
1350
+ * The one transport: spine /api/ingest, artifacts → /api/artifacts/upload.
1351
+ * `figs push` is a thin wrapper; the writing verbs call this after their local
1352
+ * append (auto-push IS push — one transport, many entry points). Runs the same
1353
+ * local checks as `figs doctor` first, so a malformed hand-written line never
1354
+ * reaches the server as a confusing 4xx. Prints its own errors and returns
1355
+ * false on failure — callers decide whether that's fatal.
1356
+ */
1357
+ async function doPush() {
1358
+ const fail = (msg) => {
1359
+ console.error(`figs: ✗ push: ${msg}`)
1360
+ return false
783
1361
  }
1362
+ const token = getToken()
1363
+ if (!token) return fail("not logged in — run `figs login` (or set FIGS_TOKEN)")
1364
+ await checkVersion({ hardFail: true })
1365
+ if (!existsSync(repoDir)) return fail("no .figs/ here — run `figs init` first")
784
1366
  const config = readJson(join(repoDir, "config.json"), {})
785
1367
  if (!config.workspaceId || !config.agentId) {
786
- die("config missing workspaceId/agentId — run `figs init`")
1368
+ return fail("config missing workspaceId/agentId — run `figs init`")
787
1369
  }
788
1370
  const endpoint =
789
1371
  process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
790
1372
 
791
1373
  const agentJson = readJson(join(repoDir, "agent.json"), null)
792
- if (!agentJson) die("missing .figs/agent.json")
1374
+ if (!agentJson) return fail("missing .figs/agent.json")
793
1375
  const agent = { ...agentJson, id: config.agentId }
794
1376
  const runs = foldById(readJsonl("runs.jsonl"))
795
1377
  const asks = foldById(readJsonl("asks.jsonl"))
796
1378
 
1379
+ // Local pre-flight — fail fast, offline, with teaching errors.
1380
+ const placeholders = findPlaceholders(agentJson)
1381
+ if (placeholders.length) {
1382
+ return fail(
1383
+ `agent.json still has template placeholders (${placeholders.map((p) => p.path).join(", ")}) — fill them in; \`figs doctor\` lists them`,
1384
+ )
1385
+ }
1386
+ const issues = validateOutbox(runs, asks)
1387
+ if (issues.length) return fail(`local validation failed:\n ${issues.join("\n ")}`)
1388
+
797
1389
  const base = endpoint.replace(/\/+$/, "")
798
1390
  let res
799
1391
  try {
@@ -803,17 +1395,17 @@ async function push() {
803
1395
  body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
804
1396
  })
805
1397
  } catch (e) {
806
- die(`push failed — cannot reach ${base} (${netReason(e)})`)
1398
+ return fail(`cannot reach ${base} (${netReason(e)})`)
807
1399
  }
808
1400
  const text = await res.text()
809
- if (!res.ok) die(`push failed (${res.status}): ${text}`)
1401
+ if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`)
810
1402
  console.log(
811
1403
  `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
812
1404
  )
813
1405
  // The wow-moment link — relay this to your human so they can see the agent.
814
1406
  console.log(` view at ${base}/w/${config.workspaceId}`)
815
1407
 
816
- await pushArtifacts(base, token, config, runs, asks)
1408
+ return pushArtifacts(base, token, config, runs, asks)
817
1409
  }
818
1410
 
819
1411
  /**
@@ -830,10 +1422,9 @@ async function pushArtifacts(base, token, config, runs, asks) {
830
1422
  const refNames = (asks ?? []).flatMap((a) =>
831
1423
  (a.refs ?? []).map((r) => r.artifact),
832
1424
  )
833
- const names = [
834
- ...new Set([...runs.map((r) => r.artifact), ...refNames].filter(Boolean)),
835
- ]
836
- if (names.length === 0) return
1425
+ const runNames = runs.flatMap((r) => [r.artifact, ...(r.artifacts ?? [])])
1426
+ const names = [...new Set([...runNames, ...refNames].filter(Boolean))]
1427
+ if (names.length === 0) return true
837
1428
 
838
1429
  let uploaded = 0
839
1430
  let unchanged = 0
@@ -883,9 +1474,10 @@ async function pushArtifacts(base, token, config, runs, asks) {
883
1474
  (missing ? `, ${missing} missing` : "") +
884
1475
  (failed ? `, ${failed} failed` : ""),
885
1476
  )
886
- // The spine already landed; signal a non-zero exit so an agent's run loop can
887
- // catch that an artifact the manager needs to read did not publish.
888
- if (failed) process.exit(1)
1477
+ // The spine already landed; a false return lets the caller exit non-zero so
1478
+ // an agent's run loop can catch that an artifact the manager needs to read
1479
+ // did not publish.
1480
+ return failed === 0
889
1481
  }
890
1482
 
891
1483
  function readJsonl(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.1.15",
3
+ "version": "0.2.0",
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
  },