@figs-so/cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +6 -4
  2. package/SPEC.md +37 -10
  3. package/figs.mjs +175 -20
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -41,8 +41,9 @@ npx @figs-so/cli@latest push # publish → it appears in you
41
41
 
42
42
  That's it — your agent now shows up at **[app.figs.so](https://app.figs.so)**. No instrumentation, no
43
43
  SDK in your agent's code. From there you decide, deliberately, how much of its real work to surface —
44
- and day to day the agent records itself in one stroke per event: `figs report` (a run) ·
45
- `figs ask` (needs a human) · `figs resolve` (close an ask). Each pushes itself.
44
+ and day to day the agent records itself in one stroke per event: `figs checkpoint` (a job opens /
45
+ progresses) · `figs report` (its outcome) · `figs ask` (needs a human) · `figs resolve` (close an
46
+ ask). Each pushes itself.
46
47
 
47
48
  ## How it works
48
49
 
@@ -73,8 +74,9 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
73
74
  | `figs login` / `logout` | device-flow browser approve / remove local token |
74
75
  | `figs workspaces [--json]` | list your workspaces (create one in the web app) |
75
76
  | `figs init [--workspace <slug>]` | generate identity + write `.figs/` (omit the flag: uses your only workspace, else lists them) |
76
- | **`figs inbox [<ask-id>]`** | start every session here — your humans' answers/verdicts, verbatim, with the next command per ask; with an id: the full zero-context handoff package (thread + artifacts restored) |
77
- | **`figs report --result '…'`** | record a run — **one job, one stable `--id`** (re-reporting an id folds progress onto that job's row); stamps the timestamp, `--attach`es artifacts, pushes itself |
77
+ | **`figs inbox [<ask-id>]`** | start every session here — your humans' answers/verdicts, verbatim, with the next command per ask, plus your unfinished (in-flight) jobs; with an id: the full zero-context handoff package (thread + artifacts restored) |
78
+ | **`figs checkpoint --id <job> --note '…'`** | save a job's progress mid-flight the **first checkpoint opens the job** (`state: in-flight`), so a crash leaves a recoverable stub the next session finds in the inbox; `--trigger` records what set the sitting in motion |
79
+ | **`figs report --result '…'`** | file a job's outcome — **one job, one stable `--id`** (re-reporting an id folds progress onto that job's row); settles the job (`state: settled`), stamps the timestamp, `--attach`es artifacts, pushes itself |
78
80
  | **`figs ask <type> --title '…'`** | raise a self-contained ask (`needs-decision` · `sign-off` · `fyi`) — options/details/attachments, pushed so a human sees it |
79
81
  | **`figs resolve <ask-id>`** | close an ask — `--chosen` verbatim-checked against its options, `--withdrawn` for the un-ask |
80
82
  | `figs push` | the bare transport — the verbs call it automatically; type it yourself after hand-edits or `--no-push` |
package/SPEC.md CHANGED
@@ -25,13 +25,26 @@
25
25
  .figs/
26
26
  ├── config.json # identity + destination (committed, non-secret)
27
27
  ├── agent.json # the charter — who this agent is (committed)
28
+ ├── CONTRACT.md # agent-authored: what this agent surfaces / holds back (committed)
29
+ ├── GUIDE.md # orientation breadcrumb, written by the CLI (committed)
28
30
  ├── runs.jsonl # activity log, one JSON object per line (outbox; gitignored)
29
31
  ├── asks.jsonl # things needing a human, one per line (outbox; gitignored)
30
32
  └── artifacts/ # files referenced by runs/asks (outbox; gitignored)
31
33
  ```
32
34
 
33
- **Commit** `config.json` + `agent.json` (identity + charter). The activity files (`runs.jsonl`,
34
- `asks.jsonl`, `artifacts/`) are a transient outbox and are typically gitignored.
35
+ **Commit** `config.json` + `agent.json` + `CONTRACT.md` + `GUIDE.md`. The activity files
36
+ (`runs.jsonl`, `asks.jsonl`, `artifacts/`) are a transient outbox and are typically gitignored.
37
+
38
+ **`CONTRACT.md` + `GUIDE.md` are companion conventions, not wire format** — they are never
39
+ pushed. `CONTRACT.md` is the standing agreement between the agent and its user about what gets
40
+ surfaced; `GUIDE.md` is an orientation stub the reference CLI writes (and never clobbers).
41
+ Implementations may add files like these; readers must ignore files this spec doesn't name.
42
+
43
+ **The membership rule — what belongs in `.figs/`:** everything in the folder is *Figs-facing* —
44
+ protocol metadata (`config.json`), the published record (the charter, the outbox), or a
45
+ convention *about* publishing (`CONTRACT.md`, `GUIDE.md`). An agent's private working state —
46
+ memory, self-checks, scratch notes — lives outside `.figs/`, elsewhere in the repo. If a file's
47
+ only reader is the agent itself, it does not belong here.
35
48
 
36
49
  ## 3. `config.json` — identity + destination
37
50
 
@@ -89,14 +102,22 @@ merge as asks): re-reporting a job's id layers progress onto its row (`status` e
89
102
  blocked-ish `warn` → `ok`) — sittings/sessions are agent plumbing and never mint records.
90
103
  Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
91
104
 
105
+ A job is either **in flight** or **settled** (`state`, below). A **checkpoint**
106
+ (`figs checkpoint`) folds progress onto the job's id and marks it in-flight — the record
107
+ survives the session working it, so a crash mid-job leaves a visible, recoverable stub
108
+ instead of nothing. A **report** files the outcome and settles it; a report with no prior
109
+ checkpoint is simply a job **born settled** (the single-sitting case). Nothing *external*
110
+ ever closes a run — only the agent's own report settles its job.
111
+
92
112
  | Field | Type | Req | Meaning |
93
113
  |---|---|:--:|---|
94
114
  | `id` | string | ✓ | Stable id (upsert key). |
95
115
  | `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran, e.g. `2026-05-28T23:41:26Z`. |
96
116
  | `unit` | string | | The `Unit.id` this run is about. |
97
117
  | `period` | string | | |
98
- | `result` | string | | One-line outcome. |
99
- | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — a run is a complete fact when reported; nothing "closes" a run. |
118
+ | `result` | string | | The job's current one-line state — where it stands while in flight; the outcome once settled. |
119
+ | `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — what the work looks like right now (a stuck job is `warn`); whether the job is *done* is `state`, the orthogonal dimension. |
120
+ | `state` | `"in-flight"` \| `"settled"` | | Default `"settled"`. **Lifecycle, verb-stamped** — `figs checkpoint` stamps `in-flight`, `figs report` stamps `settled`; agents never hand-pick it. An in-flight job whose agent died stays visibly in flight — that's the point: the next session finds it in `figs inbox` and finishes or settles it. |
100
121
  | `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). |
101
122
  | `session` | `Session` | | Where/how this ran (see [§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
102
123
 
@@ -104,9 +125,9 @@ Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), ne
104
125
 
105
126
  An optional, **self-reported** block describing the runtime session that produced a run (or raised an
106
127
  ask — see §6). Every field is optional — fill what your runtime exposes, omit the rest. This is
107
- *transparency, not attestation*: the values come from the runtime's own records — `figs report`
108
- captures them automatically; hand-authors copy what their runtime exposes. Cryptographic provenance
109
- remains [reserved](#reserved-not-in-v1).
128
+ *transparency, not attestation*: the values come from the runtime's own records — hand-authored, or
129
+ written by integrations that can copy provable values at work-time (the CLI never infers them).
130
+ Cryptographic provenance remains [reserved](#reserved-not-in-v1).
110
131
 
111
132
  | Field | Type | Meaning |
112
133
  |---|---|---|
@@ -115,6 +136,7 @@ remains [reserved](#reserved-not-in-v1).
115
136
  | `sessionId` | string | The runtime's own session identifier. |
116
137
  | `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
117
138
  | `commit` | string | The agent repo's HEAD at run time; append `+dirty` when the working tree had uncommitted changes, e.g. `1b68668+dirty`. |
139
+ | `trigger` | string | What set this sitting in motion — one self-reported line, e.g. `monthly close cron`, `inbox: answer on acme-bridge`, `Wayne, in chat`. A *fresh* sitting on a job states it (stamped from `--trigger` on `figs checkpoint`/`report`); records continuing the same session omit it. The one mechanically verified trigger stays `resolution.answer` ([§6.2](#62-resolution--how-an-ask-closed)). |
118
140
  | `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. |
119
141
 
120
142
  ## 6. `asks.jsonl` — handoffs to a human
@@ -134,6 +156,7 @@ primitive** — the agent reached the edge of its autonomy.
134
156
  | `found` | string | | What the agent found / why it's stuck. |
135
157
  | `need` | string | | What it needs from the human. |
136
158
  | `options` | string[] | | Candidate resolutions — **short, stable, quotable** strings: an answer references one *verbatim* (see [§6.2](#62-resolution--how-an-ask-closed)). On a **sign-off** they are **answer paths** — qualified verdicts the human's verdict can cite verbatim alongside approve/request-changes (e.g. `"Approved — file the 15 ready charges"`). |
159
+ | `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these stated steps, in order** (e.g. `"Post the 8 journal entries to SAP"`, `"Email the filing to Acme"`); flag anything irreversible in the step itself. This is the agent's **declared intent, not a bound plan** — readers present it as the agent's claim. Invalid on other types: a *needs-decision* has no approval; there, the chosen option carries the next step. |
137
160
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
138
161
  | `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
139
162
  | `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": … }`. |
@@ -149,7 +172,8 @@ An ask is the **anchor of a thread whose two halves are owned by different parti
149
172
 
150
173
  ```jsonc
151
174
  { "id": "acme-bridge", "status": "resolved",
152
- "resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)" } }
175
+ "resolution": { "chosen": "Strip the alpha prefix", "via": "human",
176
+ "by": "Sarah (accounting)", "ts": "2026-06-01T09:12:00Z" } }
153
177
  ```
154
178
 
155
179
  Appending keeps the local file crash-safe, concurrency-safe (multiple runners), and an honest
@@ -173,6 +197,7 @@ in the agent's own workflow; answers flowing back through the reader are arrivin
173
197
  | `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (verified — see `answer`) · answered out-of-band (self-reported) · the blocker cleared on its own. |
174
198
  | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
175
199
  | `answer` | string | The Figs answer-event id the agent acted on — written by `figs resolve` when the answer came through the inbox (attribution by mechanism, never typed). The cited event may be an answer **or a qualified verdict** (a verdict carrying `chosen`). |
200
+ | `ts` | string (ISO-8601 w/ offset) | When the agent closed it — **machine-stamped by `figs resolve`, never typed**. The agent's claim of the execution-adjacent moment, same self-reported grade as the record `ts`; readers stamp their own receipt at ingest and surface both only when they diverge. Lives *inside* `resolution` so the fold can't collide with the record's raise `ts`. |
176
201
 
177
202
  All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
178
203
  normalize it to the object form.
@@ -281,8 +306,10 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
281
306
  ```
282
307
 
283
308
  ```jsonc
284
- // .figs/runs.jsonl (one object per line)
285
- { "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",
309
+ // .figs/runs.jsonl (one object per line; records fold by id — the checkpoint opened the job, the report settled it)
310
+ { "id": "acme-2025-11", "ts": "2026-05-28T23:05:40Z", "unit": "acme", "period": "2025-11", "state": "in-flight", "result": "Statements pulled — matching now",
311
+ "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
312
+ { "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "state": "settled", "artifact": "acme-2025-11.html",
286
313
  "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
287
314
  "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
288
315
  "tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
package/figs.mjs CHANGED
@@ -111,15 +111,20 @@ const COMMANDS = {
111
111
  report: {
112
112
  args: "--result <text> [options]",
113
113
  flags: [
114
- "--result", "--id", "--unit", "--period", "--status", "--attach", "--no-push",
114
+ "--result", "--id", "--unit", "--period", "--status", "--trigger", "--attach", "--no-push",
115
115
  ],
116
- desc: "record a runone job's row in runs.jsonl; stamps id/ts, pushes",
116
+ desc: "file a job's outcome settles its row in runs.jsonl; stamps id/ts/state, pushes",
117
117
  more: [
118
118
  "One run = one JOB — a unit of work your manager would recognize; the runs",
119
119
  "list reads as the job list. Give a job a stable, meaningful --id",
120
120
  "(recon-acme-2026-11); reporting the same id again folds onto that job's row",
121
121
  "(progress evolves: blocked → ok). Sittings/sessions never mint runs —",
122
122
  "stopping to wait for a human is not a job.",
123
+ "A report SETTLES the job (state: settled). A job that outlives a sitting",
124
+ "should open with `figs checkpoint` first; a report with no prior checkpoint",
125
+ "is simply a job born settled (the single-sitting case).",
126
+ "--trigger '<what set this sitting in motion>' — state it in a fresh sitting",
127
+ "('monthly close cron', 'Wayne, in chat'); omit when continuing one.",
123
128
  "You supply the content; the CLI does the bookkeeping (id, real-clock ts,",
124
129
  "validation, artifact copy, push).",
125
130
  "Single-quote prose values ('…') — inside double quotes your shell expands $,",
@@ -131,30 +136,57 @@ const COMMANDS = {
131
136
  ],
132
137
  eg: "figs report --id recon-acme-2026-11 --result '88% matched · 31 flagged' --attach ./acme-2025-11.html",
133
138
  },
139
+ checkpoint: {
140
+ args: "--id <job> --note <text> [options]",
141
+ flags: [
142
+ "--id", "--note", "--trigger", "--status", "--unit", "--period", "--attach", "--no-push",
143
+ ],
144
+ desc: "save a job's progress mid-flight — folds onto its row, marks it in-flight, pushes",
145
+ more: [
146
+ "Your first checkpoint OPENS the job (state: in-flight) — make it the first",
147
+ "act of any job that will outlive this sitting: say what triggered it",
148
+ "(--trigger) and what you're setting out to do (--note). If you die mid-job,",
149
+ "the checkpoint is what the next session finds in `figs inbox`; without one,",
150
+ "the job never existed anywhere.",
151
+ "Checkpoint at MANAGER grain — a step a human would recognize ('statements",
152
+ "pulled — matching now'), never per tool call.",
153
+ "--note is the job's current one-line state; it shows on the job's row and",
154
+ "evolves with each fold. --status still carries the outcome look (a stuck",
155
+ "job is --status warn — in-flight and warn are independent).",
156
+ "`figs report --id <same-id>` settles the job when it's done — including",
157
+ "abandoning it (--status warn --result 'abandoned — superseded by …').",
158
+ "A checkpoint isn't a checkpoint until it's pushed — this verb pushes itself.",
159
+ ],
160
+ eg: "figs checkpoint --id recon-acme-2026-11 --note 'Statements pulled — matching now' --trigger 'monthly close cron'",
161
+ },
134
162
  ask: {
135
163
  args: "<type> --title <text> [options]",
136
164
  flags: [
137
- "--id", "--title", "--need", "--found", "--option", "--detail", "--attach",
138
- "--to", "--unit", "--run", "--stdin", "--no-push",
165
+ "--id", "--title", "--need", "--found", "--option", "--on-approve", "--detail",
166
+ "--attach", "--to", "--unit", "--run", "--stdin", "--no-push",
139
167
  ],
140
168
  desc: "raise an ask — one self-contained line in asks.jsonl, pushed so a human sees it",
141
169
  more: [
142
170
  "<type> = the answer contract: needs-decision (give me an answer) ·",
143
171
  "sign-off (give me a verdict) · fyi (no answer — a for-the-record note).",
144
- "Make it self-contained — a future session with zero context (or another human)",
145
- "must be able to act from this record alone: --found (what you saw), --need (what",
146
- "you need), --option (repeatable; short, stable, quotable — answers cite one",
147
- "verbatim), --detail 'Label=Value' (repeatable), --attach <file> (repeatable;",
148
- "for sign-offs attach the exact content for review + a brief: what to do once",
149
- "approved and what it requires).",
172
+ "Two strangers read every ask — a human deciding, a future session acting;",
173
+ "the record must carry everything both need: --found (what you saw), --need",
174
+ "(what you need), --option (repeatable; short, stable, quotable — answers cite",
175
+ "one verbatim; the option is the label, context goes in --found/--detail),",
176
+ "--detail 'Label=Value' (repeatable), --attach <file> (repeatable; a verdict",
177
+ "blesses what the ask carries — attach the exact content for review + a brief:",
178
+ "what to do once approved and what it requires).",
150
179
  "On a sign-off, --option entries are answer paths — the human's verdict can",
151
- "cite one verbatim ('Approved — file the 15 ready charges').",
180
+ "cite one verbatim ('Approved — file the 15 ready charges') — and",
181
+ "--on-approve '<step>' (repeatable, ordered; sign-off only) states what",
182
+ "approval sets in motion: an approval authorizes exactly the steps you stated.",
183
+ "Flag anything irreversible in the step itself.",
152
184
  "--run <run-id> links the run this came out of — explicit id only (other",
153
185
  "sessions may report concurrently; `figs report` prints the id it wrote).",
154
186
  "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
155
187
  "Single-quote prose values ('…') — double quotes let your shell eat $ amounts.",
156
188
  ],
157
- eg: "figs ask sign-off --title 'Send 10 payment reminders' --attach ./previews.html --run recon-2026-06",
189
+ eg: "figs ask sign-off --title 'Send 10 payment reminders' --attach ./previews.html --on-approve 'Send the 10 reminder emails' --on-approve 'Mark the invoices chased' --run recon-2026-06",
158
190
  },
159
191
  inbox: {
160
192
  args: "[<ask-id>] [--json]",
@@ -166,8 +198,9 @@ const COMMANDS = {
166
198
  "With an ask id: the full handoff package — the ask, the whole thread, and its",
167
199
  "attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
168
200
  "session can act from the record alone.",
169
- "Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
170
- "Reads onlyclosing still happens via figs resolve.",
201
+ "Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged",
202
+ "+ unfinished jobs (in-flight runs your past self's work; finish or settle).",
203
+ "Reads only — closing still happens via figs resolve / figs report.",
171
204
  ],
172
205
  eg: "figs inbox",
173
206
  },
@@ -292,6 +325,9 @@ function genId(prefix) {
292
325
  // status, not an ask type; what you need from a human is a needs-decision.
293
326
  const ASK_TYPES = ["needs-decision", "sign-off", "fyi"]
294
327
  const RUN_STATUSES = ["ok", "warn", "fail"]
328
+ // Lifecycle, orthogonal to status (outcome): checkpoint stamps in-flight,
329
+ // report stamps settled. Absent = settled (a plain report is a complete fact).
330
+ const RUN_STATES = ["in-flight", "settled"]
295
331
  const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
296
332
  const TO_VALUES = ["manager", "builder"]
297
333
  const ARTIFACT_EXTS = new Set([
@@ -318,6 +354,7 @@ function validateRun(r) {
318
354
  if (!r.id || typeof r.id !== "string") issues.push(`${label}: missing required "id"`)
319
355
  if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
320
356
  checkEnum(issues, r, "status", RUN_STATUSES, label)
357
+ checkEnum(issues, r, "state", RUN_STATES, label)
321
358
  if (r.artifact !== undefined && typeof r.artifact !== "string") {
322
359
  issues.push(`${label}.artifact: must be a single file name (use "artifacts" for a list)`)
323
360
  }
@@ -346,6 +383,15 @@ function validateAsk(a) {
346
383
  if (a.options !== undefined && (!Array.isArray(a.options) || a.options.some((o) => typeof o !== "string"))) {
347
384
  issues.push(`${label}.options: must be an array of short, quotable strings`)
348
385
  }
386
+ if (a.onApprove !== undefined) {
387
+ if (!Array.isArray(a.onApprove) || a.onApprove.some((s) => typeof s !== "string")) {
388
+ issues.push(`${label}.onApprove: must be an array of strings — the ordered steps approval sets in motion`)
389
+ } else if (a.type !== "sign-off") {
390
+ issues.push(
391
+ `${label}.onApprove: sign-off only — it is the approval contract; a ${a.type ?? "non-sign-off ask"} has no approval (the chosen option carries the next step)`,
392
+ )
393
+ }
394
+ }
349
395
  if (a.details !== undefined && (!Array.isArray(a.details) || a.details.some((d) => !d || typeof d.l !== "string"))) {
350
396
  issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
351
397
  }
@@ -509,6 +555,7 @@ else if (COMMANDS[cmd]) {
509
555
  else if (cmd === "workspaces") await workspaces()
510
556
  else if (cmd === "init") await init()
511
557
  else if (cmd === "report") await reportCmd()
558
+ else if (cmd === "checkpoint") await checkpointCmd()
512
559
  else if (cmd === "ask") await askCmd()
513
560
  else if (cmd === "inbox") await inboxCmd()
514
561
  else if (cmd === "resolve") await resolveCmd()
@@ -1082,7 +1129,12 @@ function nextMove(a) {
1082
1129
  if (last.kind === "verdict" && last.verdict === "changes_requested") {
1083
1130
  return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title '…' …`
1084
1131
  }
1085
- return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '…'`
1132
+ if (last.chosen) {
1133
+ // Pre-fill the cited option verbatim — the note is substance for --note, never for --chosen.
1134
+ const note = last.text ? ` --note '…'` : ""
1135
+ return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '${last.chosen}'${note}`
1136
+ }
1137
+ return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --note '…'`
1086
1138
  }
1087
1139
 
1088
1140
  /** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
@@ -1181,15 +1233,17 @@ async function inboxCmd() {
1181
1233
  if (data.truncated) {
1182
1234
  console.warn(`figs: ! showing the first ${items.length} — more exist (close some asks)`)
1183
1235
  }
1184
- if (items.length === 0) {
1185
- console.log("figs: inbox empty no open asks, nothing needs you")
1236
+ const jobs = data.jobs ?? []
1237
+ if (items.length === 0 && jobs.length === 0) {
1238
+ console.log("figs: ✓ inbox empty — no open asks, no unfinished jobs, nothing needs you")
1186
1239
  return
1187
1240
  }
1188
1241
  const rejected = items.filter((a) => a.status === "rejected")
1189
1242
  const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
1190
1243
  const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
1191
1244
  console.log(
1192
- `figs: inbox — ${answered.length} answered · ${rejected.length} rejected to acknowledge · ${quiet.length} waiting on your human`,
1245
+ `figs: inbox — ${answered.length} answered · ${rejected.length} rejected to acknowledge · ${quiet.length} waiting on your human` +
1246
+ (jobs.length ? ` · ${jobs.length} job${jobs.length === 1 ? "" : "s"} in flight` : ""),
1193
1247
  )
1194
1248
  const printItem = (a) => {
1195
1249
  const last = a.events[a.events.length - 1]
@@ -1202,6 +1256,17 @@ async function inboxCmd() {
1202
1256
  console.log(`\n Waiting on your human (nothing for you to do):`)
1203
1257
  for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
1204
1258
  }
1259
+ if (jobs.length) {
1260
+ console.log(`\n Unfinished jobs — in flight (your past self's work; finish or settle):`)
1261
+ for (const j of jobs) {
1262
+ console.log(
1263
+ ` · ${j.id}${j.result ? ` — ${j.result}` : ""} (last update ${agoStr(j.updatedAt ?? j.ts)})`,
1264
+ )
1265
+ console.log(
1266
+ ` → continue it (\`figs checkpoint --id ${j.id}\` as you go); \`figs report --id ${j.id}\` settles it`,
1267
+ )
1268
+ }
1269
+ }
1205
1270
  }
1206
1271
 
1207
1272
  // ---------- the resolution fold (`resolve` — the one closing verb) ----------
@@ -1281,12 +1346,18 @@ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected })
1281
1346
  if (!by && cited.byName) resolution.by = cited.byName
1282
1347
  }
1283
1348
 
1349
+ // The close's own clock — machine-stamped, never typed (same posture as the
1350
+ // record `ts`). It lives INSIDE resolution so the fold can't collide with
1351
+ // the record's raise `ts` (folds are field-level merges); the reader stamps
1352
+ // its own receipt (`closedAt`) at ingest — two clocks, claim vs receipt.
1353
+ resolution.ts = nowIso()
1354
+
1284
1355
  warnEatenDollar(resolution.chosen, resolution.note)
1285
1356
  const line = {
1286
1357
  id: askId,
1287
1358
  status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
1359
+ resolution,
1288
1360
  }
1289
- if (Object.keys(resolution).length) line.resolution = resolution
1290
1361
  return { line, warnings }
1291
1362
  }
1292
1363
 
@@ -1308,13 +1379,17 @@ async function reportCmd() {
1308
1379
  if (!result) {
1309
1380
  die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
1310
1381
  }
1311
- const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
1382
+ // state is verb-stamped, never typed: report settles the job (checkpoint
1383
+ // marks it in-flight). The settling fold overrides any earlier checkpoint.
1384
+ const run = { id: flag("--id") || genId("r"), ts: nowIso(), result, state: "settled" }
1312
1385
  const unit = flag("--unit")
1313
1386
  if (unit) run.unit = unit
1314
1387
  const period = flag("--period")
1315
1388
  if (period) run.period = period
1316
1389
  const status = flag("--status")
1317
1390
  if (status) run.status = status
1391
+ const trigger = flag("--trigger")
1392
+ if (trigger) run.session = { trigger }
1318
1393
  const attached = attachFiles(flagAll("--attach"))
1319
1394
  if (attached.length === 1) run.artifact = attached[0]
1320
1395
  else if (attached.length > 1) run.artifacts = attached
@@ -1323,9 +1398,76 @@ async function reportCmd() {
1323
1398
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1324
1399
  appendJsonl("runs.jsonl", run)
1325
1400
  console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
1401
+ // Teaching, never a gate: a settled job with open asks citing it is the
1402
+ // normal tail-of-job pattern (the ask owns the waiting) — but if the job's
1403
+ // OUTCOME depends on an answer, in-flight is the honest state. Local fold
1404
+ // only (other machines' asks are invisible here): best-effort by design.
1405
+ const openCiting = foldById(readJsonl("asks.jsonl")).filter(
1406
+ (a) => a.run === run.id && (a.status ?? "open") === "open",
1407
+ )
1408
+ if (openCiting.length > 0) {
1409
+ const n = openCiting.length
1410
+ console.log(
1411
+ `figs: note: ${n} open ask${n === 1 ? "" : "s"} cite${n === 1 ? "s" : ""} this job (${openCiting
1412
+ .map((a) => a.id)
1413
+ .join(", ")}) — a tail sign-off is fine; if the OUTCOME depends on an answer, keep the job in flight instead (\`figs checkpoint --id ${run.id} --status warn\`)`,
1414
+ )
1415
+ }
1326
1416
  await autoPush()
1327
1417
  }
1328
1418
 
1419
+ /**
1420
+ * `figs checkpoint` — save a job's progress so it survives this sitting. The
1421
+ * first checkpoint on an id OPENS the job (state: in-flight); a crash mid-job
1422
+ * then leaves a visible, recoverable stub the next session finds in
1423
+ * `figs inbox`, instead of nothing. Same fold-by-id write as report — only
1424
+ * the stamped state differs; `figs report` settles the id.
1425
+ */
1426
+ async function checkpointCmd() {
1427
+ requireFigs()
1428
+ const id = flag("--id")
1429
+ if (!id) {
1430
+ die("checkpoint needs --id '<job-id>' — the stable job id the next session will look for (e.g. recon-acme-2026-11)")
1431
+ }
1432
+ const note = flag("--note")
1433
+ if (!note) {
1434
+ die(`checkpoint needs --note '<where the job stands>' — e.g. figs checkpoint --id ${id} --note 'Statements pulled — matching now'`)
1435
+ }
1436
+ // Before the append, so "new" means new-to-this-outbox (the teaching line).
1437
+ const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
1438
+ const run = { id, ts: nowIso(), result: note, state: "in-flight" }
1439
+ const unit = flag("--unit")
1440
+ if (unit) run.unit = unit
1441
+ const period = flag("--period")
1442
+ if (period) run.period = period
1443
+ const status = flag("--status")
1444
+ if (status) run.status = status
1445
+ const trigger = flag("--trigger")
1446
+ if (trigger) run.session = { trigger }
1447
+ const attached = attachFiles(flagAll("--attach"))
1448
+ if (attached.length === 1) run.artifact = attached[0]
1449
+ else if (attached.length > 1) run.artifacts = attached
1450
+ warnEatenDollar(run.result)
1451
+ const issues = validateRun(run)
1452
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1453
+ appendJsonl("runs.jsonl", run)
1454
+ console.log(`figs: ✓ checkpoint recorded — ${JSON.stringify(run)}`)
1455
+ if (isNew) {
1456
+ console.log(
1457
+ `figs: new job opened: ${id} (in flight) — checkpoint as you go; \`figs report --id ${id}\` settles it`,
1458
+ )
1459
+ }
1460
+ await autoPush()
1461
+ // A checkpoint exists to survive a crash — local-only protects nothing.
1462
+ // autoPush already said "saved locally"; for THIS verb that reads like
1463
+ // success, so say what it actually means.
1464
+ if (process.exitCode === 1) {
1465
+ console.warn(
1466
+ `figs: ! this checkpoint is NOT protecting the job yet — nothing reached the server. Run \`figs push\` before continuing the work.`,
1467
+ )
1468
+ }
1469
+ }
1470
+
1329
1471
  async function askCmd() {
1330
1472
  requireFigs()
1331
1473
  let base = {}
@@ -1366,6 +1508,13 @@ async function askCmd() {
1366
1508
  )
1367
1509
  }
1368
1510
  }
1511
+ const onApprove = flagAll("--on-approve")
1512
+ if (onApprove.length) ask.onApprove = onApprove
1513
+ if (ask.onApprove?.length && ask.type !== "sign-off") {
1514
+ die(
1515
+ `--on-approve is the approval contract — sign-off only. A ${ask.type} has no approval; the chosen option carries the next step (put it in the --option text)`,
1516
+ )
1517
+ }
1369
1518
  const details = flagAll("--detail").map((d) => {
1370
1519
  const i = d.indexOf("=")
1371
1520
  if (i < 1) die(`--detail must be "Label=Value", got "${d}"`)
@@ -1388,11 +1537,17 @@ async function askCmd() {
1388
1537
  "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>",
1389
1538
  )
1390
1539
  }
1540
+ if (ask.type === "sign-off" && !ask.onApprove?.length) {
1541
+ console.warn(
1542
+ "figs: ! tip: state what approval sets in motion — --on-approve '<step>' (repeatable, ordered); an approver shouldn't have to guess what approve causes",
1543
+ )
1544
+ }
1391
1545
  warnEatenDollar(
1392
1546
  ask.title,
1393
1547
  ask.found,
1394
1548
  ask.need,
1395
1549
  ask.options ?? [],
1550
+ ask.onApprove ?? [],
1396
1551
  (ask.details ?? []).flatMap((d) => [d.l, d.v]),
1397
1552
  )
1398
1553
  const issues = validateAsk(ask)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {