@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.
- package/README.md +5 -7
- package/SPEC.md +93 -16
- package/figs.mjs +620 -28
- 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
|
|
|
@@ -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
|
|
38
|
-
npx @figs-so/cli@latest
|
|
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
|
|
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
|
-
| `
|
|
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).
|
|
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 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
|
-
|
|
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` → *(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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
|
619
|
-
*
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1398
|
+
return fail(`cannot reach ${base} (${netReason(e)})`)
|
|
807
1399
|
}
|
|
808
1400
|
const text = await res.text()
|
|
809
|
-
if (!res.ok)
|
|
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
|
-
|
|
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
|
|
834
|
-
|
|
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;
|
|
887
|
-
// catch that an artifact the manager needs to read
|
|
888
|
-
|
|
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.
|
|
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
|
},
|