@figs-so/cli 0.3.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.
- package/README.md +3 -2
- package/SPEC.md +10 -6
- package/figs.mjs +89 -190
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,8 +73,9 @@ 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
|
|
77
|
-
| **`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, `--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 |
|
|
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` |
|
|
80
81
|
| `figs doctor` | validate `.figs/` against the spec without pushing — the conformance check for hand-authored or non-CLI setups |
|
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)
|
|
@@ -121,7 +125,7 @@ primitive** — the agent reached the edge of its autonomy.
|
|
|
121
125
|
| Field | Type | Req | Meaning |
|
|
122
126
|
|---|---|:--:|---|
|
|
123
127
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
124
|
-
| `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. |
|
|
125
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. |
|
|
126
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. |
|
|
127
131
|
| `title` | string | ✓ | The ask, in one line. |
|
|
@@ -129,7 +133,7 @@ primitive** — the agent reached the edge of its autonomy.
|
|
|
129
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). |
|
|
130
134
|
| `found` | string | | What the agent found / why it's stuck. |
|
|
131
135
|
| `need` | string | | What it needs from the human. |
|
|
132
|
-
| `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"`). |
|
|
133
137
|
| `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
|
|
134
138
|
| `refs` | `{ label, artifact? }[]` | | Pointers to artifacts that back the ask. |
|
|
135
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": … }`. |
|
|
@@ -166,9 +170,9 @@ in the agent's own workflow; answers flowing back through the reader are arrivin
|
|
|
166
170
|
|---|---|---|
|
|
167
171
|
| `note` | string | The agent's one-line account of the close. |
|
|
168
172
|
| `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]`. |
|
|
169
|
-
| `via` | `"figs"` \| `"human"` \| `"self"` | Where the unblock came from: an answer pulled from Figs (
|
|
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. |
|
|
170
174
|
| `by` | string | Who answered, as the agent knows it (self-reported; verified attribution only exists for `via: "figs"`). |
|
|
171
|
-
| `answer` | string |
|
|
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`). |
|
|
172
176
|
|
|
173
177
|
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }` and readers
|
|
174
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
|
|
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
|
|
@@ -113,20 +111,25 @@ const COMMANDS = {
|
|
|
113
111
|
report: {
|
|
114
112
|
args: "--result <text> [options]",
|
|
115
113
|
flags: [
|
|
116
|
-
"--result", "--id", "--unit", "--period", "--status", "--attach",
|
|
117
|
-
"--resolves", "--chosen", "--by", "--note", "--no-push",
|
|
114
|
+
"--result", "--id", "--unit", "--period", "--status", "--attach", "--no-push",
|
|
118
115
|
],
|
|
119
|
-
desc: "record a run —
|
|
116
|
+
desc: "record a run — one job's row in runs.jsonl; stamps id/ts, pushes",
|
|
120
117
|
more: [
|
|
121
|
-
"
|
|
122
|
-
"
|
|
118
|
+
"One run = one JOB — a unit of work your manager would recognize; the runs",
|
|
119
|
+
"list reads as the job list. Give a job a stable, meaningful --id",
|
|
120
|
+
"(recon-acme-2026-11); reporting the same id again folds onto that job's row",
|
|
121
|
+
"(progress evolves: blocked → ok). Sittings/sessions never mint runs —",
|
|
122
|
+
"stopping to wait for a human is not a job.",
|
|
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.",
|
|
123
127
|
"--attach <file> (repeatable) copies the file into artifacts/ and links it.",
|
|
124
|
-
"--resolves <ask-id> also closes that ask (with optional --chosen/--by/--note).",
|
|
125
|
-
"--id only for deliberate stable ids (re-running the same job updates the same run).",
|
|
126
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.",
|
|
127
130
|
"Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
|
|
128
131
|
],
|
|
129
|
-
eg:
|
|
132
|
+
eg: "figs report --id recon-acme-2026-11 --result '88% matched · 31 flagged' --attach ./acme-2025-11.html",
|
|
130
133
|
},
|
|
131
134
|
ask: {
|
|
132
135
|
args: "<type> --title <text> [options]",
|
|
@@ -136,18 +139,22 @@ const COMMANDS = {
|
|
|
136
139
|
],
|
|
137
140
|
desc: "raise an ask — one self-contained line in asks.jsonl, pushed so a human sees it",
|
|
138
141
|
more: [
|
|
139
|
-
"<type>
|
|
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).",
|
|
140
144
|
"Make it self-contained — a future session with zero context (or another human)",
|
|
141
145
|
"must be able to act from this record alone: --found (what you saw), --need (what",
|
|
142
146
|
"you need), --option (repeatable; short, stable, quotable — answers cite one",
|
|
143
|
-
"verbatim), --detail
|
|
147
|
+
"verbatim), --detail 'Label=Value' (repeatable), --attach <file> (repeatable;",
|
|
144
148
|
"for sign-offs attach the exact content for review + a brief: what to do once",
|
|
145
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').",
|
|
146
152
|
"--run <run-id> links the run this came out of — explicit id only (other",
|
|
147
153
|
"sessions may report concurrently; `figs report` prints the id it wrote).",
|
|
148
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.",
|
|
149
156
|
],
|
|
150
|
-
eg:
|
|
157
|
+
eg: "figs ask sign-off --title 'Send 10 payment reminders' --attach ./previews.html --run recon-2026-06",
|
|
151
158
|
},
|
|
152
159
|
inbox: {
|
|
153
160
|
args: "[<ask-id>] [--json]",
|
|
@@ -160,7 +167,7 @@ const COMMANDS = {
|
|
|
160
167
|
"attached artifacts restored into .figs/artifacts/ (hash-verified) so a fresh",
|
|
161
168
|
"session can act from the record alone.",
|
|
162
169
|
"Scope: THIS agent's open asks + human-rejected ones you haven't acknowledged.",
|
|
163
|
-
"Reads only — closing still happens via figs resolve
|
|
170
|
+
"Reads only — closing still happens via figs resolve.",
|
|
164
171
|
],
|
|
165
172
|
eg: "figs inbox",
|
|
166
173
|
},
|
|
@@ -173,9 +180,11 @@ const COMMANDS = {
|
|
|
173
180
|
"Three closes, by who ended it: resolved (default — the need was met) ·",
|
|
174
181
|
"--withdrawn (YOU retracted it; nobody acted) · --rejected (a HUMAN declined",
|
|
175
182
|
"it — record their out-of-band no; rejected is terminal, re-raising = a new ask).",
|
|
176
|
-
"
|
|
183
|
+
"After an answer, fork on what it unlocked: nothing new → resolve right away;",
|
|
184
|
+
"real work → do the job, `figs report` it under its own id, THEN resolve —",
|
|
185
|
+
"cite the job id in --note so a reader can find the work.",
|
|
177
186
|
],
|
|
178
|
-
eg:
|
|
187
|
+
eg: "figs resolve acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
|
|
179
188
|
},
|
|
180
189
|
doctor: {
|
|
181
190
|
args: "",
|
|
@@ -277,7 +286,11 @@ function genId(prefix) {
|
|
|
277
286
|
// ---------- local validation (the spec's common mistakes, caught on write) ----
|
|
278
287
|
// The server's schema stays the source of truth; these catch what hand-authors
|
|
279
288
|
// and flag typos get wrong, with errors that teach the fix.
|
|
280
|
-
|
|
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"]
|
|
281
294
|
const RUN_STATUSES = ["ok", "warn", "fail"]
|
|
282
295
|
const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
|
|
283
296
|
const TO_VALUES = ["manager", "builder"]
|
|
@@ -322,6 +335,10 @@ function validateAsk(a) {
|
|
|
322
335
|
issues.push(
|
|
323
336
|
`${label}: missing required "type" — was it raised on another machine? (closing it from here needs the full record; cross-machine fetch is coming)`,
|
|
324
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
|
+
)
|
|
325
342
|
} else checkEnum(issues, a, "type", ASK_TYPES, label)
|
|
326
343
|
if (!a.title) issues.push(`${label}: missing required "title"`)
|
|
327
344
|
checkEnum(issues, a, "status", ASK_STATUSES, label)
|
|
@@ -943,8 +960,16 @@ async function init() {
|
|
|
943
960
|
// ====================== the writing verbs ===================================
|
|
944
961
|
// report / ask / resolve — sugar over the same files (hand-writing stays
|
|
945
962
|
// first-class). The agent supplies content; the CLI stamps id + real-clock ts,
|
|
946
|
-
//
|
|
947
|
-
//
|
|
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.
|
|
948
973
|
|
|
949
974
|
function requireFigs() {
|
|
950
975
|
if (!existsSync(repoDir)) die("no .figs/ here — run `figs init` first")
|
|
@@ -956,12 +981,6 @@ function requireFigs() {
|
|
|
956
981
|
function appendJsonl(name, obj) {
|
|
957
982
|
appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
|
|
958
983
|
}
|
|
959
|
-
/** Print a record without its (noisy) session block. */
|
|
960
|
-
function summarize(obj) {
|
|
961
|
-
const { session, ...rest } = obj
|
|
962
|
-
return JSON.stringify(rest) + (session ? " (+ session trace)" : "")
|
|
963
|
-
}
|
|
964
|
-
|
|
965
984
|
/** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
|
|
966
985
|
function attachFiles(paths) {
|
|
967
986
|
const names = []
|
|
@@ -989,134 +1008,20 @@ function attachFiles(paths) {
|
|
|
989
1008
|
return names
|
|
990
1009
|
}
|
|
991
1010
|
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const commit = captureCommit()
|
|
1007
|
-
if (commit) session.commit = commit
|
|
1008
|
-
return session
|
|
1009
|
-
} catch {
|
|
1010
|
-
return undefined
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
function newestFile(dir, filter) {
|
|
1014
|
-
if (!existsSync(dir)) return null
|
|
1015
|
-
let best = null
|
|
1016
|
-
for (const f of readdirSync(dir)) {
|
|
1017
|
-
if (!filter(f)) continue
|
|
1018
|
-
const p = join(dir, f)
|
|
1019
|
-
let st
|
|
1020
|
-
try {
|
|
1021
|
-
st = statSync(p)
|
|
1022
|
-
} catch {
|
|
1023
|
-
continue
|
|
1024
|
-
}
|
|
1025
|
-
if (!st.isFile()) continue
|
|
1026
|
-
if (!best || st.mtimeMs > best.mtimeMs) best = { path: p, name: f, mtimeMs: st.mtimeMs }
|
|
1027
|
-
}
|
|
1028
|
-
return best
|
|
1029
|
-
}
|
|
1030
|
-
function findClaudeTranscript() {
|
|
1031
|
-
const dir = join(homedir(), ".claude", "projects", process.cwd().replace(/[\\/:]/g, "-"))
|
|
1032
|
-
const f = newestFile(dir, (n) => n.endsWith(".jsonl"))
|
|
1033
|
-
return f ? { ...f, parse: parseClaudeTranscript } : null
|
|
1034
|
-
}
|
|
1035
|
-
function parseClaudeTranscript(path) {
|
|
1036
|
-
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
1037
|
-
let model, startedAt
|
|
1038
|
-
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1039
|
-
if (!line.trim()) continue
|
|
1040
|
-
let e
|
|
1041
|
-
try {
|
|
1042
|
-
e = JSON.parse(line)
|
|
1043
|
-
} catch {
|
|
1044
|
-
continue
|
|
1045
|
-
}
|
|
1046
|
-
if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
|
|
1047
|
-
const m = e.message
|
|
1048
|
-
if (!m?.usage || m.model === "<synthetic>") continue
|
|
1049
|
-
if (typeof m.model === "string") model = m.model
|
|
1050
|
-
tokens.input += m.usage.input_tokens ?? 0
|
|
1051
|
-
tokens.output += m.usage.output_tokens ?? 0
|
|
1052
|
-
tokens.cacheRead += m.usage.cache_read_input_tokens ?? 0
|
|
1053
|
-
tokens.cacheWrite += m.usage.cache_creation_input_tokens ?? 0
|
|
1054
|
-
}
|
|
1055
|
-
const out = { runtime: "claude-code", sessionId: basename(path, ".jsonl") }
|
|
1056
|
-
if (model) out.model = model
|
|
1057
|
-
if (startedAt) out.startedAt = startedAt
|
|
1058
|
-
if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) out.tokens = tokens
|
|
1059
|
-
return out
|
|
1060
|
-
}
|
|
1061
|
-
function findCodexTranscript() {
|
|
1062
|
-
const root = join(homedir(), ".codex", "sessions")
|
|
1063
|
-
if (!existsSync(root)) return null
|
|
1064
|
-
const desc = (dir) => {
|
|
1065
|
-
try {
|
|
1066
|
-
return readdirSync(dir).sort().reverse()
|
|
1067
|
-
} catch {
|
|
1068
|
-
return []
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
for (const y of desc(root)) {
|
|
1072
|
-
for (const m of desc(join(root, y))) {
|
|
1073
|
-
for (const d of desc(join(root, y, m))) {
|
|
1074
|
-
const f = newestFile(join(root, y, m, d), (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"))
|
|
1075
|
-
if (f) return { ...f, parse: parseCodexTranscript }
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
return null
|
|
1080
|
-
}
|
|
1081
|
-
function parseCodexTranscript(path) {
|
|
1082
|
-
let model, usage, startedAt
|
|
1083
|
-
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1084
|
-
if (!line.trim()) continue
|
|
1085
|
-
let e
|
|
1086
|
-
try {
|
|
1087
|
-
e = JSON.parse(line)
|
|
1088
|
-
} catch {
|
|
1089
|
-
continue
|
|
1090
|
-
}
|
|
1091
|
-
if (!startedAt && typeof e.timestamp === "string") startedAt = e.timestamp
|
|
1092
|
-
const p = e.payload ?? e
|
|
1093
|
-
if (!model && typeof p?.model === "string") model = p.model
|
|
1094
|
-
const u = p?.info?.total_token_usage ?? p?.total_token_usage
|
|
1095
|
-
if (u) usage = u
|
|
1096
|
-
}
|
|
1097
|
-
const out = { runtime: "codex" }
|
|
1098
|
-
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)
|
|
1099
|
-
if (uuid) out.sessionId = uuid[0]
|
|
1100
|
-
if (model) out.model = model
|
|
1101
|
-
if (startedAt) out.startedAt = startedAt
|
|
1102
|
-
if (usage) {
|
|
1103
|
-
out.tokens = {
|
|
1104
|
-
input: usage.input_tokens ?? 0,
|
|
1105
|
-
output: usage.output_tokens ?? 0,
|
|
1106
|
-
cacheRead: usage.cached_input_tokens ?? 0,
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
return out
|
|
1110
|
-
}
|
|
1111
|
-
function captureCommit() {
|
|
1112
|
-
try {
|
|
1113
|
-
const opts = { stdio: ["ignore", "pipe", "ignore"] }
|
|
1114
|
-
const sha = execSync("git rev-parse --short HEAD", opts).toString().trim()
|
|
1115
|
-
if (!sha) return undefined
|
|
1116
|
-
const dirty = execSync("git status --porcelain", opts).toString().trim()
|
|
1117
|
-
return dirty ? `${sha}+dirty` : sha
|
|
1118
|
-
} catch {
|
|
1119
|
-
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
|
+
)
|
|
1120
1025
|
}
|
|
1121
1026
|
}
|
|
1122
1027
|
|
|
@@ -1166,12 +1071,18 @@ function nextMove(a) {
|
|
|
1166
1071
|
const last = a.events[a.events.length - 1]
|
|
1167
1072
|
if (!last) return "waiting on your human — nothing for you to do"
|
|
1168
1073
|
if (last.kind === "verdict" && last.verdict === "approved") {
|
|
1169
|
-
|
|
1074
|
+
// A qualified verdict carries the chosen answer path — cite it in the close.
|
|
1075
|
+
const chosen = last.chosen ? ` --chosen '${last.chosen}'` : ""
|
|
1076
|
+
return (
|
|
1077
|
+
`approved — verify any prerequisites in the ask, then fork on what it unlocked:` +
|
|
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>'`
|
|
1080
|
+
)
|
|
1170
1081
|
}
|
|
1171
1082
|
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
|
|
1083
|
+
return `revise, then re-raise on the same id: figs ask ${a.type} --id ${a.id} --title '…' …`
|
|
1173
1084
|
}
|
|
1174
|
-
return `act on the answer
|
|
1085
|
+
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '…'`
|
|
1175
1086
|
}
|
|
1176
1087
|
|
|
1177
1088
|
/** Restore an ask's refs into artifacts/ — hash-verified; never clobbers. */
|
|
@@ -1220,7 +1131,7 @@ async function fetchRefs(config, refs) {
|
|
|
1220
1131
|
* `figs inbox` — session start. Bare: every ask with thread activity + the
|
|
1221
1132
|
* next command for each. With an id: the zero-context handoff package (the
|
|
1222
1133
|
* ask, the whole thread verbatim, refs restored to disk). Pure read — writes
|
|
1223
|
-
* nothing to the outbox; closing happens via resolve
|
|
1134
|
+
* nothing to the outbox; closing happens via resolve.
|
|
1224
1135
|
*/
|
|
1225
1136
|
async function inboxCmd() {
|
|
1226
1137
|
requireFigs()
|
|
@@ -1293,7 +1204,7 @@ async function inboxCmd() {
|
|
|
1293
1204
|
}
|
|
1294
1205
|
}
|
|
1295
1206
|
|
|
1296
|
-
// ---------- the resolution fold (
|
|
1207
|
+
// ---------- the resolution fold (`resolve` — the one closing verb) ----------
|
|
1297
1208
|
/**
|
|
1298
1209
|
* Build the closing fold line. Best-effort, the verified path: fetches this
|
|
1299
1210
|
* agent's inbox and, when the ask has human events, cites the one acted on —
|
|
@@ -1361,15 +1272,16 @@ async function buildResolution(askId, { chosen, by, note, withdrawn, rejected })
|
|
|
1361
1272
|
// chosen matches, else the latest event (e.g. the approval, the rejection).
|
|
1362
1273
|
const events = serverAsk?.events ?? []
|
|
1363
1274
|
if (!withdrawn && events.length) {
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
|
1367
1278
|
const cited = match ?? events[events.length - 1]
|
|
1368
1279
|
resolution.via = "figs"
|
|
1369
1280
|
resolution.answer = cited.id
|
|
1370
1281
|
if (!by && cited.byName) resolution.by = cited.byName
|
|
1371
1282
|
}
|
|
1372
1283
|
|
|
1284
|
+
warnEatenDollar(resolution.chosen, resolution.note)
|
|
1373
1285
|
const line = {
|
|
1374
1286
|
id: askId,
|
|
1375
1287
|
status: withdrawn ? "withdrawn" : rejected ? "rejected" : "resolved",
|
|
@@ -1394,7 +1306,7 @@ async function reportCmd() {
|
|
|
1394
1306
|
requireFigs()
|
|
1395
1307
|
const result = flag("--result")
|
|
1396
1308
|
if (!result) {
|
|
1397
|
-
die(
|
|
1309
|
+
die("report needs --result '<one-line outcome>' — e.g. figs report --result '88% matched · 31 flagged'")
|
|
1398
1310
|
}
|
|
1399
1311
|
const run = { id: flag("--id") || genId("r"), ts: nowIso(), result }
|
|
1400
1312
|
const unit = flag("--unit")
|
|
@@ -1406,28 +1318,11 @@ async function reportCmd() {
|
|
|
1406
1318
|
const attached = attachFiles(flagAll("--attach"))
|
|
1407
1319
|
if (attached.length === 1) run.artifact = attached[0]
|
|
1408
1320
|
else if (attached.length > 1) run.artifacts = attached
|
|
1409
|
-
|
|
1410
|
-
let resolution = null
|
|
1411
|
-
if (resolves) {
|
|
1412
|
-
run.resolves = resolves
|
|
1413
|
-
resolution = await buildResolution(resolves, {
|
|
1414
|
-
chosen: flag("--chosen"),
|
|
1415
|
-
by: flag("--by"),
|
|
1416
|
-
note: flag("--note"),
|
|
1417
|
-
})
|
|
1418
|
-
}
|
|
1419
|
-
const session = captureSession()
|
|
1420
|
-
if (session) run.session = session
|
|
1421
|
-
|
|
1321
|
+
warnEatenDollar(run.result)
|
|
1422
1322
|
const issues = validateRun(run)
|
|
1423
1323
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1424
1324
|
appendJsonl("runs.jsonl", run)
|
|
1425
|
-
console.log(`figs: ✓ run recorded — ${
|
|
1426
|
-
if (resolution) {
|
|
1427
|
-
for (const w of resolution.warnings) console.warn(`figs: ! ${w}`)
|
|
1428
|
-
appendJsonl("asks.jsonl", resolution.line)
|
|
1429
|
-
console.log(`figs: ✓ ask ${resolves} ${resolution.line.status}`)
|
|
1430
|
-
}
|
|
1325
|
+
console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
|
|
1431
1326
|
await autoPush()
|
|
1432
1327
|
}
|
|
1433
1328
|
|
|
@@ -1452,11 +1347,11 @@ async function askCmd() {
|
|
|
1452
1347
|
}
|
|
1453
1348
|
}
|
|
1454
1349
|
const type = positional() ?? base.type
|
|
1455
|
-
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 '…'`)
|
|
1456
1351
|
const ask = { ...base, id: flag("--id") ?? base.id ?? genId("ask"), ts: nowIso(), type }
|
|
1457
1352
|
if (!ask.status) ask.status = "open"
|
|
1458
1353
|
const title = flag("--title") ?? base.title
|
|
1459
|
-
if (!title) die(
|
|
1354
|
+
if (!title) die("ask needs --title '<the ask, in one line>'")
|
|
1460
1355
|
ask.title = title
|
|
1461
1356
|
for (const [f, k] of [["--need", "need"], ["--found", "found"], ["--unit", "unit"], ["--to", "to"]]) {
|
|
1462
1357
|
const v = flag(f)
|
|
@@ -1493,13 +1388,17 @@ async function askCmd() {
|
|
|
1493
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>",
|
|
1494
1389
|
)
|
|
1495
1390
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1391
|
+
warnEatenDollar(
|
|
1392
|
+
ask.title,
|
|
1393
|
+
ask.found,
|
|
1394
|
+
ask.need,
|
|
1395
|
+
ask.options ?? [],
|
|
1396
|
+
(ask.details ?? []).flatMap((d) => [d.l, d.v]),
|
|
1397
|
+
)
|
|
1499
1398
|
const issues = validateAsk(ask)
|
|
1500
1399
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1501
1400
|
appendJsonl("asks.jsonl", ask)
|
|
1502
|
-
console.log(`figs: ✓ ask raised — ${
|
|
1401
|
+
console.log(`figs: ✓ ask raised — ${JSON.stringify(ask)}`)
|
|
1503
1402
|
if (!ask.to) {
|
|
1504
1403
|
console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
|
|
1505
1404
|
}
|