@figs-so/cli 0.7.0 → 1.0.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 +66 -37
  2. package/SPEC.md +214 -153
  3. package/figs.mjs +1166 -560
  4. package/package.json +1 -1
package/figs.mjs CHANGED
@@ -2,39 +2,47 @@
2
2
  /**
3
3
  * `figs` — the agent-side CLI (v1, zero-dependency).
4
4
  *
5
- * figs status show login / workspace / agent state [--json]
6
- * figs login opens browser to approve (device flow) agent never sees the token
7
- * figs login <token> fallback: save a token you pasted (~/.figs/credentials.json)
8
- * figs logout remove the locally-saved token (~/.figs/credentials.json)
9
- * figs workspaces list the user's workspaces [--json]
10
- * figs init --workspace <slug-or-id> [--endpoint <url>]
11
- * create .figs/config.json + GUIDE.md (generates a stable agent id)
12
- * figs report --result '…' record a run (stamps id/ts, --attach files, auto-push)
13
- * figs ask <type> --title '…' raise an ask (self-contained: options/details/attachments, auto-push)
14
- * figs inbox [<ask-id>] what needs youanswers/verdicts from your humans (pure read)
15
- * figs resolve <ask-id> close an ask (verbatim-checks --chosen; cites the Figs answer it acted on)
16
- * figs doctor validate .figs/ against the spec before pushing
17
- * figs push one-way push the .figs/ spine to the ingest endpoint
18
- * figs version print the CLI version (and check for updates)
5
+ * LOCAL (no account, no network the complete product):
6
+ * figs init scaffold .figs/ here purely local, mints a stable agent id
7
+ * figs report --result '…' settle a job (stamps id/ts, --attach files)
8
+ * figs checkpoint --id … --note '…' save a job's progress mid-flight (opens it in-flight)
9
+ * figs ask <type> --title '…' raise an ask (self-contained: options/details/attachments)
10
+ * figs answer <ask-id> --by '…' record your human's reply to an ask (you run it, not them)
11
+ * figs inbox [<ask-id>] what needs you (pure local read)
12
+ * figs show <id> magnify one ask or job (thread/trail + attachments)
13
+ * figs close <ask-id> close an ask (derives the close from the reply, cites it)
14
+ * figs doctor validate .figs/ against the spec runs account-free
15
+ * figs status local/linked + agent state [--json]
16
+ * figs version print the CLI version (offline; --check for updates)
19
17
  * figs help [<command>] usage; `-h`/`--help` on any command, `-v` for version
20
18
  *
21
- * The writing verbs (report/ask/resolve) are sugar over the same filesthey
22
- * stamp ids + real-clock timestamps, validate on write with teaching errors,
23
- * copy attachments into artifacts/, then invoke the same push as `figs push`
24
- * (auto-push IS push one transport, many entry points; --no-push to batch).
19
+ * CONNECTED (needs a one-time login + a workspacestrictly additive):
20
+ * figs login [<token>] device-flow approve (agent never sees the token), or save a pasted one
21
+ * figs logout remove the locally-saved token (~/.figs/credentials.json)
22
+ * figs link [--workspace <slug|uuid>] connect .figs/ to a workspace so push can publish
23
+ * figs push publish the .figs/ spine + attachments to the endpoint
24
+ *
25
+ * The writing verbs (report/checkpoint/ask/resolve) are sugar over the same
26
+ * files — they stamp ids + real-clock timestamps, validate on write with
27
+ * teaching errors, copy attachments into artifacts/, then (when linked) invoke
28
+ * the same push as `figs push` (auto-push IS push; --no-push to batch).
25
29
  * Hand-writing the JSONL + bare `push` remains fully supported — files are the
26
30
  * protocol; the verbs are conveniences.
27
31
  *
28
32
  * Designed to be driven by an agent: non-interactive, clear output, `--json`
29
33
  * on read commands, `-h`/`--help`/`help` everywhere, and errors that say what to
30
34
  * do next. Bare `figs` prints help; unknown commands AND unknown flags exit
31
- * non-zero (no silent no-ops). Flags accept `--name value` or `--name=value`.
32
- * Network calls time out (30s) instead of hanging the agent.
35
+ * non-zero. Exit codes: 0 = recorded · 1 = nothing written (fix the input) ·
36
+ * 2 = recorded locally, publish failed (retry `figs push`, never the verb).
37
+ * Flags accept `--name value` or `--name=value`. Network calls time out (30s).
33
38
  *
34
- * Auth is the *user* (a token, configured once per machine). Identity is the
35
- * *agent* a UUID generated by `init`, stored in the committed, non-secret
36
- * .figs/config.json ({ endpoint, workspaceId, agentId }). Run from the
37
- * agent's repo root, where `.figs/` lives.
39
+ * Two facts decide behavior: local-vs-linked (does config.json declare a
40
+ * workspaceId?) and authenticated (is there a token?). The config declares
41
+ * intent; the CLI errors only when declared intent can't be met. Identity is
42
+ * the *agent* a UUID minted by `init` into the committed, non-secret
43
+ * .figs/config.json ({ agentId } locally; + { endpoint, workspaceId } once
44
+ * `figs link`ed). Auth is the *user* (a token, configured once per machine).
45
+ * Run from the agent's repo root, where `.figs/` lives.
38
46
  */
39
47
 
40
48
  import {
@@ -91,56 +99,95 @@ const COMMANDS = {
91
99
  eg: "figs login",
92
100
  },
93
101
  logout: { args: "", flags: [], desc: "remove the locally-saved token (~/.figs/credentials.json)" },
94
- workspaces: {
95
- args: "[--json]",
96
- flags: ["--json"],
97
- desc: "list your workspaces (read-only; shows the slug)",
98
- eg: "figs workspaces",
99
- },
100
102
  init: {
103
+ args: "",
104
+ flags: [],
105
+ desc: "scaffold .figs/ here — purely local, no account needed (identity + charter/contract/guide)",
106
+ more: [
107
+ "Zero flags, zero network: mints a stable agent id into config.json and scaffolds",
108
+ "the templates. You're fully operational locally from here — record runs/asks/answers,",
109
+ "validate, navigate. `figs link` later when you want it on the hosted app.",
110
+ "Idempotent: re-running keeps your identity AND any link (never unlinks), and never",
111
+ "clobbers an existing agent.json / CONTRACT.md / GUIDE.md / outbox.",
112
+ ],
113
+ eg: "figs init",
114
+ },
115
+ link: {
101
116
  args: "[--workspace <slug-or-id>] [--endpoint <url>]",
102
117
  flags: ["--workspace", "--endpoint"],
103
- desc: "scaffold .figs/ here (identity + charter/contract/guide templates)",
118
+ desc: "connect this .figs/ to a workspace on the hosted app (so `figs push` can publish)",
104
119
  more: [
105
- "--workspace takes a slug (resolved to its UUID) or a raw UUID — get it from `figs workspaces`.",
106
- "Omit --workspace and (logged in) it uses your only workspace, or lists them so you can re-run with one.",
107
- "Never clobbers: an existing agent.json / CONTRACT.md / GUIDE.md / outbox is left exactly as-is.",
120
+ "Bare: lists your workspaces (needs login); with exactly one, links it outright.",
121
+ "--workspace takes a slug (resolved via the API needs login) or a raw UUID (no login",
122
+ "needed; get it from the app). --endpoint overrides the destination (default app.figs.so).",
123
+ "Verifies the workspace when you're logged in; a UUID set while logged out is accepted",
124
+ "but unverified until your first `figs push`. Writes endpoint + workspaceId into config.json.",
125
+ "To unlink, delete those two fields from .figs/config.json (your identity + work stay).",
108
126
  ],
109
- eg: "figs init --workspace acme-corp",
127
+ eg: "figs link --workspace acme-corp",
110
128
  },
111
129
  report: {
112
130
  args: "--result <text> [options]",
113
131
  flags: [
114
- "--result", "--id", "--unit", "--period", "--status", "--attach", "--no-push",
132
+ "--result", "--id", "--unit", "--period", "--status", "--trigger", "--attach", "--no-push",
115
133
  ],
116
- desc: "record a runone job's row in runs.jsonl; stamps id/ts, pushes",
134
+ desc: "settle a jobstamps id/ts/state into runs.jsonl (auto-pushes when linked)",
117
135
  more: [
118
136
  "One run = one JOB — a unit of work your manager would recognize; the runs",
119
137
  "list reads as the job list. Give a job a stable, meaningful --id",
120
138
  "(recon-acme-2026-11); reporting the same id again folds onto that job's row",
121
139
  "(progress evolves: blocked → ok). Sittings/sessions never mint runs —",
122
140
  "stopping to wait for a human is not a job.",
141
+ "A report SETTLES the job (state: settled). A job that outlives a sitting",
142
+ "should open with `figs checkpoint` first; a report with no prior checkpoint",
143
+ "is simply a job born settled (the single-sitting case).",
144
+ "--trigger '<what set this sitting in motion>' — state it in a fresh sitting",
145
+ "('monthly close cron', 'Wayne, in chat'); omit when continuing one.",
123
146
  "You supply the content; the CLI does the bookkeeping (id, real-clock ts,",
124
147
  "validation, artifact copy, push).",
125
148
  "Single-quote prose values ('…') — inside double quotes your shell expands $,",
126
149
  "so \"($4,474.63)\" reaches figs as \"(,474.63)\": silent corruption.",
127
- "--attach <file> (repeatable) copies the file into artifacts/ and links it.",
150
+ "--attach <file> (repeatable) pins a file to this moment (attachments[]) ",
151
+ "rendered types (html/md/txt/json/images) show inline; data/docs (csv/pdf/xlsx/docx) download.",
128
152
  "--no-push writes locally only; `figs push` publishes later.",
129
- "Closing an ask is `figs resolve` — a close is not a job; never report one.",
153
+ "Closing an ask is `figs close` — a close is not a job; never report one.",
130
154
  "Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
131
155
  ],
132
156
  eg: "figs report --id recon-acme-2026-11 --result '88% matched · 31 flagged' --attach ./acme-2025-11.html",
133
157
  },
158
+ checkpoint: {
159
+ args: "--id <job> --note <text> [options]",
160
+ flags: [
161
+ "--id", "--note", "--trigger", "--status", "--unit", "--period", "--attach", "--no-push",
162
+ ],
163
+ desc: "save a job's progress mid-flight — marks it in-flight (auto-pushes when linked)",
164
+ more: [
165
+ "Your first checkpoint OPENS the job (state: in-flight) — make it the first",
166
+ "act of any job that will outlive this sitting: say what triggered it",
167
+ "(--trigger) and what you're setting out to do (--note). If you die mid-job,",
168
+ "the checkpoint is what the next session finds in `figs inbox`; without one,",
169
+ "the job never existed anywhere.",
170
+ "Checkpoint at MANAGER grain — a step a human would recognize ('statements",
171
+ "pulled — matching now'), never per tool call.",
172
+ "--note is the job's current one-line state; it shows on the job's row and",
173
+ "evolves with each fold. --status still carries the outcome look (a stuck",
174
+ "job is --status warn — in-flight and warn are independent).",
175
+ "`figs report --id <same-id>` settles the job when it's done — including",
176
+ "abandoning it (--status warn --result 'abandoned — superseded by …').",
177
+ "A checkpoint isn't a checkpoint until it's pushed — this verb pushes itself.",
178
+ ],
179
+ eg: "figs checkpoint --id recon-acme-2026-11 --note 'Statements pulled — matching now' --trigger 'monthly close cron'",
180
+ },
134
181
  ask: {
135
182
  args: "<type> --title <text> [options]",
136
183
  flags: [
137
184
  "--id", "--title", "--need", "--found", "--option", "--on-approve", "--detail",
138
185
  "--attach", "--to", "--unit", "--run", "--stdin", "--no-push",
139
186
  ],
140
- desc: "raise an ask — one self-contained line in asks.jsonl, pushed so a human sees it",
187
+ desc: "raise an ask — a self-contained line in asks.jsonl (auto-pushes when linked)",
141
188
  more: [
142
- "<type> = the answer contract: needs-decision (give me an answer) ·",
143
- "sign-off (give me a verdict) · fyi (no answer a for-the-record note).",
189
+ "<type> = the answer contract: question (give me an answer) ·",
190
+ "sign-off (give me a verdict). Two types the type IS the contract.",
144
191
  "Two strangers read every ask — a human deciding, a future session acting;",
145
192
  "the record must carry everything both need: --found (what you saw), --need",
146
193
  "(what you need), --option (repeatable; short, stable, quotable — answers cite",
@@ -160,35 +207,70 @@ const COMMANDS = {
160
207
  ],
161
208
  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",
162
209
  },
210
+ answer: {
211
+ args: "<ask-id> --by <who> (--chosen <option> | --text <reply> | --approve | --request-changes | --reject)",
212
+ flags: [
213
+ "--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push",
214
+ ],
215
+ desc: "record your human's out-of-band reply to an ask, verbatim (you run this, not them)",
216
+ more: [
217
+ "Humans don't type commands. They answer you in chat ('approved — only the 15');",
218
+ "you transcribe that into the record. --by names the HUMAN who said it, not you.",
219
+ "question → --chosen '<option verbatim>' (checked against the ask's options) or",
220
+ "--text '<what they said>'. sign-off → --approve | --request-changes | --reject",
221
+ "(a qualified verdict may also carry --chosen). Transcribe verbatim — never summarize,",
222
+ "never author the reply yourself.",
223
+ "Replies made IN the app sync down automatically (figs inbox) — `figs answer` is only",
224
+ "for replies that exist nowhere but your chat.",
225
+ "Then act, and `figs close <ask-id>` — it cites this reply automatically.",
226
+ ],
227
+ eg: "figs answer acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
228
+ },
163
229
  inbox: {
164
- args: "[<ask-id>] [--json]",
165
- flags: ["--json"],
166
- desc: "what needs you — your humans' answers/verdicts on your asks (pure read)",
230
+ args: "[<ask-id>] [--json] [--no-sync]",
231
+ flags: ["--json", "--no-sync"],
232
+ desc: "what needs you — your humans' replies on your asks + your unfinished jobs (pure read)",
167
233
  more: [
168
234
  "Start every session with this. Bare: lists every ask with thread activity —",
169
235
  "answers and verdicts verbatim, plus the exact next command for each.",
170
236
  "With an ask id: the full handoff package — the ask, the whole thread, and its",
171
- "attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
172
- "session can act from the record alone.",
173
- "Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
174
- "Reads only — closing still happens via figs resolve.",
237
+ "attached artifacts.",
238
+ "Scope: THIS agent's open asks + their replies + unfinished jobs (in-flight runs).",
239
+ "When linked, a soft messages-only down-sync runs first (degrades loudly, never blocks;",
240
+ "--no-sync to skip). Reads only — recording a chat reply is figs answer; closing is figs close.",
175
241
  ],
176
242
  eg: "figs inbox",
177
243
  },
178
- resolve: {
179
- args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn|--rejected]",
180
- flags: ["--chosen", "--by", "--note", "--withdrawn", "--rejected", "--no-push"],
181
- desc: "close an ask — appends the resolution fold line and pushes",
244
+ show: {
245
+ args: "<ask-id|job-id> [--json]",
246
+ flags: ["--json"],
247
+ desc: "magnify one ask or job — the folded record + its thread/trail + attachments (pure local)",
248
+ more: [
249
+ "Auto-detects an ask (shows the reply thread) or a job (shows its checkpoint trail).",
250
+ "Reads local files and folds them for you — no raw-JSONL spelunking. No network:",
251
+ "an attachment not on this machine is noted (view it in the app), never downloaded.",
252
+ ],
253
+ eg: "figs show acme-bridge",
254
+ },
255
+ close: {
256
+ args: "<ask-id> [--note <text>] [--run <run-id>] [--attach <file>] [--withdrawn]",
257
+ flags: [
258
+ "--note", "--run", "--attach", "--withdrawn", "--no-push",
259
+ // accepted-but-removed: handled with a teaching error pointing to the new path
260
+ "--chosen", "--by", "--answer-id", "--rejected",
261
+ ],
262
+ desc: "close an ask — derives the close from the reply on file and cites it",
182
263
  more: [
183
- "--chosen must quote one of the ask's options[] verbatim (checked).",
184
- "Three closes, by who ended it: resolved (defaultthe need was met) ·",
185
- "--withdrawn (YOU retracted it; nobody acted) · --rejected (a HUMAN declined",
186
- "it record their out-of-band no; rejected is terminal, re-raising = a new ask).",
187
- "After an answer, fork on what it unlocked: nothing new resolve right away;",
188
- "real work do the job, `figs report` it under its own id, THEN resolve —",
189
- "cite the job id in --note so a reader can find the work.",
264
+ "A pure close: it reads the newest reply for the ask (recorded by `figs answer`",
265
+ "or synced from the app) and derives the outcome resolved on an answer/approval,",
266
+ "rejected on a reject verdict; it refuses if nothing's answered yet, or if changes",
267
+ "were requested (revise and re-raise on the same id instead).",
268
+ "--run <run-id> links the JOB the reply set in motion (so a reader sees what you did).",
269
+ "--withdrawn = YOU retracted the ask (no reply needed; nobody acted).",
270
+ "After an answer, fork on what it unlocked: nothing new close right away;",
271
+ "real work → do the job, `figs report` it under its own id, then close --run <that id>.",
190
272
  ],
191
- eg: "figs resolve acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
273
+ eg: "figs close acme-bridge --run apply-bridge-2026-06",
192
274
  },
193
275
  doctor: {
194
276
  args: "",
@@ -206,7 +288,11 @@ const COMMANDS = {
206
288
  ],
207
289
  eg: "figs push",
208
290
  },
209
- version: { args: "", flags: [], desc: "print the CLI version and check for updates" },
291
+ version: {
292
+ args: "[--check]",
293
+ flags: ["--check"],
294
+ desc: "print the CLI version (offline); --check also asks the server for updates",
295
+ },
210
296
  help: { args: "[<command>]", flags: [], desc: "show this help, or detailed help for one command" },
211
297
  }
212
298
 
@@ -230,6 +316,14 @@ function die(msg) {
230
316
  function readJson(path, fallback) {
231
317
  return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback
232
318
  }
319
+ /**
320
+ * The one `--json` envelope for read verbs: `{ ok, data, warnings }`. Uniform
321
+ * so an agent parses every read the same way and can branch on `ok` and surface
322
+ * `warnings` (e.g. a degraded sync) without scraping stderr.
323
+ */
324
+ function printJson(data, { ok = true, warnings = [] } = {}) {
325
+ console.log(JSON.stringify({ ok, data, warnings }, null, 2))
326
+ }
233
327
  /** Read a flag value — supports both `--name value` and `--name=value`. */
234
328
  function flag(name) {
235
329
  const args = process.argv.slice(2)
@@ -290,18 +384,26 @@ function genId(prefix) {
290
384
  // ---------- local validation (the spec's common mistakes, caught on write) ----
291
385
  // The server's schema stays the source of truth; these catch what hand-authors
292
386
  // and flag typos get wrong, with errors that teach the fix.
293
- // Three types = three answer contracts: needs-decision (give me an answer) ·
294
- // sign-off (give me a verdict) · fyi (no answer — a for-the-record note).
295
- // "blocked" was folded into needs-decision in 0.6.0: a stuck JOB is the run's
296
- // status, not an ask type; what you need from a human is a needs-decision.
297
- const ASK_TYPES = ["needs-decision", "sign-off", "fyi"]
387
+ // Two types = two answer contracts, and the type IS the contract:
388
+ // question answer (an option or free text) · sign-off verdict.
389
+ // History (all pre-launch, free to break): "blocked" folded into the type that
390
+ // became "question" (a stuck JOB is the run's status, not an ask type);
391
+ // "needs-decision" was renamed to "question" (1.0); "fyi" was retired (1.0) — a
392
+ // for-the-record note is a settled report, not an ask.
393
+ const ASK_TYPES = ["question", "sign-off"]
298
394
  const RUN_STATUSES = ["ok", "warn", "fail"]
395
+ // Lifecycle, orthogonal to status (outcome): checkpoint stamps in-flight,
396
+ // report stamps settled. Absent = settled (a plain report is a complete fact).
397
+ const RUN_STATES = ["in-flight", "settled"]
299
398
  const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
300
399
  const TO_VALUES = ["manager", "builder"]
301
- const ARTIFACT_EXTS = new Set([
302
- ".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
303
- ])
304
- const ARTIFACT_MAX = 3 * 1024 * 1024
400
+ // Two render classes. RENDERABLE shows inline in the sandboxed viewer;
401
+ // DOWNLOAD_ONLY (data/docs back-office work products) is offered as a
402
+ // download, never rendered (lower risk than HTML rendering — nothing executes).
403
+ const RENDERABLE_EXTS = [".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]
404
+ const DOWNLOAD_ONLY_EXTS = [".csv", ".pdf", ".xlsx", ".xls", ".docx"]
405
+ const ARTIFACT_EXTS = new Set([...RENDERABLE_EXTS, ...DOWNLOAD_ONLY_EXTS])
406
+ const ARTIFACT_MAX = 10 * 1024 * 1024
305
407
 
306
408
  /** "signoff" → `did you mean "sign-off"?` — normalized nearest match. */
307
409
  function didYouMean(value, allowed) {
@@ -322,13 +424,23 @@ function validateRun(r) {
322
424
  if (!r.id || typeof r.id !== "string") issues.push(`${label}: missing required "id"`)
323
425
  if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
324
426
  checkEnum(issues, r, "status", RUN_STATUSES, label)
325
- if (r.artifact !== undefined && typeof r.artifact !== "string") {
326
- issues.push(`${label}.artifact: must be a single file name (use "artifacts" for a list)`)
427
+ checkEnum(issues, r, "state", RUN_STATES, label)
428
+ checkAttachments(issues, r, label)
429
+ return issues
430
+ }
431
+ /** attachments[] is the unified field (bare file names). The legacy run
432
+ * `artifact`/`artifacts` and ask `refs` are still READ (attachmentsOf), so we
433
+ * only type-check them here for back-compat; new writes use attachments[]. */
434
+ function checkAttachments(issues, obj, label) {
435
+ if (obj.attachments !== undefined && (!Array.isArray(obj.attachments) || obj.attachments.some((a) => typeof a !== "string"))) {
436
+ issues.push(`${label}.attachments: must be an array of file names`)
327
437
  }
328
- if (r.artifacts !== undefined && (!Array.isArray(r.artifacts) || r.artifacts.some((a) => typeof a !== "string"))) {
329
- issues.push(`${label}.artifacts: must be an array of file names`)
438
+ if (obj.artifact !== undefined && typeof obj.artifact !== "string") {
439
+ issues.push(`${label}.artifact: legacy field — use attachments[] (an array of file names)`)
440
+ }
441
+ if (obj.artifacts !== undefined && (!Array.isArray(obj.artifacts) || obj.artifacts.some((a) => typeof a !== "string"))) {
442
+ issues.push(`${label}.artifacts: legacy field — use attachments[] (an array of file names)`)
330
443
  }
331
- return issues
332
444
  }
333
445
  /** Validate one folded ask record → array of issue strings. */
334
446
  function validateAsk(a) {
@@ -341,7 +453,15 @@ function validateAsk(a) {
341
453
  )
342
454
  } else if (a.type === "blocked") {
343
455
  issues.push(
344
- `${label}.type: "blocked" was folded into needs-decision — a stuck job is the RUN's status (re-report --status onto the same job id); what you need from a human is a needs-decision ask`,
456
+ `${label}.type: "blocked" isn't an ask type — a stuck job is the RUN's status (re-report --status onto the same job id); what you need from a human is a "question"`,
457
+ )
458
+ } else if (a.type === "needs-decision") {
459
+ issues.push(
460
+ `${label}.type: "needs-decision" was renamed to "question" (the type is the answer contract: question → answer · sign-off → verdict)`,
461
+ )
462
+ } else if (a.type === "fyi") {
463
+ issues.push(
464
+ `${label}.type: "fyi" was retired — a for-the-record note is a settled report (\`figs report\`), not an ask; if you actually need a human it's a "question" or "sign-off"`,
345
465
  )
346
466
  } else checkEnum(issues, a, "type", ASK_TYPES, label)
347
467
  if (!a.title) issues.push(`${label}: missing required "title"`)
@@ -363,16 +483,53 @@ function validateAsk(a) {
363
483
  issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
364
484
  }
365
485
  if (a.refs !== undefined && (!Array.isArray(a.refs) || a.refs.some((r) => !r || typeof r.label !== "string"))) {
366
- issues.push(`${label}.refs: must be [{ "label": "…", "artifact": "<file in artifacts/>" }]`)
486
+ issues.push(`${label}.refs: legacy field — use attachments[] (an array of file names)`)
487
+ }
488
+ checkAttachments(issues, a, label)
489
+ return issues
490
+ }
491
+ // ---------- messages.jsonl — the human ledger -------------------------------
492
+ // Human replies to asks: answers + verdicts (later: note/directive). Events,
493
+ // not records — immutable, ids minted once, they accumulate (no fold). `source`
494
+ // is *where* a reply arrived, display only — the verified grade comes from mint
495
+ // origin (server-minted = attested; pushed-up = transcription), never `source`.
496
+ const MSG_KINDS = ["answer", "verdict"]
497
+ const VERDICTS = ["approved", "changes-requested", "rejected"]
498
+ const MSG_SOURCES = ["app", "chat"]
499
+ /** Validate one message event → array of issue strings. */
500
+ function validateMessage(m) {
501
+ const issues = []
502
+ const label = `message "${m.id ?? "?"}"`
503
+ if (!m.id || typeof m.id !== "string") issues.push(`${label}: missing required "id"`)
504
+ if (!m.ask || typeof m.ask !== "string") issues.push(`${label}: missing required "ask" (the ask id this replies to)`)
505
+ if (!m.ts) issues.push(`${label}: missing required "ts"`)
506
+ if (!m.by) issues.push(`${label}: missing required "by" (who said it — the human, not you)`)
507
+ checkEnum(issues, m, "kind", MSG_KINDS, label)
508
+ checkEnum(issues, m, "verdict", VERDICTS, label)
509
+ checkEnum(issues, m, "source", MSG_SOURCES, label)
510
+ if (m.kind === "verdict" && !m.verdict) {
511
+ issues.push(`${label}: a verdict message needs "verdict" (${VERDICTS.join(" / ")})`)
512
+ }
513
+ if (m.kind === "answer" && !m.chosen && !m.text) {
514
+ issues.push(`${label}: an answer needs "chosen" or "text"`)
367
515
  }
368
516
  return issues
369
517
  }
370
- /** Validate the whole local outbox (folded) — returns all issues. */
371
- function validateOutbox(runs, asks) {
372
- return [...runs.flatMap(validateRun), ...asks.flatMap(validateAsk)]
518
+
519
+ /** Validate the whole local outbox (folded records + message events). */
520
+ function validateOutbox(runs, asks, messages = []) {
521
+ return [
522
+ ...runs.flatMap(validateRun),
523
+ ...asks.flatMap(validateAsk),
524
+ ...messages.flatMap(validateMessage),
525
+ ]
373
526
  }
374
- function getToken() {
375
- return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
527
+ /** Does an ask with this id exist locally — raised here, or known via a message? */
528
+ function askExistsLocally(askId) {
529
+ return (
530
+ foldById(readJsonl("asks.jsonl")).some((a) => a.id === askId) ||
531
+ readJsonl("messages.jsonl").some((m) => m.ask === askId)
532
+ )
376
533
  }
377
534
  function resolveEndpoint() {
378
535
  const cfg = readJson(join(repoDir, "config.json"), {})
@@ -381,11 +538,96 @@ function resolveEndpoint() {
381
538
  "",
382
539
  )
383
540
  }
541
+ // ---------- credentials, keyed by endpoint origin --------------------------
542
+ // One token per endpoint origin so a prod token is never sent to a localhost
543
+ // dev endpoint (the old single-token file's real bug) and prod + dev can coexist.
544
+ // File shape: { "https://app.figs.so": { "token": "…" }, … }. The pre-1.0 shape
545
+ // was a bare { "token": "…" } — migrated on read to the default endpoint.
546
+ /** Canonical origin (scheme://host:port, no path/trailing slash) of a URL. */
547
+ function originOf(url) {
548
+ try {
549
+ return new URL(url).origin
550
+ } catch {
551
+ return String(url).replace(/\/+$/, "")
552
+ }
553
+ }
554
+ /** Load the credentials file, migrating the legacy bare-token shape in memory. */
555
+ function loadCreds() {
556
+ const raw = readJson(globalCreds, {})
557
+ if (typeof raw.token === "string") {
558
+ // Legacy single token — it was always the default endpoint's.
559
+ return { [originOf(DEFAULT_ENDPOINT)]: { token: raw.token } }
560
+ }
561
+ return raw
562
+ }
563
+ function getToken() {
564
+ return process.env.FIGS_TOKEN || loadCreds()[originOf(resolveEndpoint())]?.token
565
+ }
566
+
567
+ // ---------- the state model -------------------------------------------------
568
+ // Two orthogonal facts: local-vs-linked (does config declare a workspace?) and
569
+ // authenticated (is there a token?). The rule: the config declares intent; the
570
+ // CLI errors only when declared intent can't be met. A local repo (no
571
+ // workspaceId) is a deliberate, first-class state — never an error.
572
+
573
+ /** Linked = config declares a destination workspace (intent to publish). */
574
+ function isLinked() {
575
+ return Boolean(readJson(join(repoDir, "config.json"), {}).workspaceId)
576
+ }
577
+
578
+ // Exit codes: 0 = recorded (and published, if linked) · 1 = nothing written
579
+ // (fix the input) · 2 = recorded locally, publish failed (retry `figs push`,
580
+ // never re-run the verb). The canonical exit-2 line below is what an agent keys
581
+ // on — the number inverts some CLI priors, so the words carry the contract.
582
+ const RECORDED_LOCALLY =
583
+ "recorded locally — do NOT re-run this verb; `figs push` retries"
584
+
585
+ /**
586
+ * When there's no `.figs/` in cwd, peek UPWARD (read-only) for a parent agent's
587
+ * folder so we can tell the agent where it is — but never adopt that identity.
588
+ * The fleet topology forbids walk-up: an openfigs fleet has the recruiter's
589
+ * `.figs/` at the root and each employee's own `.figs/` in its folder; silently
590
+ * using the parent would make a child agent report as the recruiter. Bounded;
591
+ * stops at the filesystem root.
592
+ */
593
+ function peekParentFigs() {
594
+ let dir = join(process.cwd(), "..")
595
+ for (let i = 0; i < 40; i++) {
596
+ if (existsSync(join(dir, ".figs"))) {
597
+ return { dir, name: readJson(join(dir, ".figs", "agent.json"), {})?.name }
598
+ }
599
+ const parent = join(dir, "..")
600
+ if (parent === dir) break // filesystem root
601
+ dir = parent
602
+ }
603
+ return null
604
+ }
605
+ /** The "no .figs/ here" message — points at a parent if one exists, never uses it. */
606
+ function noFigsHint() {
607
+ const p = peekParentFigs()
608
+ if (p) {
609
+ return `no .figs/ here, but found one at ${p.dir}${p.name ? ` (agent: ${p.name})` : ""} — if you ARE that agent, cd there; if you're a different agent, run \`figs init\` at YOUR root`
610
+ }
611
+ return "no .figs/ here — run `figs init` first"
612
+ }
613
+
614
+ /**
615
+ * Auth headers for a token. Standard `Authorization: Bearer` is the contract;
616
+ * we ALSO send the legacy `x-figs-token` through the transition so a 1.0 CLI
617
+ * works against an app that hasn't shipped Bearer yet. Drop `x-figs-token` here
618
+ * once the app requires Bearer (next MIN_CLI bump).
619
+ */
620
+ function authHeaders(token) {
621
+ if (!token) return {}
622
+ return { authorization: `Bearer ${token}`, "x-figs-token": token }
623
+ }
624
+
384
625
  const REQUEST_TIMEOUT_MS = 30000
626
+ const SYNC_TIMEOUT_MS = 5000 // session-start sync degrades fast, never blocks
385
627
  /** `fetch` with a hard timeout so a hung server never stalls the agent. */
386
- async function fetchT(url, opts = {}) {
628
+ async function fetchT(url, opts = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
387
629
  const ctrl = new AbortController()
388
- const t = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS)
630
+ const t = setTimeout(() => ctrl.abort(), timeoutMs)
389
631
  try {
390
632
  return await fetch(url, { ...opts, signal: ctrl.signal })
391
633
  } finally {
@@ -406,7 +648,7 @@ async function request(method, path, body, token = getToken()) {
406
648
  method,
407
649
  headers: {
408
650
  "content-type": "application/json",
409
- ...(token ? { "x-figs-token": token } : {}),
651
+ ...authHeaders(token),
410
652
  },
411
653
  body: body ? JSON.stringify(body) : undefined,
412
654
  })
@@ -493,12 +735,22 @@ function printHelp(name) {
493
735
  if (c.eg) console.log(`\n e.g. ${c.eg}`)
494
736
  return
495
737
  }
496
- console.log("figs — publish your AI agent's state to Figs (https://figs.so)\n")
738
+ console.log("figs — your AI agent's work journal; report it to humans at https://figs.so\n")
497
739
  console.log("Usage: figs <command> [options]\n")
498
- console.log("Commands:")
499
- for (const [n, c] of Object.entries(COMMANDS)) {
500
- console.log(` ${`${n} ${c.args}`.trim().padEnd(pad)} ${c.desc}`)
740
+ // Grouped by layer so the local-first model is legible in the help itself:
741
+ // LOCAL works with no account; CONNECTED needs a one-time login + a workspace.
742
+ const LOCAL = ["init", "report", "checkpoint", "ask", "answer", "inbox", "show", "close", "doctor", "status", "version", "help"]
743
+ const CONNECTED = ["login", "logout", "link", "push"]
744
+ const printGroup = (title, names) => {
745
+ console.log(title)
746
+ for (const n of names) {
747
+ const c = COMMANDS[n]
748
+ if (c) console.log(` ${`${n} ${c.args}`.trim().padEnd(pad)} ${c.desc}`)
749
+ }
501
750
  }
751
+ printGroup("Local (no account needed):", LOCAL)
752
+ console.log("")
753
+ printGroup("Connected (one-time login + a workspace):", CONNECTED)
502
754
  console.log("\nGlobal flags:")
503
755
  console.log(` ${"-h, --help".padEnd(pad)} show help (or \`figs help <command>\`)`)
504
756
  console.log(` ${"-v, --version".padEnd(pad)} print the CLI version`)
@@ -511,20 +763,25 @@ function printHelp(name) {
511
763
 
512
764
  if (cmd === "help" || cmd === "-h" || cmd === "--help") printHelp(process.argv[3])
513
765
  else if (cmd === "version" || cmd === "--version" || cmd === "-v" || cmd === "-V") {
766
+ // Offline by default — printing your own version must never need the network
767
+ // (contract: no network on the hot path of a local verb). `--check` opts in.
514
768
  console.log(VERSION)
515
- await checkVersion({ force: true })
769
+ if (hasFlag("--check")) await checkVersion({ force: true })
516
770
  } else if (WANTS_HELP) printHelp(cmd)
517
771
  else if (COMMANDS[cmd]) {
518
772
  checkFlags(cmd) // reject unknown flags before running
519
773
  if (cmd === "login") await login(process.argv[3])
520
774
  else if (cmd === "logout") logout()
521
775
  else if (cmd === "status") await status()
522
- else if (cmd === "workspaces") await workspaces()
523
776
  else if (cmd === "init") await init()
777
+ else if (cmd === "link") await link()
524
778
  else if (cmd === "report") await reportCmd()
779
+ else if (cmd === "checkpoint") await checkpointCmd()
525
780
  else if (cmd === "ask") await askCmd()
781
+ else if (cmd === "answer") await answerCmd()
526
782
  else if (cmd === "inbox") await inboxCmd()
527
- else if (cmd === "resolve") await resolveCmd()
783
+ else if (cmd === "show") await showCmd()
784
+ else if (cmd === "close") await closeCmd()
528
785
  else if (cmd === "doctor") await doctor()
529
786
  else if (cmd === "push") await push()
530
787
  } else {
@@ -536,9 +793,13 @@ function sleep(ms) {
536
793
  return new Promise((r) => setTimeout(r, ms))
537
794
  }
538
795
  function saveToken(token) {
796
+ // Key the token under the endpoint origin we're logging into; preserve tokens
797
+ // for other origins (migrating the legacy shape in the process).
798
+ const creds = loadCreds()
799
+ creds[originOf(resolveEndpoint())] = { token }
539
800
  // A bearer token must not be group/other-readable: dir 0700, file 0600.
540
801
  mkdirSync(globalDir, { recursive: true, mode: 0o700 })
541
- writeFileSync(globalCreds, JSON.stringify({ token }, null, 2) + "\n", { mode: 0o600 })
802
+ writeFileSync(globalCreds, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 })
542
803
  // Enforce perms even if the dir/file pre-existed with looser modes (mode on
543
804
  // write only applies at creation).
544
805
  try {
@@ -620,15 +881,22 @@ async function login(token) {
620
881
  * can't be removed here (unset the env var to fully log out).
621
882
  */
622
883
  function logout() {
623
- if (existsSync(globalCreds)) {
884
+ const origin = originOf(resolveEndpoint())
885
+ const creds = loadCreds()
886
+ if (creds[origin]) {
887
+ delete creds[origin]
624
888
  try {
625
- rmSync(globalCreds)
889
+ if (Object.keys(creds).length) {
890
+ writeFileSync(globalCreds, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 })
891
+ } else if (existsSync(globalCreds)) {
892
+ rmSync(globalCreds) // nothing left — remove the file entirely
893
+ }
626
894
  } catch (e) {
627
- die(`could not remove ${globalCreds}: ${e?.message || e}`)
895
+ die(`could not update ${globalCreds}: ${e?.message || e}`)
628
896
  }
629
- console.log("figs: ✓ logged out removed ~/.figs/credentials.json")
897
+ console.log(`figs: ✓ logged out of ${origin} (other endpoints' tokens, if any, are kept)`)
630
898
  } else {
631
- console.log("figs: not logged in — no ~/.figs/credentials.json to remove")
899
+ console.log(`figs: not logged in to ${origin} nothing to remove`)
632
900
  }
633
901
  if (process.env.FIGS_TOKEN) {
634
902
  console.warn(
@@ -662,30 +930,30 @@ async function status() {
662
930
  }
663
931
  }
664
932
 
933
+ const linked = Boolean(cfg?.workspaceId)
934
+
665
935
  if (JSON_OUT) {
666
- console.log(
667
- JSON.stringify(
668
- {
669
- version: VERSION,
670
- endpoint,
671
- loggedIn,
672
- account: account
673
- ? { id: account.id, email: account.email, name: account.name }
674
- : null,
675
- workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
676
- config: cfg ? { workspaceId: cfg.workspaceId, agentId: cfg.agentId } : null,
677
- agentJson: hasAgent,
678
- contractMd: hasContract,
679
- },
680
- null,
681
- 2,
682
- ),
683
- )
684
- return
936
+ return printJson({
937
+ version: VERSION,
938
+ mode: linked ? "linked" : "local",
939
+ endpoint,
940
+ loggedIn,
941
+ account: account ? { id: account.id, email: account.email, name: account.name } : null,
942
+ workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
943
+ config: cfg ? { agentId: cfg.agentId, workspaceId: cfg.workspaceId ?? null } : null,
944
+ agentJson: hasAgent,
945
+ contractMd: hasContract,
946
+ })
685
947
  }
686
948
 
687
949
  const row = (k, v) => console.log(` ${(k + ":").padEnd(12)} ${v}`)
688
950
  console.log("figs status")
951
+ row(
952
+ "mode",
953
+ linked
954
+ ? "linked — publishes to the hosted app on push"
955
+ : "local — fully operational offline; `figs link` to publish",
956
+ )
689
957
  row(
690
958
  "logged in",
691
959
  loggedIn
@@ -694,7 +962,9 @@ async function status() {
694
962
  ? `can't reach ${endpoint}`
695
963
  : token
696
964
  ? "token invalid — run `figs login`"
697
- : "no — run `figs login`",
965
+ : linked
966
+ ? "no — run `figs login`, then `figs push`"
967
+ : "no (not needed for local mode)",
698
968
  )
699
969
  if (loggedIn) {
700
970
  row(
@@ -706,9 +976,7 @@ async function status() {
706
976
  }
707
977
  row(
708
978
  "workspace",
709
- cfg?.workspaceId
710
- ? cfg.workspaceId
711
- : "not initialized — run `figs init --workspace <id>`",
979
+ linked ? cfg.workspaceId : "none — `figs link` to connect one",
712
980
  )
713
981
  row("agent.json", hasAgent ? "present (identity)" : "missing — author .figs/agent.json")
714
982
  row(
@@ -717,65 +985,17 @@ async function status() {
717
985
  ? "present (activity) — follow it"
718
986
  : "none yet — Activity is optional, agree it with your user",
719
987
  )
988
+ row("journal", "records live on this machine (the app is the durable record once linked)")
720
989
  row("endpoint", endpoint)
721
990
  row("cli", VERSION)
722
991
  }
723
992
 
724
- /**
725
- * List the user's workspaces (read-only). Creating a workspace is a human,
726
- * web-side action — the agent only ever joins one it was pointed at. Surfaces
727
- * the slug, which is what `figs init --workspace <slug>` takes.
728
- */
729
- async function workspaces() {
730
- const { workspaces: list = [] } = await api("GET", "/api/workspaces")
731
- if (JSON_OUT) return void console.log(JSON.stringify(list, null, 2))
732
- if (list.length === 0) {
733
- console.log("figs: no workspaces yet — create one in the Figs web app, then re-run.")
734
- return
735
- }
736
- console.log(`figs: ${list.length} workspace${list.length === 1 ? "" : "s"}`)
737
- for (const w of list) {
738
- console.log(` ${String(w.role).padEnd(6)} ${w.slug} — ${w.name}`)
739
- }
740
- console.log(" → figs init --workspace <slug>")
741
- }
742
-
743
993
  function isUuid(s) {
744
994
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
745
995
  String(s),
746
996
  )
747
997
  }
748
998
 
749
- /**
750
- * The local `.figs/GUIDE.md` is a thin orientation breadcrumb (safe to
751
- * regenerate) — the full, always-current guide + schema is served at
752
- * `${endpoint}/llms.txt`, and the agent's own standing agreement lives in the
753
- * precious, agent-authored `.figs/CONTRACT.md` (which `init` never touches).
754
- */
755
- function guideStub(endpoint) {
756
- return `<!-- Written by \`figs init\` (safe to regenerate). Orientation only — the full
757
- guide lives at the URL below; your own contract lives in CONTRACT.md. -->
758
- # Figs — this folder
759
-
760
- This repo publishes to **Figs** (your manager's read-only window into what you do).
761
-
762
- - **Full guide + schema → ${endpoint}/llms.txt** — read this first.
763
- - **Your contract → ./CONTRACT.md** — what you publish / hold back (you + your user author it).
764
- - **Identity → config.json** (committed) · **charter → agent.json** (you write).
765
- - **Stay visible:** keep the \`figs:begin\`…\`figs:end\` block in the instruction file you load every
766
- session (CLAUDE.md/AGENTS.md/…). It's what brought you back here — don't remove it. (Phase-1 setup; see /llms.txt.)
767
-
768
- **Where are you?** You may be reading this at any time — don't assume you're starting fresh. Run
769
- \`figs status\` and check \`.figs/\` before acting:
770
- • not set up yet → author \`.figs/agent.json\`, then \`figs doctor\` && \`figs push\` to appear (Identity).
771
- • CONTRACT.md present → follow it; keep publishing what it says.
772
- • no CONTRACT.md yet → agree with your user what work to surface (Activity) — see ${endpoint}/llms.txt.
773
-
774
- Commit config.json + agent.json + CONTRACT.md + this file. runs.jsonl / asks.jsonl / artifacts/
775
- are a gitignored outbox. The token is the human's job — never generate one yourself.
776
- `
777
- }
778
-
779
999
  /**
780
1000
  * A starter `agent.json` — written by `figs init` only when none exists. The
781
1001
  * `<…>` values are placeholders the agent fills in by reading its own repo;
@@ -803,28 +1023,36 @@ function agentJsonStub(name) {
803
1023
  )
804
1024
  }
805
1025
 
806
- /** A starter activity contract — written by `figs init` only when none exists. */
1026
+ /**
1027
+ * A starter CONTRACT — the going-live record, written by `figs init` only when
1028
+ * none exists. It's the agent↔user agreement, authored in the going-live
1029
+ * conversation (not mechanically). The `<…>` prompts are answered with the user.
1030
+ */
807
1031
  function contractStub(name) {
808
- return `# Activity contract — ${name} on Figs
1032
+ return `# Contract — ${name} on Figs
809
1033
 
810
- What this agent surfaces to Figs vs. holds back. **Agree it with your user** this is the
811
- deliberate Activity step, not something to do mechanically. See \`.figs/GUIDE.md\` for the why.
1034
+ The agreement between this employee and its humans: what counts as real work, what gets surfaced,
1035
+ what's held back. **Author this WITH your user** in the going-live conversation (see the guide) —
1036
+ not mechanically. Keep it honest and current.
812
1037
 
813
- > **Maintain:** edit when the surfacing agreement changes (a new stream, a sensitivity change).
814
- > Keep it honest to what you actually push.
1038
+ > **Maintain:** edit when the work or the surfacing agreement changes.
815
1039
 
816
- ## What I surface
1040
+ ## Fit
1041
+ <are you a good fit? Figs is for recurring work a human wants to stay in the loop on. "Not yet,
1042
+ because X" is a valid, honest answer.>
817
1043
 
818
- | Stream | Surface? | Content |
819
- |--------|----------|---------|
820
- | **runs** | <yes/no> | one line per run — what I did, de-identified scope, the headline result + status. |
821
- | **artifacts** | <yes/no> | the report(s) a run produced. |
822
- | **asks** | when real | genuine blockers / decisions / sign-offs / FYIs for my manager. 0 is a fine number. |
1044
+ ## What's a job (what I report)
1045
+ - **A job is:** <the unit your manager would recognize — e.g. "one monthly reconciliation">
1046
+ - **I checkpoint:** <the mid-flight steps a human would recognize, for jobs that outlive a sitting>
1047
+ - **A job settles when:** <the headline result that means done>
823
1048
 
824
1049
  ## What I never surface
825
-
826
1050
  Raw user content — ever. Plus, for this agent: <anything sensitive to its domain>. Use
827
1051
  **de-identified labels** (\`<scope>-01\`), never customer or system names.
1052
+
1053
+ ## Inbox cadence
1054
+ <when do I process human replies? dedicated schedule (recommended) · spawned sweep · at session
1055
+ start. How it's scheduled is a build concern (OpenFigs) + your user's call — record it here.>
828
1056
  `
829
1057
  }
830
1058
 
@@ -850,72 +1078,89 @@ function findPlaceholders(obj) {
850
1078
  }
851
1079
 
852
1080
  /**
853
- * Resolve which workspace this `.figs/` belongs to. `--workspace` is optional:
854
- * - given a UUID → use it as-is (no network).
855
- * - given a slug → resolve to its UUID via the API (needs auth).
856
- * - omitted reuse the one already in config.json (idempotent re-init);
857
- * else use the user's only workspace (logged, so it's visible);
858
- * else list them and have the agent re-run with one (when
859
- * there's a real choice, the agent drives it — we never guess).
860
- * Returns the workspace UUID, or exits with an actionable message.
1081
+ * `figs link` connect this `.figs/` to a workspace so `figs push` can publish.
1082
+ * The ONLY place remote config (endpoint + workspaceId) is written; `init` stays
1083
+ * purely local. `--workspace`:
1084
+ * - a UUID written as-is; verified when logged in, else "unverified until push".
1085
+ * - a slug resolved via the API (needs login).
1086
+ * - omitted → list the user's workspaces (needs login); with exactly one, link it.
1087
+ * Identity (agentId) is preserved untouched.
861
1088
  */
862
- async function resolveWorkspaceId(workspaceArg, endpoint) {
863
- if (workspaceArg) {
864
- if (isUuid(workspaceArg)) return workspaceArg
865
- if (!getToken()) {
866
- die("not logged in run `figs login` first (resolving a workspace slug needs auth; or pass the workspace UUID)")
1089
+ async function link() {
1090
+ if (!existsSync(repoDir)) die(noFigsHint())
1091
+ const config = readJson(join(repoDir, "config.json"), {})
1092
+ if (!config.agentId) die("config missing agentId — run `figs init` first")
1093
+ // Honor --endpoint, then FIGS_ENDPOINT (dev), then any existing config, then
1094
+ // the baked default — resolveEndpoint() encodes that precedence.
1095
+ const endpoint = (flag("--endpoint") || resolveEndpoint()).replace(/\/+$/, "")
1096
+ const wsArg = flag("--workspace")
1097
+ const token = getToken()
1098
+
1099
+ let workspaceId
1100
+ if (wsArg && isUuid(wsArg)) {
1101
+ workspaceId = wsArg
1102
+ if (token) {
1103
+ // Verify access when we can; a network blip just defers it to first push.
1104
+ const r = await request("GET", "/api/workspaces", null, token)
1105
+ if (r.ok && !(r.data.workspaces ?? []).some((w) => w.id === wsArg)) {
1106
+ die(`workspace ${wsArg} isn't one you can access — run \`figs link\` (no flag) to list yours`)
1107
+ }
1108
+ } else {
1109
+ console.warn("figs: ! linked by UUID while logged out — unverified until your first `figs push`")
867
1110
  }
868
- const r = await request("GET", "/api/workspaces", null, getToken())
869
- if (!r.ok) {
870
- die(`could not resolve workspace "${workspaceArg}" (${r.status}): ${r.data.error ?? r.data.raw ?? ""}`)
1111
+ } else if (wsArg) {
1112
+ // A slug — only the server can map it to a UUID.
1113
+ if (!token) {
1114
+ die("resolving a workspace slug needs `figs login` first — or pass the workspace UUID (from the app's settings)")
871
1115
  }
1116
+ const r = await request("GET", "/api/workspaces", null, token)
1117
+ if (!r.ok) die(`could not resolve workspace "${wsArg}" (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
1118
+ const match = (r.data.workspaces ?? []).find((w) => w.slug === wsArg || w.id === wsArg)
1119
+ if (!match) die(`no workspace matching "${wsArg}" — run \`figs link\` (no flag) to list yours`)
1120
+ workspaceId = match.id
1121
+ } else {
1122
+ // Bare `figs link` — list; with exactly one, link it outright.
1123
+ if (!token) {
1124
+ die("run `figs login` first, then `figs link` to pick a workspace — or `figs link --workspace <uuid>` (no login needed)")
1125
+ }
1126
+ const r = await request("GET", "/api/workspaces", null, token)
1127
+ if (!r.ok) die(`could not list workspaces (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
872
1128
  const list = r.data.workspaces ?? []
873
- const match = list.find((w) => w.slug === workspaceArg || w.id === workspaceArg)
874
- if (!match) die(`no workspace matching "${workspaceArg}" — run \`figs workspaces\` to see yours`)
875
- return match.id
1129
+ if (list.length === 0) die(`no workspaces yet create one at ${endpoint}, then re-run \`figs link\``)
1130
+ if (list.length === 1) {
1131
+ workspaceId = list[0].id
1132
+ console.log(`figs: linking to ${list[0].slug} (${list[0].name})`)
1133
+ } else {
1134
+ console.log("figs: which workspace? re-run with one:")
1135
+ for (const w of list) console.log(` figs link --workspace ${w.slug} (${w.name})`)
1136
+ process.exit(1)
1137
+ }
876
1138
  }
877
1139
 
878
- // No --workspace: a re-init keeps the workspace already on file.
879
- const existing = readJson(join(repoDir, "config.json"), null)
880
- if (existing?.workspaceId) return existing.workspaceId
881
-
882
- // First-time init with no workspace named use the only one, else list them.
883
- if (!getToken()) {
884
- die("which workspace? run `figs login` first so I can list them, then `figs init --workspace <slug>` (or pass a workspace UUID directly)")
885
- }
886
- const r = await request("GET", "/api/workspaces", null, getToken())
887
- if (!r.ok) die(`could not list workspaces (${r.status}): ${r.data.error ?? r.data.raw ?? ""}`)
888
- const list = r.data.workspaces ?? []
889
- if (list.length === 0) {
890
- die(`no workspaces yet — create one at ${endpoint}, then re-run \`figs init --workspace <slug>\``)
891
- }
892
- if (list.length === 1) {
893
- console.log(`figs: using workspace ${list[0].slug} (${list[0].name})`)
894
- return list[0].id
895
- }
896
- console.log("figs: which workspace? re-run init with one of these:")
897
- for (const w of list) console.log(` figs init --workspace ${w.slug} (${w.name})`)
898
- process.exit(1)
1140
+ // Preserve identity; write the destination. config.json shape when linked:
1141
+ // { agentId, endpoint, workspaceId }.
1142
+ writeFileSync(
1143
+ join(repoDir, "config.json"),
1144
+ JSON.stringify({ agentId: config.agentId, endpoint, workspaceId }, null, 2) + "\n",
1145
+ )
1146
+ console.log(`figs: linked workspace ${workspaceId} @ ${endpoint}`)
1147
+ console.log(" next: `figs push` publishes everything recorded so far")
899
1148
  }
900
1149
 
901
1150
  async function init() {
902
- const endpoint = (flag("--endpoint") || resolveEndpoint()).replace(/\/+$/, "")
903
- const workspaceId = await resolveWorkspaceId(flag("--workspace"), endpoint)
904
-
905
- // config.json (identity + destination) is always (re)written — it's the one
906
- // file the CLI owns. A re-init reuses the existing identity UUID so every
907
- // runner of this repo pushes to the same agent.
1151
+ // Purely local `init` never touches the network, so it can never need an
1152
+ // account. Idempotent: keep the existing identity AND any link fields (a
1153
+ // re-init must NOT unlink), and never clobber an authored charter/contract/
1154
+ // guide or the outbox.
908
1155
  const existing = readJson(join(repoDir, "config.json"), null)
909
1156
  const agentId = existing?.agentId || randomUUID()
910
1157
  mkdirSync(repoDir, { recursive: true })
911
- writeFileSync(
912
- join(repoDir, "config.json"),
913
- JSON.stringify({ endpoint, workspaceId, agentId }, null, 2) + "\n",
914
- )
1158
+ const config = { agentId }
1159
+ if (existing?.endpoint) config.endpoint = existing.endpoint
1160
+ if (existing?.workspaceId) config.workspaceId = existing.workspaceId
1161
+ writeFileSync(join(repoDir, "config.json"), JSON.stringify(config, null, 2) + "\n")
915
1162
 
916
- // Everything else is scaffolded create-if-missing — `figs init` gives a fresh
917
- // repo a complete, ready-to-fill `.figs/`, and never clobbers an agent's
918
- // authored charter/contract/guide or its activity outbox.
1163
+ const endpoint = resolveEndpoint()
919
1164
  const created = []
920
1165
  const ensure = (rel, contents) => {
921
1166
  const p = join(repoDir, rel)
@@ -927,54 +1172,55 @@ async function init() {
927
1172
  ensure(
928
1173
  ".gitignore",
929
1174
  [
930
- "# Figs — commit config.json + agent.json + CONTRACT.md + GUIDE.md.",
931
- "# Activity is a transient outbox: emitted per run, aggregated remotely.",
1175
+ "# Figs — commit config.json + agent.json + CONTRACT.md.",
1176
+ "# The journal below is a machine-local outbox: records live on this machine;",
1177
+ "# the hosted app is the durable record once you `figs link` + `figs push`.",
932
1178
  "runs.jsonl",
933
1179
  "asks.jsonl",
1180
+ "messages.jsonl",
934
1181
  "artifacts/",
935
1182
  "credentials.json",
936
1183
  "",
937
1184
  ].join("\n"),
938
1185
  )
939
- ensure("GUIDE.md", guideStub(endpoint))
940
1186
  const name = basename(process.cwd())
941
1187
  const charterCreated = ensure("agent.json", agentJsonStub(name))
942
1188
  ensure("CONTRACT.md", contractStub(name))
943
1189
  ensure("runs.jsonl", "")
944
1190
  ensure("asks.jsonl", "")
1191
+ ensure("messages.jsonl", "")
945
1192
  mkdirSync(join(repoDir, "artifacts"), { recursive: true })
946
1193
 
947
- console.log(
948
- `figs: ✓ .figs/ ready — config.json written (agentId ${agentId}, workspace ${workspaceId})`,
949
- )
1194
+ console.log(`figs: ✓ .figs/ ready — you're operational locally (agentId ${agentId})`)
950
1195
  if (created.length) console.log(` scaffolded: ${created.join(", ")}`)
951
1196
  if (charterCreated) {
952
1197
  console.log(
953
- " Phase 1: fill in .figs/agent.json — it's a template; replace the <…> placeholders",
954
- )
955
- console.log(
956
- " (`figs doctor` flags any you miss), then `figs doctor` && `figs push` to appear.",
1198
+ " next: fill in .figs/agent.json — replace the <…> placeholders (`figs doctor` checks them)",
957
1199
  )
958
1200
  } else {
959
1201
  console.log(
960
- " Your charter (.figs/agent.json) is already here — `figs doctor` && `figs push` to publish.",
1202
+ " your charter (.figs/agent.json) is already here — `figs doctor` to validate it",
961
1203
  )
962
1204
  }
963
1205
  console.log(
964
- " Anchor Figs in the file you load every session (CLAUDE.md/AGENTS.md/…): paste the",
1206
+ " record work: `figs report` / `figs checkpoint` · raise asks: `figs ask` · recover: `figs inbox`",
1207
+ )
1208
+ console.log(
1209
+ ` publish to the hosted app (optional)${existing?.workspaceId ? " — already linked" : ": `figs link`"}, then \`figs push\``,
965
1210
  )
966
- console.log(` figs:begin block from ${endpoint}/llms.txt, or future sessions forget Figs.`)
967
1211
  console.log(
968
- " Commit config.json + agent.json + CONTRACT.md + GUIDE.md; never commit credentials.json.",
1212
+ ` Anchor Figs in the file you load each session (CLAUDE.md/AGENTS.md/…): paste the figs:begin block from ${endpoint}/llms.txt.`,
1213
+ )
1214
+ console.log(
1215
+ " Commit config.json + agent.json + CONTRACT.md; never commit credentials.json.",
969
1216
  )
970
- console.log(` Full guide: ${endpoint}/llms.txt`)
971
1217
  }
972
1218
 
973
1219
  // ====================== the writing verbs ===================================
974
- // report / ask / resolve — sugar over the same files (hand-writing stays
975
- // first-class). The agent supplies content; the CLI stamps id + real-clock ts,
976
- // validates with teaching errors, copies attachments, then invokes the same
977
- // push as `figs push`.
1220
+ // report / checkpoint / ask / answer / close — sugar over the same files
1221
+ // (hand-writing stays first-class). The agent supplies content; the CLI stamps
1222
+ // id + real-clock ts, validates with teaching errors, copies attachments, then
1223
+ // (when linked) invokes the same push as `figs push`.
978
1224
  //
979
1225
  // NOTE — no session auto-capture (removed in 0.5.0). The CLI used to infer a
980
1226
  // `session` trace (runtime/model/tokens) from "the newest transcript on this
@@ -985,15 +1231,47 @@ async function init() {
985
1231
  // from the runtime's own records at work-time.
986
1232
 
987
1233
  function requireFigs() {
988
- if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
1234
+ if (!existsSync(repoDir)) die(noFigsHint())
989
1235
  const config = readJson(join(repoDir, "config.json"), {})
990
- if (!config.workspaceId || !config.agentId) {
991
- die("config missing workspaceId/agentId run `figs init`")
992
- }
1236
+ // Identity is all a writing verb needs — workspaceId is only required to
1237
+ // publish (push/link). A repo without it is in deliberate local mode.
1238
+ if (!config.agentId) die("config missing agentId — run `figs init`")
993
1239
  }
994
1240
  function appendJsonl(name, obj) {
995
1241
  appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
996
1242
  }
1243
+
1244
+ // ---------- reference checks (warn, never block) ----------------------------
1245
+ // A dangling link is damage-limited; a blocked verb is not — so reference
1246
+ // checks WARN, they don't die. (Closing/answering a nonexistent ask DOES die —
1247
+ // that lives with those verbs.) Under the topology rule the local journal is
1248
+ // complete, so these checks are trustworthy.
1249
+
1250
+ /** Announce whether a stable --id opened a new record or folded onto one. */
1251
+ function announceFold(kind, id, isNew, suffix = "") {
1252
+ console.log(
1253
+ isNew
1254
+ ? `figs: new ${kind} opened: ${id}${suffix}`
1255
+ : `figs: folded onto existing ${kind} ${id}`,
1256
+ )
1257
+ }
1258
+ /** --unit should name a charter unit; warn on a typo (only once units exist). */
1259
+ function warnUnknownUnit(unit) {
1260
+ if (!unit) return
1261
+ const units = (readJson(join(repoDir, "agent.json"), {}).units ?? [])
1262
+ .map((u) => u?.id)
1263
+ .filter(Boolean)
1264
+ if (units.length && !units.includes(unit)) {
1265
+ console.warn(`figs: ! --unit "${unit}" isn't one of your charter units (${units.join(", ")}) — typo?`)
1266
+ }
1267
+ }
1268
+ /** --run should name a job in this journal; warn on a dangling link. */
1269
+ function warnUnknownRun(runId) {
1270
+ if (!runId) return
1271
+ if (!foldById(readJsonl("runs.jsonl")).some((r) => r.id === runId)) {
1272
+ console.warn(`figs: ! --run "${runId}" isn't a job in this journal — typo? this link will dangle`)
1273
+ }
1274
+ }
997
1275
  /** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
998
1276
  function attachFiles(paths) {
999
1277
  const names = []
@@ -1005,7 +1283,7 @@ function attachFiles(paths) {
1005
1283
  }
1006
1284
  const bytes = readFileSync(p)
1007
1285
  if (bytes.length > ARTIFACT_MAX) {
1008
- die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the 3 MB cap; compress or split it`)
1286
+ die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the ${ARTIFACT_MAX / 1048576} MB cap; compress or split it`)
1009
1287
  }
1010
1288
  const name = basename(p)
1011
1289
  const dest = join(repoDir, "artifacts", name)
@@ -1038,7 +1316,11 @@ function warnEatenDollar(...texts) {
1038
1316
  }
1039
1317
  }
1040
1318
 
1041
- // ---------- the inbox (the DOWN directiona pure read) ---------------------
1319
+ // ---------- the inbox + show (the DOWN view — pure local reads) -------------
1320
+ // Everything here reads local files: in-flight jobs (runs.jsonl), open asks
1321
+ // (asks.jsonl), reply threads (messages.jsonl). No win-logic — the agent gets
1322
+ // the complete, time-ordered truth and judges. (The soft messages-only
1323
+ // down-sync when linked is layered on top in a later step.)
1042
1324
 
1043
1325
  /** Relative time for inbox lines — rough on purpose. */
1044
1326
  function agoStr(iso) {
@@ -1047,276 +1329,462 @@ function agoStr(iso) {
1047
1329
  if (mins < 60 * 48) return `${Math.round(mins / 60)}h ago`
1048
1330
  return `${Math.round(mins / 1440)}d ago`
1049
1331
  }
1050
-
1051
- /** Fetch this agent's inbox; null on any failure when `soft` (auto-cite path). */
1052
- async function fetchInbox({ soft = false } = {}) {
1053
- const config = readJson(join(repoDir, "config.json"), {})
1054
- if (!config.agentId) {
1055
- if (soft) return null
1056
- die("config missing agentId — run `figs init`")
1057
- }
1058
- const r = await request("GET", `/api/inbox?agent=${config.agentId}`)
1059
- if (!r.ok) {
1060
- if (soft) return null
1061
- die(`inbox failed (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
1062
- }
1063
- return r.data
1332
+ /** Replies for an ask, oldest-first (messages accumulate; order by ts). */
1333
+ function repliesFor(messages, askId) {
1334
+ return messages
1335
+ .filter((m) => m.ask === askId)
1336
+ .sort((a, b) => new Date(a.ts) - new Date(b.ts))
1064
1337
  }
1065
-
1066
- /** One human event, verbatim answers are instructions; never paraphrase them. */
1067
- function eventLine(e) {
1338
+ /** One reply, verbatim — never paraphrase a human's words. `(transcribed)`
1339
+ * marks a chat reply (self-reported); app replies are attested by mint origin. */
1340
+ function messageLine(m) {
1068
1341
  const head =
1069
- e.kind === "verdict"
1070
- ? `${e.verdict.replace(/_/g, " ")} by ${e.byName} (${e.asRole})`
1071
- : `answered by ${e.byName} (${e.asRole})`
1072
- const body = [e.chosen ? `→ "${e.chosen}"` : null, e.text ? `"${e.text}"` : null]
1342
+ m.kind === "verdict"
1343
+ ? `${String(m.verdict).replace(/-/g, " ")} by ${m.by}`
1344
+ : `answered by ${m.by}`
1345
+ const grade = m.source === "app" ? "" : " (transcribed)"
1346
+ const body = [m.chosen ? `→ "${m.chosen}"` : null, m.text ? `"${m.text}"` : null]
1073
1347
  .filter(Boolean)
1074
1348
  .join(" · ")
1075
- return `${head} · ${agoStr(e.createdAt)}${body ? `\n ${body}` : ""}`
1076
- }
1077
-
1078
- /** The type×state matrix the exact next command. The stranger never needs
1079
- * to know the state machine — the protocol tells them their move. */
1080
- function nextMove(a) {
1081
- if (a.status === "rejected") {
1082
- return `a human declined this — acknowledge it: figs resolve ${a.id} --rejected`
1083
- }
1084
- const last = a.events[a.events.length - 1]
1085
- if (!last) return "waiting on your human — nothing for you to do"
1086
- if (last.kind === "verdict" && last.verdict === "approved") {
1087
- // A qualified verdict carries the chosen answer path — cite it in the close.
1088
- const chosen = last.chosen ? ` --chosen '${last.chosen}'` : ""
1089
- return (
1090
- `approved verify any prerequisites in the ask, then fork on what it unlocked:` +
1091
- `\n nothing left to do → figs resolve ${a.id}${chosen}` +
1092
- `\n real work → do the job, figs report it under its own --id, then figs resolve ${a.id}${chosen} --note 'job <id>'`
1093
- )
1349
+ return `${head}${grade} · ${agoStr(m.ts)}${body ? `\n ${body}` : ""}`
1350
+ }
1351
+ /** Attachments on a record — the new `attachments[]`, falling back to the old
1352
+ * run `artifact`/`artifacts` and ask `refs` shapes (forward-compatible reads). */
1353
+ function attachmentsOf(rec) {
1354
+ if (Array.isArray(rec.attachments)) return rec.attachments
1355
+ return [
1356
+ ...[rec.artifact, ...(rec.artifacts ?? [])].filter(Boolean),
1357
+ ...(rec.refs ?? []).map((r) => r?.artifact).filter(Boolean),
1358
+ ]
1359
+ }
1360
+ /** The newest reply on an ask the exact next command. */
1361
+ function nextMove(ask, replies) {
1362
+ const last = replies[replies.length - 1]
1363
+ if (!last) {
1364
+ return "waiting on your human relay the ask to them in chat; record their reply with `figs answer`"
1094
1365
  }
1095
- if (last.kind === "verdict" && last.verdict === "changes_requested") {
1096
- return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title '…' …`
1366
+ if (last.kind === "verdict" && last.verdict === "changes-requested") {
1367
+ return `changes requested — revise, then re-raise on the same id: figs ask ${ask.type ?? "sign-off"} --id ${ask.id} --title '…' …`
1097
1368
  }
1098
- if (last.chosen) {
1099
- // Pre-fill the cited option verbatim the note is substance for --note, never for --chosen.
1100
- const note = last.text ? ` --note '…'` : ""
1101
- return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '${last.chosen}'${note}`
1369
+ if (last.kind === "verdict" && last.verdict === "rejected") {
1370
+ return `declinedacknowledge it: figs close ${ask.id}`
1102
1371
  }
1103
- return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --note '…'`
1372
+ return `act on it (real work → figs report it under its own --id), then: figs close ${ask.id}${"" /* --run <job> when work was done */}`
1104
1373
  }
1105
1374
 
1106
- /** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
1107
- async function fetchRefs(config, refs) {
1108
- for (const ref of refs ?? []) {
1109
- if (!ref.artifact) continue
1110
- const name = ref.artifact
1111
- let res
1112
- try {
1113
- res = await fetchT(
1114
- `${resolveEndpoint()}/api/artifacts/raw?agent=${config.agentId}&name=${encodeURIComponent(name)}`,
1115
- { headers: { "x-figs-token": getToken() } },
1116
- )
1117
- } catch (e) {
1118
- console.warn(`figs: ! couldn't fetch artifacts/${name} (${netReason(e)})`)
1119
- continue
1120
- }
1121
- if (!res.ok) {
1122
- console.warn(`figs: ! couldn't fetch artifacts/${name} (${res.status})`)
1123
- continue
1124
- }
1125
- const bytes = Buffer.from(await res.arrayBuffer())
1126
- const want = res.headers.get("x-figs-sha256")
1127
- if (want && createHash("sha256").update(bytes).digest("hex") !== want) {
1128
- console.warn(`figs: ! artifacts/${name}: bytes didn't match the server's hash — skipped`)
1129
- continue
1130
- }
1131
- const dest = join(repoDir, "artifacts", name)
1132
- if (existsSync(dest)) {
1133
- if (readFileSync(dest).equals(bytes)) {
1134
- console.log(`figs: ✓ artifacts/${name} (already present)`)
1135
- } else {
1136
- console.warn(
1137
- `figs: ! artifacts/${name} exists locally with different content — left untouched (the published copy stays one fetch away)`,
1138
- )
1139
- }
1140
- continue
1141
- }
1142
- mkdirSync(join(repoDir, "artifacts"), { recursive: true })
1143
- writeFileSync(dest, bytes)
1144
- console.log(`figs: ✓ artifacts/${name} (fetched, hash ok)`)
1375
+ /**
1376
+ * The one thing that flows DOWN (spec v2 §8): this agent's human messages,
1377
+ * merged into `messages.jsonl` (append-if-id-absent). Soft it never blocks
1378
+ * the inbox; returns a status the caller surfaces. Runs only when linked + a
1379
+ * token is present. The trust grade is the server's to enforce (it forces
1380
+ * `source:"chat"` on transcriptions); the CLI just folds messages in by id.
1381
+ */
1382
+ async function syncMessages() {
1383
+ const config = readJson(join(repoDir, "config.json"), {})
1384
+ if (!config.workspaceId) return { ran: false, reason: "local" }
1385
+ const token = getToken()
1386
+ if (!token) return { ran: false, reason: "not logged in" }
1387
+ const base = resolveEndpoint()
1388
+ let res
1389
+ try {
1390
+ res = await fetchT(
1391
+ `${base}/api/messages?agent=${config.agentId}`,
1392
+ { headers: authHeaders(token) },
1393
+ SYNC_TIMEOUT_MS,
1394
+ )
1395
+ } catch (e) {
1396
+ return { ran: false, reason: `couldn't reach ${base} (${netReason(e)})` }
1397
+ }
1398
+ if (!res.ok) {
1399
+ const t = await res.text().catch(() => "")
1400
+ return { ran: false, reason: `sync failed (${res.status})${t ? `: ${t.slice(0, 120)}` : ""}` }
1145
1401
  }
1402
+ let data
1403
+ try {
1404
+ data = await res.json()
1405
+ } catch {
1406
+ return { ran: false, reason: "sync returned non-JSON" }
1407
+ }
1408
+ const incoming = Array.isArray(data.messages) ? data.messages : []
1409
+ const have = new Set(readJsonl("messages.jsonl").map((m) => m.id))
1410
+ let added = 0
1411
+ for (const m of incoming) {
1412
+ if (!m?.id || have.has(m.id)) continue // immutable + id-keyed → dedup is exact
1413
+ appendJsonl("messages.jsonl", m)
1414
+ have.add(m.id)
1415
+ added++
1416
+ }
1417
+ return { ran: true, added, truncated: Boolean(data.truncated) }
1146
1418
  }
1147
1419
 
1148
1420
  /**
1149
- * `figs inbox` — session start. Bare: every ask with thread activity + the
1150
- * next command for each. With an id: the zero-context handoff package (the
1151
- * ask, the whole thread verbatim, refs restored to disk). Pure read writes
1152
- * nothing to the outbox; closing happens via resolve.
1421
+ * `figs inbox` — session start. When linked, a soft messages-only down-sync
1422
+ * runs first (degradable; loud on failure/truncation), then everything is a
1423
+ * local read: open asks grouped by reply state + unfinished jobs, each with its
1424
+ * next command. With an id: routes to `figs show <id>` (the magnifier).
1153
1425
  */
1154
1426
  async function inboxCmd() {
1155
1427
  requireFigs()
1156
- const config = readJson(join(repoDir, "config.json"), {})
1157
- const data = await fetchInbox()
1158
- const items = data.asks ?? []
1159
- const askId = positional()
1428
+ if (positional()) return showCmd() // `figs inbox <id>` → show
1429
+
1430
+ // Down-sync first (unless --no-sync): a stale inbox that says "nothing needs
1431
+ // you" is the worst failure mode, so we touch the network here — softly.
1432
+ const sync = hasFlag("--no-sync") ? { ran: false, reason: "skipped" } : await syncMessages()
1433
+
1434
+ const asks = foldById(readJsonl("asks.jsonl"))
1435
+ const messages = readJsonl("messages.jsonl")
1436
+ const jobs = foldById(readJsonl("runs.jsonl")).filter((r) => r.state === "in-flight")
1437
+ const open = asks
1438
+ .filter((a) => (a.status ?? "open") === "open")
1439
+ .map((a) => ({ a, replies: repliesFor(messages, a.id) }))
1440
+ const answered = open.filter(
1441
+ ({ replies }) => replies.length && replies[replies.length - 1].verdict !== "changes-requested",
1442
+ )
1443
+ const changes = open.filter(
1444
+ ({ replies }) => replies.length && replies[replies.length - 1].verdict === "changes-requested",
1445
+ )
1446
+ const quiet = open.filter(({ replies }) => !replies.length)
1447
+
1448
+ // Orphan replies: a synced message whose ask isn't in this copy's journal
1449
+ // (a fresh clone). Surface it — never silently drop a human's reply.
1450
+ const localAskIds = new Set(asks.map((a) => a.id))
1451
+ const orphanAskIds = [...new Set(messages.map((m) => m.ask).filter((id) => id && !localAskIds.has(id)))]
1452
+
1453
+ // Warnings the agent can act on without scraping stderr.
1454
+ const warnings = []
1455
+ if (sync.ran && sync.truncated) {
1456
+ warnings.push("sync incomplete — the server returned a partial set; some replies may be missing")
1457
+ } else if (!sync.ran && sync.reason !== "local" && sync.reason !== "skipped") {
1458
+ warnings.push(`showing local state — couldn't sync (${sync.reason})`)
1459
+ }
1460
+
1461
+ if (hasFlag("--json")) {
1462
+ return printJson(
1463
+ {
1464
+ asks: open.map(({ a, replies }) => ({
1465
+ id: a.id, type: a.type, title: a.title, status: a.status ?? "open", replies,
1466
+ })),
1467
+ jobs,
1468
+ orphanAsks: orphanAskIds,
1469
+ sync,
1470
+ },
1471
+ { warnings },
1472
+ )
1473
+ }
1160
1474
 
1161
- if (hasFlag("--json") && !askId) {
1162
- console.log(JSON.stringify(data, null, 2))
1475
+ for (const w of warnings) console.warn(`figs: ! ${w}`)
1476
+
1477
+ if (open.length === 0 && jobs.length === 0 && orphanAskIds.length === 0) {
1478
+ console.log("figs: ✓ inbox empty — no open asks, no unfinished jobs, nothing needs you")
1163
1479
  return
1164
1480
  }
1481
+ console.log(
1482
+ `figs: inbox — ${answered.length} answered · ${changes.length} need revision · ${quiet.length} waiting on your human` +
1483
+ (jobs.length ? ` · ${jobs.length} job${jobs.length === 1 ? "" : "s"} in flight` : ""),
1484
+ )
1485
+ const printItem = ({ a, replies }) => {
1486
+ console.log(`\n ${a.id} · ${a.type ?? "?"} — ${a.title ?? ""}`)
1487
+ if (replies.length) console.log(` ${messageLine(replies[replies.length - 1])}`)
1488
+ console.log(` → ${nextMove(a, replies)}${replies.length > 1 ? ` (full thread: figs show ${a.id})` : ""}`)
1489
+ }
1490
+ for (const item of [...answered, ...changes]) printItem(item)
1491
+ if (quiet.length) {
1492
+ console.log(`\n Waiting on your human — relay these in chat, then \`figs answer\` their reply:`)
1493
+ for (const { a } of quiet) console.log(` · ${a.id} · ${a.type ?? "?"} — ${a.title ?? ""} (raised ${a.ts ? agoStr(a.ts) : "—"})`)
1494
+ }
1495
+ if (jobs.length) {
1496
+ console.log(`\n Unfinished jobs — in flight (your past self's work; finish or settle):`)
1497
+ for (const j of jobs) {
1498
+ console.log(` · ${j.id}${j.result ? ` — ${j.result}` : ""} (last update ${agoStr(j.ts)})`)
1499
+ console.log(` → continue it (\`figs checkpoint --id ${j.id}\` as you go); \`figs report --id ${j.id}\` settles it`)
1500
+ }
1501
+ }
1502
+ if (orphanAskIds.length) {
1503
+ console.log(`\n Replies on asks not in this copy's journal (raised elsewhere — full context in the app):`)
1504
+ for (const id of orphanAskIds) console.log(` · ${id} → figs show ${id}`)
1505
+ }
1506
+ }
1165
1507
 
1166
- if (askId) {
1167
- const a = items.find((x) => x.id === askId)
1168
- if (!a) {
1169
- die(
1170
- `ask "${askId}" isn't in your inbox (open asks + unacknowledged rejections only closed history lives in the app)`,
1508
+ /**
1509
+ * `figs show <id>` — the magnifier, pure local. Auto-detects an ask or a job
1510
+ * and prints the folded record + its thread/trail + attachment names. No
1511
+ * network: an attachment not on disk is noted (view it in the app), never
1512
+ * downloadedlocal is the source of truth.
1513
+ */
1514
+ function showCmd() {
1515
+ requireFigs()
1516
+ const id = positional()
1517
+ if (!id) die("show needs an id: figs show <ask-id|job-id>")
1518
+ const asks = foldById(readJsonl("asks.jsonl"))
1519
+ const runs = foldById(readJsonl("runs.jsonl"))
1520
+ const messages = readJsonl("messages.jsonl")
1521
+ const ask = asks.find((a) => a.id === id)
1522
+ const replies = repliesFor(messages, id)
1523
+
1524
+ if (ask || replies.length) {
1525
+ if (hasFlag("--json")) return printJson({ ask: ask ?? null, replies })
1526
+ if (ask) {
1527
+ console.log(`figs: ${ask.title ?? id}`)
1528
+ console.log(
1529
+ ` ${ask.type ?? "?"} · ${ask.status ?? "open"}${ask.to ? ` · for the ${ask.to}` : ""}${ask.ts ? ` · raised ${agoStr(ask.ts)}` : ""}`,
1171
1530
  )
1531
+ if (ask.found) console.log(`\n What it found:\n ${ask.found}`)
1532
+ if (ask.need) console.log(`\n What it needs:\n ${ask.need}`)
1533
+ if (ask.options?.length) {
1534
+ console.log(`\n Options (a reply cites one verbatim):`)
1535
+ for (const o of ask.options) console.log(` · ${o}`)
1536
+ }
1537
+ if (ask.details?.length) {
1538
+ console.log(`\n Details:`)
1539
+ for (const d of ask.details) console.log(` ${d.l}: ${d.v}`)
1540
+ }
1541
+ // Union across all of the ask's raw lines (raise/revise/close) — folding
1542
+ // would drop an intermediate; attachments belong to their moment.
1543
+ showAttachmentNames(
1544
+ readJsonl("asks.jsonl").filter((a) => a.id === id).flatMap(attachmentsOf),
1545
+ )
1546
+ } else {
1547
+ console.log(`figs: ask ${id} — not in this copy's journal (full context in the app); showing the replies that are here`)
1172
1548
  }
1173
- console.log(`figs: ${a.title}`)
1174
- console.log(` ${a.type} · ${a.status}${a.to ? ` · for the ${a.to}` : ""} · raised ${agoStr(a.ts)}`)
1175
- if (a.found) console.log(`\n What it found:\n ${a.found}`)
1176
- if (a.need) console.log(`\n What it needs:\n ${a.need}`)
1177
- if (a.options?.length) {
1178
- console.log(`\n Options (answers cite these verbatim):`)
1179
- for (const o of a.options) console.log(` · ${o}`)
1180
- }
1181
- if (a.details?.length) {
1182
- console.log(`\n Details:`)
1183
- for (const d of a.details) console.log(` ${d.l}: ${d.v}`)
1184
- }
1185
- if (a.events.length) {
1549
+ if (replies.length) {
1186
1550
  console.log(`\n THE THREAD (your humans' words, verbatim):`)
1187
- for (const e of a.events) console.log(` · ${eventLine(e)}`)
1551
+ for (const m of replies) console.log(` · ${messageLine(m)}`)
1188
1552
  } else {
1189
- console.log(`\n No answer yet.`)
1553
+ console.log(`\n No reply yet.`)
1190
1554
  }
1191
- if (a.refs?.length) {
1192
- console.log(`\n Attached artifacts (restoring to .figs/artifacts/):`)
1193
- await fetchRefs(config, a.refs)
1194
- }
1195
- console.log(`\n → next: ${nextMove(a)}`)
1555
+ console.log(`\n → next: ${nextMove(ask ?? { id }, replies)}`)
1196
1556
  return
1197
1557
  }
1198
1558
 
1199
- if (data.truncated) {
1200
- console.warn(`figs: ! showing the first ${items.length} — more exist (close some asks)`)
1201
- }
1202
- if (items.length === 0) {
1203
- console.log("figs: inbox empty no open asks, nothing needs you")
1559
+ const run = runs.find((r) => r.id === id)
1560
+ if (run) {
1561
+ const trail = readJsonl("runs.jsonl").filter((r) => r.id === id)
1562
+ if (hasFlag("--json")) return printJson({ run, trail })
1563
+ console.log(`figs: job ${id}${run.unit ? ` · ${run.unit}` : ""}${run.period ? ` · ${run.period}` : ""}`)
1564
+ console.log(` ${run.state ?? "settled"}${run.status ? ` · ${run.status}` : ""} — ${run.result ?? ""}`)
1565
+ console.log(`\n Trail (each checkpoint/report at the moment it happened):`)
1566
+ for (const l of trail) {
1567
+ const atts = attachmentsOf(l)
1568
+ console.log(
1569
+ ` · ${l.ts ? agoStr(l.ts) : "—"} [${l.state ?? "settled"}] ${l.result ?? ""}${atts.length ? ` · ${atts.join(", ")}` : ""}`,
1570
+ )
1571
+ }
1572
+ showAttachmentNames(trail.flatMap(attachmentsOf))
1204
1573
  return
1205
1574
  }
1206
- const rejected = items.filter((a) => a.status === "rejected")
1207
- const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
1208
- const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
1209
- console.log(
1210
- `figs: inbox ${answered.length} answered · ${rejected.length} rejected to acknowledge · ${quiet.length} waiting on your human`,
1211
- )
1212
- const printItem = (a) => {
1213
- const last = a.events[a.events.length - 1]
1214
- console.log(`\n ${a.id} · ${a.type} — ${a.title}`)
1215
- if (last) console.log(` ${eventLine(last)}`)
1216
- console.log(` → ${nextMove(a)}${a.events.length > 1 ? ` (full thread: figs inbox ${a.id})` : ""}`)
1217
- }
1218
- for (const a of [...rejected, ...answered]) printItem(a)
1219
- if (quiet.length) {
1220
- console.log(`\n Waiting on your human (nothing for you to do):`)
1221
- for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
1575
+
1576
+ die(`"${id}" isn't a run or an ask in this journal — \`figs inbox\` lists what's here`)
1577
+ }
1578
+
1579
+ /** Print attachment names (deduped), noting any not present on this machine. */
1580
+ function showAttachmentNames(names) {
1581
+ const unique = [...new Set(names)]
1582
+ if (!unique.length) return
1583
+ console.log(`\n Attachments:`)
1584
+ for (const name of unique) {
1585
+ const here = existsSync(join(repoDir, "artifacts", name))
1586
+ console.log(` · ${name}${here ? "" : " (not in this copy — view it in the app)"}`)
1222
1587
  }
1223
1588
  }
1224
1589
 
1225
- // ---------- the resolution fold (`resolve` the one closing verb) ----------
1590
+ // ---------- figs answer — record the human's reply (you run it, not them) ----
1226
1591
  /**
1227
- * Build the closing fold line. Best-effort, the verified path: fetches this
1228
- * agent's inbox and, when the ask has human events, cites the one acted on —
1229
- * `via: "figs"` + `resolution.answer: <event-id>` (+ `by` from the event)
1230
- * attribution by mechanism, not testimony. An ask found on the server but
1231
- * missing locally is appended first (its own bytes coming home), so the fold
1232
- * lands on a complete record. Offline or any failure → today's self-reported
1233
- * `via: "human"` path, unchanged.
1592
+ * `figs answer <ask-id>` transcribes a human's out-of-band reply into
1593
+ * `messages.jsonl`. It's a plain writing verb (append + auto-push). `--by` names
1594
+ * the HUMAN; the CLI stamps id/ts/source the agent never authors plumbing.
1234
1595
  */
1235
- async function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
1236
- if (withdrawn && rejected) {
1237
- die("--withdrawn and --rejected are different closes: withdrawn = YOU retracted the ask; rejected = a HUMAN declined it. Pick the one that's true.")
1238
- }
1239
- if ((withdrawn || rejected) && chosen) {
1240
- die(`--chosen marks the need as met — it can't combine with ${withdrawn ? "--withdrawn" : "--rejected"} (use --note for the account)`)
1596
+ async function answerCmd() {
1597
+ requireFigs()
1598
+ const askId = positional()
1599
+ if (!askId) {
1600
+ die("answer needs the ask id: figs answer <ask-id> --by '<who>' (--chosen | --text … | --approve|--request-changes|--reject)")
1601
+ }
1602
+ if (!askExistsLocally(askId)) {
1603
+ die(`ask "${askId}" isn't in this journal — \`figs inbox\` shows your open asks. You record a human's reply to an ask YOU raised; you don't answer one that doesn't exist here.`)
1604
+ }
1605
+ const by = flag("--by")
1606
+ if (!by) die("answer needs --by '<who said it>' — the human's name, not yours (you're transcribing their words)")
1607
+
1608
+ const verdicts = [
1609
+ ["--approve", "approved"],
1610
+ ["--request-changes", "changes-requested"],
1611
+ ["--reject", "rejected"],
1612
+ ].filter(([f]) => hasFlag(f))
1613
+ if (verdicts.length > 1) die("pick one verdict: --approve | --request-changes | --reject")
1614
+
1615
+ const chosen = flag("--chosen")
1616
+ const text = flag("--text")
1617
+ const msg = {
1618
+ id: genId("msg"),
1619
+ kind: verdicts.length ? "verdict" : "answer",
1620
+ ask: askId,
1621
+ by,
1622
+ ts: nowIso(),
1623
+ source: "chat", // transcribed here; app-minted replies arrive via inbox sync
1624
+ }
1625
+ if (verdicts.length) msg.verdict = verdicts[0][1]
1626
+ if (chosen) msg.chosen = chosen
1627
+ if (text) msg.text = text
1628
+ if (msg.kind === "answer" && !chosen && !text) {
1629
+ die("answer needs --chosen '<option verbatim>' or --text '<what they said>' (or a verdict flag for a sign-off)")
1630
+ }
1631
+
1632
+ // --chosen must quote one of the ask's options verbatim (when the ask is local).
1633
+ if (chosen) {
1634
+ const ask = foldById(readJsonl("asks.jsonl")).find((a) => a.id === askId)
1635
+ const options = ask?.options ?? []
1636
+ if (options.length && !options.includes(chosen)) {
1637
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1638
+ const near = options.find((o) => norm(o) === norm(chosen))
1639
+ if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
1640
+ die(
1641
+ `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1642
+ options.map((o) => ` · ${o}`).join("\n") +
1643
+ "\n (quote one verbatim, or use --text for a free-text reply)",
1644
+ )
1645
+ }
1241
1646
  }
1242
- const warnings = []
1243
- let ask = foldById(readJsonl("asks.jsonl")).find((a) => a.id === askId)
1244
-
1245
- // The verified path (soft — never blocks a close).
1246
- let serverAsk = null
1247
- if (getToken()) {
1248
- const inbox = await fetchInbox({ soft: true })
1249
- serverAsk = inbox?.asks?.find((a) => a.id === askId) ?? null
1250
- }
1251
- if (!ask && serverAsk) {
1252
- // Its own bytes coming home: append the record so the fold has something
1253
- // local to land on (the cross-machine close, solved inside the verb).
1254
- const record = { ...serverAsk }
1255
- delete record.events
1256
- delete record.updatedAt
1257
- if (record.resolution == null) delete record.resolution
1258
- appendJsonl("asks.jsonl", record)
1259
- warnings.push(`fetched "${askId}" from Figs (raised elsewhere) — recorded locally before closing`)
1260
- ask = record
1261
- } else if (!ask) {
1262
- warnings.push(
1263
- `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`,
1647
+
1648
+ // Double-transcription guard: a reply made in the app syncs down on its own,
1649
+ // so re-typing it here would mint a duplicate (a weaker-grade copy).
1650
+ const priorSame = readJsonl("messages.jsonl").some(
1651
+ (m) =>
1652
+ m.ask === askId &&
1653
+ ((chosen && m.chosen === chosen) ||
1654
+ (text && m.text === text) ||
1655
+ (msg.verdict && m.verdict === msg.verdict)),
1656
+ )
1657
+ if (priorSame) {
1658
+ console.warn(
1659
+ "figs: ! a reply on this ask already carries that — replies made in the app sync down automatically; `figs answer` is only for replies that exist nowhere but your chat",
1264
1660
  )
1265
1661
  }
1266
1662
 
1267
- const options = ask?.options ?? serverAsk?.options ?? []
1268
- if (chosen && options.length && !options.includes(chosen)) {
1269
- const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1270
- const near = options.find((o) => norm(o) === norm(chosen))
1271
- if (near) die(`--chosen must quote the option verbatim did you mean "${near}"?`)
1272
- die(
1273
- `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1274
- options.map((o) => ` · ${o}`).join("\n") +
1275
- "\n (quote one verbatim, or use --note for a free-text account)",
1276
- )
1663
+ warnEatenDollar(msg.chosen, msg.text)
1664
+ const issues = validateMessage(msg)
1665
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1666
+ appendJsonl("messages.jsonl", msg)
1667
+ console.log(`figs: reply recorded — ${JSON.stringify(msg)}`)
1668
+ console.log(
1669
+ `figs: transcribed ${by}'s reply. Act on it, then \`figs close ${askId}\` (it cites this) — real work first → report it, then \`figs close ${askId} --run <job>\`.`,
1670
+ )
1671
+ await autoPush()
1672
+ }
1673
+
1674
+ // ---------- figs close — the one closing verb (derives from the reply) -------
1675
+ /** The old resolve surface → teaching errors (the migration path). A function,
1676
+ * not a top-level const: the CLI dispatches during module eval, so a const
1677
+ * declared after the dispatch block would still be in its TDZ when called. */
1678
+ function closeCutFlags() {
1679
+ return {
1680
+ "--chosen":
1681
+ "replies are recorded with `figs answer <id> --chosen '…' --by '<who>'`; close only ends the ask (it cites the reply on file automatically)",
1682
+ "--by":
1683
+ "the answerer is named on the reply: `figs answer <id> … --by '<who>'`; close cites it automatically",
1684
+ "--answer-id":
1685
+ "close auto-cites the NEWEST reply on file — you don't name it",
1686
+ "--rejected":
1687
+ "record the human's reject as a verdict first: `figs answer <id> --reject --by '<who>'`, then `figs close <id>` derives 'rejected' from it",
1277
1688
  }
1689
+ }
1690
+ /** The teaching menu when an ask has no reply on file yet. */
1691
+ function closeMenu(askId) {
1692
+ return (
1693
+ `no reply on file for "${askId}" — close needs to know what happened:\n` +
1694
+ ` · answered in chat? → record it first: figs answer ${askId} --by '<who>' (--chosen … | --text …)\n` +
1695
+ ` · YOU retracted it? → figs close ${askId} --withdrawn\n` +
1696
+ ` · cleared on its own? → figs close ${askId} --note '<what happened>' (recorded via: self)`
1697
+ )
1698
+ }
1699
+ async function closeCmd() {
1700
+ requireFigs()
1701
+ const askId = positional()
1702
+ if (!askId) die("close needs the ask id: figs close <ask-id> [--note …] [--run …] [--withdrawn]")
1703
+ // The old resolve surface → teaching errors (the migration path).
1704
+ for (const [f, help] of Object.entries(closeCutFlags())) {
1705
+ if (hasFlag(f) || flag(f) !== undefined) die(`\`${f}\` is gone — ${help}`)
1706
+ }
1707
+ if (!askExistsLocally(askId)) {
1708
+ die(`ask "${askId}" isn't in this journal — \`figs inbox\` shows your open asks`)
1709
+ }
1710
+ const withdrawn = hasFlag("--withdrawn")
1711
+ const note = flag("--note")
1712
+ const runRef = flag("--run")
1713
+ warnUnknownRun(runRef)
1714
+ const attached = attachFiles(flagAll("--attach")) // proof of what was done
1715
+
1716
+ // The newest reply for this ask drives the close (messages accumulate; sort by ts).
1717
+ const replies = readJsonl("messages.jsonl")
1718
+ .filter((m) => m.ask === askId)
1719
+ .sort((a, b) => new Date(a.ts) - new Date(b.ts))
1720
+ const newest = replies[replies.length - 1]
1278
1721
 
1279
1722
  const resolution = {}
1280
- if (chosen) resolution.chosen = chosen
1281
- if (by) resolution.by = by
1282
- if (note) resolution.note = note
1283
- // `via` says where the unblock came from; out-of-band human answers are
1284
- // "human". A rejection is inherently a human's call; otherwise set it only
1285
- // when there's evidence of one (a chosen/by), never on withdrawn (nobody
1286
- // acted — that's the point).
1287
- if (rejected || (!withdrawn && (chosen || by))) resolution.via = "human"
1288
-
1289
- // Cite the event acted on, when one exists: prefer the latest answer whose
1290
- // chosen matches, else the latest event (e.g. the approval, the rejection).
1291
- const events = serverAsk?.events ?? []
1292
- if (!withdrawn && events.length) {
1293
- // Any human event can carry the chosen path — an answer, or a qualified
1294
- // verdict (verdict + chosen together). Match on the text, not the kind.
1295
- const match = chosen ? [...events].reverse().find((e) => e.chosen === chosen) : null
1296
- const cited = match ?? events[events.length - 1]
1723
+ let status
1724
+ if (withdrawn) {
1725
+ status = "withdrawn" // the agent's own act — no reply needed, nobody acted
1726
+ } else if (!newest) {
1727
+ if (!note) die(closeMenu(askId))
1728
+ status = "resolved" // cleared on its own self-reported
1729
+ resolution.via = "self"
1730
+ } else if (newest.kind === "verdict" && newest.verdict === "changes-requested") {
1731
+ die(
1732
+ `changes were requested on "${askId}" revise and re-raise on the same id (\`figs ask sign-off --id ${askId} …\`); this isn't a close`,
1733
+ )
1734
+ } else {
1735
+ // an answer, an approval, or a rejection — all derive a close, citing it
1736
+ status = newest.verdict === "rejected" ? "rejected" : "resolved"
1297
1737
  resolution.via = "figs"
1298
- resolution.answer = cited.id
1299
- if (!by && cited.byName) resolution.by = cited.byName
1738
+ resolution.answer = newest.id
1739
+ if (newest.by) resolution.by = newest.by
1740
+ if (newest.chosen) resolution.chosen = newest.chosen
1300
1741
  }
1742
+ if (note) resolution.note = note
1743
+ if (runRef) resolution.run = runRef
1744
+ // Machine-stamped, inside resolution so the fold can't collide with the raise ts.
1745
+ resolution.ts = nowIso()
1301
1746
 
1302
1747
  warnEatenDollar(resolution.chosen, resolution.note)
1303
- const line = {
1304
- id: askId,
1305
- status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
1306
- }
1307
- if (Object.keys(resolution).length) line.resolution = resolution
1308
- return { line, warnings }
1748
+ const line = { id: askId, status, resolution }
1749
+ if (attached.length) line.attachments = attached // pinned to the close moment
1750
+ appendJsonl("asks.jsonl", line)
1751
+ const cite =
1752
+ resolution.answer && newest
1753
+ ? ` — ${status === "rejected" ? "acknowledging" : "acting on"} ${newest.by ?? "the"} reply${newest.chosen ? ` '${newest.chosen}'` : ""}`
1754
+ : ""
1755
+ console.log(`figs: ✓ ask ${askId} ${status}${cite}`)
1756
+ await autoPush()
1309
1757
  }
1310
1758
 
1311
- /** The verbs' shared final step — same transport as `figs push`. */
1759
+ /**
1760
+ * The verbs' shared final step. The write already succeeded locally before this
1761
+ * runs, so the record is safe no matter what happens here — that's why every
1762
+ * not-published outcome is exit 2 (retry `figs push`), never exit 1.
1763
+ *
1764
+ * - local mode (not linked) → don't attempt; calm note; exit 0.
1765
+ * - --no-push → deliberately deferred; exit 0.
1766
+ * - linked, no token → exit 2 + the canonical line.
1767
+ * - linked, push fails → exit 2 + the canonical line.
1768
+ * - linked, push ok → exit 0.
1769
+ */
1312
1770
  async function autoPush() {
1313
1771
  if (hasFlag("--no-push")) {
1314
1772
  console.log("figs: saved locally (--no-push) — `figs push` publishes it")
1315
1773
  return
1316
1774
  }
1317
- if (!(await doPush())) {
1318
- console.warn("figs: ! saved locally the push failed (see above); fix and run `figs push`")
1319
- process.exitCode = 1
1775
+ if (!isLinked()) {
1776
+ console.log("figs: local mode — `figs link` to publish")
1777
+ return
1778
+ }
1779
+ if (!getToken()) {
1780
+ console.warn("figs: ! not logged in — `figs login`, then `figs push`")
1781
+ console.warn(`figs: ! ${RECORDED_LOCALLY}`)
1782
+ process.exitCode = 2
1783
+ return
1784
+ }
1785
+ if (!(await doPush()).ok) {
1786
+ console.warn(`figs: ! ${RECORDED_LOCALLY}`)
1787
+ process.exitCode = 2
1320
1788
  }
1321
1789
  }
1322
1790
 
@@ -1326,24 +1794,114 @@ async function reportCmd() {
1326
1794
  if (!result) {
1327
1795
  die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
1328
1796
  }
1329
- const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
1797
+ // state is verb-stamped, never typed: report settles the job (checkpoint
1798
+ // marks it in-flight). The settling fold overrides any earlier checkpoint.
1799
+ const idGiven = flag("--id")
1800
+ const id = idGiven || genId("r")
1801
+ // Before the append, so "new" means new-to-this-outbox (the typo catch).
1802
+ const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
1803
+ const run = { id, ts: nowIso(), result, state: "settled" }
1330
1804
  const unit = flag("--unit")
1331
1805
  if (unit) run.unit = unit
1332
1806
  const period = flag("--period")
1333
1807
  if (period) run.period = period
1334
1808
  const status = flag("--status")
1335
1809
  if (status) run.status = status
1810
+ const trigger = flag("--trigger")
1811
+ if (trigger) run.session = { trigger }
1336
1812
  const attached = attachFiles(flagAll("--attach"))
1337
- if (attached.length === 1) run.artifact = attached[0]
1338
- else if (attached.length > 1) run.artifacts = attached
1813
+ if (attached.length) run.attachments = attached
1339
1814
  warnEatenDollar(run.result)
1815
+ warnUnknownUnit(unit)
1340
1816
  const issues = validateRun(run)
1341
1817
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1342
1818
  appendJsonl("runs.jsonl", run)
1343
1819
  console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
1820
+ // Announce new-vs-fold only for an explicit --id — that's where a typo means
1821
+ // "I meant to continue a job but opened a sibling" (auto-ids are always new).
1822
+ if (idGiven) announceFold("job", id, isNew, " (settled)")
1823
+ // Teaching, never a gate: a settled job with open asks citing it is the
1824
+ // normal tail-of-job pattern (the ask owns the waiting) — but if the job's
1825
+ // OUTCOME depends on an answer, in-flight is the honest state. Local fold
1826
+ // only (other machines' asks are invisible here): best-effort by design.
1827
+ const openCiting = foldById(readJsonl("asks.jsonl")).filter(
1828
+ (a) => a.run === run.id && (a.status ?? "open") === "open",
1829
+ )
1830
+ if (openCiting.length > 0) {
1831
+ const n = openCiting.length
1832
+ console.log(
1833
+ `figs: note: ${n} open ask${n === 1 ? "" : "s"} cite${n === 1 ? "s" : ""} this job (${openCiting
1834
+ .map((a) => a.id)
1835
+ .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\`)`,
1836
+ )
1837
+ }
1344
1838
  await autoPush()
1345
1839
  }
1346
1840
 
1841
+ /**
1842
+ * `figs checkpoint` — save a job's progress so it survives this sitting. The
1843
+ * first checkpoint on an id OPENS the job (state: in-flight); a crash mid-job
1844
+ * then leaves a visible, recoverable stub the next session finds in
1845
+ * `figs inbox`, instead of nothing. Same fold-by-id write as report — only
1846
+ * the stamped state differs; `figs report` settles the id.
1847
+ */
1848
+ async function checkpointCmd() {
1849
+ requireFigs()
1850
+ const id = flag("--id")
1851
+ if (!id) {
1852
+ die("checkpoint needs --id '<job-id>' — the stable job id the next session will look for (e.g. recon-acme-2026-11)")
1853
+ }
1854
+ const note = flag("--note")
1855
+ if (!note) {
1856
+ die(`checkpoint needs --note '<where the job stands>' — e.g. figs checkpoint --id ${id} --note 'Statements pulled — matching now'`)
1857
+ }
1858
+ // Before the append, so "new" means new-to-this-outbox (the teaching line).
1859
+ const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
1860
+ const run = { id, ts: nowIso(), result: note, state: "in-flight" }
1861
+ const unit = flag("--unit")
1862
+ if (unit) run.unit = unit
1863
+ const period = flag("--period")
1864
+ if (period) run.period = period
1865
+ const status = flag("--status")
1866
+ if (status) run.status = status
1867
+ const trigger = flag("--trigger")
1868
+ if (trigger) run.session = { trigger }
1869
+ const attached = attachFiles(flagAll("--attach"))
1870
+ if (attached.length) run.attachments = attached
1871
+ warnEatenDollar(run.result)
1872
+ warnUnknownUnit(unit)
1873
+ const issues = validateRun(run)
1874
+ if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1875
+ // A new sitting on a SETTLED id is a legitimate reopen (the warn→ok story),
1876
+ // but usually means "I should have used a new id" — nudge without blocking.
1877
+ const settledBefore = foldById(readJsonl("runs.jsonl")).some(
1878
+ (r) => r.id === id && r.state === "settled",
1879
+ )
1880
+ appendJsonl("runs.jsonl", run)
1881
+ console.log(`figs: ✓ checkpoint recorded — ${JSON.stringify(run)}`)
1882
+ if (isNew) {
1883
+ console.log(
1884
+ `figs: new job opened: ${id} (in flight) — checkpoint as you go; \`figs report --id ${id}\` settles it`,
1885
+ )
1886
+ } else if (settledBefore) {
1887
+ console.warn(
1888
+ `figs: ! reopening a settled job (${id}) — continue only if it's truly the same job; new work wants a new id`,
1889
+ )
1890
+ } else {
1891
+ console.log(`figs: folded onto existing job ${id} (in flight)`)
1892
+ }
1893
+ await autoPush()
1894
+ // A checkpoint exists to survive a crash — but only when this repo intends to
1895
+ // publish (linked). In local mode the file IS the protection, so the calm
1896
+ // "local mode" line autoPush printed is the whole truth. When linked, a failed
1897
+ // push (exit 2) means the crash-stub never reached the server — say so loudly.
1898
+ if (process.exitCode === 2) {
1899
+ console.warn(
1900
+ `figs: ! this checkpoint is NOT protecting the job remotely yet — nothing reached the server. Run \`figs push\` before continuing the work.`,
1901
+ )
1902
+ }
1903
+ }
1904
+
1347
1905
  async function askCmd() {
1348
1906
  requireFigs()
1349
1907
  let base = {}
@@ -1404,11 +1962,17 @@ async function askCmd() {
1404
1962
  die('--run takes the explicit run id (no "last" — another session may have reported since); `figs report` prints the id of what it wrote')
1405
1963
  }
1406
1964
  if (runRef) ask.run = runRef
1965
+ warnUnknownRun(runRef)
1966
+ warnUnknownUnit(ask.unit)
1407
1967
  const attached = attachFiles(flagAll("--attach"))
1408
- if (attached.length) {
1409
- ask.refs = [...(base.refs ?? []), ...attached.map((n) => ({ label: n, artifact: n }))]
1410
- }
1411
- if (ask.type === "sign-off" && !ask.refs?.length) {
1968
+ // Unified attachments[] (bare file names). Accept attachments[] from --stdin,
1969
+ // and normalize a legacy refs[] into it (the filename is the label now).
1970
+ const baseAtts = Array.isArray(base.attachments)
1971
+ ? base.attachments
1972
+ : (base.refs ?? []).map((r) => r?.artifact).filter(Boolean)
1973
+ delete ask.refs
1974
+ if (attached.length || baseAtts.length) ask.attachments = [...baseAtts, ...attached]
1975
+ if (ask.type === "sign-off" && !ask.attachments?.length) {
1412
1976
  console.warn(
1413
1977
  "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>",
1414
1978
  )
@@ -1428,42 +1992,37 @@ async function askCmd() {
1428
1992
  )
1429
1993
  const issues = validateAsk(ask)
1430
1994
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1995
+ const askIdGiven = flag("--id") ?? base.id
1996
+ const askIsNew = !foldById(readJsonl("asks.jsonl")).some((a) => a.id === ask.id)
1431
1997
  appendJsonl("asks.jsonl", ask)
1432
1998
  console.log(`figs: ✓ ask raised — ${JSON.stringify(ask)}`)
1999
+ // Announce new-vs-fold for an explicit id — re-raising the same id (a revision)
2000
+ // folds; a typo'd id silently opens a sibling ask. Catch it.
2001
+ if (askIdGiven) announceFold("ask", ask.id, askIsNew)
1433
2002
  if (!ask.to) {
1434
2003
  console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
1435
2004
  }
1436
2005
  await autoPush()
2006
+ // Local: the agent must put the ask in front of the human — nothing else
2007
+ // surfaces it. When linked, the app surfaces it too.
1437
2008
  console.log(
1438
- "figs: your human answers in the app — start your next session with `figs inbox` to read it",
2009
+ isLinked()
2010
+ ? "figs: show this ask to your human (it's also in the app); record their reply with `figs answer`, read it back with `figs inbox`"
2011
+ : "figs: show this ask to your human in chat now — nothing else surfaces it. Record their reply with `figs answer`.",
1439
2012
  )
1440
2013
  }
1441
2014
 
1442
- async function resolveCmd() {
1443
- requireFigs()
1444
- const askId = positional()
1445
- if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
1446
- const { line, warnings } = await buildResolution(askId, {
1447
- chosen: flag("--chosen"),
1448
- by: flag("--by"),
1449
- note: flag("--note"),
1450
- withdrawn: hasFlag("--withdrawn"),
1451
- rejected: hasFlag("--rejected"),
1452
- })
1453
- for (const w of warnings) console.warn(`figs: ! ${w}`)
1454
- appendJsonl("asks.jsonl", line)
1455
- console.log(`figs: ✓ ask ${askId} ${line.status} — ${JSON.stringify(line)}`)
1456
- await autoPush()
1457
- }
1458
-
1459
- /** Validate the local .figs/ payload against the spec — no write, no push. */
2015
+ /**
2016
+ * `figs doctor` — the spec's conformance validator. Local validation is
2017
+ * normative and **runs account-free**: a fresh, no-token, offline repo gets a
2018
+ * full pass and exit 0. Server validation (when linked + logged in) is an
2019
+ * additive second opinion, never the gate.
2020
+ */
1460
2021
  async function doctor() {
1461
2022
  // Local checks first (no token/network needed) — fail fast and offline.
1462
- if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
2023
+ if (!existsSync(repoDir)) die(noFigsHint())
1463
2024
  const config = readJson(join(repoDir, "config.json"), {})
1464
- if (!config.workspaceId || !config.agentId) {
1465
- die("config missing workspaceId/agentId — run `figs init`")
1466
- }
2025
+ if (!config.agentId) die("config missing agentId — run `figs init`")
1467
2026
  const agentJson = readJson(join(repoDir, "agent.json"), null)
1468
2027
  if (!agentJson) die("missing .figs/agent.json — author it first (see .figs/GUIDE.md)")
1469
2028
 
@@ -1472,9 +2031,13 @@ async function doctor() {
1472
2031
  // are>" to the org chart. This is the "not ready to push" signal.
1473
2032
  const placeholders = findPlaceholders(agentJson)
1474
2033
  if (placeholders.length) {
1475
- console.log("figs: ✗ .figs/agent.json still has template placeholders — fill these in before pushing:")
1476
- for (const p of placeholders) console.log(` ${p.path}: ${p.value}`)
1477
- console.log(" (replace the <…> values by reading your own repo, then re-run `figs doctor`)")
2034
+ if (JSON_OUT) {
2035
+ printJson({ valid: false, placeholders }, { ok: false })
2036
+ } else {
2037
+ console.log("figs: ✗ .figs/agent.json still has template placeholders — fill these in before pushing:")
2038
+ for (const p of placeholders) console.log(` ${p.path}: ${p.value}`)
2039
+ console.log(" (replace the <…> values by reading your own repo, then re-run `figs doctor`)")
2040
+ }
1478
2041
  process.exit(1)
1479
2042
  }
1480
2043
 
@@ -1483,26 +2046,41 @@ async function doctor() {
1483
2046
  const localIssues = validateOutbox(
1484
2047
  foldById(readJsonl("runs.jsonl")),
1485
2048
  foldById(readJsonl("asks.jsonl")),
2049
+ readJsonl("messages.jsonl"),
1486
2050
  )
1487
2051
  if (localIssues.length) {
1488
- console.log("figs: ✗ local validation issues:")
1489
- for (const i of localIssues) console.log(` ${i}`)
2052
+ if (JSON_OUT) {
2053
+ printJson({ valid: false, scope: "local", issues: localIssues }, { ok: false })
2054
+ } else {
2055
+ console.log("figs: ✗ local validation issues:")
2056
+ for (const i of localIssues) console.log(` ${i}`)
2057
+ }
1490
2058
  process.exit(1)
1491
2059
  }
1492
2060
 
1493
- if (!getToken()) die("not logged in run `figs login`")
2061
+ // Local conformance passed. Server validation is the additive layer — it
2062
+ // needs both a destination (linked) and a token; without either, we're done,
2063
+ // and that is a full, legitimate pass (the spec's conformance is local).
2064
+ if (!config.workspaceId || !getToken()) {
2065
+ const why = !config.workspaceId ? "not linked" : "not logged in"
2066
+ if (JSON_OUT) return printJson({ valid: true, scope: "local", serverValidation: "skipped", reason: why })
2067
+ console.log(`figs: ✓ .figs/ passes local conformance — server validation skipped (${why})`)
2068
+ return
2069
+ }
1494
2070
  const r = await api("POST", "/api/validate", {
1495
2071
  workspaceId: config.workspaceId,
1496
2072
  agent: { ...agentJson, id: config.agentId },
1497
2073
  runs: foldById(readJsonl("runs.jsonl")),
1498
2074
  asks: foldById(readJsonl("asks.jsonl")),
2075
+ messages: readJsonl("messages.jsonl"),
1499
2076
  })
1500
2077
  if (r.ok) {
2078
+ if (JSON_OUT) return printJson({ valid: true, scope: "server" })
1501
2079
  console.log("figs: ✓ .figs/ is valid — ready to push")
1502
2080
  return
1503
2081
  }
1504
2082
  if (JSON_OUT) {
1505
- console.log(JSON.stringify(r.issues, null, 2))
2083
+ printJson({ valid: false, scope: "server", issues: r.issues }, { ok: false })
1506
2084
  } else {
1507
2085
  console.log("figs: ✗ validation issues:")
1508
2086
  for (const i of r.issues) {
@@ -1516,9 +2094,14 @@ async function doctor() {
1516
2094
  process.exit(1)
1517
2095
  }
1518
2096
 
1519
- /** `figs push` — the bare transport; exits non-zero on any failure. */
2097
+ /**
2098
+ * `figs push` — the bare transport. Exit 1 on a **structural** failure (fix
2099
+ * something: not linked, no agent.json, bad data), exit 2 on a **transient** one
2100
+ * (network/server — the records are safe; retry later).
2101
+ */
1520
2102
  async function push() {
1521
- if (!(await doPush())) process.exit(1)
2103
+ const r = await doPush()
2104
+ if (!r.ok) process.exit(r.retryable ? 2 : 1)
1522
2105
  }
1523
2106
 
1524
2107
  /**
@@ -1527,29 +2110,31 @@ async function push() {
1527
2110
  * append (auto-push IS push — one transport, many entry points). Runs the same
1528
2111
  * local checks as `figs doctor` first, so a malformed hand-written line never
1529
2112
  * reaches the server as a confusing 4xx. Prints its own errors and returns
1530
- * false on failure — callers decide whether that's fatal.
2113
+ * `{ ok, retryable }` — callers map that to an exit code.
1531
2114
  */
1532
2115
  async function doPush() {
1533
- const fail = (msg) => {
2116
+ const fail = (msg, retryable = false) => {
1534
2117
  console.error(`figs: ✗ push: ${msg}`)
1535
- return false
2118
+ return { ok: false, retryable }
2119
+ }
2120
+ if (!existsSync(repoDir)) return fail(noFigsHint())
2121
+ const config = readJson(join(repoDir, "config.json"), {})
2122
+ if (!config.agentId) return fail("config missing agentId — run `figs init`")
2123
+ if (!config.workspaceId) {
2124
+ return fail("not linked — local records are safe; `figs link` to publish")
1536
2125
  }
1537
2126
  const token = getToken()
1538
2127
  if (!token) return fail("not logged in — run `figs login` (or set FIGS_TOKEN)")
1539
2128
  await checkVersion({ hardFail: true })
1540
- if (!existsSync(repoDir)) return fail("no .figs/ here — run `figs init` first")
1541
- const config = readJson(join(repoDir, "config.json"), {})
1542
- if (!config.workspaceId || !config.agentId) {
1543
- return fail("config missing workspaceId/agentId — run `figs init`")
1544
- }
1545
- const endpoint =
1546
- process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
1547
2129
 
2130
+ const endpoint = process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
1548
2131
  const agentJson = readJson(join(repoDir, "agent.json"), null)
1549
- if (!agentJson) return fail("missing .figs/agent.json")
2132
+ if (!agentJson) return fail("missing .figs/agent.json — author it, then `figs doctor`")
1550
2133
  const agent = { ...agentJson, id: config.agentId }
1551
2134
  const runs = foldById(readJsonl("runs.jsonl"))
1552
2135
  const asks = foldById(readJsonl("asks.jsonl"))
2136
+ // Messages are immutable events — sent whole (no fold); the server dedupes by id.
2137
+ const messages = readJsonl("messages.jsonl")
1553
2138
 
1554
2139
  // Local pre-flight — fail fast, offline, with teaching errors.
1555
2140
  const placeholders = findPlaceholders(agentJson)
@@ -1558,7 +2143,7 @@ async function doPush() {
1558
2143
  `agent.json still has template placeholders (${placeholders.map((p) => p.path).join(", ")}) — fill them in; \`figs doctor\` lists them`,
1559
2144
  )
1560
2145
  }
1561
- const issues = validateOutbox(runs, asks)
2146
+ const issues = validateOutbox(runs, asks, messages)
1562
2147
  if (issues.length) return fail(`local validation failed:\n ${issues.join("\n ")}`)
1563
2148
 
1564
2149
  const base = endpoint.replace(/\/+$/, "")
@@ -1566,39 +2151,43 @@ async function doPush() {
1566
2151
  try {
1567
2152
  res = await fetchT(`${base}/api/ingest`, {
1568
2153
  method: "POST",
1569
- headers: { "content-type": "application/json", "x-figs-token": token },
1570
- body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
2154
+ headers: { "content-type": "application/json", ...authHeaders(token) },
2155
+ body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks, messages }),
1571
2156
  })
1572
2157
  } catch (e) {
1573
- return fail(`cannot reach ${base} (${netReason(e)})`)
2158
+ // Network/timeout transient; the records are safe locally.
2159
+ return fail(`cannot reach ${base} (${netReason(e)})`, true)
1574
2160
  }
1575
2161
  const text = await res.text()
1576
- if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`)
2162
+ // 4xx = the payload is wrong (re-pushing won't help) structural; 5xx = transient.
2163
+ if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`, res.status >= 500)
1577
2164
  console.log(
1578
- `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
2165
+ `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks, ${messages.length} messages`,
1579
2166
  )
1580
2167
  // The wow-moment link — relay this to your human so they can see the agent.
1581
2168
  console.log(` view at ${base}/w/${config.workspaceId}`)
1582
2169
 
1583
- return pushArtifacts(base, token, config, runs, asks)
2170
+ // The spine landed; an artifact-stage failure is transient/oversize — retry.
2171
+ return (await pushArtifacts(base, token, config))
2172
+ ? { ok: true }
2173
+ : { ok: false, retryable: true }
1584
2174
  }
1585
2175
 
1586
2176
  /**
1587
- * Upload the files referenced by runs (run.artifact) and ask refs (refs[].artifact).
1588
- * The spine ingest is JSON-only; artifacts go to a separate endpoint that stores
1589
- * them content-addressed (an unchanged file is skipped server-side). Content is
1590
- * sent base64-encoded so any type html, markdown, text, json, images — survives.
1591
- * A **server rejection** (auth/size/etc.) is fatal: it prints and exits non-zero
1592
- * so the agent never believes a report published when it didn't (esp. the ~3 MB
1593
- * cap → 413). A **missing local file** is only a warning that's the agent
1594
- * referencing an artifact it didn't actually produce, not a publish failure.
2177
+ * Upload the files attached anywhere in the journal. Collected from the RAW
2178
+ * lines (not the folded records) so a file attached at a checkpoint isn't lost
2179
+ * when a later report folds over it — attachments belong to their moment. The
2180
+ * spine ingest is JSON-only; files go to a separate content-addressed endpoint
2181
+ * (an unchanged file is skipped server-side), base64-encoded so any type
2182
+ * survives. A **server rejection** (auth/size) is fatal; a **missing local
2183
+ * file** is only a warning (the agent referenced something it didn't produce).
1595
2184
  */
1596
- async function pushArtifacts(base, token, config, runs, asks) {
1597
- const refNames = (asks ?? []).flatMap((a) =>
1598
- (a.refs ?? []).map((r) => r.artifact),
1599
- )
1600
- const runNames = runs.flatMap((r) => [r.artifact, ...(r.artifacts ?? [])])
1601
- const names = [...new Set([...runNames, ...refNames].filter(Boolean))]
2185
+ async function pushArtifacts(base, token, config) {
2186
+ const names = [
2187
+ ...new Set(
2188
+ [...readJsonl("runs.jsonl"), ...readJsonl("asks.jsonl")].flatMap(attachmentsOf),
2189
+ ),
2190
+ ]
1602
2191
  if (names.length === 0) return true
1603
2192
 
1604
2193
  let uploaded = 0
@@ -1617,7 +2206,7 @@ async function pushArtifacts(base, token, config, runs, asks) {
1617
2206
  try {
1618
2207
  res = await fetchT(`${base}/api/artifacts/upload`, {
1619
2208
  method: "POST",
1620
- headers: { "content-type": "application/json", "x-figs-token": token },
2209
+ headers: { "content-type": "application/json", ...authHeaders(token) },
1621
2210
  body: JSON.stringify({
1622
2211
  workspaceId: config.workspaceId,
1623
2212
  agentId: config.agentId,
@@ -1658,18 +2247,35 @@ async function pushArtifacts(base, token, config, runs, asks) {
1658
2247
  function readJsonl(name) {
1659
2248
  const p = join(repoDir, name)
1660
2249
  if (!existsSync(p)) return []
2250
+ const lines = readFileSync(p, "utf8").split("\n")
2251
+ // A process killed mid-append leaves a half-written FINAL line — the journal
2252
+ // must survive the agent dying while writing it. Tolerate exactly one broken
2253
+ // last (non-empty) line: warn + skip, keep the rest. A broken INTERIOR line is
2254
+ // real corruption — die loudly. (`name` files are append-only, so only the
2255
+ // tail can be torn.)
2256
+ let lastNonEmpty = -1
2257
+ for (let i = lines.length - 1; i >= 0; i--) {
2258
+ if (lines[i].trim()) {
2259
+ lastNonEmpty = i
2260
+ break
2261
+ }
2262
+ }
1661
2263
  const out = []
1662
- readFileSync(p, "utf8")
1663
- .split("\n")
1664
- .forEach((line, i) => {
1665
- const s = line.trim()
1666
- if (!s) return
1667
- try {
1668
- out.push(JSON.parse(s))
1669
- } catch {
2264
+ lines.forEach((line, i) => {
2265
+ const s = line.trim()
2266
+ if (!s) return
2267
+ try {
2268
+ out.push(JSON.parse(s))
2269
+ } catch {
2270
+ if (i === lastNonEmpty) {
2271
+ console.warn(
2272
+ `figs: ! last line of .figs/${name} is broken — likely a crash mid-write; skipped it (re-record that entry). The rest is intact.`,
2273
+ )
2274
+ } else {
1670
2275
  die(`malformed JSON in .figs/${name} line ${i + 1}: ${s.slice(0, 80)}`)
1671
2276
  }
1672
- })
2277
+ }
2278
+ })
1673
2279
  return out
1674
2280
  }
1675
2281