@figs-so/cli 0.2.0 → 0.3.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 +8 -3
  2. package/SPEC.md +6 -4
  3. package/figs.mjs +276 -31
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -40,7 +40,9 @@ npx @figs-so/cli@latest push # publish → it appears in you
40
40
  ```
41
41
 
42
42
  That's it — your agent now shows up at **[app.figs.so](https://app.figs.so)**. No instrumentation, no
43
- SDK in your agent's code. From there you decide, deliberately, how much of its real work to surface.
43
+ SDK in your agent's code. From there you decide, deliberately, how much of its real work to surface
44
+ and day to day the agent records itself in one stroke per event: `figs report` (a run) ·
45
+ `figs ask` (needs a human) · `figs resolve` (close an ask). Each pushes itself.
44
46
 
45
47
  ## How it works
46
48
 
@@ -71,8 +73,11 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
71
73
  | `figs login` / `logout` | device-flow browser approve / remove local token |
72
74
  | `figs workspaces [--json]` | list your workspaces (create one in the web app) |
73
75
  | `figs init [--workspace <slug>]` | generate identity + write `.figs/` (omit the flag: uses your only workspace, else lists them) |
74
- | `figs doctor` | validate `.figs/` against the contract before pushing |
75
- | `figs push` | one-way publish of `.figs/` |
76
+ | **`figs report --result "…"`** | record a run — stamps id + timestamp, auto-captures the session trace, `--attach`es artifacts, pushes itself (`--resolves <ask-id>` closes an ask in the same stroke) |
77
+ | **`figs ask <type> --title "…"`** | raise a self-contained ask (`blocked` · `needs-decision` · `sign-off` · `fyi`) — options/details/attachments, pushed so a human sees it |
78
+ | **`figs resolve <ask-id>`** | close an ask — `--chosen` verbatim-checked against its options, `--withdrawn` for the un-ask |
79
+ | `figs push` | the bare transport — the verbs call it automatically; type it yourself after hand-edits or `--no-push` |
80
+ | `figs doctor` | validate `.figs/` against the spec without pushing — the conformance check for hand-authored or non-CLI setups |
76
81
  | `figs status [--json]` | login / workspace / agent state |
77
82
  | `figs help [<command>]` | usage (`-h`/`--help` on any command; `-v` for version) |
78
83
 
package/SPEC.md CHANGED
@@ -122,7 +122,7 @@ primitive** — the agent reached the edge of its autonomy.
122
122
  |---|---|:--:|---|
123
123
  | `id` | string | ✓ | Stable id (upsert key). |
124
124
  | `type` | enum | ✓ | `blocked` \| `needs-decision` \| `sign-off` \| `fyi`. `fyi` is a non-blocking heads-up (no decision needed). |
125
- | `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the agent un-asked — no longer needed, nobody acted). |
125
+ | `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **asker** retracted it — no longer needed, nobody acted) \| `"rejected"` (the **answerer** declined it — a human said no; usually born in the reader's UI, but the agent may record an out-of-band rejection too). Three closes, three authors-of-the-ending. **Rejected is terminal** on this id — readers keep it sticky; re-raising is a new ask. |
126
126
  | `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder` — e.g. self-edit/logic-change flags). Absent = unaddressed; readers may guess from `type` but must present it as a guess. |
127
127
  | `title` | string | ✓ | The ask, in one line. |
128
128
  | `unit` | string | | The `Unit.id` this concerns. |
@@ -154,9 +154,11 @@ An ask is the **anchor of a thread whose two halves are owned by different parti
154
154
  These are [reserved](#reserved-not-in-v1) in v1 and **never appear in `asks.jsonl`**: nobody
155
155
  writes into the other side's record; the two ledgers cross-reference by id.
156
156
 
157
- The full state machine: `open` → *(claimed → answered — human, server-side, reserved)* →
158
- `resolved` | `withdrawn` *(agent, in `asks.jsonl`)*. In v1 only the agent-owned transitions exist;
159
- resolution happens in the agent's own workflow.
157
+ The full state machine: `open` → *(answered/verdict — human, server-side)* →
158
+ `resolved` | `withdrawn` *(agent, in `asks.jsonl`)* plus the one human-side close:
159
+ **`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
160
+ later resolution append folds onto it without reopening). Today resolution otherwise happens
161
+ in the agent's own workflow; answers flowing back through the reader are arriving incrementally.
160
162
 
161
163
  ### 6.2 `Resolution` — how an ask closed
162
164
 
package/figs.mjs CHANGED
@@ -11,7 +11,8 @@
11
11
  * create .figs/config.json + GUIDE.md (generates a stable agent id)
12
12
  * figs report --result "…" record a run (stamps id/ts/session, --attach files, auto-push)
13
13
  * figs ask <type> --title "…" raise an ask (self-contained: options/details/attachments, auto-push)
14
- * figs resolve <ask-id> close an ask (verbatim-checks --chosen, auto-push)
14
+ * figs inbox [<ask-id>] what needs you — answers/verdicts from your humans (pure read)
15
+ * figs resolve <ask-id> close an ask (verbatim-checks --chosen; cites the Figs answer it acted on)
15
16
  * figs doctor validate .figs/ against the spec before pushing
16
17
  * figs push one-way push the .figs/ spine to the ingest endpoint
17
18
  * figs version print the CLI version (and check for updates)
@@ -49,7 +50,7 @@ import {
49
50
  } from "node:fs"
50
51
  import { homedir } from "node:os"
51
52
  import { basename, extname, join } from "node:path"
52
- import { randomUUID } from "node:crypto"
53
+ import { createHash, randomUUID } from "node:crypto"
53
54
  import { execSync, spawn } from "node:child_process"
54
55
 
55
56
  // Single source of truth for the version: package.json (shipped alongside this
@@ -142,18 +143,36 @@ const COMMANDS = {
142
143
  "verbatim), --detail \"Label=Value\" (repeatable), --attach <file> (repeatable;",
143
144
  "for sign-offs attach the exact content for review + a brief: what to do once",
144
145
  "approved and what it requires).",
145
- "--run <id|last> links the run this came out of (last = newest local run).",
146
+ "--run <run-id> links the run this came out of explicit id only (other",
147
+ "sessions may report concurrently; `figs report` prints the id it wrote).",
146
148
  "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
147
149
  ],
148
- eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run last',
150
+ eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run recon-2026-06',
151
+ },
152
+ inbox: {
153
+ args: "[<ask-id>] [--json]",
154
+ flags: ["--json"],
155
+ desc: "what needs you — your humans' answers/verdicts on your asks (pure read)",
156
+ more: [
157
+ "Start every session with this. Bare: lists every ask with thread activity —",
158
+ "answers and verdicts verbatim, plus the exact next command for each.",
159
+ "With an ask id: the full handoff package — the ask, the whole thread, and its",
160
+ "attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
161
+ "session can act from the record alone.",
162
+ "Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
163
+ "Reads only — closing still happens via figs resolve / figs report --resolves.",
164
+ ],
165
+ eg: "figs inbox",
149
166
  },
150
167
  resolve: {
151
- args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn]",
152
- flags: ["--chosen", "--by", "--note", "--withdrawn", "--no-push"],
168
+ args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn|--rejected]",
169
+ flags: ["--chosen", "--by", "--note", "--withdrawn", "--rejected", "--no-push"],
153
170
  desc: "close an ask — appends the resolution fold line and pushes",
154
171
  more: [
155
172
  "--chosen must quote one of the ask's options[] verbatim (checked).",
156
- "--withdrawn = the ask is no longer needed, nobody acted (don't mark resolved).",
173
+ "Three closes, by who ended it: resolved (default the need was met) ·",
174
+ "--withdrawn (YOU retracted it; nobody acted) · --rejected (a HUMAN declined",
175
+ "it — record their out-of-band no; rejected is terminal, re-raising = a new ask).",
157
176
  "Use `figs report --resolves <ask-id>` instead when a run did the work.",
158
177
  ],
159
178
  eg: 'figs resolve acme-bridge --chosen "Strip the alpha prefix" --by "Sarah (accounting)"',
@@ -234,7 +253,9 @@ function positional() {
234
253
  }
235
254
  return undefined
236
255
  }
237
- const BOOLEAN_FLAGS = new Set(["--no-push", "--stdin", "--withdrawn", "--json", "-h", "--help"])
256
+ const BOOLEAN_FLAGS = new Set([
257
+ "--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "-h", "--help",
258
+ ])
238
259
 
239
260
  /** ISO-8601 with the machine's real UTC offset (never the agent's guess). */
240
261
  function nowIso() {
@@ -258,7 +279,7 @@ function genId(prefix) {
258
279
  // and flag typos get wrong, with errors that teach the fix.
259
280
  const ASK_TYPES = ["blocked", "needs-decision", "sign-off", "fyi"]
260
281
  const RUN_STATUSES = ["ok", "warn", "fail"]
261
- const ASK_STATUSES = ["open", "resolved", "withdrawn"]
282
+ const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
262
283
  const TO_VALUES = ["manager", "builder"]
263
284
  const ARTIFACT_EXTS = new Set([
264
285
  ".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
@@ -472,6 +493,7 @@ else if (COMMANDS[cmd]) {
472
493
  else if (cmd === "init") await init()
473
494
  else if (cmd === "report") await reportCmd()
474
495
  else if (cmd === "ask") await askCmd()
496
+ else if (cmd === "inbox") await inboxCmd()
475
497
  else if (cmd === "resolve") await resolveCmd()
476
498
  else if (cmd === "doctor") await doctor()
477
499
  else if (cmd === "push") await push()
@@ -1098,37 +1120,260 @@ function captureCommit() {
1098
1120
  }
1099
1121
  }
1100
1122
 
1123
+ // ---------- the inbox (the DOWN direction — a pure read) ---------------------
1124
+
1125
+ /** Relative time for inbox lines — rough on purpose. */
1126
+ function agoStr(iso) {
1127
+ const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000))
1128
+ if (mins < 60) return `${mins}m ago`
1129
+ if (mins < 60 * 48) return `${Math.round(mins / 60)}h ago`
1130
+ return `${Math.round(mins / 1440)}d ago`
1131
+ }
1132
+
1133
+ /** Fetch this agent's inbox; null on any failure when `soft` (auto-cite path). */
1134
+ async function fetchInbox({ soft = false } = {}) {
1135
+ const config = readJson(join(repoDir, "config.json"), {})
1136
+ if (!config.agentId) {
1137
+ if (soft) return null
1138
+ die("config missing agentId — run `figs init`")
1139
+ }
1140
+ const r = await request("GET", `/api/inbox?agent=${config.agentId}`)
1141
+ if (!r.ok) {
1142
+ if (soft) return null
1143
+ die(`inbox failed (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
1144
+ }
1145
+ return r.data
1146
+ }
1147
+
1148
+ /** One human event, verbatim — answers are instructions; never paraphrase them. */
1149
+ function eventLine(e) {
1150
+ const head =
1151
+ e.kind === "verdict"
1152
+ ? `${e.verdict.replace(/_/g, " ")} by ${e.byName} (${e.asRole})`
1153
+ : `answered by ${e.byName} (${e.asRole})`
1154
+ const body = [e.chosen ? `→ "${e.chosen}"` : null, e.text ? `"${e.text}"` : null]
1155
+ .filter(Boolean)
1156
+ .join(" · ")
1157
+ return `${head} · ${agoStr(e.createdAt)}${body ? `\n ${body}` : ""}`
1158
+ }
1159
+
1160
+ /** The type×state matrix → the exact next command. The stranger never needs
1161
+ * to know the state machine — the protocol tells them their move. */
1162
+ function nextMove(a) {
1163
+ if (a.status === "rejected") {
1164
+ return `a human declined this — acknowledge it: figs resolve ${a.id} --rejected`
1165
+ }
1166
+ const last = a.events[a.events.length - 1]
1167
+ if (!last) return "waiting on your human — nothing for you to do"
1168
+ if (last.kind === "verdict" && last.verdict === "approved") {
1169
+ return `approved — verify any prerequisites in the ask, do it, then: figs report --resolves ${a.id}`
1170
+ }
1171
+ if (last.kind === "verdict" && last.verdict === "changes_requested") {
1172
+ return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title "…" …`
1173
+ }
1174
+ return `act on the answer, then: figs report --resolves ${a.id} (or figs resolve ${a.id} --chosen "…")`
1175
+ }
1176
+
1177
+ /** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
1178
+ async function fetchRefs(config, refs) {
1179
+ for (const ref of refs ?? []) {
1180
+ if (!ref.artifact) continue
1181
+ const name = ref.artifact
1182
+ let res
1183
+ try {
1184
+ res = await fetchT(
1185
+ `${resolveEndpoint()}/api/artifacts/raw?agent=${config.agentId}&name=${encodeURIComponent(name)}`,
1186
+ { headers: { "x-figs-token": getToken() } },
1187
+ )
1188
+ } catch (e) {
1189
+ console.warn(`figs: ! couldn't fetch artifacts/${name} (${netReason(e)})`)
1190
+ continue
1191
+ }
1192
+ if (!res.ok) {
1193
+ console.warn(`figs: ! couldn't fetch artifacts/${name} (${res.status})`)
1194
+ continue
1195
+ }
1196
+ const bytes = Buffer.from(await res.arrayBuffer())
1197
+ const want = res.headers.get("x-figs-sha256")
1198
+ if (want && createHash("sha256").update(bytes).digest("hex") !== want) {
1199
+ console.warn(`figs: ! artifacts/${name}: bytes didn't match the server's hash — skipped`)
1200
+ continue
1201
+ }
1202
+ const dest = join(repoDir, "artifacts", name)
1203
+ if (existsSync(dest)) {
1204
+ if (readFileSync(dest).equals(bytes)) {
1205
+ console.log(`figs: ✓ artifacts/${name} (already present)`)
1206
+ } else {
1207
+ console.warn(
1208
+ `figs: ! artifacts/${name} exists locally with different content — left untouched (the published copy stays one fetch away)`,
1209
+ )
1210
+ }
1211
+ continue
1212
+ }
1213
+ mkdirSync(join(repoDir, "artifacts"), { recursive: true })
1214
+ writeFileSync(dest, bytes)
1215
+ console.log(`figs: ✓ artifacts/${name} (fetched, hash ok)`)
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * `figs inbox` — session start. Bare: every ask with thread activity + the
1221
+ * next command for each. With an id: the zero-context handoff package (the
1222
+ * ask, the whole thread verbatim, refs restored to disk). Pure read — writes
1223
+ * nothing to the outbox; closing happens via resolve / report --resolves.
1224
+ */
1225
+ async function inboxCmd() {
1226
+ requireFigs()
1227
+ const config = readJson(join(repoDir, "config.json"), {})
1228
+ const data = await fetchInbox()
1229
+ const items = data.asks ?? []
1230
+ const askId = positional()
1231
+
1232
+ if (hasFlag("--json") && !askId) {
1233
+ console.log(JSON.stringify(data, null, 2))
1234
+ return
1235
+ }
1236
+
1237
+ if (askId) {
1238
+ const a = items.find((x) => x.id === askId)
1239
+ if (!a) {
1240
+ die(
1241
+ `ask "${askId}" isn't in your inbox (open asks + unacknowledged rejections only — closed history lives in the app)`,
1242
+ )
1243
+ }
1244
+ console.log(`figs: ${a.title}`)
1245
+ console.log(` ${a.type} · ${a.status}${a.to ? ` · for the ${a.to}` : ""} · raised ${agoStr(a.ts)}`)
1246
+ if (a.found) console.log(`\n What it found:\n ${a.found}`)
1247
+ if (a.need) console.log(`\n What it needs:\n ${a.need}`)
1248
+ if (a.options?.length) {
1249
+ console.log(`\n Options (answers cite these verbatim):`)
1250
+ for (const o of a.options) console.log(` · ${o}`)
1251
+ }
1252
+ if (a.details?.length) {
1253
+ console.log(`\n Details:`)
1254
+ for (const d of a.details) console.log(` ${d.l}: ${d.v}`)
1255
+ }
1256
+ if (a.events.length) {
1257
+ console.log(`\n THE THREAD (your humans' words, verbatim):`)
1258
+ for (const e of a.events) console.log(` · ${eventLine(e)}`)
1259
+ } else {
1260
+ console.log(`\n No answer yet.`)
1261
+ }
1262
+ if (a.refs?.length) {
1263
+ console.log(`\n Attached artifacts (restoring to .figs/artifacts/):`)
1264
+ await fetchRefs(config, a.refs)
1265
+ }
1266
+ console.log(`\n → next: ${nextMove(a)}`)
1267
+ return
1268
+ }
1269
+
1270
+ if (data.truncated) {
1271
+ console.warn(`figs: ! showing the first ${items.length} — more exist (close some asks)`)
1272
+ }
1273
+ if (items.length === 0) {
1274
+ console.log("figs: ✓ inbox empty — no open asks, nothing needs you")
1275
+ return
1276
+ }
1277
+ const rejected = items.filter((a) => a.status === "rejected")
1278
+ const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
1279
+ const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
1280
+ console.log(
1281
+ `figs: inbox — ${answered.length} answered · ${rejected.length} rejected to acknowledge · ${quiet.length} waiting on your human`,
1282
+ )
1283
+ const printItem = (a) => {
1284
+ const last = a.events[a.events.length - 1]
1285
+ console.log(`\n ${a.id} · ${a.type} — ${a.title}`)
1286
+ if (last) console.log(` ${eventLine(last)}`)
1287
+ console.log(` → ${nextMove(a)}${a.events.length > 1 ? ` (full thread: figs inbox ${a.id})` : ""}`)
1288
+ }
1289
+ for (const a of [...rejected, ...answered]) printItem(a)
1290
+ if (quiet.length) {
1291
+ console.log(`\n Waiting on your human (nothing for you to do):`)
1292
+ for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
1293
+ }
1294
+ }
1295
+
1101
1296
  // ---------- the resolution fold (shared by `resolve` and `report --resolves`) -
1102
- function buildResolution(askId, { chosen, by, note, withdrawn }) {
1103
- if (withdrawn && chosen) {
1104
- die("--withdrawn and --chosen are mutually exclusive (withdrawn = the ask is no longer needed, nobody acted)")
1297
+ /**
1298
+ * Build the closing fold line. Best-effort, the verified path: fetches this
1299
+ * agent's inbox and, when the ask has human events, cites the one acted on —
1300
+ * `via: "figs"` + `resolution.answer: <event-id>` (+ `by` from the event) —
1301
+ * attribution by mechanism, not testimony. An ask found on the server but
1302
+ * missing locally is appended first (its own bytes coming home), so the fold
1303
+ * lands on a complete record. Offline or any failure → today's self-reported
1304
+ * `via: "human"` path, unchanged.
1305
+ */
1306
+ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
1307
+ if (withdrawn && rejected) {
1308
+ die("--withdrawn and --rejected are different closes: withdrawn = YOU retracted the ask; rejected = a HUMAN declined it. Pick the one that's true.")
1309
+ }
1310
+ if ((withdrawn || rejected) && chosen) {
1311
+ die(`--chosen marks the need as met — it can't combine with ${withdrawn ? "--withdrawn" : "--rejected"} (use --note for the account)`)
1105
1312
  }
1106
- const asks = foldById(readJsonl("asks.jsonl"))
1107
- const ask = asks.find((a) => a.id === askId)
1108
1313
  const warnings = []
1109
- if (!ask) {
1314
+ let ask = foldById(readJsonl("asks.jsonl")).find((a) => a.id === askId)
1315
+
1316
+ // The verified path (soft — never blocks a close).
1317
+ let serverAsk = null
1318
+ if (getToken()) {
1319
+ const inbox = await fetchInbox({ soft: true })
1320
+ serverAsk = inbox?.asks?.find((a) => a.id === askId) ?? null
1321
+ }
1322
+ if (!ask && serverAsk) {
1323
+ // Its own bytes coming home: append the record so the fold has something
1324
+ // local to land on (the cross-machine close, solved inside the verb).
1325
+ const record = { ...serverAsk }
1326
+ delete record.events
1327
+ delete record.updatedAt
1328
+ if (record.resolution == null) delete record.resolution
1329
+ appendJsonl("asks.jsonl", record)
1330
+ warnings.push(`fetched "${askId}" from Figs (raised elsewhere) — recorded locally before closing`)
1331
+ ask = record
1332
+ } else if (!ask) {
1110
1333
  warnings.push(
1111
1334
  `ask "${askId}" isn't in the local asks.jsonl (raised on another machine, or pruned) — recording the close anyway; the server folds it onto the full record`,
1112
1335
  )
1113
- } else if (chosen && ask.options?.length && !ask.options.includes(chosen)) {
1336
+ }
1337
+
1338
+ const options = ask?.options ?? serverAsk?.options ?? []
1339
+ if (chosen && options.length && !options.includes(chosen)) {
1114
1340
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1115
- const near = ask.options.find((o) => norm(o) === norm(chosen))
1341
+ const near = options.find((o) => norm(o) === norm(chosen))
1116
1342
  if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
1117
1343
  die(
1118
1344
  `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1119
- ask.options.map((o) => ` · ${o}`).join("\n") +
1345
+ options.map((o) => ` · ${o}`).join("\n") +
1120
1346
  "\n (quote one verbatim, or use --note for a free-text account)",
1121
1347
  )
1122
1348
  }
1349
+
1123
1350
  const resolution = {}
1124
1351
  if (chosen) resolution.chosen = chosen
1125
1352
  if (by) resolution.by = by
1126
1353
  if (note) resolution.note = note
1127
1354
  // `via` says where the unblock came from; out-of-band human answers are
1128
- // "human". Set it only when there's evidence of one (a chosen/by), never on
1129
- // withdrawn (nobody acted that's the point).
1130
- if (!withdrawn && (chosen || by)) resolution.via = "human"
1131
- const line = { id: askId, status: withdrawn ? "withdrawn" : "resolved" }
1355
+ // "human". A rejection is inherently a human's call; otherwise set it only
1356
+ // when there's evidence of one (a chosen/by), never on withdrawn (nobody
1357
+ // acted that's the point).
1358
+ if (rejected || (!withdrawn && (chosen || by))) resolution.via = "human"
1359
+
1360
+ // Cite the event acted on, when one exists: prefer the latest answer whose
1361
+ // chosen matches, else the latest event (e.g. the approval, the rejection).
1362
+ const events = serverAsk?.events ?? []
1363
+ if (!withdrawn && events.length) {
1364
+ const match = chosen
1365
+ ? [...events].reverse().find((e) => e.kind === "answer" && e.chosen === chosen)
1366
+ : null
1367
+ const cited = match ?? events[events.length - 1]
1368
+ resolution.via = "figs"
1369
+ resolution.answer = cited.id
1370
+ if (!by && cited.byName) resolution.by = cited.byName
1371
+ }
1372
+
1373
+ const line = {
1374
+ id: askId,
1375
+ status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
1376
+ }
1132
1377
  if (Object.keys(resolution).length) line.resolution = resolution
1133
1378
  return { line, warnings }
1134
1379
  }
@@ -1165,7 +1410,7 @@ async function reportCmd() {
1165
1410
  let resolution = null
1166
1411
  if (resolves) {
1167
1412
  run.resolves = resolves
1168
- resolution = buildResolution(resolves, {
1413
+ resolution = await buildResolution(resolves, {
1169
1414
  chosen: flag("--chosen"),
1170
1415
  by: flag("--by"),
1171
1416
  note: flag("--note"),
@@ -1234,12 +1479,11 @@ async function askCmd() {
1234
1479
  if (details.length) ask.details = [...(base.details ?? []), ...details]
1235
1480
  const runRef = flag("--run")
1236
1481
  if (runRef === "last") {
1237
- const runs = foldById(readJsonl("runs.jsonl"))
1238
- if (!runs.length) {
1239
- die("--run last: no runs in the local runs.jsonl — `figs report` one first, or pass an explicit id")
1240
- }
1241
- ask.run = runs.reduce((a, b) => ((a.ts ?? "") > (b.ts ?? "") ? a : b)).id
1242
- } else if (runRef) ask.run = runRef
1482
+ // Deliberately unsupported: concurrent sessions of the same agent report
1483
+ // runs in parallel — "the latest run" may be someone else's. Explicit only.
1484
+ die('--run takes the explicit run id (no "last"another session may have reported since); `figs report` prints the id of what it wrote')
1485
+ }
1486
+ if (runRef) ask.run = runRef
1243
1487
  const attached = attachFiles(flagAll("--attach"))
1244
1488
  if (attached.length) {
1245
1489
  ask.refs = [...(base.refs ?? []), ...attached.map((n) => ({ label: n, artifact: n }))]
@@ -1261,7 +1505,7 @@ async function askCmd() {
1261
1505
  }
1262
1506
  await autoPush()
1263
1507
  console.log(
1264
- "figs: answers arrive out-of-band for now (`figs inbox` is coming) — your human replies in the app thread or directly to you",
1508
+ "figs: your human answers in the app start your next session with `figs inbox` to read it",
1265
1509
  )
1266
1510
  }
1267
1511
 
@@ -1269,11 +1513,12 @@ async function resolveCmd() {
1269
1513
  requireFigs()
1270
1514
  const askId = positional()
1271
1515
  if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
1272
- const { line, warnings } = buildResolution(askId, {
1516
+ const { line, warnings } = await buildResolution(askId, {
1273
1517
  chosen: flag("--chosen"),
1274
1518
  by: flag("--by"),
1275
1519
  note: flag("--note"),
1276
1520
  withdrawn: hasFlag("--withdrawn"),
1521
+ rejected: hasFlag("--rejected"),
1277
1522
  })
1278
1523
  for (const w of warnings) console.warn(`figs: ! ${w}`)
1279
1524
  appendJsonl("asks.jsonl", line)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Figs CLI — publish your AI agent's state to Figs (figs.so). Run by the agent.",
5
5
  "type": "module",
6
6
  "bin": {