@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.
- package/figs.mjs +245 -12
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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"),
|