@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.
- package/README.md +10 -6
- package/SPEC.md +95 -16
- package/figs.mjs +621 -23
- 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
|
|
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
|
[](https://www.npmjs.com/package/@figs-so/cli)
|
|
14
|
-
· License: **MIT** (this repo — protocol + CLI) · The app: **
|
|
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
|
-
|
|
|
76
|
-
|
|
|
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
|
-
| `
|
|
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).
|
|
107
|
-
| `status` | `"open"` \| `"resolved"`
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1410
|
+
return fail(`cannot reach ${base} (${netReason(e)})`)
|
|
813
1411
|
}
|
|
814
1412
|
const text = await res.text()
|
|
815
|
-
if (!res.ok)
|
|
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
|
-
|
|
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
|
|
840
|
-
|
|
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;
|
|
893
|
-
// catch that an artifact the manager needs to read
|
|
894
|
-
|
|
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
|
|
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
|
},
|