@figs-so/cli 0.8.0 → 1.0.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 +66 -39
- package/SPEC.md +208 -173
- package/figs.mjs +1060 -578
- package/package.json +1 -1
package/figs.mjs
CHANGED
|
@@ -2,39 +2,47 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `figs` — the agent-side CLI (v1, zero-dependency).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* figs
|
|
7
|
-
* figs
|
|
8
|
-
* figs
|
|
9
|
-
* figs
|
|
10
|
-
* figs
|
|
11
|
-
*
|
|
12
|
-
* figs
|
|
13
|
-
* figs
|
|
14
|
-
* figs
|
|
15
|
-
* figs
|
|
16
|
-
* figs
|
|
17
|
-
* figs push one-way push the .figs/ spine to the ingest endpoint
|
|
18
|
-
* figs version print the CLI version (and check for updates)
|
|
5
|
+
* LOCAL (no account, no network — the complete product):
|
|
6
|
+
* figs init scaffold .figs/ here — purely local, mints a stable agent id
|
|
7
|
+
* figs report --result '…' settle a job (stamps id/ts, --attach files)
|
|
8
|
+
* figs checkpoint --id … --note '…' save a job's progress mid-flight (opens it in-flight)
|
|
9
|
+
* figs ask <type> --title '…' raise an ask (self-contained: options/details/attachments)
|
|
10
|
+
* figs answer <ask-id> --by '…' record your human's reply to an ask (you run it, not them)
|
|
11
|
+
* figs inbox [<ask-id>] what needs you (pure local read)
|
|
12
|
+
* figs show <id> magnify one ask or job (thread/trail + attachments)
|
|
13
|
+
* figs close <ask-id> close an ask (derives the close from the reply, cites it)
|
|
14
|
+
* figs doctor validate .figs/ against the spec — runs account-free
|
|
15
|
+
* figs status local/linked + agent state [--json]
|
|
16
|
+
* figs version print the CLI version (offline; --check for updates)
|
|
19
17
|
* figs help [<command>] usage; `-h`/`--help` on any command, `-v` for version
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* CONNECTED (needs a one-time login + a workspace — strictly additive):
|
|
20
|
+
* figs login [<token>] device-flow approve (agent never sees the token), or save a pasted one
|
|
21
|
+
* figs logout remove the locally-saved token (~/.figs/credentials.json)
|
|
22
|
+
* figs link [--workspace <slug|uuid>] connect .figs/ to a workspace so push can publish
|
|
23
|
+
* figs push publish the .figs/ spine + attachments to the endpoint
|
|
24
|
+
*
|
|
25
|
+
* The writing verbs (report/checkpoint/ask/resolve) are sugar over the same
|
|
26
|
+
* files — they stamp ids + real-clock timestamps, validate on write with
|
|
27
|
+
* teaching errors, copy attachments into artifacts/, then (when linked) invoke
|
|
28
|
+
* the same push as `figs push` (auto-push IS push; --no-push to batch).
|
|
25
29
|
* Hand-writing the JSONL + bare `push` remains fully supported — files are the
|
|
26
30
|
* protocol; the verbs are conveniences.
|
|
27
31
|
*
|
|
28
32
|
* Designed to be driven by an agent: non-interactive, clear output, `--json`
|
|
29
33
|
* on read commands, `-h`/`--help`/`help` everywhere, and errors that say what to
|
|
30
34
|
* do next. Bare `figs` prints help; unknown commands AND unknown flags exit
|
|
31
|
-
* non-zero
|
|
32
|
-
*
|
|
35
|
+
* non-zero. Exit codes: 0 = recorded · 1 = nothing written (fix the input) ·
|
|
36
|
+
* 2 = recorded locally, publish failed (retry `figs push`, never the verb).
|
|
37
|
+
* Flags accept `--name value` or `--name=value`. Network calls time out (30s).
|
|
33
38
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* agent
|
|
39
|
+
* Two facts decide behavior: local-vs-linked (does config.json declare a
|
|
40
|
+
* workspaceId?) and authenticated (is there a token?). The config declares
|
|
41
|
+
* intent; the CLI errors only when declared intent can't be met. Identity is
|
|
42
|
+
* the *agent* — a UUID minted by `init` into the committed, non-secret
|
|
43
|
+
* .figs/config.json ({ agentId } locally; + { endpoint, workspaceId } once
|
|
44
|
+
* `figs link`ed). Auth is the *user* (a token, configured once per machine).
|
|
45
|
+
* Run from the agent's repo root, where `.figs/` lives.
|
|
38
46
|
*/
|
|
39
47
|
|
|
40
48
|
import {
|
|
@@ -91,29 +99,39 @@ const COMMANDS = {
|
|
|
91
99
|
eg: "figs login",
|
|
92
100
|
},
|
|
93
101
|
logout: { args: "", flags: [], desc: "remove the locally-saved token (~/.figs/credentials.json)" },
|
|
94
|
-
workspaces: {
|
|
95
|
-
args: "[--json]",
|
|
96
|
-
flags: ["--json"],
|
|
97
|
-
desc: "list your workspaces (read-only; shows the slug)",
|
|
98
|
-
eg: "figs workspaces",
|
|
99
|
-
},
|
|
100
102
|
init: {
|
|
103
|
+
args: "",
|
|
104
|
+
flags: [],
|
|
105
|
+
desc: "scaffold .figs/ here — purely local, no account needed (identity + charter/contract/guide)",
|
|
106
|
+
more: [
|
|
107
|
+
"Zero flags, zero network: mints a stable agent id into config.json and scaffolds",
|
|
108
|
+
"the templates. You're fully operational locally from here — record runs/asks/answers,",
|
|
109
|
+
"validate, navigate. `figs link` later when you want it on the hosted app.",
|
|
110
|
+
"Idempotent: re-running keeps your identity AND any link (never unlinks), and never",
|
|
111
|
+
"clobbers an existing agent.json / CONTRACT.md / GUIDE.md / outbox.",
|
|
112
|
+
],
|
|
113
|
+
eg: "figs init",
|
|
114
|
+
},
|
|
115
|
+
link: {
|
|
101
116
|
args: "[--workspace <slug-or-id>] [--endpoint <url>]",
|
|
102
117
|
flags: ["--workspace", "--endpoint"],
|
|
103
|
-
desc: "
|
|
118
|
+
desc: "connect this .figs/ to a workspace on the hosted app (so `figs push` can publish)",
|
|
104
119
|
more: [
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
120
|
+
"Bare: lists your workspaces (needs login); with exactly one, links it outright.",
|
|
121
|
+
"--workspace takes a slug (resolved via the API — needs login) or a raw UUID (no login",
|
|
122
|
+
"needed; get it from the app). --endpoint overrides the destination (default app.figs.so).",
|
|
123
|
+
"Verifies the workspace when you're logged in; a UUID set while logged out is accepted",
|
|
124
|
+
"but unverified until your first `figs push`. Writes endpoint + workspaceId into config.json.",
|
|
125
|
+
"To unlink, delete those two fields from .figs/config.json (your identity + work stay).",
|
|
108
126
|
],
|
|
109
|
-
eg: "figs
|
|
127
|
+
eg: "figs link --workspace acme-corp",
|
|
110
128
|
},
|
|
111
129
|
report: {
|
|
112
130
|
args: "--result <text> [options]",
|
|
113
131
|
flags: [
|
|
114
132
|
"--result", "--id", "--unit", "--period", "--status", "--trigger", "--attach", "--no-push",
|
|
115
133
|
],
|
|
116
|
-
desc: "
|
|
134
|
+
desc: "settle a job — stamps id/ts/state into runs.jsonl (auto-pushes when linked)",
|
|
117
135
|
more: [
|
|
118
136
|
"One run = one JOB — a unit of work your manager would recognize; the runs",
|
|
119
137
|
"list reads as the job list. Give a job a stable, meaningful --id",
|
|
@@ -129,9 +147,10 @@ const COMMANDS = {
|
|
|
129
147
|
"validation, artifact copy, push).",
|
|
130
148
|
"Single-quote prose values ('…') — inside double quotes your shell expands $,",
|
|
131
149
|
"so \"($4,474.63)\" reaches figs as \"(,474.63)\": silent corruption.",
|
|
132
|
-
"--attach <file> (repeatable)
|
|
150
|
+
"--attach <file> (repeatable) pins a file to this moment (attachments[]) —",
|
|
151
|
+
"rendered types (html/md/txt/json/images) show inline; data/docs (csv/pdf/xlsx/docx) download.",
|
|
133
152
|
"--no-push writes locally only; `figs push` publishes later.",
|
|
134
|
-
"Closing an ask is `figs
|
|
153
|
+
"Closing an ask is `figs close` — a close is not a job; never report one.",
|
|
135
154
|
"Hand-writing runs.jsonl works too — this verb is sugar over the same file.",
|
|
136
155
|
],
|
|
137
156
|
eg: "figs report --id recon-acme-2026-11 --result '88% matched · 31 flagged' --attach ./acme-2025-11.html",
|
|
@@ -141,7 +160,7 @@ const COMMANDS = {
|
|
|
141
160
|
flags: [
|
|
142
161
|
"--id", "--note", "--trigger", "--status", "--unit", "--period", "--attach", "--no-push",
|
|
143
162
|
],
|
|
144
|
-
desc: "save a job's progress mid-flight —
|
|
163
|
+
desc: "save a job's progress mid-flight — marks it in-flight (auto-pushes when linked)",
|
|
145
164
|
more: [
|
|
146
165
|
"Your first checkpoint OPENS the job (state: in-flight) — make it the first",
|
|
147
166
|
"act of any job that will outlive this sitting: say what triggered it",
|
|
@@ -165,10 +184,10 @@ const COMMANDS = {
|
|
|
165
184
|
"--id", "--title", "--need", "--found", "--option", "--on-approve", "--detail",
|
|
166
185
|
"--attach", "--to", "--unit", "--run", "--stdin", "--no-push",
|
|
167
186
|
],
|
|
168
|
-
desc: "raise an ask —
|
|
187
|
+
desc: "raise an ask — a self-contained line in asks.jsonl (auto-pushes when linked)",
|
|
169
188
|
more: [
|
|
170
|
-
"<type> = the answer contract:
|
|
171
|
-
"sign-off (give me a verdict)
|
|
189
|
+
"<type> = the answer contract: question (give me an answer) ·",
|
|
190
|
+
"sign-off (give me a verdict). Two types — the type IS the contract.",
|
|
172
191
|
"Two strangers read every ask — a human deciding, a future session acting;",
|
|
173
192
|
"the record must carry everything both need: --found (what you saw), --need",
|
|
174
193
|
"(what you need), --option (repeatable; short, stable, quotable — answers cite",
|
|
@@ -188,36 +207,70 @@ const COMMANDS = {
|
|
|
188
207
|
],
|
|
189
208
|
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",
|
|
190
209
|
},
|
|
210
|
+
answer: {
|
|
211
|
+
args: "<ask-id> --by <who> (--chosen <option> | --text <reply> | --approve | --request-changes | --reject)",
|
|
212
|
+
flags: [
|
|
213
|
+
"--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push",
|
|
214
|
+
],
|
|
215
|
+
desc: "record your human's out-of-band reply to an ask, verbatim (you run this, not them)",
|
|
216
|
+
more: [
|
|
217
|
+
"Humans don't type commands. They answer you in chat ('approved — only the 15');",
|
|
218
|
+
"you transcribe that into the record. --by names the HUMAN who said it, not you.",
|
|
219
|
+
"question → --chosen '<option verbatim>' (checked against the ask's options) or",
|
|
220
|
+
"--text '<what they said>'. sign-off → --approve | --request-changes | --reject",
|
|
221
|
+
"(a qualified verdict may also carry --chosen). Transcribe verbatim — never summarize,",
|
|
222
|
+
"never author the reply yourself.",
|
|
223
|
+
"Replies made IN the app sync down automatically (figs inbox) — `figs answer` is only",
|
|
224
|
+
"for replies that exist nowhere but your chat.",
|
|
225
|
+
"Then act, and `figs close <ask-id>` — it cites this reply automatically.",
|
|
226
|
+
],
|
|
227
|
+
eg: "figs answer acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
|
|
228
|
+
},
|
|
191
229
|
inbox: {
|
|
192
|
-
args: "[<ask-id>] [--json]",
|
|
193
|
-
flags: ["--json"],
|
|
194
|
-
desc: "what needs you — your humans'
|
|
230
|
+
args: "[<ask-id>] [--json] [--no-sync]",
|
|
231
|
+
flags: ["--json", "--no-sync"],
|
|
232
|
+
desc: "what needs you — your humans' replies on your asks + your unfinished jobs (pure read)",
|
|
195
233
|
more: [
|
|
196
234
|
"Start every session with this. Bare: lists every ask with thread activity —",
|
|
197
235
|
"answers and verdicts verbatim, plus the exact next command for each.",
|
|
198
236
|
"With an ask id: the full handoff package — the ask, the whole thread, and its",
|
|
199
|
-
"attached artifacts
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"Reads only — closing still happens via figs resolve / figs report.",
|
|
237
|
+
"attached artifacts.",
|
|
238
|
+
"Scope: THIS agent's open asks + their replies + unfinished jobs (in-flight runs).",
|
|
239
|
+
"When linked, a soft messages-only down-sync runs first (degrades loudly, never blocks;",
|
|
240
|
+
"--no-sync to skip). Reads only — recording a chat reply is figs answer; closing is figs close.",
|
|
204
241
|
],
|
|
205
242
|
eg: "figs inbox",
|
|
206
243
|
},
|
|
207
|
-
|
|
208
|
-
args: "<ask-id> [--
|
|
209
|
-
flags: ["--
|
|
210
|
-
desc: "
|
|
244
|
+
show: {
|
|
245
|
+
args: "<ask-id|job-id> [--json]",
|
|
246
|
+
flags: ["--json"],
|
|
247
|
+
desc: "magnify one ask or job — the folded record + its thread/trail + attachments (pure local)",
|
|
211
248
|
more: [
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"it — record their out-of-band no; rejected is terminal, re-raising = a new ask).",
|
|
216
|
-
"After an answer, fork on what it unlocked: nothing new → resolve right away;",
|
|
217
|
-
"real work → do the job, `figs report` it under its own id, THEN resolve —",
|
|
218
|
-
"cite the job id in --note so a reader can find the work.",
|
|
249
|
+
"Auto-detects an ask (shows the reply thread) or a job (shows its checkpoint trail).",
|
|
250
|
+
"Reads local files and folds them for you — no raw-JSONL spelunking. No network:",
|
|
251
|
+
"an attachment not on this machine is noted (view it in the app), never downloaded.",
|
|
219
252
|
],
|
|
220
|
-
eg: "figs
|
|
253
|
+
eg: "figs show acme-bridge",
|
|
254
|
+
},
|
|
255
|
+
close: {
|
|
256
|
+
args: "<ask-id> [--note <text>] [--run <run-id>] [--attach <file>] [--withdrawn]",
|
|
257
|
+
flags: [
|
|
258
|
+
"--note", "--run", "--attach", "--withdrawn", "--no-push",
|
|
259
|
+
// accepted-but-removed: handled with a teaching error pointing to the new path
|
|
260
|
+
"--chosen", "--by", "--answer-id", "--rejected",
|
|
261
|
+
],
|
|
262
|
+
desc: "close an ask — derives the close from the reply on file and cites it",
|
|
263
|
+
more: [
|
|
264
|
+
"A pure close: it reads the newest reply for the ask (recorded by `figs answer`",
|
|
265
|
+
"or synced from the app) and derives the outcome — resolved on an answer/approval,",
|
|
266
|
+
"rejected on a reject verdict; it refuses if nothing's answered yet, or if changes",
|
|
267
|
+
"were requested (revise and re-raise on the same id instead).",
|
|
268
|
+
"--run <run-id> links the JOB the reply set in motion (so a reader sees what you did).",
|
|
269
|
+
"--withdrawn = YOU retracted the ask (no reply needed; nobody acted).",
|
|
270
|
+
"After an answer, fork on what it unlocked: nothing new → close right away;",
|
|
271
|
+
"real work → do the job, `figs report` it under its own id, then close --run <that id>.",
|
|
272
|
+
],
|
|
273
|
+
eg: "figs close acme-bridge --run apply-bridge-2026-06",
|
|
221
274
|
},
|
|
222
275
|
doctor: {
|
|
223
276
|
args: "",
|
|
@@ -235,7 +288,11 @@ const COMMANDS = {
|
|
|
235
288
|
],
|
|
236
289
|
eg: "figs push",
|
|
237
290
|
},
|
|
238
|
-
version: {
|
|
291
|
+
version: {
|
|
292
|
+
args: "[--check]",
|
|
293
|
+
flags: ["--check"],
|
|
294
|
+
desc: "print the CLI version (offline); --check also asks the server for updates",
|
|
295
|
+
},
|
|
239
296
|
help: { args: "[<command>]", flags: [], desc: "show this help, or detailed help for one command" },
|
|
240
297
|
}
|
|
241
298
|
|
|
@@ -259,6 +316,14 @@ function die(msg) {
|
|
|
259
316
|
function readJson(path, fallback) {
|
|
260
317
|
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback
|
|
261
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* The one `--json` envelope for read verbs: `{ ok, data, warnings }`. Uniform
|
|
321
|
+
* so an agent parses every read the same way and can branch on `ok` and surface
|
|
322
|
+
* `warnings` (e.g. a degraded sync) without scraping stderr.
|
|
323
|
+
*/
|
|
324
|
+
function printJson(data, { ok = true, warnings = [] } = {}) {
|
|
325
|
+
console.log(JSON.stringify({ ok, data, warnings }, null, 2))
|
|
326
|
+
}
|
|
262
327
|
/** Read a flag value — supports both `--name value` and `--name=value`. */
|
|
263
328
|
function flag(name) {
|
|
264
329
|
const args = process.argv.slice(2)
|
|
@@ -319,21 +384,26 @@ function genId(prefix) {
|
|
|
319
384
|
// ---------- local validation (the spec's common mistakes, caught on write) ----
|
|
320
385
|
// The server's schema stays the source of truth; these catch what hand-authors
|
|
321
386
|
// and flag typos get wrong, with errors that teach the fix.
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
387
|
+
// Two types = two answer contracts, and the type IS the contract:
|
|
388
|
+
// question → answer (an option or free text) · sign-off → verdict.
|
|
389
|
+
// History (all pre-launch, free to break): "blocked" folded into the type that
|
|
390
|
+
// became "question" (a stuck JOB is the run's status, not an ask type);
|
|
391
|
+
// "needs-decision" was renamed to "question" (1.0); "fyi" was retired (1.0) — a
|
|
392
|
+
// for-the-record note is a settled report, not an ask.
|
|
393
|
+
const ASK_TYPES = ["question", "sign-off"]
|
|
327
394
|
const RUN_STATUSES = ["ok", "warn", "fail"]
|
|
328
395
|
// Lifecycle, orthogonal to status (outcome): checkpoint stamps in-flight,
|
|
329
396
|
// report stamps settled. Absent = settled (a plain report is a complete fact).
|
|
330
397
|
const RUN_STATES = ["in-flight", "settled"]
|
|
331
398
|
const ASK_STATUSES = ["open", "resolved", "withdrawn", "rejected"]
|
|
332
399
|
const TO_VALUES = ["manager", "builder"]
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
400
|
+
// Two render classes. RENDERABLE shows inline in the sandboxed viewer;
|
|
401
|
+
// DOWNLOAD_ONLY (data/docs — back-office work products) is offered as a
|
|
402
|
+
// download, never rendered (lower risk than HTML rendering — nothing executes).
|
|
403
|
+
const RENDERABLE_EXTS = [".html", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]
|
|
404
|
+
const DOWNLOAD_ONLY_EXTS = [".csv", ".pdf", ".xlsx", ".xls", ".docx"]
|
|
405
|
+
const ARTIFACT_EXTS = new Set([...RENDERABLE_EXTS, ...DOWNLOAD_ONLY_EXTS])
|
|
406
|
+
const ARTIFACT_MAX = 10 * 1024 * 1024
|
|
337
407
|
|
|
338
408
|
/** "signoff" → `did you mean "sign-off"?` — normalized nearest match. */
|
|
339
409
|
function didYouMean(value, allowed) {
|
|
@@ -355,13 +425,22 @@ function validateRun(r) {
|
|
|
355
425
|
if (!r.ts) issues.push(`${label}: missing required "ts" (ISO-8601 — \`figs report\` stamps it for you)`)
|
|
356
426
|
checkEnum(issues, r, "status", RUN_STATUSES, label)
|
|
357
427
|
checkEnum(issues, r, "state", RUN_STATES, label)
|
|
358
|
-
|
|
359
|
-
|
|
428
|
+
checkAttachments(issues, r, label)
|
|
429
|
+
return issues
|
|
430
|
+
}
|
|
431
|
+
/** attachments[] is the unified field (bare file names). The legacy run
|
|
432
|
+
* `artifact`/`artifacts` and ask `refs` are still READ (attachmentsOf), so we
|
|
433
|
+
* only type-check them here for back-compat; new writes use attachments[]. */
|
|
434
|
+
function checkAttachments(issues, obj, label) {
|
|
435
|
+
if (obj.attachments !== undefined && (!Array.isArray(obj.attachments) || obj.attachments.some((a) => typeof a !== "string"))) {
|
|
436
|
+
issues.push(`${label}.attachments: must be an array of file names`)
|
|
360
437
|
}
|
|
361
|
-
if (
|
|
362
|
-
issues.push(`${label}.
|
|
438
|
+
if (obj.artifact !== undefined && typeof obj.artifact !== "string") {
|
|
439
|
+
issues.push(`${label}.artifact: legacy field — use attachments[] (an array of file names)`)
|
|
440
|
+
}
|
|
441
|
+
if (obj.artifacts !== undefined && (!Array.isArray(obj.artifacts) || obj.artifacts.some((a) => typeof a !== "string"))) {
|
|
442
|
+
issues.push(`${label}.artifacts: legacy field — use attachments[] (an array of file names)`)
|
|
363
443
|
}
|
|
364
|
-
return issues
|
|
365
444
|
}
|
|
366
445
|
/** Validate one folded ask record → array of issue strings. */
|
|
367
446
|
function validateAsk(a) {
|
|
@@ -374,7 +453,15 @@ function validateAsk(a) {
|
|
|
374
453
|
)
|
|
375
454
|
} else if (a.type === "blocked") {
|
|
376
455
|
issues.push(
|
|
377
|
-
`${label}.type: "blocked"
|
|
456
|
+
`${label}.type: "blocked" isn't an ask type — a stuck job is the RUN's status (re-report --status onto the same job id); what you need from a human is a "question"`,
|
|
457
|
+
)
|
|
458
|
+
} else if (a.type === "needs-decision") {
|
|
459
|
+
issues.push(
|
|
460
|
+
`${label}.type: "needs-decision" was renamed to "question" (the type is the answer contract: question → answer · sign-off → verdict)`,
|
|
461
|
+
)
|
|
462
|
+
} else if (a.type === "fyi") {
|
|
463
|
+
issues.push(
|
|
464
|
+
`${label}.type: "fyi" was retired — a for-the-record note is a settled report (\`figs report\`), not an ask; if you actually need a human it's a "question" or "sign-off"`,
|
|
378
465
|
)
|
|
379
466
|
} else checkEnum(issues, a, "type", ASK_TYPES, label)
|
|
380
467
|
if (!a.title) issues.push(`${label}: missing required "title"`)
|
|
@@ -396,16 +483,53 @@ function validateAsk(a) {
|
|
|
396
483
|
issues.push(`${label}.details: must be [{ "l": "Label", "v": "Value" }]`)
|
|
397
484
|
}
|
|
398
485
|
if (a.refs !== undefined && (!Array.isArray(a.refs) || a.refs.some((r) => !r || typeof r.label !== "string"))) {
|
|
399
|
-
issues.push(`${label}.refs:
|
|
486
|
+
issues.push(`${label}.refs: legacy field — use attachments[] (an array of file names)`)
|
|
400
487
|
}
|
|
488
|
+
checkAttachments(issues, a, label)
|
|
401
489
|
return issues
|
|
402
490
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
491
|
+
// ---------- messages.jsonl — the human ledger -------------------------------
|
|
492
|
+
// Human replies to asks: answers + verdicts (later: note/directive). Events,
|
|
493
|
+
// not records — immutable, ids minted once, they accumulate (no fold). `source`
|
|
494
|
+
// is *where* a reply arrived, display only — the verified grade comes from mint
|
|
495
|
+
// origin (server-minted = attested; pushed-up = transcription), never `source`.
|
|
496
|
+
const MSG_KINDS = ["answer", "verdict"]
|
|
497
|
+
const VERDICTS = ["approved", "changes-requested", "rejected"]
|
|
498
|
+
const MSG_SOURCES = ["app", "chat"]
|
|
499
|
+
/** Validate one message event → array of issue strings. */
|
|
500
|
+
function validateMessage(m) {
|
|
501
|
+
const issues = []
|
|
502
|
+
const label = `message "${m.id ?? "?"}"`
|
|
503
|
+
if (!m.id || typeof m.id !== "string") issues.push(`${label}: missing required "id"`)
|
|
504
|
+
if (!m.ask || typeof m.ask !== "string") issues.push(`${label}: missing required "ask" (the ask id this replies to)`)
|
|
505
|
+
if (!m.ts) issues.push(`${label}: missing required "ts"`)
|
|
506
|
+
if (!m.by) issues.push(`${label}: missing required "by" (who said it — the human, not you)`)
|
|
507
|
+
checkEnum(issues, m, "kind", MSG_KINDS, label)
|
|
508
|
+
checkEnum(issues, m, "verdict", VERDICTS, label)
|
|
509
|
+
checkEnum(issues, m, "source", MSG_SOURCES, label)
|
|
510
|
+
if (m.kind === "verdict" && !m.verdict) {
|
|
511
|
+
issues.push(`${label}: a verdict message needs "verdict" (${VERDICTS.join(" / ")})`)
|
|
512
|
+
}
|
|
513
|
+
if (m.kind === "answer" && !m.chosen && !m.text) {
|
|
514
|
+
issues.push(`${label}: an answer needs "chosen" or "text"`)
|
|
515
|
+
}
|
|
516
|
+
return issues
|
|
406
517
|
}
|
|
407
|
-
|
|
408
|
-
|
|
518
|
+
|
|
519
|
+
/** Validate the whole local outbox (folded records + message events). */
|
|
520
|
+
function validateOutbox(runs, asks, messages = []) {
|
|
521
|
+
return [
|
|
522
|
+
...runs.flatMap(validateRun),
|
|
523
|
+
...asks.flatMap(validateAsk),
|
|
524
|
+
...messages.flatMap(validateMessage),
|
|
525
|
+
]
|
|
526
|
+
}
|
|
527
|
+
/** Does an ask with this id exist locally — raised here, or known via a message? */
|
|
528
|
+
function askExistsLocally(askId) {
|
|
529
|
+
return (
|
|
530
|
+
foldById(readJsonl("asks.jsonl")).some((a) => a.id === askId) ||
|
|
531
|
+
readJsonl("messages.jsonl").some((m) => m.ask === askId)
|
|
532
|
+
)
|
|
409
533
|
}
|
|
410
534
|
function resolveEndpoint() {
|
|
411
535
|
const cfg = readJson(join(repoDir, "config.json"), {})
|
|
@@ -414,11 +538,96 @@ function resolveEndpoint() {
|
|
|
414
538
|
"",
|
|
415
539
|
)
|
|
416
540
|
}
|
|
541
|
+
// ---------- credentials, keyed by endpoint origin --------------------------
|
|
542
|
+
// One token per endpoint origin so a prod token is never sent to a localhost
|
|
543
|
+
// dev endpoint (the old single-token file's real bug) and prod + dev can coexist.
|
|
544
|
+
// File shape: { "https://app.figs.so": { "token": "…" }, … }. The pre-1.0 shape
|
|
545
|
+
// was a bare { "token": "…" } — migrated on read to the default endpoint.
|
|
546
|
+
/** Canonical origin (scheme://host:port, no path/trailing slash) of a URL. */
|
|
547
|
+
function originOf(url) {
|
|
548
|
+
try {
|
|
549
|
+
return new URL(url).origin
|
|
550
|
+
} catch {
|
|
551
|
+
return String(url).replace(/\/+$/, "")
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Load the credentials file, migrating the legacy bare-token shape in memory. */
|
|
555
|
+
function loadCreds() {
|
|
556
|
+
const raw = readJson(globalCreds, {})
|
|
557
|
+
if (typeof raw.token === "string") {
|
|
558
|
+
// Legacy single token — it was always the default endpoint's.
|
|
559
|
+
return { [originOf(DEFAULT_ENDPOINT)]: { token: raw.token } }
|
|
560
|
+
}
|
|
561
|
+
return raw
|
|
562
|
+
}
|
|
563
|
+
function getToken() {
|
|
564
|
+
return process.env.FIGS_TOKEN || loadCreds()[originOf(resolveEndpoint())]?.token
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------- the state model -------------------------------------------------
|
|
568
|
+
// Two orthogonal facts: local-vs-linked (does config declare a workspace?) and
|
|
569
|
+
// authenticated (is there a token?). The rule: the config declares intent; the
|
|
570
|
+
// CLI errors only when declared intent can't be met. A local repo (no
|
|
571
|
+
// workspaceId) is a deliberate, first-class state — never an error.
|
|
572
|
+
|
|
573
|
+
/** Linked = config declares a destination workspace (intent to publish). */
|
|
574
|
+
function isLinked() {
|
|
575
|
+
return Boolean(readJson(join(repoDir, "config.json"), {}).workspaceId)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Exit codes: 0 = recorded (and published, if linked) · 1 = nothing written
|
|
579
|
+
// (fix the input) · 2 = recorded locally, publish failed (retry `figs push`,
|
|
580
|
+
// never re-run the verb). The canonical exit-2 line below is what an agent keys
|
|
581
|
+
// on — the number inverts some CLI priors, so the words carry the contract.
|
|
582
|
+
const RECORDED_LOCALLY =
|
|
583
|
+
"recorded locally — do NOT re-run this verb; `figs push` retries"
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* When there's no `.figs/` in cwd, peek UPWARD (read-only) for a parent agent's
|
|
587
|
+
* folder so we can tell the agent where it is — but never adopt that identity.
|
|
588
|
+
* The fleet topology forbids walk-up: an openfigs fleet has the recruiter's
|
|
589
|
+
* `.figs/` at the root and each employee's own `.figs/` in its folder; silently
|
|
590
|
+
* using the parent would make a child agent report as the recruiter. Bounded;
|
|
591
|
+
* stops at the filesystem root.
|
|
592
|
+
*/
|
|
593
|
+
function peekParentFigs() {
|
|
594
|
+
let dir = join(process.cwd(), "..")
|
|
595
|
+
for (let i = 0; i < 40; i++) {
|
|
596
|
+
if (existsSync(join(dir, ".figs"))) {
|
|
597
|
+
return { dir, name: readJson(join(dir, ".figs", "agent.json"), {})?.name }
|
|
598
|
+
}
|
|
599
|
+
const parent = join(dir, "..")
|
|
600
|
+
if (parent === dir) break // filesystem root
|
|
601
|
+
dir = parent
|
|
602
|
+
}
|
|
603
|
+
return null
|
|
604
|
+
}
|
|
605
|
+
/** The "no .figs/ here" message — points at a parent if one exists, never uses it. */
|
|
606
|
+
function noFigsHint() {
|
|
607
|
+
const p = peekParentFigs()
|
|
608
|
+
if (p) {
|
|
609
|
+
return `no .figs/ here, but found one at ${p.dir}${p.name ? ` (agent: ${p.name})` : ""} — if you ARE that agent, cd there; if you're a different agent, run \`figs init\` at YOUR root`
|
|
610
|
+
}
|
|
611
|
+
return "no .figs/ here — run `figs init` first"
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Auth headers for a token. Standard `Authorization: Bearer` is the contract;
|
|
616
|
+
* we ALSO send the legacy `x-figs-token` through the transition so a 1.0 CLI
|
|
617
|
+
* works against an app that hasn't shipped Bearer yet. Drop `x-figs-token` here
|
|
618
|
+
* once the app requires Bearer (next MIN_CLI bump).
|
|
619
|
+
*/
|
|
620
|
+
function authHeaders(token) {
|
|
621
|
+
if (!token) return {}
|
|
622
|
+
return { authorization: `Bearer ${token}`, "x-figs-token": token }
|
|
623
|
+
}
|
|
624
|
+
|
|
417
625
|
const REQUEST_TIMEOUT_MS = 30000
|
|
626
|
+
const SYNC_TIMEOUT_MS = 5000 // session-start sync degrades fast, never blocks
|
|
418
627
|
/** `fetch` with a hard timeout so a hung server never stalls the agent. */
|
|
419
|
-
async function fetchT(url, opts = {}) {
|
|
628
|
+
async function fetchT(url, opts = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
420
629
|
const ctrl = new AbortController()
|
|
421
|
-
const t = setTimeout(() => ctrl.abort(),
|
|
630
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
422
631
|
try {
|
|
423
632
|
return await fetch(url, { ...opts, signal: ctrl.signal })
|
|
424
633
|
} finally {
|
|
@@ -439,7 +648,7 @@ async function request(method, path, body, token = getToken()) {
|
|
|
439
648
|
method,
|
|
440
649
|
headers: {
|
|
441
650
|
"content-type": "application/json",
|
|
442
|
-
...(token
|
|
651
|
+
...authHeaders(token),
|
|
443
652
|
},
|
|
444
653
|
body: body ? JSON.stringify(body) : undefined,
|
|
445
654
|
})
|
|
@@ -526,12 +735,22 @@ function printHelp(name) {
|
|
|
526
735
|
if (c.eg) console.log(`\n e.g. ${c.eg}`)
|
|
527
736
|
return
|
|
528
737
|
}
|
|
529
|
-
console.log("figs —
|
|
738
|
+
console.log("figs — your AI agent's work journal; report it to humans at https://figs.so\n")
|
|
530
739
|
console.log("Usage: figs <command> [options]\n")
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
740
|
+
// Grouped by layer so the local-first model is legible in the help itself:
|
|
741
|
+
// LOCAL works with no account; CONNECTED needs a one-time login + a workspace.
|
|
742
|
+
const LOCAL = ["init", "report", "checkpoint", "ask", "answer", "inbox", "show", "close", "doctor", "status", "version", "help"]
|
|
743
|
+
const CONNECTED = ["login", "logout", "link", "push"]
|
|
744
|
+
const printGroup = (title, names) => {
|
|
745
|
+
console.log(title)
|
|
746
|
+
for (const n of names) {
|
|
747
|
+
const c = COMMANDS[n]
|
|
748
|
+
if (c) console.log(` ${`${n} ${c.args}`.trim().padEnd(pad)} ${c.desc}`)
|
|
749
|
+
}
|
|
534
750
|
}
|
|
751
|
+
printGroup("Local (no account needed):", LOCAL)
|
|
752
|
+
console.log("")
|
|
753
|
+
printGroup("Connected (one-time login + a workspace):", CONNECTED)
|
|
535
754
|
console.log("\nGlobal flags:")
|
|
536
755
|
console.log(` ${"-h, --help".padEnd(pad)} show help (or \`figs help <command>\`)`)
|
|
537
756
|
console.log(` ${"-v, --version".padEnd(pad)} print the CLI version`)
|
|
@@ -544,21 +763,25 @@ function printHelp(name) {
|
|
|
544
763
|
|
|
545
764
|
if (cmd === "help" || cmd === "-h" || cmd === "--help") printHelp(process.argv[3])
|
|
546
765
|
else if (cmd === "version" || cmd === "--version" || cmd === "-v" || cmd === "-V") {
|
|
766
|
+
// Offline by default — printing your own version must never need the network
|
|
767
|
+
// (contract: no network on the hot path of a local verb). `--check` opts in.
|
|
547
768
|
console.log(VERSION)
|
|
548
|
-
await checkVersion({ force: true })
|
|
769
|
+
if (hasFlag("--check")) await checkVersion({ force: true })
|
|
549
770
|
} else if (WANTS_HELP) printHelp(cmd)
|
|
550
771
|
else if (COMMANDS[cmd]) {
|
|
551
772
|
checkFlags(cmd) // reject unknown flags before running
|
|
552
773
|
if (cmd === "login") await login(process.argv[3])
|
|
553
774
|
else if (cmd === "logout") logout()
|
|
554
775
|
else if (cmd === "status") await status()
|
|
555
|
-
else if (cmd === "workspaces") await workspaces()
|
|
556
776
|
else if (cmd === "init") await init()
|
|
777
|
+
else if (cmd === "link") await link()
|
|
557
778
|
else if (cmd === "report") await reportCmd()
|
|
558
779
|
else if (cmd === "checkpoint") await checkpointCmd()
|
|
559
780
|
else if (cmd === "ask") await askCmd()
|
|
781
|
+
else if (cmd === "answer") await answerCmd()
|
|
560
782
|
else if (cmd === "inbox") await inboxCmd()
|
|
561
|
-
else if (cmd === "
|
|
783
|
+
else if (cmd === "show") await showCmd()
|
|
784
|
+
else if (cmd === "close") await closeCmd()
|
|
562
785
|
else if (cmd === "doctor") await doctor()
|
|
563
786
|
else if (cmd === "push") await push()
|
|
564
787
|
} else {
|
|
@@ -570,9 +793,13 @@ function sleep(ms) {
|
|
|
570
793
|
return new Promise((r) => setTimeout(r, ms))
|
|
571
794
|
}
|
|
572
795
|
function saveToken(token) {
|
|
796
|
+
// Key the token under the endpoint origin we're logging into; preserve tokens
|
|
797
|
+
// for other origins (migrating the legacy shape in the process).
|
|
798
|
+
const creds = loadCreds()
|
|
799
|
+
creds[originOf(resolveEndpoint())] = { token }
|
|
573
800
|
// A bearer token must not be group/other-readable: dir 0700, file 0600.
|
|
574
801
|
mkdirSync(globalDir, { recursive: true, mode: 0o700 })
|
|
575
|
-
writeFileSync(globalCreds, JSON.stringify(
|
|
802
|
+
writeFileSync(globalCreds, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 })
|
|
576
803
|
// Enforce perms even if the dir/file pre-existed with looser modes (mode on
|
|
577
804
|
// write only applies at creation).
|
|
578
805
|
try {
|
|
@@ -654,15 +881,22 @@ async function login(token) {
|
|
|
654
881
|
* can't be removed here (unset the env var to fully log out).
|
|
655
882
|
*/
|
|
656
883
|
function logout() {
|
|
657
|
-
|
|
884
|
+
const origin = originOf(resolveEndpoint())
|
|
885
|
+
const creds = loadCreds()
|
|
886
|
+
if (creds[origin]) {
|
|
887
|
+
delete creds[origin]
|
|
658
888
|
try {
|
|
659
|
-
|
|
889
|
+
if (Object.keys(creds).length) {
|
|
890
|
+
writeFileSync(globalCreds, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 })
|
|
891
|
+
} else if (existsSync(globalCreds)) {
|
|
892
|
+
rmSync(globalCreds) // nothing left — remove the file entirely
|
|
893
|
+
}
|
|
660
894
|
} catch (e) {
|
|
661
|
-
die(`could not
|
|
895
|
+
die(`could not update ${globalCreds}: ${e?.message || e}`)
|
|
662
896
|
}
|
|
663
|
-
console.log(
|
|
897
|
+
console.log(`figs: ✓ logged out of ${origin} (other endpoints' tokens, if any, are kept)`)
|
|
664
898
|
} else {
|
|
665
|
-
console.log(
|
|
899
|
+
console.log(`figs: not logged in to ${origin} — nothing to remove`)
|
|
666
900
|
}
|
|
667
901
|
if (process.env.FIGS_TOKEN) {
|
|
668
902
|
console.warn(
|
|
@@ -696,30 +930,30 @@ async function status() {
|
|
|
696
930
|
}
|
|
697
931
|
}
|
|
698
932
|
|
|
933
|
+
const linked = Boolean(cfg?.workspaceId)
|
|
934
|
+
|
|
699
935
|
if (JSON_OUT) {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
agentJson: hasAgent,
|
|
712
|
-
contractMd: hasContract,
|
|
713
|
-
},
|
|
714
|
-
null,
|
|
715
|
-
2,
|
|
716
|
-
),
|
|
717
|
-
)
|
|
718
|
-
return
|
|
936
|
+
return printJson({
|
|
937
|
+
version: VERSION,
|
|
938
|
+
mode: linked ? "linked" : "local",
|
|
939
|
+
endpoint,
|
|
940
|
+
loggedIn,
|
|
941
|
+
account: account ? { id: account.id, email: account.email, name: account.name } : null,
|
|
942
|
+
workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
|
|
943
|
+
config: cfg ? { agentId: cfg.agentId, workspaceId: cfg.workspaceId ?? null } : null,
|
|
944
|
+
agentJson: hasAgent,
|
|
945
|
+
contractMd: hasContract,
|
|
946
|
+
})
|
|
719
947
|
}
|
|
720
948
|
|
|
721
949
|
const row = (k, v) => console.log(` ${(k + ":").padEnd(12)} ${v}`)
|
|
722
950
|
console.log("figs status")
|
|
951
|
+
row(
|
|
952
|
+
"mode",
|
|
953
|
+
linked
|
|
954
|
+
? "linked — publishes to the hosted app on push"
|
|
955
|
+
: "local — fully operational offline; `figs link` to publish",
|
|
956
|
+
)
|
|
723
957
|
row(
|
|
724
958
|
"logged in",
|
|
725
959
|
loggedIn
|
|
@@ -728,7 +962,9 @@ async function status() {
|
|
|
728
962
|
? `can't reach ${endpoint}`
|
|
729
963
|
: token
|
|
730
964
|
? "token invalid — run `figs login`"
|
|
731
|
-
:
|
|
965
|
+
: linked
|
|
966
|
+
? "no — run `figs login`, then `figs push`"
|
|
967
|
+
: "no (not needed for local mode)",
|
|
732
968
|
)
|
|
733
969
|
if (loggedIn) {
|
|
734
970
|
row(
|
|
@@ -740,9 +976,7 @@ async function status() {
|
|
|
740
976
|
}
|
|
741
977
|
row(
|
|
742
978
|
"workspace",
|
|
743
|
-
cfg
|
|
744
|
-
? cfg.workspaceId
|
|
745
|
-
: "not initialized — run `figs init --workspace <id>`",
|
|
979
|
+
linked ? cfg.workspaceId : "none — `figs link` to connect one",
|
|
746
980
|
)
|
|
747
981
|
row("agent.json", hasAgent ? "present (identity)" : "missing — author .figs/agent.json")
|
|
748
982
|
row(
|
|
@@ -751,65 +985,17 @@ async function status() {
|
|
|
751
985
|
? "present (activity) — follow it"
|
|
752
986
|
: "none yet — Activity is optional, agree it with your user",
|
|
753
987
|
)
|
|
988
|
+
row("journal", "records live on this machine (the app is the durable record once linked)")
|
|
754
989
|
row("endpoint", endpoint)
|
|
755
990
|
row("cli", VERSION)
|
|
756
991
|
}
|
|
757
992
|
|
|
758
|
-
/**
|
|
759
|
-
* List the user's workspaces (read-only). Creating a workspace is a human,
|
|
760
|
-
* web-side action — the agent only ever joins one it was pointed at. Surfaces
|
|
761
|
-
* the slug, which is what `figs init --workspace <slug>` takes.
|
|
762
|
-
*/
|
|
763
|
-
async function workspaces() {
|
|
764
|
-
const { workspaces: list = [] } = await api("GET", "/api/workspaces")
|
|
765
|
-
if (JSON_OUT) return void console.log(JSON.stringify(list, null, 2))
|
|
766
|
-
if (list.length === 0) {
|
|
767
|
-
console.log("figs: no workspaces yet — create one in the Figs web app, then re-run.")
|
|
768
|
-
return
|
|
769
|
-
}
|
|
770
|
-
console.log(`figs: ${list.length} workspace${list.length === 1 ? "" : "s"}`)
|
|
771
|
-
for (const w of list) {
|
|
772
|
-
console.log(` ${String(w.role).padEnd(6)} ${w.slug} — ${w.name}`)
|
|
773
|
-
}
|
|
774
|
-
console.log(" → figs init --workspace <slug>")
|
|
775
|
-
}
|
|
776
|
-
|
|
777
993
|
function isUuid(s) {
|
|
778
994
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
779
995
|
String(s),
|
|
780
996
|
)
|
|
781
997
|
}
|
|
782
998
|
|
|
783
|
-
/**
|
|
784
|
-
* The local `.figs/GUIDE.md` is a thin orientation breadcrumb (safe to
|
|
785
|
-
* regenerate) — the full, always-current guide + schema is served at
|
|
786
|
-
* `${endpoint}/llms.txt`, and the agent's own standing agreement lives in the
|
|
787
|
-
* precious, agent-authored `.figs/CONTRACT.md` (which `init` never touches).
|
|
788
|
-
*/
|
|
789
|
-
function guideStub(endpoint) {
|
|
790
|
-
return `<!-- Written by \`figs init\` (safe to regenerate). Orientation only — the full
|
|
791
|
-
guide lives at the URL below; your own contract lives in CONTRACT.md. -->
|
|
792
|
-
# Figs — this folder
|
|
793
|
-
|
|
794
|
-
This repo publishes to **Figs** (your manager's read-only window into what you do).
|
|
795
|
-
|
|
796
|
-
- **Full guide + schema → ${endpoint}/llms.txt** — read this first.
|
|
797
|
-
- **Your contract → ./CONTRACT.md** — what you publish / hold back (you + your user author it).
|
|
798
|
-
- **Identity → config.json** (committed) · **charter → agent.json** (you write).
|
|
799
|
-
- **Stay visible:** keep the \`figs:begin\`…\`figs:end\` block in the instruction file you load every
|
|
800
|
-
session (CLAUDE.md/AGENTS.md/…). It's what brought you back here — don't remove it. (Phase-1 setup; see /llms.txt.)
|
|
801
|
-
|
|
802
|
-
**Where are you?** You may be reading this at any time — don't assume you're starting fresh. Run
|
|
803
|
-
\`figs status\` and check \`.figs/\` before acting:
|
|
804
|
-
• not set up yet → author \`.figs/agent.json\`, then \`figs doctor\` && \`figs push\` to appear (Identity).
|
|
805
|
-
• CONTRACT.md present → follow it; keep publishing what it says.
|
|
806
|
-
• no CONTRACT.md yet → agree with your user what work to surface (Activity) — see ${endpoint}/llms.txt.
|
|
807
|
-
|
|
808
|
-
Commit config.json + agent.json + CONTRACT.md + this file. runs.jsonl / asks.jsonl / artifacts/
|
|
809
|
-
are a gitignored outbox. The token is the human's job — never generate one yourself.
|
|
810
|
-
`
|
|
811
|
-
}
|
|
812
|
-
|
|
813
999
|
/**
|
|
814
1000
|
* A starter `agent.json` — written by `figs init` only when none exists. The
|
|
815
1001
|
* `<…>` values are placeholders the agent fills in by reading its own repo;
|
|
@@ -837,28 +1023,36 @@ function agentJsonStub(name) {
|
|
|
837
1023
|
)
|
|
838
1024
|
}
|
|
839
1025
|
|
|
840
|
-
/**
|
|
1026
|
+
/**
|
|
1027
|
+
* A starter CONTRACT — the going-live record, written by `figs init` only when
|
|
1028
|
+
* none exists. It's the agent↔user agreement, authored in the going-live
|
|
1029
|
+
* conversation (not mechanically). The `<…>` prompts are answered with the user.
|
|
1030
|
+
*/
|
|
841
1031
|
function contractStub(name) {
|
|
842
|
-
return `#
|
|
1032
|
+
return `# Contract — ${name} on Figs
|
|
843
1033
|
|
|
844
|
-
|
|
845
|
-
|
|
1034
|
+
The agreement between this employee and its humans: what counts as real work, what gets surfaced,
|
|
1035
|
+
what's held back. **Author this WITH your user** in the going-live conversation (see the guide) —
|
|
1036
|
+
not mechanically. Keep it honest and current.
|
|
846
1037
|
|
|
847
|
-
> **Maintain:** edit when the surfacing agreement changes
|
|
848
|
-
> Keep it honest to what you actually push.
|
|
1038
|
+
> **Maintain:** edit when the work or the surfacing agreement changes.
|
|
849
1039
|
|
|
850
|
-
##
|
|
1040
|
+
## Fit
|
|
1041
|
+
<are you a good fit? Figs is for recurring work a human wants to stay in the loop on. "Not yet,
|
|
1042
|
+
because X" is a valid, honest answer.>
|
|
851
1043
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
| **asks** | when real | genuine blockers / decisions / sign-offs / FYIs for my manager. 0 is a fine number. |
|
|
1044
|
+
## What's a job (what I report)
|
|
1045
|
+
- **A job is:** <the unit your manager would recognize — e.g. "one monthly reconciliation">
|
|
1046
|
+
- **I checkpoint:** <the mid-flight steps a human would recognize, for jobs that outlive a sitting>
|
|
1047
|
+
- **A job settles when:** <the headline result that means done>
|
|
857
1048
|
|
|
858
1049
|
## What I never surface
|
|
859
|
-
|
|
860
1050
|
Raw user content — ever. Plus, for this agent: <anything sensitive to its domain>. Use
|
|
861
1051
|
**de-identified labels** (\`<scope>-01\`), never customer or system names.
|
|
1052
|
+
|
|
1053
|
+
## Inbox cadence
|
|
1054
|
+
<when do I process human replies? dedicated schedule (recommended) · spawned sweep · at session
|
|
1055
|
+
start. How it's scheduled is a build concern (OpenFigs) + your user's call — record it here.>
|
|
862
1056
|
`
|
|
863
1057
|
}
|
|
864
1058
|
|
|
@@ -884,72 +1078,89 @@ function findPlaceholders(obj) {
|
|
|
884
1078
|
}
|
|
885
1079
|
|
|
886
1080
|
/**
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* -
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
* Returns the workspace UUID, or exits with an actionable message.
|
|
1081
|
+
* `figs link` — connect this `.figs/` to a workspace so `figs push` can publish.
|
|
1082
|
+
* The ONLY place remote config (endpoint + workspaceId) is written; `init` stays
|
|
1083
|
+
* purely local. `--workspace`:
|
|
1084
|
+
* - a UUID → written as-is; verified when logged in, else "unverified until push".
|
|
1085
|
+
* - a slug → resolved via the API (needs login).
|
|
1086
|
+
* - omitted → list the user's workspaces (needs login); with exactly one, link it.
|
|
1087
|
+
* Identity (agentId) is preserved untouched.
|
|
895
1088
|
*/
|
|
896
|
-
async function
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1089
|
+
async function link() {
|
|
1090
|
+
if (!existsSync(repoDir)) die(noFigsHint())
|
|
1091
|
+
const config = readJson(join(repoDir, "config.json"), {})
|
|
1092
|
+
if (!config.agentId) die("config missing agentId — run `figs init` first")
|
|
1093
|
+
// Honor --endpoint, then FIGS_ENDPOINT (dev), then any existing config, then
|
|
1094
|
+
// the baked default — resolveEndpoint() encodes that precedence.
|
|
1095
|
+
const endpoint = (flag("--endpoint") || resolveEndpoint()).replace(/\/+$/, "")
|
|
1096
|
+
const wsArg = flag("--workspace")
|
|
1097
|
+
const token = getToken()
|
|
1098
|
+
|
|
1099
|
+
let workspaceId
|
|
1100
|
+
if (wsArg && isUuid(wsArg)) {
|
|
1101
|
+
workspaceId = wsArg
|
|
1102
|
+
if (token) {
|
|
1103
|
+
// Verify access when we can; a network blip just defers it to first push.
|
|
1104
|
+
const r = await request("GET", "/api/workspaces", null, token)
|
|
1105
|
+
if (r.ok && !(r.data.workspaces ?? []).some((w) => w.id === wsArg)) {
|
|
1106
|
+
die(`workspace ${wsArg} isn't one you can access — run \`figs link\` (no flag) to list yours`)
|
|
1107
|
+
}
|
|
1108
|
+
} else {
|
|
1109
|
+
console.warn("figs: ! linked by UUID while logged out — unverified until your first `figs push`")
|
|
901
1110
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1111
|
+
} else if (wsArg) {
|
|
1112
|
+
// A slug — only the server can map it to a UUID.
|
|
1113
|
+
if (!token) {
|
|
1114
|
+
die("resolving a workspace slug needs `figs login` first — or pass the workspace UUID (from the app's settings)")
|
|
1115
|
+
}
|
|
1116
|
+
const r = await request("GET", "/api/workspaces", null, token)
|
|
1117
|
+
if (!r.ok) die(`could not resolve workspace "${wsArg}" (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
|
|
1118
|
+
const match = (r.data.workspaces ?? []).find((w) => w.slug === wsArg || w.id === wsArg)
|
|
1119
|
+
if (!match) die(`no workspace matching "${wsArg}" — run \`figs link\` (no flag) to list yours`)
|
|
1120
|
+
workspaceId = match.id
|
|
1121
|
+
} else {
|
|
1122
|
+
// Bare `figs link` — list; with exactly one, link it outright.
|
|
1123
|
+
if (!token) {
|
|
1124
|
+
die("run `figs login` first, then `figs link` to pick a workspace — or `figs link --workspace <uuid>` (no login needed)")
|
|
905
1125
|
}
|
|
1126
|
+
const r = await request("GET", "/api/workspaces", null, token)
|
|
1127
|
+
if (!r.ok) die(`could not list workspaces (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
|
|
906
1128
|
const list = r.data.workspaces ?? []
|
|
907
|
-
|
|
908
|
-
if (
|
|
909
|
-
|
|
1129
|
+
if (list.length === 0) die(`no workspaces yet — create one at ${endpoint}, then re-run \`figs link\``)
|
|
1130
|
+
if (list.length === 1) {
|
|
1131
|
+
workspaceId = list[0].id
|
|
1132
|
+
console.log(`figs: linking to ${list[0].slug} (${list[0].name})`)
|
|
1133
|
+
} else {
|
|
1134
|
+
console.log("figs: which workspace? re-run with one:")
|
|
1135
|
+
for (const w of list) console.log(` figs link --workspace ${w.slug} (${w.name})`)
|
|
1136
|
+
process.exit(1)
|
|
1137
|
+
}
|
|
910
1138
|
}
|
|
911
1139
|
|
|
912
|
-
//
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const r = await request("GET", "/api/workspaces", null, getToken())
|
|
921
|
-
if (!r.ok) die(`could not list workspaces (${r.status}): ${r.data.error ?? r.data.raw ?? ""}`)
|
|
922
|
-
const list = r.data.workspaces ?? []
|
|
923
|
-
if (list.length === 0) {
|
|
924
|
-
die(`no workspaces yet — create one at ${endpoint}, then re-run \`figs init --workspace <slug>\``)
|
|
925
|
-
}
|
|
926
|
-
if (list.length === 1) {
|
|
927
|
-
console.log(`figs: using workspace ${list[0].slug} (${list[0].name})`)
|
|
928
|
-
return list[0].id
|
|
929
|
-
}
|
|
930
|
-
console.log("figs: which workspace? re-run init with one of these:")
|
|
931
|
-
for (const w of list) console.log(` figs init --workspace ${w.slug} (${w.name})`)
|
|
932
|
-
process.exit(1)
|
|
1140
|
+
// Preserve identity; write the destination. config.json shape when linked:
|
|
1141
|
+
// { agentId, endpoint, workspaceId }.
|
|
1142
|
+
writeFileSync(
|
|
1143
|
+
join(repoDir, "config.json"),
|
|
1144
|
+
JSON.stringify({ agentId: config.agentId, endpoint, workspaceId }, null, 2) + "\n",
|
|
1145
|
+
)
|
|
1146
|
+
console.log(`figs: ✓ linked — workspace ${workspaceId} @ ${endpoint}`)
|
|
1147
|
+
console.log(" next: `figs push` publishes everything recorded so far")
|
|
933
1148
|
}
|
|
934
1149
|
|
|
935
1150
|
async function init() {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
//
|
|
940
|
-
// file the CLI owns. A re-init reuses the existing identity UUID so every
|
|
941
|
-
// runner of this repo pushes to the same agent.
|
|
1151
|
+
// Purely local — `init` never touches the network, so it can never need an
|
|
1152
|
+
// account. Idempotent: keep the existing identity AND any link fields (a
|
|
1153
|
+
// re-init must NOT unlink), and never clobber an authored charter/contract/
|
|
1154
|
+
// guide or the outbox.
|
|
942
1155
|
const existing = readJson(join(repoDir, "config.json"), null)
|
|
943
1156
|
const agentId = existing?.agentId || randomUUID()
|
|
944
1157
|
mkdirSync(repoDir, { recursive: true })
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
)
|
|
1158
|
+
const config = { agentId }
|
|
1159
|
+
if (existing?.endpoint) config.endpoint = existing.endpoint
|
|
1160
|
+
if (existing?.workspaceId) config.workspaceId = existing.workspaceId
|
|
1161
|
+
writeFileSync(join(repoDir, "config.json"), JSON.stringify(config, null, 2) + "\n")
|
|
949
1162
|
|
|
950
|
-
|
|
951
|
-
// repo a complete, ready-to-fill `.figs/`, and never clobbers an agent's
|
|
952
|
-
// authored charter/contract/guide or its activity outbox.
|
|
1163
|
+
const endpoint = resolveEndpoint()
|
|
953
1164
|
const created = []
|
|
954
1165
|
const ensure = (rel, contents) => {
|
|
955
1166
|
const p = join(repoDir, rel)
|
|
@@ -961,54 +1172,55 @@ async function init() {
|
|
|
961
1172
|
ensure(
|
|
962
1173
|
".gitignore",
|
|
963
1174
|
[
|
|
964
|
-
"# Figs — commit config.json + agent.json + CONTRACT.md
|
|
965
|
-
"#
|
|
1175
|
+
"# Figs — commit config.json + agent.json + CONTRACT.md.",
|
|
1176
|
+
"# The journal below is a machine-local outbox: records live on this machine;",
|
|
1177
|
+
"# the hosted app is the durable record once you `figs link` + `figs push`.",
|
|
966
1178
|
"runs.jsonl",
|
|
967
1179
|
"asks.jsonl",
|
|
1180
|
+
"messages.jsonl",
|
|
968
1181
|
"artifacts/",
|
|
969
1182
|
"credentials.json",
|
|
970
1183
|
"",
|
|
971
1184
|
].join("\n"),
|
|
972
1185
|
)
|
|
973
|
-
ensure("GUIDE.md", guideStub(endpoint))
|
|
974
1186
|
const name = basename(process.cwd())
|
|
975
1187
|
const charterCreated = ensure("agent.json", agentJsonStub(name))
|
|
976
1188
|
ensure("CONTRACT.md", contractStub(name))
|
|
977
1189
|
ensure("runs.jsonl", "")
|
|
978
1190
|
ensure("asks.jsonl", "")
|
|
1191
|
+
ensure("messages.jsonl", "")
|
|
979
1192
|
mkdirSync(join(repoDir, "artifacts"), { recursive: true })
|
|
980
1193
|
|
|
981
|
-
console.log(
|
|
982
|
-
`figs: ✓ .figs/ ready — config.json written (agentId ${agentId}, workspace ${workspaceId})`,
|
|
983
|
-
)
|
|
1194
|
+
console.log(`figs: ✓ .figs/ ready — you're operational locally (agentId ${agentId})`)
|
|
984
1195
|
if (created.length) console.log(` scaffolded: ${created.join(", ")}`)
|
|
985
1196
|
if (charterCreated) {
|
|
986
1197
|
console.log(
|
|
987
|
-
"
|
|
988
|
-
)
|
|
989
|
-
console.log(
|
|
990
|
-
" (`figs doctor` flags any you miss), then `figs doctor` && `figs push` to appear.",
|
|
1198
|
+
" next: fill in .figs/agent.json — replace the <…> placeholders (`figs doctor` checks them)",
|
|
991
1199
|
)
|
|
992
1200
|
} else {
|
|
993
1201
|
console.log(
|
|
994
|
-
"
|
|
1202
|
+
" your charter (.figs/agent.json) is already here — `figs doctor` to validate it",
|
|
995
1203
|
)
|
|
996
1204
|
}
|
|
997
1205
|
console.log(
|
|
998
|
-
"
|
|
1206
|
+
" record work: `figs report` / `figs checkpoint` · raise asks: `figs ask` · recover: `figs inbox`",
|
|
1207
|
+
)
|
|
1208
|
+
console.log(
|
|
1209
|
+
` publish to the hosted app (optional)${existing?.workspaceId ? " — already linked" : ": `figs link`"}, then \`figs push\``,
|
|
1210
|
+
)
|
|
1211
|
+
console.log(
|
|
1212
|
+
` Anchor Figs in the file you load each session (CLAUDE.md/AGENTS.md/…): paste the figs:begin block from ${endpoint}/llms.txt.`,
|
|
999
1213
|
)
|
|
1000
|
-
console.log(` figs:begin block from ${endpoint}/llms.txt, or future sessions forget Figs.`)
|
|
1001
1214
|
console.log(
|
|
1002
|
-
" Commit config.json + agent.json + CONTRACT.md
|
|
1215
|
+
" Commit config.json + agent.json + CONTRACT.md; never commit credentials.json.",
|
|
1003
1216
|
)
|
|
1004
|
-
console.log(` Full guide: ${endpoint}/llms.txt`)
|
|
1005
1217
|
}
|
|
1006
1218
|
|
|
1007
1219
|
// ====================== the writing verbs ===================================
|
|
1008
|
-
// report / ask /
|
|
1009
|
-
// first-class). The agent supplies content; the CLI stamps
|
|
1010
|
-
// validates with teaching errors, copies attachments, then
|
|
1011
|
-
// push as `figs push`.
|
|
1220
|
+
// report / checkpoint / ask / answer / close — sugar over the same files
|
|
1221
|
+
// (hand-writing stays first-class). The agent supplies content; the CLI stamps
|
|
1222
|
+
// id + real-clock ts, validates with teaching errors, copies attachments, then
|
|
1223
|
+
// (when linked) invokes the same push as `figs push`.
|
|
1012
1224
|
//
|
|
1013
1225
|
// NOTE — no session auto-capture (removed in 0.5.0). The CLI used to infer a
|
|
1014
1226
|
// `session` trace (runtime/model/tokens) from "the newest transcript on this
|
|
@@ -1019,15 +1231,47 @@ async function init() {
|
|
|
1019
1231
|
// from the runtime's own records at work-time.
|
|
1020
1232
|
|
|
1021
1233
|
function requireFigs() {
|
|
1022
|
-
if (!existsSync(repoDir)) die(
|
|
1234
|
+
if (!existsSync(repoDir)) die(noFigsHint())
|
|
1023
1235
|
const config = readJson(join(repoDir, "config.json"), {})
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1236
|
+
// Identity is all a writing verb needs — workspaceId is only required to
|
|
1237
|
+
// publish (push/link). A repo without it is in deliberate local mode.
|
|
1238
|
+
if (!config.agentId) die("config missing agentId — run `figs init`")
|
|
1027
1239
|
}
|
|
1028
1240
|
function appendJsonl(name, obj) {
|
|
1029
1241
|
appendFileSync(join(repoDir, name), JSON.stringify(obj) + "\n")
|
|
1030
1242
|
}
|
|
1243
|
+
|
|
1244
|
+
// ---------- reference checks (warn, never block) ----------------------------
|
|
1245
|
+
// A dangling link is damage-limited; a blocked verb is not — so reference
|
|
1246
|
+
// checks WARN, they don't die. (Closing/answering a nonexistent ask DOES die —
|
|
1247
|
+
// that lives with those verbs.) Under the topology rule the local journal is
|
|
1248
|
+
// complete, so these checks are trustworthy.
|
|
1249
|
+
|
|
1250
|
+
/** Announce whether a stable --id opened a new record or folded onto one. */
|
|
1251
|
+
function announceFold(kind, id, isNew, suffix = "") {
|
|
1252
|
+
console.log(
|
|
1253
|
+
isNew
|
|
1254
|
+
? `figs: new ${kind} opened: ${id}${suffix}`
|
|
1255
|
+
: `figs: folded onto existing ${kind} ${id}`,
|
|
1256
|
+
)
|
|
1257
|
+
}
|
|
1258
|
+
/** --unit should name a charter unit; warn on a typo (only once units exist). */
|
|
1259
|
+
function warnUnknownUnit(unit) {
|
|
1260
|
+
if (!unit) return
|
|
1261
|
+
const units = (readJson(join(repoDir, "agent.json"), {}).units ?? [])
|
|
1262
|
+
.map((u) => u?.id)
|
|
1263
|
+
.filter(Boolean)
|
|
1264
|
+
if (units.length && !units.includes(unit)) {
|
|
1265
|
+
console.warn(`figs: ! --unit "${unit}" isn't one of your charter units (${units.join(", ")}) — typo?`)
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/** --run should name a job in this journal; warn on a dangling link. */
|
|
1269
|
+
function warnUnknownRun(runId) {
|
|
1270
|
+
if (!runId) return
|
|
1271
|
+
if (!foldById(readJsonl("runs.jsonl")).some((r) => r.id === runId)) {
|
|
1272
|
+
console.warn(`figs: ! --run "${runId}" isn't a job in this journal — typo? this link will dangle`)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1031
1275
|
/** Copy attachments into artifacts/ — ext + size checks; immutable once there. */
|
|
1032
1276
|
function attachFiles(paths) {
|
|
1033
1277
|
const names = []
|
|
@@ -1039,7 +1283,7 @@ function attachFiles(paths) {
|
|
|
1039
1283
|
}
|
|
1040
1284
|
const bytes = readFileSync(p)
|
|
1041
1285
|
if (bytes.length > ARTIFACT_MAX) {
|
|
1042
|
-
die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the
|
|
1286
|
+
die(`--attach: ${basename(p)} is ${(bytes.length / 1048576).toFixed(1)} MB — over the ${ARTIFACT_MAX / 1048576} MB cap; compress or split it`)
|
|
1043
1287
|
}
|
|
1044
1288
|
const name = basename(p)
|
|
1045
1289
|
const dest = join(repoDir, "artifacts", name)
|
|
@@ -1072,7 +1316,11 @@ function warnEatenDollar(...texts) {
|
|
|
1072
1316
|
}
|
|
1073
1317
|
}
|
|
1074
1318
|
|
|
1075
|
-
// ---------- the inbox (the DOWN
|
|
1319
|
+
// ---------- the inbox + show (the DOWN view — pure local reads) -------------
|
|
1320
|
+
// Everything here reads local files: in-flight jobs (runs.jsonl), open asks
|
|
1321
|
+
// (asks.jsonl), reply threads (messages.jsonl). No win-logic — the agent gets
|
|
1322
|
+
// the complete, time-ordered truth and judges. (The soft messages-only
|
|
1323
|
+
// down-sync when linked is layered on top in a later step.)
|
|
1076
1324
|
|
|
1077
1325
|
/** Relative time for inbox lines — rough on purpose. */
|
|
1078
1326
|
function agoStr(iso) {
|
|
@@ -1081,295 +1329,462 @@ function agoStr(iso) {
|
|
|
1081
1329
|
if (mins < 60 * 48) return `${Math.round(mins / 60)}h ago`
|
|
1082
1330
|
return `${Math.round(mins / 1440)}d ago`
|
|
1083
1331
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
if (soft) return null
|
|
1090
|
-
die("config missing agentId — run `figs init`")
|
|
1091
|
-
}
|
|
1092
|
-
const r = await request("GET", `/api/inbox?agent=${config.agentId}`)
|
|
1093
|
-
if (!r.ok) {
|
|
1094
|
-
if (soft) return null
|
|
1095
|
-
die(`inbox failed (${r.status || "network"}): ${r.data.error ?? r.data.raw ?? ""}`)
|
|
1096
|
-
}
|
|
1097
|
-
return r.data
|
|
1332
|
+
/** Replies for an ask, oldest-first (messages accumulate; order by ts). */
|
|
1333
|
+
function repliesFor(messages, askId) {
|
|
1334
|
+
return messages
|
|
1335
|
+
.filter((m) => m.ask === askId)
|
|
1336
|
+
.sort((a, b) => new Date(a.ts) - new Date(b.ts))
|
|
1098
1337
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
function
|
|
1338
|
+
/** One reply, verbatim — never paraphrase a human's words. `(transcribed)`
|
|
1339
|
+
* marks a chat reply (self-reported); app replies are attested by mint origin. */
|
|
1340
|
+
function messageLine(m) {
|
|
1102
1341
|
const head =
|
|
1103
|
-
|
|
1104
|
-
? `${
|
|
1105
|
-
: `answered by ${
|
|
1106
|
-
const
|
|
1342
|
+
m.kind === "verdict"
|
|
1343
|
+
? `${String(m.verdict).replace(/-/g, " ")} by ${m.by}`
|
|
1344
|
+
: `answered by ${m.by}`
|
|
1345
|
+
const grade = m.source === "app" ? "" : " (transcribed)"
|
|
1346
|
+
const body = [m.chosen ? `→ "${m.chosen}"` : null, m.text ? `"${m.text}"` : null]
|
|
1107
1347
|
.filter(Boolean)
|
|
1108
1348
|
.join(" · ")
|
|
1109
|
-
return `${head} · ${agoStr(
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
`\n nothing left to do → figs resolve ${a.id}${chosen}` +
|
|
1126
|
-
`\n real work → do the job, figs report it under its own --id, then figs resolve ${a.id}${chosen} --note 'job <id>'`
|
|
1127
|
-
)
|
|
1349
|
+
return `${head}${grade} · ${agoStr(m.ts)}${body ? `\n ${body}` : ""}`
|
|
1350
|
+
}
|
|
1351
|
+
/** Attachments on a record — the new `attachments[]`, falling back to the old
|
|
1352
|
+
* run `artifact`/`artifacts` and ask `refs` shapes (forward-compatible reads). */
|
|
1353
|
+
function attachmentsOf(rec) {
|
|
1354
|
+
if (Array.isArray(rec.attachments)) return rec.attachments
|
|
1355
|
+
return [
|
|
1356
|
+
...[rec.artifact, ...(rec.artifacts ?? [])].filter(Boolean),
|
|
1357
|
+
...(rec.refs ?? []).map((r) => r?.artifact).filter(Boolean),
|
|
1358
|
+
]
|
|
1359
|
+
}
|
|
1360
|
+
/** The newest reply on an ask → the exact next command. */
|
|
1361
|
+
function nextMove(ask, replies) {
|
|
1362
|
+
const last = replies[replies.length - 1]
|
|
1363
|
+
if (!last) {
|
|
1364
|
+
return "waiting on your human — relay the ask to them in chat; record their reply with `figs answer`"
|
|
1128
1365
|
}
|
|
1129
|
-
if (last.kind === "verdict" && last.verdict === "
|
|
1130
|
-
return `revise, then re-raise on the same id: figs ask ${
|
|
1366
|
+
if (last.kind === "verdict" && last.verdict === "changes-requested") {
|
|
1367
|
+
return `changes requested — revise, then re-raise on the same id: figs ask ${ask.type ?? "sign-off"} --id ${ask.id} --title '…' …`
|
|
1131
1368
|
}
|
|
1132
|
-
if (last.
|
|
1133
|
-
|
|
1134
|
-
const note = last.text ? ` --note '…'` : ""
|
|
1135
|
-
return `act on the answer (real work → figs report it under its own --id), then: figs resolve ${a.id} --chosen '${last.chosen}'${note}`
|
|
1369
|
+
if (last.kind === "verdict" && last.verdict === "rejected") {
|
|
1370
|
+
return `declined — acknowledge it: figs close ${ask.id}`
|
|
1136
1371
|
}
|
|
1137
|
-
return `act on
|
|
1372
|
+
return `act on it (real work → figs report it under its own --id), then: figs close ${ask.id}${"" /* --run <job> when work was done */}`
|
|
1138
1373
|
}
|
|
1139
1374
|
|
|
1140
|
-
/**
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
if (existsSync(dest)) {
|
|
1167
|
-
if (readFileSync(dest).equals(bytes)) {
|
|
1168
|
-
console.log(`figs: ✓ artifacts/${name} (already present)`)
|
|
1169
|
-
} else {
|
|
1170
|
-
console.warn(
|
|
1171
|
-
`figs: ! artifacts/${name} exists locally with different content — left untouched (the published copy stays one fetch away)`,
|
|
1172
|
-
)
|
|
1173
|
-
}
|
|
1174
|
-
continue
|
|
1175
|
-
}
|
|
1176
|
-
mkdirSync(join(repoDir, "artifacts"), { recursive: true })
|
|
1177
|
-
writeFileSync(dest, bytes)
|
|
1178
|
-
console.log(`figs: ✓ artifacts/${name} (fetched, hash ok)`)
|
|
1375
|
+
/**
|
|
1376
|
+
* The one thing that flows DOWN (spec v2 §8): this agent's human messages,
|
|
1377
|
+
* merged into `messages.jsonl` (append-if-id-absent). Soft — it never blocks
|
|
1378
|
+
* the inbox; returns a status the caller surfaces. Runs only when linked + a
|
|
1379
|
+
* token is present. The trust grade is the server's to enforce (it forces
|
|
1380
|
+
* `source:"chat"` on transcriptions); the CLI just folds messages in by id.
|
|
1381
|
+
*/
|
|
1382
|
+
async function syncMessages() {
|
|
1383
|
+
const config = readJson(join(repoDir, "config.json"), {})
|
|
1384
|
+
if (!config.workspaceId) return { ran: false, reason: "local" }
|
|
1385
|
+
const token = getToken()
|
|
1386
|
+
if (!token) return { ran: false, reason: "not logged in" }
|
|
1387
|
+
const base = resolveEndpoint()
|
|
1388
|
+
let res
|
|
1389
|
+
try {
|
|
1390
|
+
res = await fetchT(
|
|
1391
|
+
`${base}/api/messages?agent=${config.agentId}`,
|
|
1392
|
+
{ headers: authHeaders(token) },
|
|
1393
|
+
SYNC_TIMEOUT_MS,
|
|
1394
|
+
)
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
return { ran: false, reason: `couldn't reach ${base} (${netReason(e)})` }
|
|
1397
|
+
}
|
|
1398
|
+
if (!res.ok) {
|
|
1399
|
+
const t = await res.text().catch(() => "")
|
|
1400
|
+
return { ran: false, reason: `sync failed (${res.status})${t ? `: ${t.slice(0, 120)}` : ""}` }
|
|
1179
1401
|
}
|
|
1402
|
+
let data
|
|
1403
|
+
try {
|
|
1404
|
+
data = await res.json()
|
|
1405
|
+
} catch {
|
|
1406
|
+
return { ran: false, reason: "sync returned non-JSON" }
|
|
1407
|
+
}
|
|
1408
|
+
const incoming = Array.isArray(data.messages) ? data.messages : []
|
|
1409
|
+
const have = new Set(readJsonl("messages.jsonl").map((m) => m.id))
|
|
1410
|
+
let added = 0
|
|
1411
|
+
for (const m of incoming) {
|
|
1412
|
+
if (!m?.id || have.has(m.id)) continue // immutable + id-keyed → dedup is exact
|
|
1413
|
+
appendJsonl("messages.jsonl", m)
|
|
1414
|
+
have.add(m.id)
|
|
1415
|
+
added++
|
|
1416
|
+
}
|
|
1417
|
+
return { ran: true, added, truncated: Boolean(data.truncated) }
|
|
1180
1418
|
}
|
|
1181
1419
|
|
|
1182
1420
|
/**
|
|
1183
|
-
* `figs inbox` — session start.
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1421
|
+
* `figs inbox` — session start. When linked, a soft messages-only down-sync
|
|
1422
|
+
* runs first (degradable; loud on failure/truncation), then everything is a
|
|
1423
|
+
* local read: open asks grouped by reply state + unfinished jobs, each with its
|
|
1424
|
+
* next command. With an id: routes to `figs show <id>` (the magnifier).
|
|
1187
1425
|
*/
|
|
1188
1426
|
async function inboxCmd() {
|
|
1189
1427
|
requireFigs()
|
|
1190
|
-
|
|
1191
|
-
const data = await fetchInbox()
|
|
1192
|
-
const items = data.asks ?? []
|
|
1193
|
-
const askId = positional()
|
|
1428
|
+
if (positional()) return showCmd() // `figs inbox <id>` → show
|
|
1194
1429
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
}
|
|
1430
|
+
// Down-sync first (unless --no-sync): a stale inbox that says "nothing needs
|
|
1431
|
+
// you" is the worst failure mode, so we touch the network here — softly.
|
|
1432
|
+
const sync = hasFlag("--no-sync") ? { ran: false, reason: "skipped" } : await syncMessages()
|
|
1199
1433
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
if (a.refs?.length) {
|
|
1226
|
-
console.log(`\n Attached artifacts (restoring to .figs/artifacts/):`)
|
|
1227
|
-
await fetchRefs(config, a.refs)
|
|
1228
|
-
}
|
|
1229
|
-
console.log(`\n → next: ${nextMove(a)}`)
|
|
1230
|
-
return
|
|
1434
|
+
const asks = foldById(readJsonl("asks.jsonl"))
|
|
1435
|
+
const messages = readJsonl("messages.jsonl")
|
|
1436
|
+
const jobs = foldById(readJsonl("runs.jsonl")).filter((r) => r.state === "in-flight")
|
|
1437
|
+
const open = asks
|
|
1438
|
+
.filter((a) => (a.status ?? "open") === "open")
|
|
1439
|
+
.map((a) => ({ a, replies: repliesFor(messages, a.id) }))
|
|
1440
|
+
const answered = open.filter(
|
|
1441
|
+
({ replies }) => replies.length && replies[replies.length - 1].verdict !== "changes-requested",
|
|
1442
|
+
)
|
|
1443
|
+
const changes = open.filter(
|
|
1444
|
+
({ replies }) => replies.length && replies[replies.length - 1].verdict === "changes-requested",
|
|
1445
|
+
)
|
|
1446
|
+
const quiet = open.filter(({ replies }) => !replies.length)
|
|
1447
|
+
|
|
1448
|
+
// Orphan replies: a synced message whose ask isn't in this copy's journal
|
|
1449
|
+
// (a fresh clone). Surface it — never silently drop a human's reply.
|
|
1450
|
+
const localAskIds = new Set(asks.map((a) => a.id))
|
|
1451
|
+
const orphanAskIds = [...new Set(messages.map((m) => m.ask).filter((id) => id && !localAskIds.has(id)))]
|
|
1452
|
+
|
|
1453
|
+
// Warnings the agent can act on without scraping stderr.
|
|
1454
|
+
const warnings = []
|
|
1455
|
+
if (sync.ran && sync.truncated) {
|
|
1456
|
+
warnings.push("sync incomplete — the server returned a partial set; some replies may be missing")
|
|
1457
|
+
} else if (!sync.ran && sync.reason !== "local" && sync.reason !== "skipped") {
|
|
1458
|
+
warnings.push(`showing local state — couldn't sync (${sync.reason})`)
|
|
1231
1459
|
}
|
|
1232
1460
|
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1461
|
+
if (hasFlag("--json")) {
|
|
1462
|
+
return printJson(
|
|
1463
|
+
{
|
|
1464
|
+
asks: open.map(({ a, replies }) => ({
|
|
1465
|
+
id: a.id, type: a.type, title: a.title, status: a.status ?? "open", replies,
|
|
1466
|
+
})),
|
|
1467
|
+
jobs,
|
|
1468
|
+
orphanAsks: orphanAskIds,
|
|
1469
|
+
sync,
|
|
1470
|
+
},
|
|
1471
|
+
{ warnings },
|
|
1472
|
+
)
|
|
1235
1473
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1474
|
+
|
|
1475
|
+
for (const w of warnings) console.warn(`figs: ! ${w}`)
|
|
1476
|
+
|
|
1477
|
+
if (open.length === 0 && jobs.length === 0 && orphanAskIds.length === 0) {
|
|
1238
1478
|
console.log("figs: ✓ inbox empty — no open asks, no unfinished jobs, nothing needs you")
|
|
1239
1479
|
return
|
|
1240
1480
|
}
|
|
1241
|
-
const rejected = items.filter((a) => a.status === "rejected")
|
|
1242
|
-
const answered = items.filter((a) => a.status === "open" && a.events.length > 0)
|
|
1243
|
-
const quiet = items.filter((a) => a.status === "open" && a.events.length === 0)
|
|
1244
1481
|
console.log(
|
|
1245
|
-
`figs: inbox — ${answered.length} answered · ${
|
|
1482
|
+
`figs: inbox — ${answered.length} answered · ${changes.length} need revision · ${quiet.length} waiting on your human` +
|
|
1246
1483
|
(jobs.length ? ` · ${jobs.length} job${jobs.length === 1 ? "" : "s"} in flight` : ""),
|
|
1247
1484
|
)
|
|
1248
|
-
const printItem = (a) => {
|
|
1249
|
-
|
|
1250
|
-
console.log(
|
|
1251
|
-
|
|
1252
|
-
console.log(` → ${nextMove(a)}${a.events.length > 1 ? ` (full thread: figs inbox ${a.id})` : ""}`)
|
|
1485
|
+
const printItem = ({ a, replies }) => {
|
|
1486
|
+
console.log(`\n ${a.id} · ${a.type ?? "?"} — ${a.title ?? ""}`)
|
|
1487
|
+
if (replies.length) console.log(` ${messageLine(replies[replies.length - 1])}`)
|
|
1488
|
+
console.log(` → ${nextMove(a, replies)}${replies.length > 1 ? ` (full thread: figs show ${a.id})` : ""}`)
|
|
1253
1489
|
}
|
|
1254
|
-
for (const
|
|
1490
|
+
for (const item of [...answered, ...changes]) printItem(item)
|
|
1255
1491
|
if (quiet.length) {
|
|
1256
|
-
console.log(`\n Waiting on your human
|
|
1257
|
-
for (const a of quiet) console.log(` · ${a.id} · ${a.type} — ${a.title} (raised ${agoStr(a.ts)})`)
|
|
1492
|
+
console.log(`\n Waiting on your human — relay these in chat, then \`figs answer\` their reply:`)
|
|
1493
|
+
for (const { a } of quiet) console.log(` · ${a.id} · ${a.type ?? "?"} — ${a.title ?? ""} (raised ${a.ts ? agoStr(a.ts) : "—"})`)
|
|
1258
1494
|
}
|
|
1259
1495
|
if (jobs.length) {
|
|
1260
1496
|
console.log(`\n Unfinished jobs — in flight (your past self's work; finish or settle):`)
|
|
1261
1497
|
for (const j of jobs) {
|
|
1498
|
+
console.log(` · ${j.id}${j.result ? ` — ${j.result}` : ""} (last update ${agoStr(j.ts)})`)
|
|
1499
|
+
console.log(` → continue it (\`figs checkpoint --id ${j.id}\` as you go); \`figs report --id ${j.id}\` settles it`)
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (orphanAskIds.length) {
|
|
1503
|
+
console.log(`\n Replies on asks not in this copy's journal (raised elsewhere — full context in the app):`)
|
|
1504
|
+
for (const id of orphanAskIds) console.log(` · ${id} → figs show ${id}`)
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* `figs show <id>` — the magnifier, pure local. Auto-detects an ask or a job
|
|
1510
|
+
* and prints the folded record + its thread/trail + attachment names. No
|
|
1511
|
+
* network: an attachment not on disk is noted (view it in the app), never
|
|
1512
|
+
* downloaded — local is the source of truth.
|
|
1513
|
+
*/
|
|
1514
|
+
function showCmd() {
|
|
1515
|
+
requireFigs()
|
|
1516
|
+
const id = positional()
|
|
1517
|
+
if (!id) die("show needs an id: figs show <ask-id|job-id>")
|
|
1518
|
+
const asks = foldById(readJsonl("asks.jsonl"))
|
|
1519
|
+
const runs = foldById(readJsonl("runs.jsonl"))
|
|
1520
|
+
const messages = readJsonl("messages.jsonl")
|
|
1521
|
+
const ask = asks.find((a) => a.id === id)
|
|
1522
|
+
const replies = repliesFor(messages, id)
|
|
1523
|
+
|
|
1524
|
+
if (ask || replies.length) {
|
|
1525
|
+
if (hasFlag("--json")) return printJson({ ask: ask ?? null, replies })
|
|
1526
|
+
if (ask) {
|
|
1527
|
+
console.log(`figs: ${ask.title ?? id}`)
|
|
1262
1528
|
console.log(
|
|
1263
|
-
`
|
|
1529
|
+
` ${ask.type ?? "?"} · ${ask.status ?? "open"}${ask.to ? ` · for the ${ask.to}` : ""}${ask.ts ? ` · raised ${agoStr(ask.ts)}` : ""}`,
|
|
1264
1530
|
)
|
|
1531
|
+
if (ask.found) console.log(`\n What it found:\n ${ask.found}`)
|
|
1532
|
+
if (ask.need) console.log(`\n What it needs:\n ${ask.need}`)
|
|
1533
|
+
if (ask.options?.length) {
|
|
1534
|
+
console.log(`\n Options (a reply cites one verbatim):`)
|
|
1535
|
+
for (const o of ask.options) console.log(` · ${o}`)
|
|
1536
|
+
}
|
|
1537
|
+
if (ask.details?.length) {
|
|
1538
|
+
console.log(`\n Details:`)
|
|
1539
|
+
for (const d of ask.details) console.log(` ${d.l}: ${d.v}`)
|
|
1540
|
+
}
|
|
1541
|
+
// Union across all of the ask's raw lines (raise/revise/close) — folding
|
|
1542
|
+
// would drop an intermediate; attachments belong to their moment.
|
|
1543
|
+
showAttachmentNames(
|
|
1544
|
+
readJsonl("asks.jsonl").filter((a) => a.id === id).flatMap(attachmentsOf),
|
|
1545
|
+
)
|
|
1546
|
+
} else {
|
|
1547
|
+
console.log(`figs: ask ${id} — not in this copy's journal (full context in the app); showing the replies that are here`)
|
|
1548
|
+
}
|
|
1549
|
+
if (replies.length) {
|
|
1550
|
+
console.log(`\n THE THREAD (your humans' words, verbatim):`)
|
|
1551
|
+
for (const m of replies) console.log(` · ${messageLine(m)}`)
|
|
1552
|
+
} else {
|
|
1553
|
+
console.log(`\n No reply yet.`)
|
|
1554
|
+
}
|
|
1555
|
+
console.log(`\n → next: ${nextMove(ask ?? { id }, replies)}`)
|
|
1556
|
+
return
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const run = runs.find((r) => r.id === id)
|
|
1560
|
+
if (run) {
|
|
1561
|
+
const trail = readJsonl("runs.jsonl").filter((r) => r.id === id)
|
|
1562
|
+
if (hasFlag("--json")) return printJson({ run, trail })
|
|
1563
|
+
console.log(`figs: job ${id}${run.unit ? ` · ${run.unit}` : ""}${run.period ? ` · ${run.period}` : ""}`)
|
|
1564
|
+
console.log(` ${run.state ?? "settled"}${run.status ? ` · ${run.status}` : ""} — ${run.result ?? ""}`)
|
|
1565
|
+
console.log(`\n Trail (each checkpoint/report at the moment it happened):`)
|
|
1566
|
+
for (const l of trail) {
|
|
1567
|
+
const atts = attachmentsOf(l)
|
|
1265
1568
|
console.log(
|
|
1266
|
-
`
|
|
1569
|
+
` · ${l.ts ? agoStr(l.ts) : "—"} [${l.state ?? "settled"}] ${l.result ?? ""}${atts.length ? ` · ${atts.join(", ")}` : ""}`,
|
|
1267
1570
|
)
|
|
1268
1571
|
}
|
|
1572
|
+
showAttachmentNames(trail.flatMap(attachmentsOf))
|
|
1573
|
+
return
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
die(`"${id}" isn't a run or an ask in this journal — \`figs inbox\` lists what's here`)
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Print attachment names (deduped), noting any not present on this machine. */
|
|
1580
|
+
function showAttachmentNames(names) {
|
|
1581
|
+
const unique = [...new Set(names)]
|
|
1582
|
+
if (!unique.length) return
|
|
1583
|
+
console.log(`\n Attachments:`)
|
|
1584
|
+
for (const name of unique) {
|
|
1585
|
+
const here = existsSync(join(repoDir, "artifacts", name))
|
|
1586
|
+
console.log(` · ${name}${here ? "" : " (not in this copy — view it in the app)"}`)
|
|
1269
1587
|
}
|
|
1270
1588
|
}
|
|
1271
1589
|
|
|
1272
|
-
// ---------- the
|
|
1590
|
+
// ---------- figs answer — record the human's reply (you run it, not them) ----
|
|
1273
1591
|
/**
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
* attribution by mechanism, not testimony. An ask found on the server but
|
|
1278
|
-
* missing locally is appended first (its own bytes coming home), so the fold
|
|
1279
|
-
* lands on a complete record. Offline or any failure → today's self-reported
|
|
1280
|
-
* `via: "human"` path, unchanged.
|
|
1592
|
+
* `figs answer <ask-id>` transcribes a human's out-of-band reply into
|
|
1593
|
+
* `messages.jsonl`. It's a plain writing verb (append + auto-push). `--by` names
|
|
1594
|
+
* the HUMAN; the CLI stamps id/ts/source — the agent never authors plumbing.
|
|
1281
1595
|
*/
|
|
1282
|
-
async function
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1596
|
+
async function answerCmd() {
|
|
1597
|
+
requireFigs()
|
|
1598
|
+
const askId = positional()
|
|
1599
|
+
if (!askId) {
|
|
1600
|
+
die("answer needs the ask id: figs answer <ask-id> --by '<who>' (--chosen … | --text … | --approve|--request-changes|--reject)")
|
|
1601
|
+
}
|
|
1602
|
+
if (!askExistsLocally(askId)) {
|
|
1603
|
+
die(`ask "${askId}" isn't in this journal — \`figs inbox\` shows your open asks. You record a human's reply to an ask YOU raised; you don't answer one that doesn't exist here.`)
|
|
1604
|
+
}
|
|
1605
|
+
const by = flag("--by")
|
|
1606
|
+
if (!by) die("answer needs --by '<who said it>' — the human's name, not yours (you're transcribing their words)")
|
|
1607
|
+
|
|
1608
|
+
const verdicts = [
|
|
1609
|
+
["--approve", "approved"],
|
|
1610
|
+
["--request-changes", "changes-requested"],
|
|
1611
|
+
["--reject", "rejected"],
|
|
1612
|
+
].filter(([f]) => hasFlag(f))
|
|
1613
|
+
if (verdicts.length > 1) die("pick one verdict: --approve | --request-changes | --reject")
|
|
1614
|
+
|
|
1615
|
+
const chosen = flag("--chosen")
|
|
1616
|
+
const text = flag("--text")
|
|
1617
|
+
const msg = {
|
|
1618
|
+
id: genId("msg"),
|
|
1619
|
+
kind: verdicts.length ? "verdict" : "answer",
|
|
1620
|
+
ask: askId,
|
|
1621
|
+
by,
|
|
1622
|
+
ts: nowIso(),
|
|
1623
|
+
source: "chat", // transcribed here; app-minted replies arrive via inbox sync
|
|
1624
|
+
}
|
|
1625
|
+
if (verdicts.length) msg.verdict = verdicts[0][1]
|
|
1626
|
+
if (chosen) msg.chosen = chosen
|
|
1627
|
+
if (text) msg.text = text
|
|
1628
|
+
if (msg.kind === "answer" && !chosen && !text) {
|
|
1629
|
+
die("answer needs --chosen '<option verbatim>' or --text '<what they said>' (or a verdict flag for a sign-off)")
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// --chosen must quote one of the ask's options verbatim (when the ask is local).
|
|
1633
|
+
if (chosen) {
|
|
1634
|
+
const ask = foldById(readJsonl("asks.jsonl")).find((a) => a.id === askId)
|
|
1635
|
+
const options = ask?.options ?? []
|
|
1636
|
+
if (options.length && !options.includes(chosen)) {
|
|
1637
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
1638
|
+
const near = options.find((o) => norm(o) === norm(chosen))
|
|
1639
|
+
if (near) die(`--chosen must quote the option verbatim — did you mean "${near}"?`)
|
|
1640
|
+
die(
|
|
1641
|
+
`--chosen "${chosen}" doesn't match any of the ask's options:\n` +
|
|
1642
|
+
options.map((o) => ` · ${o}`).join("\n") +
|
|
1643
|
+
"\n (quote one verbatim, or use --text for a free-text reply)",
|
|
1644
|
+
)
|
|
1645
|
+
}
|
|
1288
1646
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
delete record.events
|
|
1303
|
-
delete record.updatedAt
|
|
1304
|
-
if (record.resolution == null) delete record.resolution
|
|
1305
|
-
appendJsonl("asks.jsonl", record)
|
|
1306
|
-
warnings.push(`fetched "${askId}" from Figs (raised elsewhere) — recorded locally before closing`)
|
|
1307
|
-
ask = record
|
|
1308
|
-
} else if (!ask) {
|
|
1309
|
-
warnings.push(
|
|
1310
|
-
`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`,
|
|
1647
|
+
|
|
1648
|
+
// Double-transcription guard: a reply made in the app syncs down on its own,
|
|
1649
|
+
// so re-typing it here would mint a duplicate (a weaker-grade copy).
|
|
1650
|
+
const priorSame = readJsonl("messages.jsonl").some(
|
|
1651
|
+
(m) =>
|
|
1652
|
+
m.ask === askId &&
|
|
1653
|
+
((chosen && m.chosen === chosen) ||
|
|
1654
|
+
(text && m.text === text) ||
|
|
1655
|
+
(msg.verdict && m.verdict === msg.verdict)),
|
|
1656
|
+
)
|
|
1657
|
+
if (priorSame) {
|
|
1658
|
+
console.warn(
|
|
1659
|
+
"figs: ! a reply on this ask already carries that — replies made in the app sync down automatically; `figs answer` is only for replies that exist nowhere but your chat",
|
|
1311
1660
|
)
|
|
1312
1661
|
}
|
|
1313
1662
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1663
|
+
warnEatenDollar(msg.chosen, msg.text)
|
|
1664
|
+
const issues = validateMessage(msg)
|
|
1665
|
+
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1666
|
+
appendJsonl("messages.jsonl", msg)
|
|
1667
|
+
console.log(`figs: ✓ reply recorded — ${JSON.stringify(msg)}`)
|
|
1668
|
+
console.log(
|
|
1669
|
+
`figs: transcribed ${by}'s reply. Act on it, then \`figs close ${askId}\` (it cites this) — real work first → report it, then \`figs close ${askId} --run <job>\`.`,
|
|
1670
|
+
)
|
|
1671
|
+
await autoPush()
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ---------- figs close — the one closing verb (derives from the reply) -------
|
|
1675
|
+
/** The old resolve surface → teaching errors (the migration path). A function,
|
|
1676
|
+
* not a top-level const: the CLI dispatches during module eval, so a const
|
|
1677
|
+
* declared after the dispatch block would still be in its TDZ when called. */
|
|
1678
|
+
function closeCutFlags() {
|
|
1679
|
+
return {
|
|
1680
|
+
"--chosen":
|
|
1681
|
+
"replies are recorded with `figs answer <id> --chosen '…' --by '<who>'`; close only ends the ask (it cites the reply on file automatically)",
|
|
1682
|
+
"--by":
|
|
1683
|
+
"the answerer is named on the reply: `figs answer <id> … --by '<who>'`; close cites it automatically",
|
|
1684
|
+
"--answer-id":
|
|
1685
|
+
"close auto-cites the NEWEST reply on file — you don't name it",
|
|
1686
|
+
"--rejected":
|
|
1687
|
+
"record the human's reject as a verdict first: `figs answer <id> --reject --by '<who>'`, then `figs close <id>` derives 'rejected' from it",
|
|
1324
1688
|
}
|
|
1689
|
+
}
|
|
1690
|
+
/** The teaching menu when an ask has no reply on file yet. */
|
|
1691
|
+
function closeMenu(askId) {
|
|
1692
|
+
return (
|
|
1693
|
+
`no reply on file for "${askId}" — close needs to know what happened:\n` +
|
|
1694
|
+
` · answered in chat? → record it first: figs answer ${askId} --by '<who>' (--chosen … | --text …)\n` +
|
|
1695
|
+
` · YOU retracted it? → figs close ${askId} --withdrawn\n` +
|
|
1696
|
+
` · cleared on its own? → figs close ${askId} --note '<what happened>' (recorded via: self)`
|
|
1697
|
+
)
|
|
1698
|
+
}
|
|
1699
|
+
async function closeCmd() {
|
|
1700
|
+
requireFigs()
|
|
1701
|
+
const askId = positional()
|
|
1702
|
+
if (!askId) die("close needs the ask id: figs close <ask-id> [--note …] [--run …] [--withdrawn]")
|
|
1703
|
+
// The old resolve surface → teaching errors (the migration path).
|
|
1704
|
+
for (const [f, help] of Object.entries(closeCutFlags())) {
|
|
1705
|
+
if (hasFlag(f) || flag(f) !== undefined) die(`\`${f}\` is gone — ${help}`)
|
|
1706
|
+
}
|
|
1707
|
+
if (!askExistsLocally(askId)) {
|
|
1708
|
+
die(`ask "${askId}" isn't in this journal — \`figs inbox\` shows your open asks`)
|
|
1709
|
+
}
|
|
1710
|
+
const withdrawn = hasFlag("--withdrawn")
|
|
1711
|
+
const note = flag("--note")
|
|
1712
|
+
const runRef = flag("--run")
|
|
1713
|
+
warnUnknownRun(runRef)
|
|
1714
|
+
const attached = attachFiles(flagAll("--attach")) // proof of what was done
|
|
1715
|
+
|
|
1716
|
+
// The newest reply for this ask drives the close (messages accumulate; sort by ts).
|
|
1717
|
+
const replies = readJsonl("messages.jsonl")
|
|
1718
|
+
.filter((m) => m.ask === askId)
|
|
1719
|
+
.sort((a, b) => new Date(a.ts) - new Date(b.ts))
|
|
1720
|
+
const newest = replies[replies.length - 1]
|
|
1325
1721
|
|
|
1326
1722
|
const resolution = {}
|
|
1327
|
-
|
|
1328
|
-
if (
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
if (
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
// verdict (verdict + chosen together). Match on the text, not the kind.
|
|
1342
|
-
const match = chosen ? [...events].reverse().find((e) => e.chosen === chosen) : null
|
|
1343
|
-
const cited = match ?? events[events.length - 1]
|
|
1723
|
+
let status
|
|
1724
|
+
if (withdrawn) {
|
|
1725
|
+
status = "withdrawn" // the agent's own act — no reply needed, nobody acted
|
|
1726
|
+
} else if (!newest) {
|
|
1727
|
+
if (!note) die(closeMenu(askId))
|
|
1728
|
+
status = "resolved" // cleared on its own — self-reported
|
|
1729
|
+
resolution.via = "self"
|
|
1730
|
+
} else if (newest.kind === "verdict" && newest.verdict === "changes-requested") {
|
|
1731
|
+
die(
|
|
1732
|
+
`changes were requested on "${askId}" — revise and re-raise on the same id (\`figs ask sign-off --id ${askId} …\`); this isn't a close`,
|
|
1733
|
+
)
|
|
1734
|
+
} else {
|
|
1735
|
+
// an answer, an approval, or a rejection — all derive a close, citing it
|
|
1736
|
+
status = newest.verdict === "rejected" ? "rejected" : "resolved"
|
|
1344
1737
|
resolution.via = "figs"
|
|
1345
|
-
resolution.answer =
|
|
1346
|
-
if (
|
|
1738
|
+
resolution.answer = newest.id
|
|
1739
|
+
if (newest.by) resolution.by = newest.by
|
|
1740
|
+
if (newest.chosen) resolution.chosen = newest.chosen
|
|
1347
1741
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
//
|
|
1351
|
-
// the record's raise `ts` (folds are field-level merges); the reader stamps
|
|
1352
|
-
// its own receipt (`closedAt`) at ingest — two clocks, claim vs receipt.
|
|
1742
|
+
if (note) resolution.note = note
|
|
1743
|
+
if (runRef) resolution.run = runRef
|
|
1744
|
+
// Machine-stamped, inside resolution so the fold can't collide with the raise ts.
|
|
1353
1745
|
resolution.ts = nowIso()
|
|
1354
1746
|
|
|
1355
1747
|
warnEatenDollar(resolution.chosen, resolution.note)
|
|
1356
|
-
const line = {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1748
|
+
const line = { id: askId, status, resolution }
|
|
1749
|
+
if (attached.length) line.attachments = attached // pinned to the close moment
|
|
1750
|
+
appendJsonl("asks.jsonl", line)
|
|
1751
|
+
const cite =
|
|
1752
|
+
resolution.answer && newest
|
|
1753
|
+
? ` — ${status === "rejected" ? "acknowledging" : "acting on"} ${newest.by ?? "the"} reply${newest.chosen ? ` '${newest.chosen}'` : ""}`
|
|
1754
|
+
: ""
|
|
1755
|
+
console.log(`figs: ✓ ask ${askId} ${status}${cite}`)
|
|
1756
|
+
await autoPush()
|
|
1362
1757
|
}
|
|
1363
1758
|
|
|
1364
|
-
/**
|
|
1759
|
+
/**
|
|
1760
|
+
* The verbs' shared final step. The write already succeeded locally before this
|
|
1761
|
+
* runs, so the record is safe no matter what happens here — that's why every
|
|
1762
|
+
* not-published outcome is exit 2 (retry `figs push`), never exit 1.
|
|
1763
|
+
*
|
|
1764
|
+
* - local mode (not linked) → don't attempt; calm note; exit 0.
|
|
1765
|
+
* - --no-push → deliberately deferred; exit 0.
|
|
1766
|
+
* - linked, no token → exit 2 + the canonical line.
|
|
1767
|
+
* - linked, push fails → exit 2 + the canonical line.
|
|
1768
|
+
* - linked, push ok → exit 0.
|
|
1769
|
+
*/
|
|
1365
1770
|
async function autoPush() {
|
|
1366
1771
|
if (hasFlag("--no-push")) {
|
|
1367
1772
|
console.log("figs: saved locally (--no-push) — `figs push` publishes it")
|
|
1368
1773
|
return
|
|
1369
1774
|
}
|
|
1370
|
-
if (!(
|
|
1371
|
-
console.
|
|
1372
|
-
|
|
1775
|
+
if (!isLinked()) {
|
|
1776
|
+
console.log("figs: local mode — `figs link` to publish")
|
|
1777
|
+
return
|
|
1778
|
+
}
|
|
1779
|
+
if (!getToken()) {
|
|
1780
|
+
console.warn("figs: ! not logged in — `figs login`, then `figs push`")
|
|
1781
|
+
console.warn(`figs: ! ${RECORDED_LOCALLY}`)
|
|
1782
|
+
process.exitCode = 2
|
|
1783
|
+
return
|
|
1784
|
+
}
|
|
1785
|
+
if (!(await doPush()).ok) {
|
|
1786
|
+
console.warn(`figs: ! ${RECORDED_LOCALLY}`)
|
|
1787
|
+
process.exitCode = 2
|
|
1373
1788
|
}
|
|
1374
1789
|
}
|
|
1375
1790
|
|
|
@@ -1381,7 +1796,11 @@ async function reportCmd() {
|
|
|
1381
1796
|
}
|
|
1382
1797
|
// state is verb-stamped, never typed: report settles the job (checkpoint
|
|
1383
1798
|
// marks it in-flight). The settling fold overrides any earlier checkpoint.
|
|
1384
|
-
const
|
|
1799
|
+
const idGiven = flag("--id")
|
|
1800
|
+
const id = idGiven || genId("r")
|
|
1801
|
+
// Before the append, so "new" means new-to-this-outbox (the typo catch).
|
|
1802
|
+
const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
|
|
1803
|
+
const run = { id, ts: nowIso(), result, state: "settled" }
|
|
1385
1804
|
const unit = flag("--unit")
|
|
1386
1805
|
if (unit) run.unit = unit
|
|
1387
1806
|
const period = flag("--period")
|
|
@@ -1391,13 +1810,16 @@ async function reportCmd() {
|
|
|
1391
1810
|
const trigger = flag("--trigger")
|
|
1392
1811
|
if (trigger) run.session = { trigger }
|
|
1393
1812
|
const attached = attachFiles(flagAll("--attach"))
|
|
1394
|
-
if (attached.length
|
|
1395
|
-
else if (attached.length > 1) run.artifacts = attached
|
|
1813
|
+
if (attached.length) run.attachments = attached
|
|
1396
1814
|
warnEatenDollar(run.result)
|
|
1815
|
+
warnUnknownUnit(unit)
|
|
1397
1816
|
const issues = validateRun(run)
|
|
1398
1817
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1399
1818
|
appendJsonl("runs.jsonl", run)
|
|
1400
1819
|
console.log(`figs: ✓ run recorded — ${JSON.stringify(run)}`)
|
|
1820
|
+
// Announce new-vs-fold only for an explicit --id — that's where a typo means
|
|
1821
|
+
// "I meant to continue a job but opened a sibling" (auto-ids are always new).
|
|
1822
|
+
if (idGiven) announceFold("job", id, isNew, " (settled)")
|
|
1401
1823
|
// Teaching, never a gate: a settled job with open asks citing it is the
|
|
1402
1824
|
// normal tail-of-job pattern (the ask owns the waiting) — but if the job's
|
|
1403
1825
|
// OUTCOME depends on an answer, in-flight is the honest state. Local fold
|
|
@@ -1445,25 +1867,37 @@ async function checkpointCmd() {
|
|
|
1445
1867
|
const trigger = flag("--trigger")
|
|
1446
1868
|
if (trigger) run.session = { trigger }
|
|
1447
1869
|
const attached = attachFiles(flagAll("--attach"))
|
|
1448
|
-
if (attached.length
|
|
1449
|
-
else if (attached.length > 1) run.artifacts = attached
|
|
1870
|
+
if (attached.length) run.attachments = attached
|
|
1450
1871
|
warnEatenDollar(run.result)
|
|
1872
|
+
warnUnknownUnit(unit)
|
|
1451
1873
|
const issues = validateRun(run)
|
|
1452
1874
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1875
|
+
// A new sitting on a SETTLED id is a legitimate reopen (the warn→ok story),
|
|
1876
|
+
// but usually means "I should have used a new id" — nudge without blocking.
|
|
1877
|
+
const settledBefore = foldById(readJsonl("runs.jsonl")).some(
|
|
1878
|
+
(r) => r.id === id && r.state === "settled",
|
|
1879
|
+
)
|
|
1453
1880
|
appendJsonl("runs.jsonl", run)
|
|
1454
1881
|
console.log(`figs: ✓ checkpoint recorded — ${JSON.stringify(run)}`)
|
|
1455
1882
|
if (isNew) {
|
|
1456
1883
|
console.log(
|
|
1457
1884
|
`figs: new job opened: ${id} (in flight) — checkpoint as you go; \`figs report --id ${id}\` settles it`,
|
|
1458
1885
|
)
|
|
1886
|
+
} else if (settledBefore) {
|
|
1887
|
+
console.warn(
|
|
1888
|
+
`figs: ! reopening a settled job (${id}) — continue only if it's truly the same job; new work wants a new id`,
|
|
1889
|
+
)
|
|
1890
|
+
} else {
|
|
1891
|
+
console.log(`figs: folded onto existing job ${id} (in flight)`)
|
|
1459
1892
|
}
|
|
1460
1893
|
await autoPush()
|
|
1461
|
-
// A checkpoint exists to survive a crash —
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
|
|
1894
|
+
// A checkpoint exists to survive a crash — but only when this repo intends to
|
|
1895
|
+
// publish (linked). In local mode the file IS the protection, so the calm
|
|
1896
|
+
// "local mode" line autoPush printed is the whole truth. When linked, a failed
|
|
1897
|
+
// push (exit 2) means the crash-stub never reached the server — say so loudly.
|
|
1898
|
+
if (process.exitCode === 2) {
|
|
1465
1899
|
console.warn(
|
|
1466
|
-
`figs: ! this checkpoint is NOT protecting the job yet — nothing reached the server. Run \`figs push\` before continuing the work.`,
|
|
1900
|
+
`figs: ! this checkpoint is NOT protecting the job remotely yet — nothing reached the server. Run \`figs push\` before continuing the work.`,
|
|
1467
1901
|
)
|
|
1468
1902
|
}
|
|
1469
1903
|
}
|
|
@@ -1528,11 +1962,17 @@ async function askCmd() {
|
|
|
1528
1962
|
die('--run takes the explicit run id (no "last" — another session may have reported since); `figs report` prints the id of what it wrote')
|
|
1529
1963
|
}
|
|
1530
1964
|
if (runRef) ask.run = runRef
|
|
1965
|
+
warnUnknownRun(runRef)
|
|
1966
|
+
warnUnknownUnit(ask.unit)
|
|
1531
1967
|
const attached = attachFiles(flagAll("--attach"))
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1968
|
+
// Unified attachments[] (bare file names). Accept attachments[] from --stdin,
|
|
1969
|
+
// and normalize a legacy refs[] into it (the filename is the label now).
|
|
1970
|
+
const baseAtts = Array.isArray(base.attachments)
|
|
1971
|
+
? base.attachments
|
|
1972
|
+
: (base.refs ?? []).map((r) => r?.artifact).filter(Boolean)
|
|
1973
|
+
delete ask.refs
|
|
1974
|
+
if (attached.length || baseAtts.length) ask.attachments = [...baseAtts, ...attached]
|
|
1975
|
+
if (ask.type === "sign-off" && !ask.attachments?.length) {
|
|
1536
1976
|
console.warn(
|
|
1537
1977
|
"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>",
|
|
1538
1978
|
)
|
|
@@ -1552,42 +1992,37 @@ async function askCmd() {
|
|
|
1552
1992
|
)
|
|
1553
1993
|
const issues = validateAsk(ask)
|
|
1554
1994
|
if (issues.length) die(`not written:\n ${issues.join("\n ")}`)
|
|
1995
|
+
const askIdGiven = flag("--id") ?? base.id
|
|
1996
|
+
const askIsNew = !foldById(readJsonl("asks.jsonl")).some((a) => a.id === ask.id)
|
|
1555
1997
|
appendJsonl("asks.jsonl", ask)
|
|
1556
1998
|
console.log(`figs: ✓ ask raised — ${JSON.stringify(ask)}`)
|
|
1999
|
+
// Announce new-vs-fold for an explicit id — re-raising the same id (a revision)
|
|
2000
|
+
// folds; a typo'd id silently opens a sibling ask. Catch it.
|
|
2001
|
+
if (askIdGiven) announceFold("ask", ask.id, askIsNew)
|
|
1557
2002
|
if (!ask.to) {
|
|
1558
2003
|
console.log("figs: tip: address asks with --to manager|builder so they route to the right person")
|
|
1559
2004
|
}
|
|
1560
2005
|
await autoPush()
|
|
2006
|
+
// Local: the agent must put the ask in front of the human — nothing else
|
|
2007
|
+
// surfaces it. When linked, the app surfaces it too.
|
|
1561
2008
|
console.log(
|
|
1562
|
-
|
|
2009
|
+
isLinked()
|
|
2010
|
+
? "figs: show this ask to your human (it's also in the app); record their reply with `figs answer`, read it back with `figs inbox`"
|
|
2011
|
+
: "figs: show this ask to your human in chat now — nothing else surfaces it. Record their reply with `figs answer`.",
|
|
1563
2012
|
)
|
|
1564
2013
|
}
|
|
1565
2014
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
by: flag("--by"),
|
|
1573
|
-
note: flag("--note"),
|
|
1574
|
-
withdrawn: hasFlag("--withdrawn"),
|
|
1575
|
-
rejected: hasFlag("--rejected"),
|
|
1576
|
-
})
|
|
1577
|
-
for (const w of warnings) console.warn(`figs: ! ${w}`)
|
|
1578
|
-
appendJsonl("asks.jsonl", line)
|
|
1579
|
-
console.log(`figs: ✓ ask ${askId} ${line.status} — ${JSON.stringify(line)}`)
|
|
1580
|
-
await autoPush()
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
/** Validate the local .figs/ payload against the spec — no write, no push. */
|
|
2015
|
+
/**
|
|
2016
|
+
* `figs doctor` — the spec's conformance validator. Local validation is
|
|
2017
|
+
* normative and **runs account-free**: a fresh, no-token, offline repo gets a
|
|
2018
|
+
* full pass and exit 0. Server validation (when linked + logged in) is an
|
|
2019
|
+
* additive second opinion, never the gate.
|
|
2020
|
+
*/
|
|
1584
2021
|
async function doctor() {
|
|
1585
2022
|
// Local checks first (no token/network needed) — fail fast and offline.
|
|
1586
|
-
if (!existsSync(repoDir)) die(
|
|
2023
|
+
if (!existsSync(repoDir)) die(noFigsHint())
|
|
1587
2024
|
const config = readJson(join(repoDir, "config.json"), {})
|
|
1588
|
-
if (!config.
|
|
1589
|
-
die("config missing workspaceId/agentId — run `figs init`")
|
|
1590
|
-
}
|
|
2025
|
+
if (!config.agentId) die("config missing agentId — run `figs init`")
|
|
1591
2026
|
const agentJson = readJson(join(repoDir, "agent.json"), null)
|
|
1592
2027
|
if (!agentJson) die("missing .figs/agent.json — author it first (see .figs/GUIDE.md)")
|
|
1593
2028
|
|
|
@@ -1596,9 +2031,13 @@ async function doctor() {
|
|
|
1596
2031
|
// are>" to the org chart. This is the "not ready to push" signal.
|
|
1597
2032
|
const placeholders = findPlaceholders(agentJson)
|
|
1598
2033
|
if (placeholders.length) {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2034
|
+
if (JSON_OUT) {
|
|
2035
|
+
printJson({ valid: false, placeholders }, { ok: false })
|
|
2036
|
+
} else {
|
|
2037
|
+
console.log("figs: ✗ .figs/agent.json still has template placeholders — fill these in before pushing:")
|
|
2038
|
+
for (const p of placeholders) console.log(` ${p.path}: ${p.value}`)
|
|
2039
|
+
console.log(" (replace the <…> values by reading your own repo, then re-run `figs doctor`)")
|
|
2040
|
+
}
|
|
1602
2041
|
process.exit(1)
|
|
1603
2042
|
}
|
|
1604
2043
|
|
|
@@ -1607,26 +2046,41 @@ async function doctor() {
|
|
|
1607
2046
|
const localIssues = validateOutbox(
|
|
1608
2047
|
foldById(readJsonl("runs.jsonl")),
|
|
1609
2048
|
foldById(readJsonl("asks.jsonl")),
|
|
2049
|
+
readJsonl("messages.jsonl"),
|
|
1610
2050
|
)
|
|
1611
2051
|
if (localIssues.length) {
|
|
1612
|
-
|
|
1613
|
-
|
|
2052
|
+
if (JSON_OUT) {
|
|
2053
|
+
printJson({ valid: false, scope: "local", issues: localIssues }, { ok: false })
|
|
2054
|
+
} else {
|
|
2055
|
+
console.log("figs: ✗ local validation issues:")
|
|
2056
|
+
for (const i of localIssues) console.log(` ${i}`)
|
|
2057
|
+
}
|
|
1614
2058
|
process.exit(1)
|
|
1615
2059
|
}
|
|
1616
2060
|
|
|
1617
|
-
|
|
2061
|
+
// Local conformance passed. Server validation is the additive layer — it
|
|
2062
|
+
// needs both a destination (linked) and a token; without either, we're done,
|
|
2063
|
+
// and that is a full, legitimate pass (the spec's conformance is local).
|
|
2064
|
+
if (!config.workspaceId || !getToken()) {
|
|
2065
|
+
const why = !config.workspaceId ? "not linked" : "not logged in"
|
|
2066
|
+
if (JSON_OUT) return printJson({ valid: true, scope: "local", serverValidation: "skipped", reason: why })
|
|
2067
|
+
console.log(`figs: ✓ .figs/ passes local conformance — server validation skipped (${why})`)
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
1618
2070
|
const r = await api("POST", "/api/validate", {
|
|
1619
2071
|
workspaceId: config.workspaceId,
|
|
1620
2072
|
agent: { ...agentJson, id: config.agentId },
|
|
1621
2073
|
runs: foldById(readJsonl("runs.jsonl")),
|
|
1622
2074
|
asks: foldById(readJsonl("asks.jsonl")),
|
|
2075
|
+
messages: readJsonl("messages.jsonl"),
|
|
1623
2076
|
})
|
|
1624
2077
|
if (r.ok) {
|
|
2078
|
+
if (JSON_OUT) return printJson({ valid: true, scope: "server" })
|
|
1625
2079
|
console.log("figs: ✓ .figs/ is valid — ready to push")
|
|
1626
2080
|
return
|
|
1627
2081
|
}
|
|
1628
2082
|
if (JSON_OUT) {
|
|
1629
|
-
|
|
2083
|
+
printJson({ valid: false, scope: "server", issues: r.issues }, { ok: false })
|
|
1630
2084
|
} else {
|
|
1631
2085
|
console.log("figs: ✗ validation issues:")
|
|
1632
2086
|
for (const i of r.issues) {
|
|
@@ -1640,9 +2094,14 @@ async function doctor() {
|
|
|
1640
2094
|
process.exit(1)
|
|
1641
2095
|
}
|
|
1642
2096
|
|
|
1643
|
-
/**
|
|
2097
|
+
/**
|
|
2098
|
+
* `figs push` — the bare transport. Exit 1 on a **structural** failure (fix
|
|
2099
|
+
* something: not linked, no agent.json, bad data), exit 2 on a **transient** one
|
|
2100
|
+
* (network/server — the records are safe; retry later).
|
|
2101
|
+
*/
|
|
1644
2102
|
async function push() {
|
|
1645
|
-
|
|
2103
|
+
const r = await doPush()
|
|
2104
|
+
if (!r.ok) process.exit(r.retryable ? 2 : 1)
|
|
1646
2105
|
}
|
|
1647
2106
|
|
|
1648
2107
|
/**
|
|
@@ -1651,29 +2110,31 @@ async function push() {
|
|
|
1651
2110
|
* append (auto-push IS push — one transport, many entry points). Runs the same
|
|
1652
2111
|
* local checks as `figs doctor` first, so a malformed hand-written line never
|
|
1653
2112
|
* reaches the server as a confusing 4xx. Prints its own errors and returns
|
|
1654
|
-
*
|
|
2113
|
+
* `{ ok, retryable }` — callers map that to an exit code.
|
|
1655
2114
|
*/
|
|
1656
2115
|
async function doPush() {
|
|
1657
|
-
const fail = (msg) => {
|
|
2116
|
+
const fail = (msg, retryable = false) => {
|
|
1658
2117
|
console.error(`figs: ✗ push: ${msg}`)
|
|
1659
|
-
return false
|
|
2118
|
+
return { ok: false, retryable }
|
|
2119
|
+
}
|
|
2120
|
+
if (!existsSync(repoDir)) return fail(noFigsHint())
|
|
2121
|
+
const config = readJson(join(repoDir, "config.json"), {})
|
|
2122
|
+
if (!config.agentId) return fail("config missing agentId — run `figs init`")
|
|
2123
|
+
if (!config.workspaceId) {
|
|
2124
|
+
return fail("not linked — local records are safe; `figs link` to publish")
|
|
1660
2125
|
}
|
|
1661
2126
|
const token = getToken()
|
|
1662
2127
|
if (!token) return fail("not logged in — run `figs login` (or set FIGS_TOKEN)")
|
|
1663
2128
|
await checkVersion({ hardFail: true })
|
|
1664
|
-
if (!existsSync(repoDir)) return fail("no .figs/ here — run `figs init` first")
|
|
1665
|
-
const config = readJson(join(repoDir, "config.json"), {})
|
|
1666
|
-
if (!config.workspaceId || !config.agentId) {
|
|
1667
|
-
return fail("config missing workspaceId/agentId — run `figs init`")
|
|
1668
|
-
}
|
|
1669
|
-
const endpoint =
|
|
1670
|
-
process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
|
|
1671
2129
|
|
|
2130
|
+
const endpoint = process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
|
|
1672
2131
|
const agentJson = readJson(join(repoDir, "agent.json"), null)
|
|
1673
|
-
if (!agentJson) return fail("missing .figs/agent.json")
|
|
2132
|
+
if (!agentJson) return fail("missing .figs/agent.json — author it, then `figs doctor`")
|
|
1674
2133
|
const agent = { ...agentJson, id: config.agentId }
|
|
1675
2134
|
const runs = foldById(readJsonl("runs.jsonl"))
|
|
1676
2135
|
const asks = foldById(readJsonl("asks.jsonl"))
|
|
2136
|
+
// Messages are immutable events — sent whole (no fold); the server dedupes by id.
|
|
2137
|
+
const messages = readJsonl("messages.jsonl")
|
|
1677
2138
|
|
|
1678
2139
|
// Local pre-flight — fail fast, offline, with teaching errors.
|
|
1679
2140
|
const placeholders = findPlaceholders(agentJson)
|
|
@@ -1682,7 +2143,7 @@ async function doPush() {
|
|
|
1682
2143
|
`agent.json still has template placeholders (${placeholders.map((p) => p.path).join(", ")}) — fill them in; \`figs doctor\` lists them`,
|
|
1683
2144
|
)
|
|
1684
2145
|
}
|
|
1685
|
-
const issues = validateOutbox(runs, asks)
|
|
2146
|
+
const issues = validateOutbox(runs, asks, messages)
|
|
1686
2147
|
if (issues.length) return fail(`local validation failed:\n ${issues.join("\n ")}`)
|
|
1687
2148
|
|
|
1688
2149
|
const base = endpoint.replace(/\/+$/, "")
|
|
@@ -1690,39 +2151,43 @@ async function doPush() {
|
|
|
1690
2151
|
try {
|
|
1691
2152
|
res = await fetchT(`${base}/api/ingest`, {
|
|
1692
2153
|
method: "POST",
|
|
1693
|
-
headers: { "content-type": "application/json",
|
|
1694
|
-
body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
|
|
2154
|
+
headers: { "content-type": "application/json", ...authHeaders(token) },
|
|
2155
|
+
body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks, messages }),
|
|
1695
2156
|
})
|
|
1696
2157
|
} catch (e) {
|
|
1697
|
-
|
|
2158
|
+
// Network/timeout — transient; the records are safe locally.
|
|
2159
|
+
return fail(`cannot reach ${base} (${netReason(e)})`, true)
|
|
1698
2160
|
}
|
|
1699
2161
|
const text = await res.text()
|
|
1700
|
-
|
|
2162
|
+
// 4xx = the payload is wrong (re-pushing won't help) → structural; 5xx = transient.
|
|
2163
|
+
if (!res.ok) return fail(`server rejected it (${res.status}): ${text}`, res.status >= 500)
|
|
1701
2164
|
console.log(
|
|
1702
|
-
`figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
|
|
2165
|
+
`figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks, ${messages.length} messages`,
|
|
1703
2166
|
)
|
|
1704
2167
|
// The wow-moment link — relay this to your human so they can see the agent.
|
|
1705
2168
|
console.log(` view at ${base}/w/${config.workspaceId}`)
|
|
1706
2169
|
|
|
1707
|
-
|
|
2170
|
+
// The spine landed; an artifact-stage failure is transient/oversize — retry.
|
|
2171
|
+
return (await pushArtifacts(base, token, config))
|
|
2172
|
+
? { ok: true }
|
|
2173
|
+
: { ok: false, retryable: true }
|
|
1708
2174
|
}
|
|
1709
2175
|
|
|
1710
2176
|
/**
|
|
1711
|
-
* Upload the files
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
* referencing an artifact it didn't actually produce, not a publish failure.
|
|
2177
|
+
* Upload the files attached anywhere in the journal. Collected from the RAW
|
|
2178
|
+
* lines (not the folded records) so a file attached at a checkpoint isn't lost
|
|
2179
|
+
* when a later report folds over it — attachments belong to their moment. The
|
|
2180
|
+
* spine ingest is JSON-only; files go to a separate content-addressed endpoint
|
|
2181
|
+
* (an unchanged file is skipped server-side), base64-encoded so any type
|
|
2182
|
+
* survives. A **server rejection** (auth/size) is fatal; a **missing local
|
|
2183
|
+
* file** is only a warning (the agent referenced something it didn't produce).
|
|
1719
2184
|
*/
|
|
1720
|
-
async function pushArtifacts(base, token, config
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2185
|
+
async function pushArtifacts(base, token, config) {
|
|
2186
|
+
const names = [
|
|
2187
|
+
...new Set(
|
|
2188
|
+
[...readJsonl("runs.jsonl"), ...readJsonl("asks.jsonl")].flatMap(attachmentsOf),
|
|
2189
|
+
),
|
|
2190
|
+
]
|
|
1726
2191
|
if (names.length === 0) return true
|
|
1727
2192
|
|
|
1728
2193
|
let uploaded = 0
|
|
@@ -1741,7 +2206,7 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
1741
2206
|
try {
|
|
1742
2207
|
res = await fetchT(`${base}/api/artifacts/upload`, {
|
|
1743
2208
|
method: "POST",
|
|
1744
|
-
headers: { "content-type": "application/json",
|
|
2209
|
+
headers: { "content-type": "application/json", ...authHeaders(token) },
|
|
1745
2210
|
body: JSON.stringify({
|
|
1746
2211
|
workspaceId: config.workspaceId,
|
|
1747
2212
|
agentId: config.agentId,
|
|
@@ -1782,18 +2247,35 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
1782
2247
|
function readJsonl(name) {
|
|
1783
2248
|
const p = join(repoDir, name)
|
|
1784
2249
|
if (!existsSync(p)) return []
|
|
2250
|
+
const lines = readFileSync(p, "utf8").split("\n")
|
|
2251
|
+
// A process killed mid-append leaves a half-written FINAL line — the journal
|
|
2252
|
+
// must survive the agent dying while writing it. Tolerate exactly one broken
|
|
2253
|
+
// last (non-empty) line: warn + skip, keep the rest. A broken INTERIOR line is
|
|
2254
|
+
// real corruption — die loudly. (`name` files are append-only, so only the
|
|
2255
|
+
// tail can be torn.)
|
|
2256
|
+
let lastNonEmpty = -1
|
|
2257
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2258
|
+
if (lines[i].trim()) {
|
|
2259
|
+
lastNonEmpty = i
|
|
2260
|
+
break
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
1785
2263
|
const out = []
|
|
1786
|
-
|
|
1787
|
-
.
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2264
|
+
lines.forEach((line, i) => {
|
|
2265
|
+
const s = line.trim()
|
|
2266
|
+
if (!s) return
|
|
2267
|
+
try {
|
|
2268
|
+
out.push(JSON.parse(s))
|
|
2269
|
+
} catch {
|
|
2270
|
+
if (i === lastNonEmpty) {
|
|
2271
|
+
console.warn(
|
|
2272
|
+
`figs: ! last line of .figs/${name} is broken — likely a crash mid-write; skipped it (re-record that entry). The rest is intact.`,
|
|
2273
|
+
)
|
|
2274
|
+
} else {
|
|
1794
2275
|
die(`malformed JSON in .figs/${name} line ${i + 1}: ${s.slice(0, 80)}`)
|
|
1795
2276
|
}
|
|
1796
|
-
}
|
|
2277
|
+
}
|
|
2278
|
+
})
|
|
1797
2279
|
return out
|
|
1798
2280
|
}
|
|
1799
2281
|
|