@figs-so/cli 0.7.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 +36 -10
  3. package/figs.mjs +133 -9
  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
@@ -150,7 +172,8 @@ An ask is the **anchor of a thread whose two halves are owned by different parti
150
172
 
151
173
  ```jsonc
152
174
  { "id": "acme-bridge", "status": "resolved",
153
- "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" } }
154
177
  ```
155
178
 
156
179
  Appending keeps the local file crash-safe, concurrency-safe (multiple runners), and an honest
@@ -174,6 +197,7 @@ in the agent's own workflow; answers flowing back through the reader are arrivin
174
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. |
175
198
  | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
176
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`. |
177
201
 
178
202
  All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
179
203
  normalize it to the object form.
@@ -282,8 +306,10 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
282
306
  ```
283
307
 
284
308
  ```jsonc
285
- // .figs/runs.jsonl (one object per line)
286
- { "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",
287
313
  "session": { "runtime": "claude-code", "model": "claude-fable-5", "sessionId": "3fffcd97-d4f5-4b77-8243-8f450d7c9614",
288
314
  "startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
289
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,6 +136,29 @@ 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: [
@@ -170,8 +198,9 @@ const COMMANDS = {
170
198
  "With an ask id: the full handoff package — the ask, the whole thread, and its",
171
199
  "attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
172
200
  "session can act from the record alone.",
173
- "Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
174
- "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.",
175
204
  ],
176
205
  eg: "figs inbox",
177
206
  },
@@ -296,6 +325,9 @@ function genId(prefix) {
296
325
  // status, not an ask type; what you need from a human is a needs-decision.
297
326
  const ASK_TYPES = ["needs-decision", "sign-off", "fyi"]
298
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"]
299
331
  const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
300
332
  const TO_VALUES = ["manager", "builder"]
301
333
  const ARTIFACT_EXTS = new Set([
@@ -322,6 +354,7 @@ function validateRun(r) {
322
354
  if (!r.id || typeof r.id !== "string") issues.push(`${label}: missing required "id"`)
323
355
  if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
324
356
  checkEnum(issues, r, "status", RUN_STATUSES, label)
357
+ checkEnum(issues, r, "state", RUN_STATES, label)
325
358
  if (r.artifact !== undefined && typeof r.artifact !== "string") {
326
359
  issues.push(`${label}.artifact: must be a single file name (use "artifacts" for a list)`)
327
360
  }
@@ -522,6 +555,7 @@ else if (COMMANDS[cmd]) {
522
555
  else if (cmd === "workspaces") await workspaces()
523
556
  else if (cmd === "init") await init()
524
557
  else if (cmd === "report") await reportCmd()
558
+ else if (cmd === "checkpoint") await checkpointCmd()
525
559
  else if (cmd === "ask") await askCmd()
526
560
  else if (cmd === "inbox") await inboxCmd()
527
561
  else if (cmd === "resolve") await resolveCmd()
@@ -1199,15 +1233,17 @@ async function inboxCmd() {
1199
1233
  if (data.truncated) {
1200
1234
  console.warn(`figs: ! showing the first ${items.length} — more exist (close some asks)`)
1201
1235
  }
1202
- if (items.length === 0) {
1203
- 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")
1204
1239
  return
1205
1240
  }
1206
1241
  const rejected = items.filter((a) => a.status === "rejected")
1207
1242
  const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
1208
1243
  const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
1209
1244
  console.log(
1210
- `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` : ""),
1211
1247
  )
1212
1248
  const printItem = (a) => {
1213
1249
  const last = a.events[a.events.length - 1]
@@ -1220,6 +1256,17 @@ async function inboxCmd() {
1220
1256
  console.log(`\n Waiting on your human (nothing for you to do):`)
1221
1257
  for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
1222
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
+ }
1223
1270
  }
1224
1271
 
1225
1272
  // ---------- the resolution fold (`resolve` — the one closing verb) ----------
@@ -1299,12 +1346,18 @@ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected })
1299
1346
  if (!by && cited.byName) resolution.by = cited.byName
1300
1347
  }
1301
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
+
1302
1355
  warnEatenDollar(resolution.chosen, resolution.note)
1303
1356
  const line = {
1304
1357
  id: askId,
1305
1358
  status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
1359
+ resolution,
1306
1360
  }
1307
- if (Object.keys(resolution).length) line.resolution = resolution
1308
1361
  return { line, warnings }
1309
1362
  }
1310
1363
 
@@ -1326,13 +1379,17 @@ async function reportCmd() {
1326
1379
  if (!result) {
1327
1380
  die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
1328
1381
  }
1329
- 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" }
1330
1385
  const unit = flag("--unit")
1331
1386
  if (unit) run.unit = unit
1332
1387
  const period = flag("--period")
1333
1388
  if (period) run.period = period
1334
1389
  const status = flag("--status")
1335
1390
  if (status) run.status = status
1391
+ const trigger = flag("--trigger")
1392
+ if (trigger) run.session = { trigger }
1336
1393
  const attached = attachFiles(flagAll("--attach"))
1337
1394
  if (attached.length === 1) run.artifact = attached[0]
1338
1395
  else if (attached.length > 1) run.artifacts = attached
@@ -1341,9 +1398,76 @@ async function reportCmd() {
1341
1398
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1342
1399
  appendJsonl("runs.jsonl", run)
1343
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
+ }
1344
1416
  await autoPush()
1345
1417
  }
1346
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
+
1347
1471
  async function askCmd() {
1348
1472
  requireFigs()
1349
1473
  let base = {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.7.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": {