@figs-so/cli 0.1.16 → 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 +2 -3
- package/SPEC.md +93 -16
- package/figs.mjs +609 -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
|
|
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
|
|
@@ -96,12 +109,69 @@ 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 <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 {
|
|
@@ -732,7 +918,370 @@ async function init() {
|
|
|
732
918
|
console.log(` Full guide: ${endpoint}/llms.txt`)
|
|
733
919
|
}
|
|
734
920
|
|
|
735
|
-
|
|
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. */
|
|
736
1285
|
async function doctor() {
|
|
737
1286
|
// Local checks first (no token/network needed) — fail fast and offline.
|
|
738
1287
|
if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
|
|
@@ -754,6 +1303,18 @@ async function doctor() {
|
|
|
754
1303
|
process.exit(1)
|
|
755
1304
|
}
|
|
756
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
|
+
|
|
757
1318
|
if (!getToken()) die("not logged in — run `figs login`")
|
|
758
1319
|
const r = await api("POST", "/api/validate", {
|
|
759
1320
|
workspaceId: config.workspaceId,
|
|
@@ -780,26 +1341,51 @@ async function doctor() {
|
|
|
780
1341
|
process.exit(1)
|
|
781
1342
|
}
|
|
782
1343
|
|
|
1344
|
+
/** `figs push` — the bare transport; exits non-zero on any failure. */
|
|
783
1345
|
async function push() {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
|
789
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")
|
|
790
1366
|
const config = readJson(join(repoDir, "config.json"), {})
|
|
791
1367
|
if (!config.workspaceId || !config.agentId) {
|
|
792
|
-
|
|
1368
|
+
return fail("config missing workspaceId/agentId — run `figs init`")
|
|
793
1369
|
}
|
|
794
1370
|
const endpoint =
|
|
795
1371
|
process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
|
|
796
1372
|
|
|
797
1373
|
const agentJson = readJson(join(repoDir, "agent.json"), null)
|
|
798
|
-
if (!agentJson)
|
|
1374
|
+
if (!agentJson) return fail("missing .figs/agent.json")
|
|
799
1375
|
const agent = { ...agentJson, id: config.agentId }
|
|
800
1376
|
const runs = foldById(readJsonl("runs.jsonl"))
|
|
801
1377
|
const asks = foldById(readJsonl("asks.jsonl"))
|
|
802
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
|
+
|
|
803
1389
|
const base = endpoint.replace(/\/+$/, "")
|
|
804
1390
|
let res
|
|
805
1391
|
try {
|
|
@@ -809,17 +1395,17 @@ async function push() {
|
|
|
809
1395
|
body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
|
|
810
1396
|
})
|
|
811
1397
|
} catch (e) {
|
|
812
|
-
|
|
1398
|
+
return fail(`cannot reach ${base} (${netReason(e)})`)
|
|
813
1399
|
}
|
|
814
1400
|
const text = await res.text()
|
|
815
|
-
if (!res.ok)
|
|
1401
|
+
if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`)
|
|
816
1402
|
console.log(
|
|
817
1403
|
`figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
|
|
818
1404
|
)
|
|
819
1405
|
// The wow-moment link — relay this to your human so they can see the agent.
|
|
820
1406
|
console.log(` view at ${base}/w/${config.workspaceId}`)
|
|
821
1407
|
|
|
822
|
-
|
|
1408
|
+
return pushArtifacts(base, token, config, runs, asks)
|
|
823
1409
|
}
|
|
824
1410
|
|
|
825
1411
|
/**
|
|
@@ -836,10 +1422,9 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
836
1422
|
const refNames = (asks ?? []).flatMap((a) =>
|
|
837
1423
|
(a.refs ?? []).map((r) => r.artifact),
|
|
838
1424
|
)
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
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
|
|
843
1428
|
|
|
844
1429
|
let uploaded = 0
|
|
845
1430
|
let unchanged = 0
|
|
@@ -889,9 +1474,10 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
889
1474
|
(missing ? `, ${missing} missing` : "") +
|
|
890
1475
|
(failed ? `, ${failed} failed` : ""),
|
|
891
1476
|
)
|
|
892
|
-
// The spine already landed;
|
|
893
|
-
// catch that an artifact the manager needs to read
|
|
894
|
-
|
|
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
|
|
895
1481
|
}
|
|
896
1482
|
|
|
897
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
|
},
|