@figs-so/cli 0.4.0 → 0.6.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 +2 -2
  2. package/SPEC.md +4 -4
  3. package/figs.mjs +73 -168
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -74,8 +74,8 @@ are shorthand for exactly that (always current, no version drift). Prefer a real
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
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 |
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 |
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, `--attach`es artifacts, pushes itself |
78
+ | **`figs ask <type> --title ''`** | raise a self-contained ask (`needs-decision` · `sign-off` · `fyi`) — options/details/attachments, pushed so a human sees it |
79
79
  | **`figs resolve <ask-id>`** | close an ask — `--chosen` verbatim-checked against its options, `--withdrawn` for the un-ask |
80
80
  | `figs push` | the bare transport — the verbs call it automatically; type it yourself after hand-edits or `--no-push` |
81
81
  | `figs doctor` | validate `.figs/` against the spec without pushing — the conformance check for hand-authored or non-CLI setups |
package/SPEC.md CHANGED
@@ -125,7 +125,7 @@ primitive** — the agent reached the edge of its autonomy.
125
125
  | Field | Type | Req | Meaning |
126
126
  |---|---|:--:|---|
127
127
  | `id` | string | ✓ | Stable id (upsert key). |
128
- | `type` | enum | ✓ | `blocked` \| `needs-decision` \| `sign-off` \| `fyi`. `fyi` is a non-blocking heads-up (no decision needed). |
128
+ | `type` | enum | ✓ | `needs-decision` \| `sign-off` \| `fyi` — **the type is the answer contract**: *needs-decision* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request changes / reject), *fyi* wants nothing (a for-the-record note; readers never count it as needing a human). `blocked` was **folded into `needs-decision`** (2026-06, pre-launch in-place edit): a stuck job is the *run's* `status`, not an ask type. |
129
129
  | `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. |
130
130
  | `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. |
131
131
  | `title` | string | ✓ | The ask, in one line. |
@@ -133,7 +133,7 @@ primitive** — the agent reached the edge of its autonomy.
133
133
  | `run` | string | | The run `id` this ask was raised during (the work that surfaced it). **Optional** — asks also arise outside runs (a self-found issue, expired credentials). |
134
134
  | `found` | string | | What the agent found / why it's stuck. |
135
135
  | `need` | string | | What it needs from the human. |
136
- | `options` | string[] | | Candidate resolutions — **short, stable, quotable** strings: a future answer references one *verbatim* (see [§6.2](#62-resolution--how-an-ask-closed)). |
136
+ | `options` | string[] | | Candidate resolutions — **short, stable, quotable** strings: an answer references one *verbatim* (see [§6.2](#62-resolution--how-an-ask-closed)). On a **sign-off** they are **answer paths** — qualified verdicts the human's verdict can cite verbatim alongside approve/request-changes (e.g. `"Approved — file the 15 ready charges"`). |
137
137
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
138
138
  | `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
139
139
  | `resolution` | string \| `Resolution` | | The agent's account of the close ([§6.2](#62-resolution--how-an-ask-closed)). A bare string is shorthand for `{ "note": … }`. |
@@ -170,9 +170,9 @@ in the agent's own workflow; answers flowing back through the reader are arrivin
170
170
  |---|---|---|
171
171
  | `note` | string | The agent's one-line account of the close. |
172
172
  | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
173
- | `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (verifiable, future) · answered out-of-band (self-reported) · the blocker cleared on its own. |
173
+ | `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (verified — see `answer`) · answered out-of-band (self-reported) · the blocker cleared on its own. |
174
174
  | `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
175
- | `answer` | string | **Reserved** — the Figs answer-event id the agent acted on, once answer-down ships. |
175
+ | `answer` | string | The Figs answer-event id the agent acted on — written by `figs resolve` when the answer came through the inbox (attribution by mechanism, never typed). The cited event may be an answer **or a qualified verdict** (a verdict carrying `chosen`). |
176
176
 
177
177
  All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
178
178
  normalize it to the object form.
package/figs.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  * figs workspaces list the user's workspaces [--json]
10
10
  * figs init --workspace <slug-or-id> [--endpoint <url>]
11
11
  * create .figs/config.json + GUIDE.md (generates a stable agent id)
12
- * figs report --result "" record a run (stamps id/ts/session, --attach files, auto-push)
13
- * figs ask <type> --title "" raise an ask (self-contained: options/details/attachments, auto-push)
12
+ * figs report --result '' record a run (stamps id/ts, --attach files, auto-push)
13
+ * figs ask <type> --title '' raise an ask (self-contained: options/details/attachments, auto-push)
14
14
  * figs inbox [<ask-id>] what needs you — answers/verdicts from your humans (pure read)
15
15
  * figs resolve <ask-id> close an ask (verbatim-checks --chosen; cites the Figs answer it acted on)
16
16
  * figs doctor validate .figs/ against the spec before pushing
@@ -42,16 +42,14 @@ import {
42
42
  chmodSync,
43
43
  existsSync,
44
44
  mkdirSync,
45
- readdirSync,
46
45
  readFileSync,
47
46
  rmSync,
48
- statSync,
49
47
  writeFileSync,
50
48
  } from "node:fs"
51
49
  import { homedir } from "node:os"
52
50
  import { basename, extname, join } from "node:path"
53
51
  import { createHash, randomUUID } from "node:crypto"
54
- import { execSync, spawn } from "node:child_process"
52
+ import { spawn } from "node:child_process"
55
53
 
56
54
  // Single source of truth for the version: package.json (shipped alongside this
57
55
  // file in the published package). One edit keeps `figs version`, the floor
@@ -115,21 +113,23 @@ const COMMANDS = {
115
113
  flags: [
116
114
  "--result", "--id", "--unit", "--period", "--status", "--attach", "--no-push",
117
115
  ],
118
- desc: "record a run — one job's row in runs.jsonl; stamps id/ts/session, pushes",
116
+ desc: "record a run — one job's row in runs.jsonl; stamps id/ts, pushes",
119
117
  more: [
120
118
  "One run = one JOB — a unit of work your manager would recognize; the runs",
121
119
  "list reads as the job list. Give a job a stable, meaningful --id",
122
120
  "(recon-acme-2026-11); reporting the same id again folds onto that job's row",
123
121
  "(progress evolves: blocked → ok). Sittings/sessions never mint runs —",
124
122
  "stopping to wait for a human is not a job.",
125
- "You supply the content; the CLI does the bookkeeping (id, real-clock ts, session",
126
- "trace from your runtime's own records, validation, artifact copy, push).",
123
+ "You supply the content; the CLI does the bookkeeping (id, real-clock ts,",
124
+ "validation, artifact copy, push).",
125
+ "Single-quote prose values ('…') — inside double quotes your shell expands $,",
126
+ "so \"($4,474.63)\" reaches figs as \"(,474.63)\": silent corruption.",
127
127
  "--attach <file> (repeatable) copies the file into artifacts/ and links it.",
128
128
  "--no-push writes locally only; `figs push` publishes later.",
129
129
  "Closing an ask is `figs resolve` — a close is not a job; never report one.",
130
130
  "Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
131
131
  ],
132
- eg: 'figs report --id recon-acme-2026-11 --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",
133
133
  },
134
134
  ask: {
135
135
  args: "<type> --title <text> [options]",
@@ -139,18 +139,22 @@ const COMMANDS = {
139
139
  ],
140
140
  desc: "raise an ask — one self-contained line in asks.jsonl, pushed so a human sees it",
141
141
  more: [
142
- "<type> is one of: blocked · needs-decision · sign-off · fyi.",
142
+ "<type> = the answer contract: needs-decision (give me an answer) ·",
143
+ "sign-off (give me a verdict) · fyi (no answer — a for-the-record note).",
143
144
  "Make it self-contained — a future session with zero context (or another human)",
144
145
  "must be able to act from this record alone: --found (what you saw), --need (what",
145
146
  "you need), --option (repeatable; short, stable, quotable — answers cite one",
146
- "verbatim), --detail \"Label=Value\" (repeatable), --attach <file> (repeatable;",
147
+ "verbatim), --detail 'Label=Value' (repeatable), --attach <file> (repeatable;",
147
148
  "for sign-offs attach the exact content for review + a brief: what to do once",
148
149
  "approved and what it requires).",
150
+ "On a sign-off, --option entries are answer paths — the human's verdict can",
151
+ "cite one verbatim ('Approved — file the 15 ready charges').",
149
152
  "--run <run-id> links the run this came out of — explicit id only (other",
150
153
  "sessions may report concurrently; `figs report` prints the id it wrote).",
151
154
  "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
155
+ "Single-quote prose values ('…') — double quotes let your shell eat $ amounts.",
152
156
  ],
153
- eg: 'figs ask sign-off --title "Send 10 payment reminders" --attach ./previews.html --run recon-2026-06',
157
+ eg: "figs ask sign-off --title 'Send 10 payment reminders' --attach ./previews.html --run recon-2026-06",
154
158
  },
155
159
  inbox: {
156
160
  args: "[<ask-id>] [--json]",
@@ -180,7 +184,7 @@ const COMMANDS = {
180
184
  "real work → do the job, `figs report` it under its own id, THEN resolve —",
181
185
  "cite the job id in --note so a reader can find the work.",
182
186
  ],
183
- eg: 'figs resolve acme-bridge --chosen "Strip the alpha prefix" --by "Sarah (accounting)"',
187
+ eg: "figs resolve acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
184
188
  },
185
189
  doctor: {
186
190
  args: "",
@@ -282,7 +286,11 @@ function genId(prefix) {
282
286
  // ---------- local validation (the spec's common mistakes, caught on write) ----
283
287
  // The server's schema stays the source of truth; these catch what hand-authors
284
288
  // and flag typos get wrong, with errors that teach the fix.
285
- const ASK_TYPES = ["blocked", "needs-decision", "sign-off", "fyi"]
289
+ // Three types = three answer contracts: needs-decision (give me an answer) ·
290
+ // sign-off (give me a verdict) · fyi (no answer — a for-the-record note).
291
+ // "blocked" was folded into needs-decision in 0.6.0: a stuck JOB is the run's
292
+ // status, not an ask type; what you need from a human is a needs-decision.
293
+ const ASK_TYPES = ["needs-decision", "sign-off", "fyi"]
286
294
  const RUN_STATUSES = ["ok", "warn", "fail"]
287
295
  const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
288
296
  const TO_VALUES = ["manager", "builder"]
@@ -327,6 +335,10 @@ function validateAsk(a) {
327
335
  issues.push(
328
336
  `${label}: missing required "type" — was it raised on another machine? (closing it from here needs the full record; cross-machine fetch is coming)`,
329
337
  )
338
+ } else if (a.type === "blocked") {
339
+ issues.push(
340
+ `${label}.type: "blocked" was folded into needs-decision — a stuck job is the RUN's status (re-report --status onto the same job id); what you need from a human is a needs-decision ask`,
341
+ )
330
342
  } else checkEnum(issues, a, "type", ASK_TYPES, label)
331
343
  if (!a.title) issues.push(`${label}: missing required "title"`)
332
344
  checkEnum(issues, a, "status", ASK_STATUSES, label)
@@ -948,8 +960,16 @@ async function init() {
948
960
  // ====================== the writing verbs ===================================
949
961
  // report / ask / resolve — sugar over the same files (hand-writing stays
950
962
  // first-class). The agent supplies content; the CLI stamps id + real-clock ts,
951
- // captures the session trace, validates with teaching errors, copies
952
- // attachments, then invokes the same push as `figs push`.
963
+ // validates with teaching errors, copies attachments, then invokes the same
964
+ // push as `figs push`.
965
+ //
966
+ // NOTE — no session auto-capture (removed in 0.5.0). The CLI used to infer a
967
+ // `session` trace (runtime/model/tokens) from "the newest transcript on this
968
+ // machine"; in nested/headless runs that stamped the WRONG runtime+model — a
969
+ // fabricated audit line (e.g. gpt-5.5 on a Claude Code run). A trace must be
970
+ // true or absent, never false, so inference is gone. The spec's optional
971
+ // `session` block remains legal for integrations that can copy provable values
972
+ // from the runtime's own records at work-time.
953
973
 
954
974
  function requireFigs() {
955
975
  if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
@@ -961,12 +981,6 @@ function requireFigs() {
961
981
  function appendJsonl(name, obj) {
962
982
  appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
963
983
  }
964
- /** Print a record without its (noisy) session block. */
965
- function summarize(obj) {
966
- const { session, ...rest } = obj
967
- return JSON.stringify(rest) + (session ? " (+ session trace)" : "")
968
- }
969
-
970
984
  /** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
971
985
  function attachFiles(paths) {
972
986
  const names = []
@@ -994,134 +1008,20 @@ function attachFiles(paths) {
994
1008
  return names
995
1009
  }
996
1010
 
997
- // ---------- session auto-capture --------------------------------------------
998
- // The trace comes from the runtime's own records, never from the model's
999
- // memory. Best-effort by design: any failure the optional block is omitted;
1000
- // a report is never blocked on trace capture.
1001
- function captureSession() {
1002
- try {
1003
- const candidates = [findClaudeTranscript(), findCodexTranscript()].filter(Boolean)
1004
- if (!candidates.length) return undefined
1005
- // The transcript being written *now* is the newest one, whichever runtime
1006
- // owns it. Anything older than a day is a leftover, not this session.
1007
- const best = candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
1008
- if (Date.now() - best.mtimeMs > 86400000) return undefined
1009
- const session = best.parse(best.path)
1010
- if (!session) return undefined
1011
- const commit = captureCommit()
1012
- if (commit) session.commit = commit
1013
- return session
1014
- } catch {
1015
- return undefined
1016
- }
1017
- }
1018
- function newestFile(dir, filter) {
1019
- if (!existsSync(dir)) return null
1020
- let best = null
1021
- for (const f of readdirSync(dir)) {
1022
- if (!filter(f)) continue
1023
- const p = join(dir, f)
1024
- let st
1025
- try {
1026
- st = statSync(p)
1027
- } catch {
1028
- continue
1029
- }
1030
- if (!st.isFile()) continue
1031
- if (!best || st.mtimeMs > best.mtimeMs) best = { path: p, name: f, mtimeMs: st.mtimeMs }
1032
- }
1033
- return best
1034
- }
1035
- function findClaudeTranscript() {
1036
- const dir = join(homedir(), ".claude", "projects", process.cwd().replace(/[\\/:]/g, "-"))
1037
- const f = newestFile(dir, (n) => n.endsWith(".jsonl"))
1038
- return f ? { ...f, parse: parseClaudeTranscript } : null
1039
- }
1040
- function parseClaudeTranscript(path) {
1041
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1042
- let model, startedAt
1043
- for (const line of readFileSync(path, "utf8").split("\n")) {
1044
- if (!line.trim()) continue
1045
- let e
1046
- try {
1047
- e = JSON.parse(line)
1048
- } catch {
1049
- continue
1050
- }
1051
- if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1052
- const m = e.message
1053
- if (!m?.usage || m.model === "<synthetic>") continue
1054
- if (typeof m.model === "string") model = m.model
1055
- tokens.input += m.usage.input_tokens ?? 0
1056
- tokens.output += m.usage.output_tokens ?? 0
1057
- tokens.cacheRead += m.usage.cache_read_input_tokens ?? 0
1058
- tokens.cacheWrite += m.usage.cache_creation_input_tokens ?? 0
1059
- }
1060
- const out = { runtime: "claude-code", sessionId: basename(path, ".jsonl") }
1061
- if (model) out.model = model
1062
- if (startedAt) out.startedAt = startedAt
1063
- if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) out.tokens = tokens
1064
- return out
1065
- }
1066
- function findCodexTranscript() {
1067
- const root = join(homedir(), ".codex", "sessions")
1068
- if (!existsSync(root)) return null
1069
- const desc = (dir) => {
1070
- try {
1071
- return readdirSync(dir).sort().reverse()
1072
- } catch {
1073
- return []
1074
- }
1075
- }
1076
- for (const y of desc(root)) {
1077
- for (const m of desc(join(root, y))) {
1078
- for (const d of desc(join(root, y, m))) {
1079
- const f = newestFile(join(root, y, m, d), (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"))
1080
- if (f) return { ...f, parse: parseCodexTranscript }
1081
- }
1082
- }
1083
- }
1084
- return null
1085
- }
1086
- function parseCodexTranscript(path) {
1087
- let model, usage, startedAt
1088
- for (const line of readFileSync(path, "utf8").split("\n")) {
1089
- if (!line.trim()) continue
1090
- let e
1091
- try {
1092
- e = JSON.parse(line)
1093
- } catch {
1094
- continue
1095
- }
1096
- if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
1097
- const p = e.payload ?? e
1098
- if (!model && typeof p?.model === "string") model = p.model
1099
- const u = p?.info?.total_token_usage ?? p?.total_token_usage
1100
- if (u) usage = u
1101
- }
1102
- const out = { runtime: "codex" }
1103
- const uuid = basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
1104
- if (uuid) out.sessionId = uuid[0]
1105
- if (model) out.model = model
1106
- if (startedAt) out.startedAt = startedAt
1107
- if (usage) {
1108
- out.tokens = {
1109
- input: usage.input_tokens ?? 0,
1110
- output: usage.output_tokens ?? 0,
1111
- cacheRead: usage.cached_input_tokens ?? 0,
1112
- }
1113
- }
1114
- return out
1115
- }
1116
- function captureCommit() {
1117
- try {
1118
- const opts = { stdio: ["ignore", "pipe", "ignore"] }
1119
- const sha = execSync("git rev-parse --short HEAD", opts).toString().trim()
1120
- if (!sha) return undefined
1121
- const dirty = execSync("git status --porcelain", opts).toString().trim()
1122
- return dirty ? `${sha}+dirty` : sha
1123
- } catch {
1124
- return undefined
1011
+ // A `$` eaten by the caller's shell (double-quoted "$4,474.63" → ",474.63")
1012
+ // leaves a signature legit prose essentially never has: an orphaned thousands
1013
+ // group or a bare ".00" cents tail. Best-effort tripwire warn and teach,
1014
+ // never block (the heuristic can't be certain, and records fold by id, so a
1015
+ // corrected re-run heals the row). "$1K" → "K" leaves no signature; that case
1016
+ // is why every emitted template teaches single-quoted prose in the first place.
1017
+ function warnEatenDollar(...texts) {
1018
+ // regex lives inside the function: the CLI dispatches commands during module
1019
+ // evaluation, so a top-level const here would still be in its TDZ when called
1020
+ const eaten = /(^|[\s([{])(,\d{3}\b|\.00\b)/
1021
+ if (texts.flat().filter(Boolean).some((t) => eaten.test(String(t)))) {
1022
+ console.warn(
1023
+ "figs: ! a value looks like your shell ate a `$` (\"$4,474.63\" in double quotes becomes \",474.63\") — single-quote prose args ('…') and re-run; the same id folds the fix onto the record",
1024
+ )
1125
1025
  }
1126
1026
  }
1127
1027
 
@@ -1171,16 +1071,18 @@ function nextMove(a) {
1171
1071
  const last = a.events[a.events.length - 1]
1172
1072
  if (!last) return "waiting on your human — nothing for you to do"
1173
1073
  if (last.kind === "verdict" && last.verdict === "approved") {
1074
+ // A qualified verdict carries the chosen answer path — cite it in the close.
1075
+ const chosen = last.chosen ? ` --chosen '${last.chosen}'` : ""
1174
1076
  return (
1175
1077
  `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>"`
1078
+ `\n nothing left to do → figs resolve ${a.id}${chosen}` +
1079
+ `\n real work → do the job, figs report it under its own --id, then figs resolve ${a.id}${chosen} --note 'job <id>'`
1178
1080
  )
1179
1081
  }
1180
1082
  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 "" …`
1083
+ return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title '' …`
1182
1084
  }
1183
- return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen ""`
1085
+ return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen ''`
1184
1086
  }
1185
1087
 
1186
1088
  /** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
@@ -1370,15 +1272,16 @@ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected })
1370
1272
  // chosen matches, else the latest event (e.g. the approval, the rejection).
1371
1273
  const events = serverAsk?.events ?? []
1372
1274
  if (!withdrawn && events.length) {
1373
- const match = chosen
1374
- ? [...events].reverse().find((e) => e.kind === "answer" && e.chosen === chosen)
1375
- : null
1275
+ // Any human event can carry the chosen path — an answer, or a qualified
1276
+ // verdict (verdict + chosen together). Match on the text, not the kind.
1277
+ const match = chosen ? [...events].reverse().find((e) => e.chosen === chosen) : null
1376
1278
  const cited = match ?? events[events.length - 1]
1377
1279
  resolution.via = "figs"
1378
1280
  resolution.answer = cited.id
1379
1281
  if (!by && cited.byName) resolution.by = cited.byName
1380
1282
  }
1381
1283
 
1284
+ warnEatenDollar(resolution.chosen, resolution.note)
1382
1285
  const line = {
1383
1286
  id: askId,
1384
1287
  status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
@@ -1403,7 +1306,7 @@ async function reportCmd() {
1403
1306
  requireFigs()
1404
1307
  const result = flag("--result")
1405
1308
  if (!result) {
1406
- die('report needs --result "<one-line outcome>" — e.g. figs report --result "88% matched · 31 flagged"')
1309
+ die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
1407
1310
  }
1408
1311
  const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
1409
1312
  const unit = flag("--unit")
@@ -1415,13 +1318,11 @@ async function reportCmd() {
1415
1318
  const attached = attachFiles(flagAll("--attach"))
1416
1319
  if (attached.length === 1) run.artifact = attached[0]
1417
1320
  else if (attached.length > 1) run.artifacts = attached
1418
- const session = captureSession()
1419
- if (session) run.session = session
1420
-
1321
+ warnEatenDollar(run.result)
1421
1322
  const issues = validateRun(run)
1422
1323
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1423
1324
  appendJsonl("runs.jsonl", run)
1424
- console.log(`figs: ✓ run recorded — ${summarize(run)}`)
1325
+ console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
1425
1326
  await autoPush()
1426
1327
  }
1427
1328
 
@@ -1446,11 +1347,11 @@ async function askCmd() {
1446
1347
  }
1447
1348
  }
1448
1349
  const type = positional() ?? base.type
1449
- if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title ""`)
1350
+ if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title ''`)
1450
1351
  const ask = { ...base, id: flag("--id") ?? base.id ?? genId("ask"), ts: nowIso(), type }
1451
1352
  if (!ask.status) ask.status = "open"
1452
1353
  const title = flag("--title") ?? base.title
1453
- if (!title) die('ask needs --title "<the ask, in one line>"')
1354
+ if (!title) die("ask needs --title '<the ask, in one line>'")
1454
1355
  ask.title = title
1455
1356
  for (const [f, k] of [["--need", "need"], ["--found", "found"], ["--unit", "unit"], ["--to", "to"]]) {
1456
1357
  const v = flag(f)
@@ -1487,13 +1388,17 @@ async function askCmd() {
1487
1388
  "figs: ! tip: a sign-off reviews best with attachments — the exact content to approve, plus a brief (what to do once approved + what it requires). Add --attach <file>",
1488
1389
  )
1489
1390
  }
1490
- const session = captureSession()
1491
- if (session) ask.session = session
1492
-
1391
+ warnEatenDollar(
1392
+ ask.title,
1393
+ ask.found,
1394
+ ask.need,
1395
+ ask.options ?? [],
1396
+ (ask.details ?? []).flatMap((d) => [d.l, d.v]),
1397
+ )
1493
1398
  const issues = validateAsk(ask)
1494
1399
  if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
1495
1400
  appendJsonl("asks.jsonl", ask)
1496
- console.log(`figs: ✓ ask raised — ${summarize(ask)}`)
1401
+ console.log(`figs: ✓ ask raised — ${JSON.stringify(ask)}`)
1497
1402
  if (!ask.to) {
1498
1403
  console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
1499
1404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {