@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.
- package/README.md +8 -3
- package/SPEC.md +6 -4
- package/figs.mjs +276 -31
- 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
|
-
|
|
|
75
|
-
|
|
|
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
|
|
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` → *(
|
|
158
|
-
`resolved` | `withdrawn` *(agent, in `asks.jsonl`)
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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([
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
-
|
|
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".
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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:
|
|
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)
|