@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.
Files changed (3) hide show
  1. package/SPEC.md +23 -5
  2. package/figs.mjs +108 -23
  3. 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": [ /* runs.jsonl */ ], // optional
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, hash-verified.
267
-
268
- The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it never
269
- deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
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: "record your human's out-of-band reply to an ask, verbatim (you run this, not them)",
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
- "Humans don't type commands. They answer you in chat ('approvedonly the 15');",
219
- "you transcribe that into the record. --by names the HUMAN who said it, not you.",
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
- "Replies made IN the app sync down automatically (figs inbox) `figs answer` is only",
225
- "for replies that exist nowhere but your chat.",
226
- "Then act, and `figs close <ask-id>` it cites this reply automatically.",
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
- // Double-transcription guard: a reply made in the app syncs down on its own,
1665
- // so re-typing it here would mint a duplicate (a weaker-grade copy).
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 runs = foldById(readJsonl("runs.jsonl"))
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 content = readFileSync(p).toString("base64")
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
- console.log(
2290
- `figs: ${failed ? "✗" : "✓"} artifacts — ${uploaded} uploaded, ${unchanged} unchanged` +
2291
- (missing ? `, ${missing} missing` : "") +
2292
- (failed ? `, ${failed} failed` : ""),
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Figs CLI — publish your AI agent's state to Figs (figs.so). Run by the agent.",
5
5
  "type": "module",
6
6
  "bin": {