@figs-so/cli 0.2.1 → 0.4.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 +2 -1
- package/SPEC.md +6 -2
- package/figs.mjs +261 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,8 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
|
|
|
73
73
|
| `figs login` / `logout` | device-flow browser approve / remove local token |
|
|
74
74
|
| `figs workspaces [--json]` | list your workspaces (create one in the web app) |
|
|
75
75
|
| `figs init [--workspace <slug>]` | generate identity + write `.figs/` (omit the flag: uses your only workspace, else lists them) |
|
|
76
|
-
| **`figs
|
|
76
|
+
| **`figs inbox [<ask-id>]`** | start every session here — your humans' answers/verdicts, verbatim, with the next command per ask; with an id: the full zero-context handoff package (thread + artifacts restored) |
|
|
77
|
+
| **`figs report --result "…"`** | record a run — **one job, one stable `--id`** (re-reporting an id folds progress onto that job's row); stamps the timestamp, auto-captures the session trace, `--attach`es artifacts, pushes itself |
|
|
77
78
|
| **`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
79
|
| **`figs resolve <ask-id>`** | close an ask — `--chosen` verbatim-checked against its options, `--withdrawn` for the un-ask |
|
|
79
80
|
| `figs push` | the bare transport — the verbs call it automatically; type it yourself after hand-edits or `--no-push` |
|
package/SPEC.md
CHANGED
|
@@ -82,7 +82,12 @@ Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipel
|
|
|
82
82
|
|
|
83
83
|
## 5. `runs.jsonl` — activity
|
|
84
84
|
|
|
85
|
-
One JSON object per line (JSON Lines).
|
|
85
|
+
One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's
|
|
86
|
+
*manager* would recognize ("recon — Acme — November"), under a **stable, meaningful id**
|
|
87
|
+
(`recon-acme-2026-11`); the runs list reads as the job list. Records **fold by `id`** (same
|
|
88
|
+
merge as asks): re-reporting a job's id layers progress onto its row (`status` evolves
|
|
89
|
+
blocked-ish `warn` → `ok`) — sittings/sessions are agent plumbing and never mint records.
|
|
90
|
+
Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
|
|
86
91
|
|
|
87
92
|
| Field | Type | Req | Meaning |
|
|
88
93
|
|---|---|:--:|---|
|
|
@@ -93,7 +98,6 @@ One JSON object per line (JSON Lines). Each is something the agent did.
|
|
|
93
98
|
| `result` | string | | One-line outcome. |
|
|
94
99
|
| `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — a run is a complete fact when reported; nothing "closes" a run. |
|
|
95
100
|
| `artifacts` | string[] | | File names under `artifacts/` to attach. Singular `artifact` (string) remains valid shorthand for one — readers normalize to the array (same pattern as `resolution`'s bare-string shorthand). |
|
|
96
|
-
| `resolves` | string | | The ask `id` this run executes/closes (the agent did the answered/approved thing and is reporting back — see [§6.1](#61-lifecycle--two-ledgers-split-by-author)). |
|
|
97
101
|
| `session` | `Session` | | Where/how this ran (see [§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
|
|
98
102
|
|
|
99
103
|
### 5.1 `Session` — runtime metadata (optional)
|
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
|
|
@@ -112,20 +113,23 @@ const COMMANDS = {
|
|
|
112
113
|
report: {
|
|
113
114
|
args: "--result <text> [options]",
|
|
114
115
|
flags: [
|
|
115
|
-
"--result", "--id", "--unit", "--period", "--status", "--attach",
|
|
116
|
-
"--resolves", "--chosen", "--by", "--note", "--no-push",
|
|
116
|
+
"--result", "--id", "--unit", "--period", "--status", "--attach", "--no-push",
|
|
117
117
|
],
|
|
118
|
-
desc: "record a run —
|
|
118
|
+
desc: "record a run — one job's row in runs.jsonl; stamps id/ts/session, pushes",
|
|
119
119
|
more: [
|
|
120
|
+
"One run = one JOB — a unit of work your manager would recognize; the runs",
|
|
121
|
+
"list reads as the job list. Give a job a stable, meaningful --id",
|
|
122
|
+
"(recon-acme-2026-11); reporting the same id again folds onto that job's row",
|
|
123
|
+
"(progress evolves: blocked → ok). Sittings/sessions never mint runs —",
|
|
124
|
+
"stopping to wait for a human is not a job.",
|
|
120
125
|
"You supply the content; the CLI does the bookkeeping (id, real-clock ts, session",
|
|
121
126
|
"trace from your runtime's own records, validation, artifact copy, push).",
|
|
122
127
|
"--attach <file> (repeatable) copies the file into artifacts/ and links it.",
|
|
123
|
-
"--resolves <ask-id> also closes that ask (with optional --chosen/--by/--note).",
|
|
124
|
-
"--id only for deliberate stable ids (re-running the same job updates the same run).",
|
|
125
128
|
"--no-push writes locally only; `figs push` publishes later.",
|
|
129
|
+
"Closing an ask is `figs resolve` — a close is not a job; never report one.",
|
|
126
130
|
"Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
|
|
127
131
|
],
|
|
128
|
-
eg: 'figs report --result "88% matched · 31 flagged" --attach ./acme-2025-11.html',
|
|
132
|
+
eg: 'figs report --id recon-acme-2026-11 --result "88% matched · 31 flagged" --attach ./acme-2025-11.html',
|
|
129
133
|
},
|
|
130
134
|
ask: {
|
|
131
135
|
args: "<type> --title <text> [options]",
|
|
@@ -148,6 +152,21 @@ const COMMANDS = {
|
|
|
148
152
|
],
|
|
149
153
|
eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run recon-2026-06',
|
|
150
154
|
},
|
|
155
|
+
inbox: {
|
|
156
|
+
args: "[<ask-id>] [--json]",
|
|
157
|
+
flags: ["--json"],
|
|
158
|
+
desc: "what needs you — your humans' answers/verdicts on your asks (pure read)",
|
|
159
|
+
more: [
|
|
160
|
+
"Start every session with this. Bare: lists every ask with thread activity —",
|
|
161
|
+
"answers and verdicts verbatim, plus the exact next command for each.",
|
|
162
|
+
"With an ask id: the full handoff package — the ask, the whole thread, and its",
|
|
163
|
+
"attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
|
|
164
|
+
"session can act from the record alone.",
|
|
165
|
+
"Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
|
|
166
|
+
"Reads only — closing still happens via figs resolve.",
|
|
167
|
+
],
|
|
168
|
+
eg: "figs inbox",
|
|
169
|
+
},
|
|
151
170
|
resolve: {
|
|
152
171
|
args: "<ask-id> [--chosen <option>] [--by <who>] [--note <text>] [--withdrawn|--rejected]",
|
|
153
172
|
flags: ["--chosen", "--by", "--note", "--withdrawn", "--rejected", "--no-push"],
|
|
@@ -157,7 +176,9 @@ const COMMANDS = {
|
|
|
157
176
|
"Three closes, by who ended it: resolved (default — the need was met) ·",
|
|
158
177
|
"--withdrawn (YOU retracted it; nobody acted) · --rejected (a HUMAN declined",
|
|
159
178
|
"it — record their out-of-band no; rejected is terminal, re-raising = a new ask).",
|
|
160
|
-
"
|
|
179
|
+
"After an answer, fork on what it unlocked: nothing new → resolve right away;",
|
|
180
|
+
"real work → do the job, `figs report` it under its own id, THEN resolve —",
|
|
181
|
+
"cite the job id in --note so a reader can find the work.",
|
|
161
182
|
],
|
|
162
183
|
eg: 'figs resolve acme-bridge --chosen "Strip the alpha prefix" --by "Sarah (accounting)"',
|
|
163
184
|
},
|
|
@@ -477,6 +498,7 @@ else if (COMMANDS[cmd]) {
|
|
|
477
498
|
else if (cmd === "init") await init()
|
|
478
499
|
else if (cmd === "report") await reportCmd()
|
|
479
500
|
else if (cmd === "ask") await askCmd()
|
|
501
|
+
else if (cmd === "inbox") await inboxCmd()
|
|
480
502
|
else if (cmd === "resolve") await resolveCmd()
|
|
481
503
|
else if (cmd === "doctor") await doctor()
|
|
482
504
|
else if (cmd === "push") await push()
|
|
@@ -1103,31 +1125,237 @@ function captureCommit() {
|
|
|
1103
1125
|
}
|
|
1104
1126
|
}
|
|
1105
1127
|
|
|
1106
|
-
// ---------- the
|
|
1107
|
-
|
|
1128
|
+
// ---------- the inbox (the DOWN direction — a pure read) ---------------------
|
|
1129
|
+
|
|
1130
|
+
/** Relative time for inbox lines — rough on purpose. */
|
|
1131
|
+
function agoStr(iso) {
|
|
1132
|
+
const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000))
|
|
1133
|
+
if (mins < 60) return `${mins}m ago`
|
|
1134
|
+
if (mins < 60 * 48) return `${Math.round(mins / 60)}h ago`
|
|
1135
|
+
return `${Math.round(mins / 1440)}d ago`
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Fetch this agent's inbox; null on any failure when `soft` (auto-cite path). */
|
|
1139
|
+
async function fetchInbox({ soft = false } = {}) {
|
|
1140
|
+
const config = readJson(join(repoDir, "config.json"), {})
|
|
1141
|
+
if (!config.agentId) {
|
|
1142
|
+
if (soft) return null
|
|
1143
|
+
die("config missing agentId — run `figs init`")
|
|
1144
|
+
}
|
|
1145
|
+
const r = await request("GET", `/api/inbox?agent=${config.agentId}`)
|
|
1146
|
+
if (!r.ok) {
|
|
1147
|
+
if (soft) return null
|
|
1148
|
+
die(`inbox failed (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
|
|
1149
|
+
}
|
|
1150
|
+
return r.data
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** One human event, verbatim — answers are instructions; never paraphrase them. */
|
|
1154
|
+
function eventLine(e) {
|
|
1155
|
+
const head =
|
|
1156
|
+
e.kind === "verdict"
|
|
1157
|
+
? `${e.verdict.replace(/_/g, " ")} by ${e.byName} (${e.asRole})`
|
|
1158
|
+
: `answered by ${e.byName} (${e.asRole})`
|
|
1159
|
+
const body = [e.chosen ? `→ "${e.chosen}"` : null, e.text ? `"${e.text}"` : null]
|
|
1160
|
+
.filter(Boolean)
|
|
1161
|
+
.join(" · ")
|
|
1162
|
+
return `${head} · ${agoStr(e.createdAt)}${body ? `\n ${body}` : ""}`
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** The type×state matrix → the exact next command. The stranger never needs
|
|
1166
|
+
* to know the state machine — the protocol tells them their move. */
|
|
1167
|
+
function nextMove(a) {
|
|
1168
|
+
if (a.status === "rejected") {
|
|
1169
|
+
return `a human declined this — acknowledge it: figs resolve ${a.id} --rejected`
|
|
1170
|
+
}
|
|
1171
|
+
const last = a.events[a.events.length - 1]
|
|
1172
|
+
if (!last) return "waiting on your human — nothing for you to do"
|
|
1173
|
+
if (last.kind === "verdict" && last.verdict === "approved") {
|
|
1174
|
+
return (
|
|
1175
|
+
`approved — verify any prerequisites in the ask, then fork on what it unlocked:` +
|
|
1176
|
+
`\n nothing left to do → figs resolve ${a.id}` +
|
|
1177
|
+
`\n real work → do the job, figs report it under its own --id, then figs resolve ${a.id} --note "job <id>"`
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
if (last.kind === "verdict" && last.verdict === "changes_requested") {
|
|
1181
|
+
return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title "…" …`
|
|
1182
|
+
}
|
|
1183
|
+
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen "…"`
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
|
|
1187
|
+
async function fetchRefs(config, refs) {
|
|
1188
|
+
for (const ref of refs ?? []) {
|
|
1189
|
+
if (!ref.artifact) continue
|
|
1190
|
+
const name = ref.artifact
|
|
1191
|
+
let res
|
|
1192
|
+
try {
|
|
1193
|
+
res = await fetchT(
|
|
1194
|
+
`${resolveEndpoint()}/api/artifacts/raw?agent=${config.agentId}&name=${encodeURIComponent(name)}`,
|
|
1195
|
+
{ headers: { "x-figs-token": getToken() } },
|
|
1196
|
+
)
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
console.warn(`figs: ! couldn't fetch artifacts/${name} (${netReason(e)})`)
|
|
1199
|
+
continue
|
|
1200
|
+
}
|
|
1201
|
+
if (!res.ok) {
|
|
1202
|
+
console.warn(`figs: ! couldn't fetch artifacts/${name} (${res.status})`)
|
|
1203
|
+
continue
|
|
1204
|
+
}
|
|
1205
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
1206
|
+
const want = res.headers.get("x-figs-sha256")
|
|
1207
|
+
if (want && createHash("sha256").update(bytes).digest("hex") !== want) {
|
|
1208
|
+
console.warn(`figs: ! artifacts/${name}: bytes didn't match the server's hash — skipped`)
|
|
1209
|
+
continue
|
|
1210
|
+
}
|
|
1211
|
+
const dest = join(repoDir, "artifacts", name)
|
|
1212
|
+
if (existsSync(dest)) {
|
|
1213
|
+
if (readFileSync(dest).equals(bytes)) {
|
|
1214
|
+
console.log(`figs: ✓ artifacts/${name} (already present)`)
|
|
1215
|
+
} else {
|
|
1216
|
+
console.warn(
|
|
1217
|
+
`figs: ! artifacts/${name} exists locally with different content — left untouched (the published copy stays one fetch away)`,
|
|
1218
|
+
)
|
|
1219
|
+
}
|
|
1220
|
+
continue
|
|
1221
|
+
}
|
|
1222
|
+
mkdirSync(join(repoDir, "artifacts"), { recursive: true })
|
|
1223
|
+
writeFileSync(dest, bytes)
|
|
1224
|
+
console.log(`figs: ✓ artifacts/${name} (fetched, hash ok)`)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* `figs inbox` — session start. Bare: every ask with thread activity + the
|
|
1230
|
+
* next command for each. With an id: the zero-context handoff package (the
|
|
1231
|
+
* ask, the whole thread verbatim, refs restored to disk). Pure read — writes
|
|
1232
|
+
* nothing to the outbox; closing happens via resolve.
|
|
1233
|
+
*/
|
|
1234
|
+
async function inboxCmd() {
|
|
1235
|
+
requireFigs()
|
|
1236
|
+
const config = readJson(join(repoDir, "config.json"), {})
|
|
1237
|
+
const data = await fetchInbox()
|
|
1238
|
+
const items = data.asks ?? []
|
|
1239
|
+
const askId = positional()
|
|
1240
|
+
|
|
1241
|
+
if (hasFlag("--json") && !askId) {
|
|
1242
|
+
console.log(JSON.stringify(data, null, 2))
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (askId) {
|
|
1247
|
+
const a = items.find((x) => x.id === askId)
|
|
1248
|
+
if (!a) {
|
|
1249
|
+
die(
|
|
1250
|
+
`ask "${askId}" isn't in your inbox (open asks + unacknowledged rejections only — closed history lives in the app)`,
|
|
1251
|
+
)
|
|
1252
|
+
}
|
|
1253
|
+
console.log(`figs: ${a.title}`)
|
|
1254
|
+
console.log(` ${a.type} · ${a.status}${a.to ? ` · for the ${a.to}` : ""} · raised ${agoStr(a.ts)}`)
|
|
1255
|
+
if (a.found) console.log(`\n What it found:\n ${a.found}`)
|
|
1256
|
+
if (a.need) console.log(`\n What it needs:\n ${a.need}`)
|
|
1257
|
+
if (a.options?.length) {
|
|
1258
|
+
console.log(`\n Options (answers cite these verbatim):`)
|
|
1259
|
+
for (const o of a.options) console.log(` · ${o}`)
|
|
1260
|
+
}
|
|
1261
|
+
if (a.details?.length) {
|
|
1262
|
+
console.log(`\n Details:`)
|
|
1263
|
+
for (const d of a.details) console.log(` ${d.l}: ${d.v}`)
|
|
1264
|
+
}
|
|
1265
|
+
if (a.events.length) {
|
|
1266
|
+
console.log(`\n THE THREAD (your humans' words, verbatim):`)
|
|
1267
|
+
for (const e of a.events) console.log(` · ${eventLine(e)}`)
|
|
1268
|
+
} else {
|
|
1269
|
+
console.log(`\n No answer yet.`)
|
|
1270
|
+
}
|
|
1271
|
+
if (a.refs?.length) {
|
|
1272
|
+
console.log(`\n Attached artifacts (restoring to .figs/artifacts/):`)
|
|
1273
|
+
await fetchRefs(config, a.refs)
|
|
1274
|
+
}
|
|
1275
|
+
console.log(`\n → next: ${nextMove(a)}`)
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (data.truncated) {
|
|
1280
|
+
console.warn(`figs: ! showing the first ${items.length} — more exist (close some asks)`)
|
|
1281
|
+
}
|
|
1282
|
+
if (items.length === 0) {
|
|
1283
|
+
console.log("figs: ✓ inbox empty — no open asks, nothing needs you")
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
const rejected = items.filter((a) => a.status === "rejected")
|
|
1287
|
+
const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
|
|
1288
|
+
const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
|
|
1289
|
+
console.log(
|
|
1290
|
+
`figs: inbox — ${answered.length} answered · ${rejected.length} rejected to acknowledge · ${quiet.length} waiting on your human`,
|
|
1291
|
+
)
|
|
1292
|
+
const printItem = (a) => {
|
|
1293
|
+
const last = a.events[a.events.length - 1]
|
|
1294
|
+
console.log(`\n ${a.id} · ${a.type} — ${a.title}`)
|
|
1295
|
+
if (last) console.log(` ${eventLine(last)}`)
|
|
1296
|
+
console.log(` → ${nextMove(a)}${a.events.length > 1 ? ` (full thread: figs inbox ${a.id})` : ""}`)
|
|
1297
|
+
}
|
|
1298
|
+
for (const a of [...rejected, ...answered]) printItem(a)
|
|
1299
|
+
if (quiet.length) {
|
|
1300
|
+
console.log(`\n Waiting on your human (nothing for you to do):`)
|
|
1301
|
+
for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ---------- the resolution fold (`resolve` — the one closing verb) ----------
|
|
1306
|
+
/**
|
|
1307
|
+
* Build the closing fold line. Best-effort, the verified path: fetches this
|
|
1308
|
+
* agent's inbox and, when the ask has human events, cites the one acted on —
|
|
1309
|
+
* `via: "figs"` + `resolution.answer: <event-id>` (+ `by` from the event) —
|
|
1310
|
+
* attribution by mechanism, not testimony. An ask found on the server but
|
|
1311
|
+
* missing locally is appended first (its own bytes coming home), so the fold
|
|
1312
|
+
* lands on a complete record. Offline or any failure → today's self-reported
|
|
1313
|
+
* `via: "human"` path, unchanged.
|
|
1314
|
+
*/
|
|
1315
|
+
async function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
|
|
1108
1316
|
if (withdrawn && rejected) {
|
|
1109
1317
|
die("--withdrawn and --rejected are different closes: withdrawn = YOU retracted the ask; rejected = a HUMAN declined it. Pick the one that's true.")
|
|
1110
1318
|
}
|
|
1111
1319
|
if ((withdrawn || rejected) && chosen) {
|
|
1112
1320
|
die(`--chosen marks the need as met — it can't combine with ${withdrawn ? "--withdrawn" : "--rejected"} (use --note for the account)`)
|
|
1113
1321
|
}
|
|
1114
|
-
const asks = foldById(readJsonl("asks.jsonl"))
|
|
1115
|
-
const ask = asks.find((a) => a.id === askId)
|
|
1116
1322
|
const warnings = []
|
|
1117
|
-
|
|
1323
|
+
let ask = foldById(readJsonl("asks.jsonl")).find((a) => a.id === askId)
|
|
1324
|
+
|
|
1325
|
+
// The verified path (soft — never blocks a close).
|
|
1326
|
+
let serverAsk = null
|
|
1327
|
+
if (getToken()) {
|
|
1328
|
+
const inbox = await fetchInbox({ soft: true })
|
|
1329
|
+
serverAsk = inbox?.asks?.find((a) => a.id === askId) ?? null
|
|
1330
|
+
}
|
|
1331
|
+
if (!ask && serverAsk) {
|
|
1332
|
+
// Its own bytes coming home: append the record so the fold has something
|
|
1333
|
+
// local to land on (the cross-machine close, solved inside the verb).
|
|
1334
|
+
const record = { ...serverAsk }
|
|
1335
|
+
delete record.events
|
|
1336
|
+
delete record.updatedAt
|
|
1337
|
+
if (record.resolution == null) delete record.resolution
|
|
1338
|
+
appendJsonl("asks.jsonl", record)
|
|
1339
|
+
warnings.push(`fetched "${askId}" from Figs (raised elsewhere) — recorded locally before closing`)
|
|
1340
|
+
ask = record
|
|
1341
|
+
} else if (!ask) {
|
|
1118
1342
|
warnings.push(
|
|
1119
1343
|
`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
1344
|
)
|
|
1121
|
-
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const options = ask?.options ?? serverAsk?.options ?? []
|
|
1348
|
+
if (chosen && options.length && !options.includes(chosen)) {
|
|
1122
1349
|
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
1123
|
-
const near =
|
|
1350
|
+
const near = options.find((o) => norm(o) === norm(chosen))
|
|
1124
1351
|
if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
|
|
1125
1352
|
die(
|
|
1126
1353
|
`--chosen "${chosen}" doesn't match any of the ask's options:\n` +
|
|
1127
|
-
|
|
1354
|
+
options.map((o) => ` · ${o}`).join("\n") +
|
|
1128
1355
|
"\n (quote one verbatim, or use --note for a free-text account)",
|
|
1129
1356
|
)
|
|
1130
1357
|
}
|
|
1358
|
+
|
|
1131
1359
|
const resolution = {}
|
|
1132
1360
|
if (chosen) resolution.chosen = chosen
|
|
1133
1361
|
if (by) resolution.by = by
|
|
@@ -1137,6 +1365,20 @@ function buildResolution(askId, { chosen, by, note, withdrawn, rejected }) {
|
|
|
1137
1365
|
// when there's evidence of one (a chosen/by), never on withdrawn (nobody
|
|
1138
1366
|
// acted — that's the point).
|
|
1139
1367
|
if (rejected || (!withdrawn && (chosen || by))) resolution.via = "human"
|
|
1368
|
+
|
|
1369
|
+
// Cite the event acted on, when one exists: prefer the latest answer whose
|
|
1370
|
+
// chosen matches, else the latest event (e.g. the approval, the rejection).
|
|
1371
|
+
const events = serverAsk?.events ?? []
|
|
1372
|
+
if (!withdrawn && events.length) {
|
|
1373
|
+
const match = chosen
|
|
1374
|
+
? [...events].reverse().find((e) => e.kind === "answer" && e.chosen === chosen)
|
|
1375
|
+
: null
|
|
1376
|
+
const cited = match ?? events[events.length - 1]
|
|
1377
|
+
resolution.via = "figs"
|
|
1378
|
+
resolution.answer = cited.id
|
|
1379
|
+
if (!by && cited.byName) resolution.by = cited.byName
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1140
1382
|
const line = {
|
|
1141
1383
|
id: askId,
|
|
1142
1384
|
status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
|
|
@@ -1173,16 +1415,6 @@ async function reportCmd() {
|
|
|
1173
1415
|
const attached = attachFiles(flagAll("--attach"))
|
|
1174
1416
|
if (attached.length === 1) run.artifact = attached[0]
|
|
1175
1417
|
else if (attached.length > 1) run.artifacts = attached
|
|
1176
|
-
const resolves = flag("--resolves")
|
|
1177
|
-
let resolution = null
|
|
1178
|
-
if (resolves) {
|
|
1179
|
-
run.resolves = resolves
|
|
1180
|
-
resolution = buildResolution(resolves, {
|
|
1181
|
-
chosen: flag("--chosen"),
|
|
1182
|
-
by: flag("--by"),
|
|
1183
|
-
note: flag("--note"),
|
|
1184
|
-
})
|
|
1185
|
-
}
|
|
1186
1418
|
const session = captureSession()
|
|
1187
1419
|
if (session) run.session = session
|
|
1188
1420
|
|
|
@@ -1190,11 +1422,6 @@ async function reportCmd() {
|
|
|
1190
1422
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1191
1423
|
appendJsonl("runs.jsonl", run)
|
|
1192
1424
|
console.log(`figs: ✓ run recorded — ${summarize(run)}`)
|
|
1193
|
-
if (resolution) {
|
|
1194
|
-
for (const w of resolution.warnings) console.warn(`figs: ! ${w}`)
|
|
1195
|
-
appendJsonl("asks.jsonl", resolution.line)
|
|
1196
|
-
console.log(`figs: ✓ ask ${resolves} ${resolution.line.status}`)
|
|
1197
|
-
}
|
|
1198
1425
|
await autoPush()
|
|
1199
1426
|
}
|
|
1200
1427
|
|
|
@@ -1272,7 +1499,7 @@ async function askCmd() {
|
|
|
1272
1499
|
}
|
|
1273
1500
|
await autoPush()
|
|
1274
1501
|
console.log(
|
|
1275
|
-
"figs:
|
|
1502
|
+
"figs: your human answers in the app — start your next session with `figs inbox` to read it",
|
|
1276
1503
|
)
|
|
1277
1504
|
}
|
|
1278
1505
|
|
|
@@ -1280,7 +1507,7 @@ async function resolveCmd() {
|
|
|
1280
1507
|
requireFigs()
|
|
1281
1508
|
const askId = positional()
|
|
1282
1509
|
if (!askId) die("resolve needs the ask id: figs resolve <ask-id> [--chosen …] [--withdrawn]")
|
|
1283
|
-
const { line, warnings } = buildResolution(askId, {
|
|
1510
|
+
const { line, warnings } = await buildResolution(askId, {
|
|
1284
1511
|
chosen: flag("--chosen"),
|
|
1285
1512
|
by: flag("--by"),
|
|
1286
1513
|
note: flag("--note"),
|