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