@figs-so/cli 0.2.1 → 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 (2) hide show
  1. package/figs.mjs +245 -12
  2. package/package.json +1 -1
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
@@ -148,6 +149,21 @@ const COMMANDS = {
148
149
  ],
149
150
  eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run recon-2026-06',
150
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",
166
+ },
151
167
  resolve: {
152
168
  args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn|--rejected]",
153
169
  flags: ["--chosen", "--by", "--note", "--withdrawn", "--rejected", "--no-push"],
@@ -477,6 +493,7 @@ else if (COMMANDS[cmd]) {
477
493
  else if (cmd === "init") await init()
478
494
  else if (cmd === "report") await reportCmd()
479
495
  else if (cmd === "ask") await askCmd()
496
+ else if (cmd === "inbox") await inboxCmd()
480
497
  else if (cmd === "resolve") await resolveCmd()
481
498
  else if (cmd === "doctor") await doctor()
482
499
  else if (cmd === "push") await push()
@@ -1103,31 +1120,233 @@ function captureCommit() {
1103
1120
  }
1104
1121
  }
1105
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
+
1106
1296
  // ---------- the resolution fold (shared by `resolve` and `report --resolves`) -
1107
- function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
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 }) {
1108
1307
  if (withdrawn && rejected) {
1109
1308
  die("--withdrawn and --rejected are different closes: withdrawn = YOU retracted the ask; rejected = a HUMAN declined it. Pick the one that's true.")
1110
1309
  }
1111
1310
  if ((withdrawn || rejected) && chosen) {
1112
1311
  die(`--chosen marks the need as met — it can't combine with ${withdrawn ? "--withdrawn" : "--rejected"} (use --note for the account)`)
1113
1312
  }
1114
- const asks = foldById(readJsonl("asks.jsonl"))
1115
- const ask = asks.find((a) => a.id === askId)
1116
1313
  const warnings = []
1117
- 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) {
1118
1333
  warnings.push(
1119
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`,
1120
1335
  )
1121
- } 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)) {
1122
1340
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
1123
- const near = ask.options.find((o) => norm(o) === norm(chosen))
1341
+ const near = options.find((o) => norm(o) === norm(chosen))
1124
1342
  if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
1125
1343
  die(
1126
1344
  `--chosen "${chosen}" doesn't match any of the ask's options:\n` +
1127
- ask.options.map((o) => ` · ${o}`).join("\n") +
1345
+ options.map((o) => ` · ${o}`).join("\n") +
1128
1346
  "\n (quote one verbatim, or use --note for a free-text account)",
1129
1347
  )
1130
1348
  }
1349
+
1131
1350
  const resolution = {}
1132
1351
  if (chosen) resolution.chosen = chosen
1133
1352
  if (by) resolution.by = by
@@ -1137,6 +1356,20 @@ function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
1137
1356
  // when there's evidence of one (a chosen/by), never on withdrawn (nobody
1138
1357
  // acted — that's the point).
1139
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
+
1140
1373
  const line = {
1141
1374
  id: askId,
1142
1375
  status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
@@ -1177,7 +1410,7 @@ async function reportCmd() {
1177
1410
  let resolution = null
1178
1411
  if (resolves) {
1179
1412
  run.resolves = resolves
1180
- resolution = buildResolution(resolves, {
1413
+ resolution = await buildResolution(resolves, {
1181
1414
  chosen: flag("--chosen"),
1182
1415
  by: flag("--by"),
1183
1416
  note: flag("--note"),
@@ -1272,7 +1505,7 @@ async function askCmd() {
1272
1505
  }
1273
1506
  await autoPush()
1274
1507
  console.log(
1275
- "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",
1276
1509
  )
1277
1510
  }
1278
1511
 
@@ -1280,7 +1513,7 @@ async function resolveCmd() {
1280
1513
  requireFigs()
1281
1514
  const askId = positional()
1282
1515
  if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
1283
- const { line, warnings } = buildResolution(askId, {
1516
+ const { line, warnings } = await buildResolution(askId, {
1284
1517
  chosen: flag("--chosen"),
1285
1518
  by: flag("--by"),
1286
1519
  note: flag("--note"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.2.1",
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": {