@figs-so/cli 1.1.0 → 1.3.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 +16 -5
  2. package/figs.mjs +57 -5
  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 | | |
@@ -156,9 +164,9 @@ edge of its autonomy.
156
164
  | Field | Type | Req | Meaning |
157
165
  |---|---|:--:|---|
158
166
  | `id` | string | ✓ | Stable id (upsert key). |
159
- | `type` | enum | ✓ | `question` \| `sign-off` — **the type is the answer contract**: *question* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request-changes / reject). (`needs-decision` was renamed `question`; `fyi` was retired — a for-the-record note is a settled report, not an ask; `blocked` is the run's `status`, not an ask type.) |
167
+ | `type` | enum | ✓ | `question` \| `sign-off` — **the type is the answer contract**: *question* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request-changes / reject). (`needs-decision` was renamed `question`; `fyi` was retired — a for-the-record note / assumption / heads-up is a `checkpoint` on the job (or a settled `report`), not an ask; `blocked` is the run's `status`, not an ask type.) |
160
168
  | `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **agent** retracted it; nobody acted) \| `"rejected"` (a human declined it). **Rejected is terminal** on this id — re-raising is a new ask. |
161
- | `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder`). Absent = unaddressed. |
169
+ | `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder`). Absent = unaddressed; `figs ask` **defaults it to `manager`** (the common case) when omitted, so a reader needn't infer. |
162
170
  | `title` | string | ✓ | The ask, in one line. |
163
171
  | `unit` | string | | The `Unit.id` this concerns. |
164
172
  | `run` | string | | The run `id` this ask was raised during. **Optional** — asks also arise outside runs. |
@@ -257,7 +265,8 @@ 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
@@ -265,8 +274,10 @@ minimum CLI version requires Bearer.)
265
274
  ```
266
275
  2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
267
276
 
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
277
+ The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it
278
+ **rebuilds each run's timeline from `runEvents`, one entry per fold, deduped by `eventId`** (a run with no
279
+ `runEvents` — an older CLI, or legacy/hand-authored lines without an `eventId` — keeps one timeline entry
280
+ 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
281
  server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
271
282
  and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
272
283
  agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
package/figs.mjs CHANGED
@@ -89,10 +89,11 @@ const COMMANDS = {
89
89
  eg: "figs status --json",
90
90
  },
91
91
  login: {
92
- args: "[<token>]",
93
- flags: [],
92
+ args: "[<token>] [--force]",
93
+ flags: ["--force"],
94
94
  desc: "log in — browser device-flow, or save a pasted token",
95
95
  more: [
96
+ "already logged in? it's a no-op that points you to `figs link` — pass --force to log in again.",
96
97
  "no arg → device flow: opens a browser for a human to approve (you never see the token).",
97
98
  "<token> → save a token you already have to ~/.figs/credentials.json.",
98
99
  ],
@@ -385,6 +386,19 @@ function genId(prefix) {
385
386
  return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
386
387
  }
387
388
 
389
+ // A run line's stable, machine-minted identity (the plumbing-id class, rule 7 —
390
+ // no verb accepts it; like the agent UUID). It survives folding: a job's id
391
+ // collapses many lines into one record, but each line keeps its own eventId.
392
+ // `figs push` sends the raw fold lines (with these ids) as `runEvents` so the
393
+ // app can rebuild the full in-flight timeline — opened → checkpoints → settled —
394
+ // even when a long account-free history is published in a single first link-late
395
+ // push (it would otherwise fold to one "settled" snapshot, losing the lifecycle).
396
+ // UUID-backed so it's a collision-free dedupe key: the server appends one
397
+ // timeline row per eventId, ON CONFLICT DO NOTHING, so re-pushes never duplicate.
398
+ function genEventId() {
399
+ return `evt-${randomUUID()}`
400
+ }
401
+
388
402
  // ---------- local validation (the spec's common mistakes, caught on write) ----
389
403
  // The server's schema stays the source of truth; these catch what hand-authors
390
404
  // and flag typos get wrong, with errors that teach the fix.
@@ -846,6 +860,17 @@ async function login(token) {
846
860
  return
847
861
  }
848
862
 
863
+ // Already logged in? Don't start a needless device flow — it strands a cold/headless
864
+ // agent with no human to approve. Token presence is enough to short-circuit; a stale
865
+ // token surfaces on the next link/push. `--force` re-runs the flow deliberately.
866
+ if (!hasFlag("--force") && getToken()) {
867
+ console.log(`figs: ✓ already logged in to ${originOf(resolveEndpoint())}`)
868
+ console.log(
869
+ " `figs status` for details · `figs link` to connect a workspace · `figs login --force` to log in again",
870
+ )
871
+ return
872
+ }
873
+
849
874
  const start = await request("POST", "/api/device/start")
850
875
  if (!start.ok) die(`could not start login (${start.status})`)
851
876
  const d = start.data
@@ -1804,7 +1829,7 @@ async function reportCmd() {
1804
1829
  const id = idGiven || genId("r")
1805
1830
  // Before the append, so "new" means new-to-this-outbox (the typo catch).
1806
1831
  const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
1807
- const run = { id, ts: nowIso(), result, state: "settled" }
1832
+ const run = { id, eventId: genEventId(), ts: nowIso(), result, state: "settled" }
1808
1833
  const unit = flag("--unit")
1809
1834
  if (unit) run.unit = unit
1810
1835
  const period = flag("--period")
@@ -1824,6 +1849,14 @@ async function reportCmd() {
1824
1849
  // Announce new-vs-fold only for an explicit --id — that's where a typo means
1825
1850
  // "I meant to continue a job but opened a sibling" (auto-ids are always new).
1826
1851
  if (idGiven) announceFold("job", id, isNew, " (settled)")
1852
+ // The "why it started": a born-settled job that never checkpointed still shows
1853
+ // "triggered by …" in the manager's timeline when --trigger is set. Nudge it
1854
+ // only on a fresh job (never on a fold/continuation — that would cry wolf).
1855
+ if (isNew && !trigger) {
1856
+ console.log(
1857
+ `figs: tip: \`--trigger '<what set this in motion>'\` records the "why" — your manager sees it on the job's timeline`,
1858
+ )
1859
+ }
1827
1860
  // Teaching, never a gate: a settled job with open asks citing it is the
1828
1861
  // normal tail-of-job pattern (the ask owns the waiting) — but if the job's
1829
1862
  // OUTCOME depends on an answer, in-flight is the honest state. Local fold
@@ -1861,7 +1894,7 @@ async function checkpointCmd() {
1861
1894
  }
1862
1895
  // Before the append, so "new" means new-to-this-outbox (the teaching line).
1863
1896
  const isNew = !foldById(readJsonl("runs.jsonl")).some((r) => r.id === id)
1864
- const run = { id, ts: nowIso(), result: note, state: "in-flight" }
1897
+ const run = { id, eventId: genEventId(), ts: nowIso(), result: note, state: "in-flight" }
1865
1898
  const unit = flag("--unit")
1866
1899
  if (unit) run.unit = unit
1867
1900
  const period = flag("--period")
@@ -1887,6 +1920,11 @@ async function checkpointCmd() {
1887
1920
  console.log(
1888
1921
  `figs: new job opened: ${id} (in flight) — checkpoint as you go; \`figs report --id ${id}\` settles it`,
1889
1922
  )
1923
+ if (!trigger) {
1924
+ console.log(
1925
+ `figs: tip: add \`--trigger '<what set this in motion>'\` so your manager sees why this job started`,
1926
+ )
1927
+ }
1890
1928
  } else if (settledBefore) {
1891
1929
  console.warn(
1892
1930
  `figs: ! reopening a settled job (${id}) — continue only if it's truly the same job; new work wants a new id`,
@@ -1937,6 +1975,10 @@ async function askCmd() {
1937
1975
  const v = flag(f)
1938
1976
  if (v) ask[k] = v
1939
1977
  }
1978
+ // Default an unaddressed ask to the manager (the work-accountable human) — the common
1979
+ // case; `--to builder` is the explicit exception. Saves the reader from inferring ("guess").
1980
+ // (Hand-authored JSONL may still omit `to` → unaddressed; the verb is sugar that defaults.)
1981
+ if (!ask.to) ask.to = "manager"
1940
1982
  const options = flagAll("--option")
1941
1983
  if (options.length) ask.options = options
1942
1984
  for (const o of ask.options ?? []) {
@@ -2135,10 +2177,19 @@ async function doPush() {
2135
2177
  const agentJson = readJson(join(repoDir, "agent.json"), null)
2136
2178
  if (!agentJson) return fail("missing .figs/agent.json — author it, then `figs doctor`")
2137
2179
  const agent = { ...agentJson, id: config.agentId }
2138
- const runs = foldById(readJsonl("runs.jsonl"))
2180
+ const runLines = readJsonl("runs.jsonl")
2181
+ const runs = foldById(runLines)
2139
2182
  const asks = foldById(readJsonl("asks.jsonl"))
2140
2183
  // Messages are immutable events — sent whole (no fold); the server dedupes by id.
2141
2184
  const messages = readJsonl("messages.jsonl")
2185
+ // The raw fold lines ride alongside the folded spine: `runs` (folded) still
2186
+ // drives the run row + validation; `runEvents` lets the server rebuild the
2187
+ // per-fold timeline (opened → checkpoints → settled), so a link-late first
2188
+ // push doesn't collapse a whole offline lifecycle into one "settled" snapshot.
2189
+ // Only eventId-bearing lines (CLI-written) — legacy/hand-authored lines lack
2190
+ // the dedupe key, so the server keeps its current one-row-per-push behavior
2191
+ // for them (and runs they belong to are untouched).
2192
+ const runEvents = runLines.filter((r) => r.eventId)
2142
2193
  // One-time confirm that a name change on an already-registered id is a real
2143
2194
  // rename, not a copied folder (the server's rename guard refuses it otherwise).
2144
2195
  const confirmRename = hasFlag("--rename")
@@ -2163,6 +2214,7 @@ async function doPush() {
2163
2214
  workspaceId: config.workspaceId,
2164
2215
  agent,
2165
2216
  runs,
2217
+ runEvents,
2166
2218
  asks,
2167
2219
  messages,
2168
2220
  ...(confirmRename ? { confirmRename: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.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": {