@figs-so/cli 0.4.0 → 0.7.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 -2
- package/SPEC.md +5 -4
- package/figs.mjs +111 -175
- 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
|
|
78
|
-
| **`figs ask <type> --title
|
|
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 | ✓ | `
|
|
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,8 @@ 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:
|
|
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
|
+
| `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these stated steps, in order** (e.g. `"Post the 8 journal entries to SAP"`, `"Email the filing to Acme"`); flag anything irreversible in the step itself. This is the agent's **declared intent, not a bound plan** — readers present it as the agent's claim. Invalid on other types: a *needs-decision* has no approval; there, the chosen option carries the next step. |
|
|
137
138
|
| `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
|
|
138
139
|
| `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
|
|
139
140
|
| `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 +171,9 @@ in the agent's own workflow; answers flowing back through the reader are arrivin
|
|
|
170
171
|
|---|---|---|
|
|
171
172
|
| `note` | string | The agent's one-line account of the close. |
|
|
172
173
|
| `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 (
|
|
174
|
+
| `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
175
|
| `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
|
|
175
|
-
| `answer` | string |
|
|
176
|
+
| `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
177
|
|
|
177
178
|
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
|
|
178
179
|
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
|
|
13
|
-
* figs ask <type> --title
|
|
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 {
|
|
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,42 +113,52 @@ 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
|
|
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,
|
|
126
|
-
"
|
|
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:
|
|
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]",
|
|
136
136
|
flags: [
|
|
137
|
-
"--id", "--title", "--need", "--found", "--option", "--
|
|
138
|
-
"--to", "--unit", "--run", "--stdin", "--no-push",
|
|
137
|
+
"--id", "--title", "--need", "--found", "--option", "--on-approve", "--detail",
|
|
138
|
+
"--attach", "--to", "--unit", "--run", "--stdin", "--no-push",
|
|
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>
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
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).",
|
|
144
|
+
"Two strangers read every ask — a human deciding, a future session acting;",
|
|
145
|
+
"the record must carry everything both need: --found (what you saw), --need",
|
|
146
|
+
"(what you need), --option (repeatable; short, stable, quotable — answers cite",
|
|
147
|
+
"one verbatim; the option is the label, context goes in --found/--detail),",
|
|
148
|
+
"--detail 'Label=Value' (repeatable), --attach <file> (repeatable; a verdict",
|
|
149
|
+
"blesses what the ask carries — attach the exact content for review + a brief:",
|
|
150
|
+
"what to do once approved and what it requires).",
|
|
151
|
+
"On a sign-off, --option entries are answer paths — the human's verdict can",
|
|
152
|
+
"cite one verbatim ('Approved — file the 15 ready charges') — and",
|
|
153
|
+
"--on-approve '<step>' (repeatable, ordered; sign-off only) states what",
|
|
154
|
+
"approval sets in motion: an approval authorizes exactly the steps you stated.",
|
|
155
|
+
"Flag anything irreversible in the step itself.",
|
|
149
156
|
"--run <run-id> links the run this came out of — explicit id only (other",
|
|
150
157
|
"sessions may report concurrently; `figs report` prints the id it wrote).",
|
|
151
158
|
"--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
|
|
159
|
+
"Single-quote prose values ('…') — double quotes let your shell eat $ amounts.",
|
|
152
160
|
],
|
|
153
|
-
eg:
|
|
161
|
+
eg: "figs ask sign-off --title 'Send 10 payment reminders' --attach ./previews.html --on-approve 'Send the 10 reminder emails' --on-approve 'Mark the invoices chased' --run recon-2026-06",
|
|
154
162
|
},
|
|
155
163
|
inbox: {
|
|
156
164
|
args: "[<ask-id>] [--json]",
|
|
@@ -180,7 +188,7 @@ const COMMANDS = {
|
|
|
180
188
|
"real work → do the job, `figs report` it under its own id, THEN resolve —",
|
|
181
189
|
"cite the job id in --note so a reader can find the work.",
|
|
182
190
|
],
|
|
183
|
-
eg:
|
|
191
|
+
eg: "figs resolve acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
|
|
184
192
|
},
|
|
185
193
|
doctor: {
|
|
186
194
|
args: "",
|
|
@@ -282,7 +290,11 @@ function genId(prefix) {
|
|
|
282
290
|
// ---------- local validation (the spec's common mistakes, caught on write) ----
|
|
283
291
|
// The server's schema stays the source of truth; these catch what hand-authors
|
|
284
292
|
// and flag typos get wrong, with errors that teach the fix.
|
|
285
|
-
|
|
293
|
+
// Three types = three answer contracts: needs-decision (give me an answer) ·
|
|
294
|
+
// sign-off (give me a verdict) · fyi (no answer — a for-the-record note).
|
|
295
|
+
// "blocked" was folded into needs-decision in 0.6.0: a stuck JOB is the run's
|
|
296
|
+
// status, not an ask type; what you need from a human is a needs-decision.
|
|
297
|
+
const ASK_TYPES = ["needs-decision", "sign-off", "fyi"]
|
|
286
298
|
const RUN_STATUSES = ["ok", "warn", "fail"]
|
|
287
299
|
const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
|
|
288
300
|
const TO_VALUES = ["manager", "builder"]
|
|
@@ -327,6 +339,10 @@ function validateAsk(a) {
|
|
|
327
339
|
issues.push(
|
|
328
340
|
`${label}: missing required "type" — was it raised on another machine? (closing it from here needs the full record; cross-machine fetch is coming)`,
|
|
329
341
|
)
|
|
342
|
+
} else if (a.type === "blocked") {
|
|
343
|
+
issues.push(
|
|
344
|
+
`${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`,
|
|
345
|
+
)
|
|
330
346
|
} else checkEnum(issues, a, "type", ASK_TYPES, label)
|
|
331
347
|
if (!a.title) issues.push(`${label}: missing required "title"`)
|
|
332
348
|
checkEnum(issues, a, "status", ASK_STATUSES, label)
|
|
@@ -334,6 +350,15 @@ function validateAsk(a) {
|
|
|
334
350
|
if (a.options !== undefined && (!Array.isArray(a.options) || a.options.some((o) => typeof o !== "string"))) {
|
|
335
351
|
issues.push(`${label}.options: must be an array of short, quotable strings`)
|
|
336
352
|
}
|
|
353
|
+
if (a.onApprove !== undefined) {
|
|
354
|
+
if (!Array.isArray(a.onApprove) || a.onApprove.some((s) => typeof s !== "string")) {
|
|
355
|
+
issues.push(`${label}.onApprove: must be an array of strings — the ordered steps approval sets in motion`)
|
|
356
|
+
} else if (a.type !== "sign-off") {
|
|
357
|
+
issues.push(
|
|
358
|
+
`${label}.onApprove: sign-off only — it is the approval contract; a ${a.type ?? "non-sign-off ask"} has no approval (the chosen option carries the next step)`,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
337
362
|
if (a.details !== undefined && (!Array.isArray(a.details) || a.details.some((d) => !d || typeof d.l !== "string"))) {
|
|
338
363
|
issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
|
|
339
364
|
}
|
|
@@ -948,8 +973,16 @@ async function init() {
|
|
|
948
973
|
// ====================== the writing verbs ===================================
|
|
949
974
|
// report / ask / resolve — sugar over the same files (hand-writing stays
|
|
950
975
|
// first-class). The agent supplies content; the CLI stamps id + real-clock ts,
|
|
951
|
-
//
|
|
952
|
-
//
|
|
976
|
+
// validates with teaching errors, copies attachments, then invokes the same
|
|
977
|
+
// push as `figs push`.
|
|
978
|
+
//
|
|
979
|
+
// NOTE — no session auto-capture (removed in 0.5.0). The CLI used to infer a
|
|
980
|
+
// `session` trace (runtime/model/tokens) from "the newest transcript on this
|
|
981
|
+
// machine"; in nested/headless runs that stamped the WRONG runtime+model — a
|
|
982
|
+
// fabricated audit line (e.g. gpt-5.5 on a Claude Code run). A trace must be
|
|
983
|
+
// true or absent, never false, so inference is gone. The spec's optional
|
|
984
|
+
// `session` block remains legal for integrations that can copy provable values
|
|
985
|
+
// from the runtime's own records at work-time.
|
|
953
986
|
|
|
954
987
|
function requireFigs() {
|
|
955
988
|
if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
|
|
@@ -961,12 +994,6 @@ function requireFigs() {
|
|
|
961
994
|
function appendJsonl(name, obj) {
|
|
962
995
|
appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
|
|
963
996
|
}
|
|
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
997
|
/** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
|
|
971
998
|
function attachFiles(paths) {
|
|
972
999
|
const names = []
|
|
@@ -994,134 +1021,20 @@ function attachFiles(paths) {
|
|
|
994
1021
|
return names
|
|
995
1022
|
}
|
|
996
1023
|
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
1024
|
+
// A `$` eaten by the caller's shell (double-quoted "$4,474.63" → ",474.63")
|
|
1025
|
+
// leaves a signature legit prose essentially never has: an orphaned thousands
|
|
1026
|
+
// group or a bare ".00" cents tail. Best-effort tripwire — warn and teach,
|
|
1027
|
+
// never block (the heuristic can't be certain, and records fold by id, so a
|
|
1028
|
+
// corrected re-run heals the row). "$1K" → "K" leaves no signature; that case
|
|
1029
|
+
// is why every emitted template teaches single-quoted prose in the first place.
|
|
1030
|
+
function warnEatenDollar(...texts) {
|
|
1031
|
+
// regex lives inside the function: the CLI dispatches commands during module
|
|
1032
|
+
// evaluation, so a top-level const here would still be in its TDZ when called
|
|
1033
|
+
const eaten = /(^|[\s([{])(,\d{3}\b|\.00\b)/
|
|
1034
|
+
if (texts.flat().filter(Boolean).some((t) => eaten.test(String(t)))) {
|
|
1035
|
+
console.warn(
|
|
1036
|
+
"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",
|
|
1037
|
+
)
|
|
1125
1038
|
}
|
|
1126
1039
|
}
|
|
1127
1040
|
|
|
@@ -1171,16 +1084,23 @@ function nextMove(a) {
|
|
|
1171
1084
|
const last = a.events[a.events.length - 1]
|
|
1172
1085
|
if (!last) return "waiting on your human — nothing for you to do"
|
|
1173
1086
|
if (last.kind === "verdict" && last.verdict === "approved") {
|
|
1087
|
+
// A qualified verdict carries the chosen answer path — cite it in the close.
|
|
1088
|
+
const chosen = last.chosen ? ` --chosen '${last.chosen}'` : ""
|
|
1174
1089
|
return (
|
|
1175
1090
|
`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
|
|
1091
|
+
`\n nothing left to do → figs resolve ${a.id}${chosen}` +
|
|
1092
|
+
`\n real work → do the job, figs report it under its own --id, then figs resolve ${a.id}${chosen} --note 'job <id>'`
|
|
1178
1093
|
)
|
|
1179
1094
|
}
|
|
1180
1095
|
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
|
|
1096
|
+
return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title '…' …`
|
|
1097
|
+
}
|
|
1098
|
+
if (last.chosen) {
|
|
1099
|
+
// Pre-fill the cited option verbatim — the note is substance for --note, never for --chosen.
|
|
1100
|
+
const note = last.text ? ` --note '…'` : ""
|
|
1101
|
+
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '${last.chosen}'${note}`
|
|
1182
1102
|
}
|
|
1183
|
-
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --
|
|
1103
|
+
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --note '…'`
|
|
1184
1104
|
}
|
|
1185
1105
|
|
|
1186
1106
|
/** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
|
|
@@ -1370,15 +1290,16 @@ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected })
|
|
|
1370
1290
|
// chosen matches, else the latest event (e.g. the approval, the rejection).
|
|
1371
1291
|
const events = serverAsk?.events ?? []
|
|
1372
1292
|
if (!withdrawn && events.length) {
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1293
|
+
// Any human event can carry the chosen path — an answer, or a qualified
|
|
1294
|
+
// verdict (verdict + chosen together). Match on the text, not the kind.
|
|
1295
|
+
const match = chosen ? [...events].reverse().find((e) => e.chosen === chosen) : null
|
|
1376
1296
|
const cited = match ?? events[events.length - 1]
|
|
1377
1297
|
resolution.via = "figs"
|
|
1378
1298
|
resolution.answer = cited.id
|
|
1379
1299
|
if (!by && cited.byName) resolution.by = cited.byName
|
|
1380
1300
|
}
|
|
1381
1301
|
|
|
1302
|
+
warnEatenDollar(resolution.chosen, resolution.note)
|
|
1382
1303
|
const line = {
|
|
1383
1304
|
id: askId,
|
|
1384
1305
|
status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
|
|
@@ -1403,7 +1324,7 @@ async function reportCmd() {
|
|
|
1403
1324
|
requireFigs()
|
|
1404
1325
|
const result = flag("--result")
|
|
1405
1326
|
if (!result) {
|
|
1406
|
-
die(
|
|
1327
|
+
die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
|
|
1407
1328
|
}
|
|
1408
1329
|
const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
|
|
1409
1330
|
const unit = flag("--unit")
|
|
@@ -1415,13 +1336,11 @@ async function reportCmd() {
|
|
|
1415
1336
|
const attached = attachFiles(flagAll("--attach"))
|
|
1416
1337
|
if (attached.length === 1) run.artifact = attached[0]
|
|
1417
1338
|
else if (attached.length > 1) run.artifacts = attached
|
|
1418
|
-
|
|
1419
|
-
if (session) run.session = session
|
|
1420
|
-
|
|
1339
|
+
warnEatenDollar(run.result)
|
|
1421
1340
|
const issues = validateRun(run)
|
|
1422
1341
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1423
1342
|
appendJsonl("runs.jsonl", run)
|
|
1424
|
-
console.log(`figs: ✓ run recorded — ${
|
|
1343
|
+
console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
|
|
1425
1344
|
await autoPush()
|
|
1426
1345
|
}
|
|
1427
1346
|
|
|
@@ -1446,11 +1365,11 @@ async function askCmd() {
|
|
|
1446
1365
|
}
|
|
1447
1366
|
}
|
|
1448
1367
|
const type = positional() ?? base.type
|
|
1449
|
-
if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title
|
|
1368
|
+
if (!type) die(`ask needs a type: figs ask <${ASK_TYPES.join("|")}> --title '…'`)
|
|
1450
1369
|
const ask = { ...base, id: flag("--id") ?? base.id ?? genId("ask"), ts: nowIso(), type }
|
|
1451
1370
|
if (!ask.status) ask.status = "open"
|
|
1452
1371
|
const title = flag("--title") ?? base.title
|
|
1453
|
-
if (!title) die(
|
|
1372
|
+
if (!title) die("ask needs --title '<the ask, in one line>'")
|
|
1454
1373
|
ask.title = title
|
|
1455
1374
|
for (const [f, k] of [["--need", "need"], ["--found", "found"], ["--unit", "unit"], ["--to", "to"]]) {
|
|
1456
1375
|
const v = flag(f)
|
|
@@ -1465,6 +1384,13 @@ async function askCmd() {
|
|
|
1465
1384
|
)
|
|
1466
1385
|
}
|
|
1467
1386
|
}
|
|
1387
|
+
const onApprove = flagAll("--on-approve")
|
|
1388
|
+
if (onApprove.length) ask.onApprove = onApprove
|
|
1389
|
+
if (ask.onApprove?.length && ask.type !== "sign-off") {
|
|
1390
|
+
die(
|
|
1391
|
+
`--on-approve is the approval contract — sign-off only. A ${ask.type} has no approval; the chosen option carries the next step (put it in the --option text)`,
|
|
1392
|
+
)
|
|
1393
|
+
}
|
|
1468
1394
|
const details = flagAll("--detail").map((d) => {
|
|
1469
1395
|
const i = d.indexOf("=")
|
|
1470
1396
|
if (i < 1) die(`--detail must be "Label=Value", got "${d}"`)
|
|
@@ -1487,13 +1413,23 @@ async function askCmd() {
|
|
|
1487
1413
|
"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
1414
|
)
|
|
1489
1415
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1416
|
+
if (ask.type === "sign-off" && !ask.onApprove?.length) {
|
|
1417
|
+
console.warn(
|
|
1418
|
+
"figs: ! tip: state what approval sets in motion — --on-approve '<step>' (repeatable, ordered); an approver shouldn't have to guess what approve causes",
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
warnEatenDollar(
|
|
1422
|
+
ask.title,
|
|
1423
|
+
ask.found,
|
|
1424
|
+
ask.need,
|
|
1425
|
+
ask.options ?? [],
|
|
1426
|
+
ask.onApprove ?? [],
|
|
1427
|
+
(ask.details ?? []).flatMap((d) => [d.l, d.v]),
|
|
1428
|
+
)
|
|
1493
1429
|
const issues = validateAsk(ask)
|
|
1494
1430
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1495
1431
|
appendJsonl("asks.jsonl", ask)
|
|
1496
|
-
console.log(`figs: ✓ ask raised — ${
|
|
1432
|
+
console.log(`figs: ✓ ask raised — ${JSON.stringify(ask)}`)
|
|
1497
1433
|
if (!ask.to) {
|
|
1498
1434
|
console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
|
|
1499
1435
|
}
|