@figs-so/cli 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SPEC.md +23 -5
- package/figs.mjs +108 -23
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -119,9 +119,17 @@ crash mid-job leaves a visible, recoverable stub. A **report** files the outcome
|
|
|
119
119
|
report with no prior checkpoint is a job **born settled** (the single-sitting case). Nothing *external*
|
|
120
120
|
ever closes a run — only the agent's own report settles its job.
|
|
121
121
|
|
|
122
|
+
**Folding is local; the lifecycle still publishes.** The folded record is the job's *current* state; its
|
|
123
|
+
history is the sequence of lines. On push the CLI sends **both** — the folded record (the row) *and* the
|
|
124
|
+
raw fold lines (`runEvents`, §8) — so a reader rebuilds the timeline (opened → checkpoints → settled)
|
|
125
|
+
even when a whole account-free history is published in a single first push (which would otherwise collapse
|
|
126
|
+
to one "settled" snapshot). Each line's `eventId` is the dedupe key: one timeline entry per fold,
|
|
127
|
+
idempotent across re-pushes.
|
|
128
|
+
|
|
122
129
|
| Field | Type | Req | Meaning |
|
|
123
130
|
|---|---|:--:|---|
|
|
124
131
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
132
|
+
| `eventId` | string | | Machine-minted per-fold id (`evt-…`) — **plumbing, never typed** (like the agent UUID, §4). Survives folding: a job's id collapses many lines into one record, but each line keeps its own. Lets a reader rebuild the per-fold timeline from the raw lines published as `runEvents` (§8). Absent on hand-authored/legacy lines — they fold to one published snapshot, as before. |
|
|
125
133
|
| `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran. Machine-stamped by the CLI, never typed. |
|
|
126
134
|
| `unit` | string | | The `Unit.id` this run is about. |
|
|
127
135
|
| `period` | string | | |
|
|
@@ -257,16 +265,26 @@ minimum CLI version requires Bearer.)
|
|
|
257
265
|
{
|
|
258
266
|
"workspaceId": "<uuid>", // from config.json
|
|
259
267
|
"agent": { /* agent.json */ },
|
|
260
|
-
"runs":
|
|
268
|
+
"runs": [ /* runs.jsonl folded by id */ ], // optional — the current state per job (the row)
|
|
269
|
+
"runEvents": [ /* runs.jsonl raw fold lines, eventId-bearing only */ ], // optional — rebuilds the timeline
|
|
261
270
|
"asks": [ /* asks.jsonl */ ], // optional
|
|
262
271
|
"messages": [ /* messages.jsonl */ ], // optional — transcribed replies the reader lacks
|
|
263
272
|
"confirmRename": true // optional — `figs push --rename`: confirm a real name change
|
|
264
273
|
}
|
|
265
274
|
```
|
|
266
|
-
2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
275
|
+
2. **Each attached file the server lacks** → `POST {endpoint}/api/artifacts/upload`, base64-encoded,
|
|
276
|
+
hash-verified. The `/api/ingest` response returns the agent's **artifact manifest**
|
|
277
|
+
(`{ "<name>": "sha256:<hex>" }` for every stored file); the CLI re-hashes its local files and uploads
|
|
278
|
+
only those whose hash differs or are absent — **content-addressed on the sha256 of the raw file
|
|
279
|
+
bytes** (hashed before base64), so unchanged bytes never cross the wire. A response without the
|
|
280
|
+
manifest field triggers a full upload (older readers omit it; older CLIs ignore it). Attachments stay
|
|
281
|
+
immutable (§7): same name + different bytes is refused `409`. `figs push --reupload` forces a full
|
|
282
|
+
re-send (recovery if a stored object ever drifts from its row).
|
|
283
|
+
|
|
284
|
+
The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it
|
|
285
|
+
**rebuilds each run's timeline from `runEvents`, one entry per fold, deduped by `eventId`** (a run with no
|
|
286
|
+
`runEvents` — an older CLI, or legacy/hand-authored lines without an `eventId` — keeps one timeline entry
|
|
287
|
+
per content-changing push, the prior behavior); it never deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
|
|
270
288
|
server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
|
|
271
289
|
and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
|
|
272
290
|
agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
|
package/figs.mjs
CHANGED
|
@@ -211,19 +211,21 @@ const COMMANDS = {
|
|
|
211
211
|
answer: {
|
|
212
212
|
args: "<ask-id> --by <who> (--chosen <option> | --text <reply> | --approve | --request-changes | --reject)",
|
|
213
213
|
flags: [
|
|
214
|
-
"--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push",
|
|
214
|
+
"--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push", "--force",
|
|
215
215
|
],
|
|
216
|
-
desc: "
|
|
216
|
+
desc: "transcribe a human's out-of-band reply (chat/console, not the app), verbatim (you run this, not them)",
|
|
217
217
|
more: [
|
|
218
|
-
"
|
|
219
|
-
"
|
|
218
|
+
"For a reply your user gave you in chat/console rather than the app — fine even when",
|
|
219
|
+
"linked (it records as a 'relayed' reply, the honest lower-trust grade). Humans don't",
|
|
220
|
+
"type commands; you transcribe what they said. --by names the HUMAN who said it, not you.",
|
|
220
221
|
"question → --chosen '<option verbatim>' (checked against the ask's options) or",
|
|
221
222
|
"--text '<what they said>'. sign-off → --approve | --request-changes | --reject",
|
|
222
223
|
"(a qualified verdict may also carry --chosen). Transcribe verbatim — never summarize,",
|
|
223
224
|
"never author the reply yourself.",
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
225
|
+
"Just don't RE-transcribe a reply that already came through the app: it's attested and",
|
|
226
|
+
"syncs down on its own (figs inbox), and a re-typed copy would downgrade it — so answer",
|
|
227
|
+
"REFUSES if the ask already has an app reply (--force only for a separate out-of-band one).",
|
|
228
|
+
"When inbox already shows the reply, skip straight to `figs close <ask-id>`.",
|
|
227
229
|
],
|
|
228
230
|
eg: "figs answer acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
|
|
229
231
|
},
|
|
@@ -280,16 +282,20 @@ const COMMANDS = {
|
|
|
280
282
|
},
|
|
281
283
|
push: {
|
|
282
284
|
args: "",
|
|
283
|
-
flags: ["--rename"],
|
|
285
|
+
flags: ["--rename", "--reupload"],
|
|
284
286
|
desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
|
|
285
287
|
more: [
|
|
286
288
|
"Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected.",
|
|
287
289
|
"The writing verbs (report/ask/resolve) call this automatically — you only need it",
|
|
288
290
|
"after hand-editing files, after --no-push, or to retry a failed auto-push.",
|
|
291
|
+
"Artifacts already on the server (same content) are skipped — their bytes aren't",
|
|
292
|
+
"re-sent; only new or changed files upload.",
|
|
289
293
|
"--rename: confirm a genuine name change on an already-registered agent (one",
|
|
290
294
|
"time). The server refuses a name that doesn't match the registered one — it's",
|
|
291
295
|
"the fingerprint of a copied folder; if that's what happened, rotate identity",
|
|
292
296
|
"instead with `rm -rf .figs && figs init`, don't --rename.",
|
|
297
|
+
"--reupload: re-send every artifact even if the server already has it (bypasses",
|
|
298
|
+
"the content-hash skip) — recovery if a stored file ever drifts from its record.",
|
|
293
299
|
],
|
|
294
300
|
eg: "figs push",
|
|
295
301
|
},
|
|
@@ -366,7 +372,7 @@ function positional() {
|
|
|
366
372
|
return undefined
|
|
367
373
|
}
|
|
368
374
|
const BOOLEAN_FLAGS = new Set([
|
|
369
|
-
"--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "-h", "--help",
|
|
375
|
+
"--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "--force", "--reupload", "-h", "--help",
|
|
370
376
|
])
|
|
371
377
|
|
|
372
378
|
/** ISO-8601 with the machine's real UTC offset (never the agent's guess). */
|
|
@@ -386,6 +392,26 @@ function genId(prefix) {
|
|
|
386
392
|
return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
|
|
387
393
|
}
|
|
388
394
|
|
|
395
|
+
// A run line's stable, machine-minted identity (the plumbing-id class, rule 7 —
|
|
396
|
+
// no verb accepts it; like the agent UUID). It survives folding: a job's id
|
|
397
|
+
// collapses many lines into one record, but each line keeps its own eventId.
|
|
398
|
+
// `figs push` sends the raw fold lines (with these ids) as `runEvents` so the
|
|
399
|
+
// app can rebuild the full in-flight timeline — opened → checkpoints → settled —
|
|
400
|
+
// even when a long account-free history is published in a single first link-late
|
|
401
|
+
// push (it would otherwise fold to one "settled" snapshot, losing the lifecycle).
|
|
402
|
+
// UUID-backed so it's a collision-free dedupe key: the server appends one
|
|
403
|
+
// timeline row per eventId, ON CONFLICT DO NOTHING, so re-pushes never duplicate.
|
|
404
|
+
function genEventId() {
|
|
405
|
+
return `evt-${randomUUID()}`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Human-readable byte size — for the push line ("X not re-sent"). */
|
|
409
|
+
function fmtBytes(n) {
|
|
410
|
+
if (n < 1024) return `${n} B`
|
|
411
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
412
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
413
|
+
}
|
|
414
|
+
|
|
389
415
|
// ---------- local validation (the spec's common mistakes, caught on write) ----
|
|
390
416
|
// The server's schema stays the source of truth; these catch what hand-authors
|
|
391
417
|
// and flag typos get wrong, with errors that teach the fix.
|
|
@@ -1661,8 +1687,24 @@ async function answerCmd() {
|
|
|
1661
1687
|
}
|
|
1662
1688
|
}
|
|
1663
1689
|
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1690
|
+
// Attested-reply guard (the trust-grade protector). A reply made in the app is
|
|
1691
|
+
// attested (source:"app") and syncs down on its own via `figs inbox`. Re-transcribing
|
|
1692
|
+
// it here mints a weaker source:"chat" duplicate — and since `close` cites the NEWEST
|
|
1693
|
+
// reply, the self-report would supersede the attested one, silently downgrading the
|
|
1694
|
+
// resolution from ✓VERIFIED to "relayed by agent". So refuse if the ask already has an
|
|
1695
|
+
// app-minted reply (type-agnostic: verdicts and answers alike); --force records a
|
|
1696
|
+
// genuinely-out-of-band chat reply anyway. Local check — the app reply is already in
|
|
1697
|
+
// messages.jsonl from a prior inbox sync (no network on this hot path; contract rule 6).
|
|
1698
|
+
const appReply = readJsonl("messages.jsonl").find((m) => m.ask === askId && m.source === "app")
|
|
1699
|
+
if (appReply && !hasFlag("--force")) {
|
|
1700
|
+
die(
|
|
1701
|
+
`ask "${askId}" already has an attested reply from the app (synced here via \`figs inbox\`).\n` +
|
|
1702
|
+
` Don't transcribe app replies — the app is the answer channel. → just \`figs close ${askId}\`.\n` +
|
|
1703
|
+
` (\`figs answer\` is only for a reply that exists nowhere but your chat. --force to record one anyway.)`,
|
|
1704
|
+
)
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Softer guard for the no-app case (e.g. re-typing a chat reply): a content-match dupe.
|
|
1666
1708
|
const priorSame = readJsonl("messages.jsonl").some(
|
|
1667
1709
|
(m) =>
|
|
1668
1710
|
m.ask === askId &&
|
|
@@ -1816,7 +1858,7 @@ async function reportCmd() {
|
|
|
1816
1858
|
const id = idGiven || genId("r")
|
|
1817
1859
|
// Before the append, so "new" means new-to-this-outbox (the typo catch).
|
|
1818
1860
|
const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
|
|
1819
|
-
const run = { id, ts: nowIso(), result, state: "settled" }
|
|
1861
|
+
const run = { id, eventId: genEventId(), ts: nowIso(), result, state: "settled" }
|
|
1820
1862
|
const unit = flag("--unit")
|
|
1821
1863
|
if (unit) run.unit = unit
|
|
1822
1864
|
const period = flag("--period")
|
|
@@ -1881,7 +1923,7 @@ async function checkpointCmd() {
|
|
|
1881
1923
|
}
|
|
1882
1924
|
// Before the append, so "new" means new-to-this-outbox (the teaching line).
|
|
1883
1925
|
const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
|
|
1884
|
-
const run = { id, ts: nowIso(), result: note, state: "in-flight" }
|
|
1926
|
+
const run = { id, eventId: genEventId(), ts: nowIso(), result: note, state: "in-flight" }
|
|
1885
1927
|
const unit = flag("--unit")
|
|
1886
1928
|
if (unit) run.unit = unit
|
|
1887
1929
|
const period = flag("--period")
|
|
@@ -2164,10 +2206,19 @@ async function doPush() {
|
|
|
2164
2206
|
const agentJson = readJson(join(repoDir, "agent.json"), null)
|
|
2165
2207
|
if (!agentJson) return fail("missing .figs/agent.json — author it, then `figs doctor`")
|
|
2166
2208
|
const agent = { ...agentJson, id: config.agentId }
|
|
2167
|
-
const
|
|
2209
|
+
const runLines = readJsonl("runs.jsonl")
|
|
2210
|
+
const runs = foldById(runLines)
|
|
2168
2211
|
const asks = foldById(readJsonl("asks.jsonl"))
|
|
2169
2212
|
// Messages are immutable events — sent whole (no fold); the server dedupes by id.
|
|
2170
2213
|
const messages = readJsonl("messages.jsonl")
|
|
2214
|
+
// The raw fold lines ride alongside the folded spine: `runs` (folded) still
|
|
2215
|
+
// drives the run row + validation; `runEvents` lets the server rebuild the
|
|
2216
|
+
// per-fold timeline (opened → checkpoints → settled), so a link-late first
|
|
2217
|
+
// push doesn't collapse a whole offline lifecycle into one "settled" snapshot.
|
|
2218
|
+
// Only eventId-bearing lines (CLI-written) — legacy/hand-authored lines lack
|
|
2219
|
+
// the dedupe key, so the server keeps its current one-row-per-push behavior
|
|
2220
|
+
// for them (and runs they belong to are untouched).
|
|
2221
|
+
const runEvents = runLines.filter((r) => r.eventId)
|
|
2171
2222
|
// One-time confirm that a name change on an already-registered id is a real
|
|
2172
2223
|
// rename, not a copied folder (the server's rename guard refuses it otherwise).
|
|
2173
2224
|
const confirmRename = hasFlag("--rename")
|
|
@@ -2192,6 +2243,7 @@ async function doPush() {
|
|
|
2192
2243
|
workspaceId: config.workspaceId,
|
|
2193
2244
|
agent,
|
|
2194
2245
|
runs,
|
|
2246
|
+
runEvents,
|
|
2195
2247
|
asks,
|
|
2196
2248
|
messages,
|
|
2197
2249
|
...(confirmRename ? { confirmRename: true } : {}),
|
|
@@ -2220,8 +2272,18 @@ async function doPush() {
|
|
|
2220
2272
|
// The wow-moment link — relay this to your human so they can see the agent.
|
|
2221
2273
|
console.log(` view at ${base}/w/${config.workspaceId}`)
|
|
2222
2274
|
|
|
2275
|
+
// The ingest response carries the agent's artifact manifest ({ name: "sha256:…" }) —
|
|
2276
|
+
// pushArtifacts re-hashes locally and skips re-sending bytes the server already
|
|
2277
|
+
// has. Absent (older server / non-JSON body) → it uploads everything, as before.
|
|
2278
|
+
let artifactManifest
|
|
2279
|
+
try {
|
|
2280
|
+
artifactManifest = JSON.parse(text)?.artifactManifest
|
|
2281
|
+
} catch {
|
|
2282
|
+
// non-JSON success body — fall back to upload-all
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2223
2285
|
// The spine landed; an artifact-stage failure is transient/oversize — retry.
|
|
2224
|
-
return (await pushArtifacts(base, token, config))
|
|
2286
|
+
return (await pushArtifacts(base, token, config, artifactManifest))
|
|
2225
2287
|
? { ok: true }
|
|
2226
2288
|
: { ok: false, retryable: true }
|
|
2227
2289
|
}
|
|
@@ -2235,7 +2297,7 @@ async function doPush() {
|
|
|
2235
2297
|
* survives. A **server rejection** (auth/size) is fatal; a **missing local
|
|
2236
2298
|
* file** is only a warning (the agent referenced something it didn't produce).
|
|
2237
2299
|
*/
|
|
2238
|
-
async function pushArtifacts(base, token, config) {
|
|
2300
|
+
async function pushArtifacts(base, token, config, manifest) {
|
|
2239
2301
|
const names = [
|
|
2240
2302
|
...new Set(
|
|
2241
2303
|
[...readJsonl("runs.jsonl"), ...readJsonl("asks.jsonl")].flatMap(attachmentsOf),
|
|
@@ -2243,10 +2305,18 @@ async function pushArtifacts(base, token, config) {
|
|
|
2243
2305
|
]
|
|
2244
2306
|
if (names.length === 0) return true
|
|
2245
2307
|
|
|
2308
|
+
// Content-addressed skip: ingest returns the server's artifacts as
|
|
2309
|
+
// { name: "sha256:<hex>" }; we re-hash each local file and only POST its bytes
|
|
2310
|
+
// when they differ (or the server lacks it). Without a manifest (older server)
|
|
2311
|
+
// we upload everything, as before. `--reupload` forces a full re-send — the
|
|
2312
|
+
// manual recovery if a stored object ever drifts from its row.
|
|
2313
|
+
const reupload = hasFlag("--reupload")
|
|
2246
2314
|
let uploaded = 0
|
|
2315
|
+
let skipped = 0
|
|
2247
2316
|
let unchanged = 0
|
|
2248
2317
|
let missing = 0
|
|
2249
2318
|
let failed = 0
|
|
2319
|
+
let savedBytes = 0
|
|
2250
2320
|
for (const name of names) {
|
|
2251
2321
|
const p = join(repoDir, "artifacts", name)
|
|
2252
2322
|
if (!existsSync(p)) {
|
|
@@ -2254,7 +2324,15 @@ async function pushArtifacts(base, token, config) {
|
|
|
2254
2324
|
missing++
|
|
2255
2325
|
continue
|
|
2256
2326
|
}
|
|
2257
|
-
const
|
|
2327
|
+
const bytes = readFileSync(p)
|
|
2328
|
+
// sha256 of the RAW bytes — must match the server's hash of the decoded
|
|
2329
|
+
// upload (SPEC §8). Hash before base64, never the base64 text.
|
|
2330
|
+
const localHash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`
|
|
2331
|
+
if (!reupload && manifest && manifest[name] === localHash) {
|
|
2332
|
+
skipped++
|
|
2333
|
+
savedBytes += bytes.length
|
|
2334
|
+
continue
|
|
2335
|
+
}
|
|
2258
2336
|
let res
|
|
2259
2337
|
try {
|
|
2260
2338
|
res = await fetchT(`${base}/api/artifacts/upload`, {
|
|
@@ -2264,7 +2342,7 @@ async function pushArtifacts(base, token, config) {
|
|
|
2264
2342
|
workspaceId: config.workspaceId,
|
|
2265
2343
|
agentId: config.agentId,
|
|
2266
2344
|
name,
|
|
2267
|
-
content,
|
|
2345
|
+
content: bytes.toString("base64"),
|
|
2268
2346
|
}),
|
|
2269
2347
|
})
|
|
2270
2348
|
} catch (e) {
|
|
@@ -2282,15 +2360,22 @@ async function pushArtifacts(base, token, config) {
|
|
|
2282
2360
|
failed++
|
|
2283
2361
|
continue
|
|
2284
2362
|
}
|
|
2363
|
+
// We sent it; the server may still report it unchanged on the fallback path
|
|
2364
|
+
// (no manifest to skip with — the bytes crossed the wire, storage was spared).
|
|
2285
2365
|
const body = await res.json().catch(() => ({}))
|
|
2286
2366
|
if (body.unchanged) unchanged++
|
|
2287
2367
|
else uploaded++
|
|
2288
2368
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
(
|
|
2293
|
-
|
|
2369
|
+
const parts = [`${uploaded} uploaded`]
|
|
2370
|
+
if (skipped) {
|
|
2371
|
+
parts.push(
|
|
2372
|
+
`${skipped} skipped (already synced${savedBytes ? ` — ${fmtBytes(savedBytes)} not re-sent` : ""})`,
|
|
2373
|
+
)
|
|
2374
|
+
}
|
|
2375
|
+
if (unchanged) parts.push(`${unchanged} unchanged`)
|
|
2376
|
+
if (missing) parts.push(`${missing} missing`)
|
|
2377
|
+
if (failed) parts.push(`${failed} failed`)
|
|
2378
|
+
console.log(`figs: ${failed ? "✗" : "✓"} artifacts — ${parts.join(", ")}`)
|
|
2294
2379
|
// The spine already landed; a false return lets the caller exit non-zero so
|
|
2295
2380
|
// an agent's run loop can catch that an artifact the manager needs to read
|
|
2296
2381
|
// did not publish.
|