@figs-so/cli 1.0.0 → 1.2.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 +10 -4
  2. package/figs.mjs +58 -5
  3. package/package.json +1 -1
package/SPEC.md CHANGED
@@ -156,9 +156,9 @@ edge of its autonomy.
156
156
  | Field | Type | Req | Meaning |
157
157
  |---|---|:--:|---|
158
158
  | `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.) |
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 / 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
160
  | `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. |
161
+ | `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
162
  | `title` | string | ✓ | The ask, in one line. |
163
163
  | `unit` | string | | The `Unit.id` this concerns. |
164
164
  | `run` | string | | The run `id` this ask was raised during. **Optional** — asks also arise outside runs. |
@@ -259,7 +259,8 @@ minimum CLI version requires Bearer.)
259
259
  "agent": { /* agent.json */ },
260
260
  "runs": [ /* runs.jsonl */ ], // optional
261
261
  "asks": [ /* asks.jsonl */ ], // optional
262
- "messages": [ /* messages.jsonl */ ] // optional — transcribed replies the reader lacks
262
+ "messages": [ /* messages.jsonl */ ], // optional — transcribed replies the reader lacks
263
+ "confirmRename": true // optional — `figs push --rename`: confirm a real name change
263
264
  }
264
265
  ```
265
266
  2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
@@ -270,7 +271,12 @@ server refuses a fold older than the record's stored close/settle (a stale machi
270
271
  and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
271
272
  agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
272
273
  `{ "error", "code": "agent_moved", "workspaceId"? }`; the agent recovers by setting
273
- `config.json#workspaceId` to the named workspace and pushing again.
274
+ `config.json#workspaceId` to the named workspace and pushing again. **A push never silently
275
+ re-identifies an agent:** a `name` differing from the one registered for that `agentId` is the
276
+ fingerprint of a copied folder and is rejected `409` `{ "error", "code": "agent_renamed" }` — the
277
+ agent rotates identity (`rm -rf .figs && figs init`) for a genuinely new agent, or sets
278
+ `confirmRename` (`figs push --rename`) once to confirm a real rename. `name` is the signal because a
279
+ copy-to-make-another always renames while role/mandate evolve legitimately; the check is `name` only.
274
280
 
275
281
  **Down — the reply sync.** Delivery is **agent-pulled**, never pushed into the repo: a reader exposes a
276
282
  read returning **this agent's human messages** (answers/verdicts), which the CLI merges into
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
  ],
@@ -279,12 +280,16 @@ const COMMANDS = {
279
280
  },
280
281
  push: {
281
282
  args: "",
282
- flags: [],
283
+ flags: ["--rename"],
283
284
  desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
284
285
  more: [
285
286
  "Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected.",
286
287
  "The writing verbs (report/ask/resolve) call this automatically — you only need it",
287
288
  "after hand-editing files, after --no-push, or to retry a failed auto-push.",
289
+ "--rename: confirm a genuine name change on an already-registered agent (one",
290
+ "time). The server refuses a name that doesn't match the registered one — it's",
291
+ "the fingerprint of a copied folder; if that's what happened, rotate identity",
292
+ "instead with `rm -rf .figs && figs init`, don't --rename.",
288
293
  ],
289
294
  eg: "figs push",
290
295
  },
@@ -842,6 +847,17 @@ async function login(token) {
842
847
  return
843
848
  }
844
849
 
850
+ // Already logged in? Don't start a needless device flow — it strands a cold/headless
851
+ // agent with no human to approve. Token presence is enough to short-circuit; a stale
852
+ // token surfaces on the next link/push. `--force` re-runs the flow deliberately.
853
+ if (!hasFlag("--force") && getToken()) {
854
+ console.log(`figs: ✓ already logged in to ${originOf(resolveEndpoint())}`)
855
+ console.log(
856
+ " `figs status` for details · `figs link` to connect a workspace · `figs login --force` to log in again",
857
+ )
858
+ return
859
+ }
860
+
845
861
  const start = await request("POST", "/api/device/start")
846
862
  if (!start.ok) die(`could not start login (${start.status})`)
847
863
  const d = start.data
@@ -1820,6 +1836,14 @@ async function reportCmd() {
1820
1836
  // Announce new-vs-fold only for an explicit --id — that's where a typo means
1821
1837
  // "I meant to continue a job but opened a sibling" (auto-ids are always new).
1822
1838
  if (idGiven) announceFold("job", id, isNew, " (settled)")
1839
+ // The "why it started": a born-settled job that never checkpointed still shows
1840
+ // "triggered by …" in the manager's timeline when --trigger is set. Nudge it
1841
+ // only on a fresh job (never on a fold/continuation — that would cry wolf).
1842
+ if (isNew && !trigger) {
1843
+ console.log(
1844
+ `figs: tip: \`--trigger '<what set this in motion>'\` records the "why" — your manager sees it on the job's timeline`,
1845
+ )
1846
+ }
1823
1847
  // Teaching, never a gate: a settled job with open asks citing it is the
1824
1848
  // normal tail-of-job pattern (the ask owns the waiting) — but if the job's
1825
1849
  // OUTCOME depends on an answer, in-flight is the honest state. Local fold
@@ -1883,6 +1907,11 @@ async function checkpointCmd() {
1883
1907
  console.log(
1884
1908
  `figs: new job opened: ${id} (in flight) — checkpoint as you go; \`figs report --id ${id}\` settles it`,
1885
1909
  )
1910
+ if (!trigger) {
1911
+ console.log(
1912
+ `figs: tip: add \`--trigger '<what set this in motion>'\` so your manager sees why this job started`,
1913
+ )
1914
+ }
1886
1915
  } else if (settledBefore) {
1887
1916
  console.warn(
1888
1917
  `figs: ! reopening a settled job (${id}) — continue only if it's truly the same job; new work wants a new id`,
@@ -1933,6 +1962,10 @@ async function askCmd() {
1933
1962
  const v = flag(f)
1934
1963
  if (v) ask[k] = v
1935
1964
  }
1965
+ // Default an unaddressed ask to the manager (the work-accountable human) — the common
1966
+ // case; `--to builder` is the explicit exception. Saves the reader from inferring ("guess").
1967
+ // (Hand-authored JSONL may still omit `to` → unaddressed; the verb is sugar that defaults.)
1968
+ if (!ask.to) ask.to = "manager"
1936
1969
  const options = flagAll("--option")
1937
1970
  if (options.length) ask.options = options
1938
1971
  for (const o of ask.options ?? []) {
@@ -2135,6 +2168,9 @@ async function doPush() {
2135
2168
  const asks = foldById(readJsonl("asks.jsonl"))
2136
2169
  // Messages are immutable events — sent whole (no fold); the server dedupes by id.
2137
2170
  const messages = readJsonl("messages.jsonl")
2171
+ // One-time confirm that a name change on an already-registered id is a real
2172
+ // rename, not a copied folder (the server's rename guard refuses it otherwise).
2173
+ const confirmRename = hasFlag("--rename")
2138
2174
 
2139
2175
  // Local pre-flight — fail fast, offline, with teaching errors.
2140
2176
  const placeholders = findPlaceholders(agentJson)
@@ -2152,7 +2188,14 @@ async function doPush() {
2152
2188
  res = await fetchT(`${base}/api/ingest`, {
2153
2189
  method: "POST",
2154
2190
  headers: { "content-type": "application/json", ...authHeaders(token) },
2155
- body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks, messages }),
2191
+ body: JSON.stringify({
2192
+ workspaceId: config.workspaceId,
2193
+ agent,
2194
+ runs,
2195
+ asks,
2196
+ messages,
2197
+ ...(confirmRename ? { confirmRename: true } : {}),
2198
+ }),
2156
2199
  })
2157
2200
  } catch (e) {
2158
2201
  // Network/timeout — transient; the records are safe locally.
@@ -2160,7 +2203,17 @@ async function doPush() {
2160
2203
  }
2161
2204
  const text = await res.text()
2162
2205
  // 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)
2206
+ // The server's teaching message rides in { error } surface that, not raw JSON.
2207
+ if (!res.ok) {
2208
+ let detail = text
2209
+ try {
2210
+ const body = JSON.parse(text)
2211
+ if (body?.error) detail = body.error
2212
+ } catch {
2213
+ // non-JSON body — keep the raw text
2214
+ }
2215
+ return fail(`server rejected it (${res.status}): ${detail}`, res.status >= 500)
2216
+ }
2164
2217
  console.log(
2165
2218
  `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks, ${messages.length} messages`,
2166
2219
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {