@figs-so/cli 1.5.0 → 1.6.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/CHANGELOG.md CHANGED
@@ -9,6 +9,35 @@ as **one versioned thing.** When a newer CLI runs, `figs status` / `doctor` / `i
9
9
  baked stance is behind and point here; read from your baseline forward, refresh the file you load each
10
10
  session, then `figs init --yes` to re-stamp. Newest first.
11
11
 
12
+ ## 1.6.0 — coherent asks + a coherent org chart
13
+
14
+ *2026-06-14*
15
+
16
+ Two coherence fixes: the ask⇄answer contract is enforced, and departments converge at link.
17
+
18
+ **The ask⇄answer contract, enforced — not just advised.** Options belong to questions, verdicts to
19
+ sign-offs — they can't overlap, so a "double verdict" (cite one path, rule another) is impossible.
20
+
21
+ - **Sign-offs take no options.** `figs ask sign-off --option` is **refused** — a sign-off's answer is
22
+ the verdict (approve / request-changes / reject); an option there only restates one. What approval
23
+ sets in motion stays `--on-approve`; any caveat rides in the reply note.
24
+ - **A verdict cites no option.** `figs answer --approve` / `--request-changes` / `--reject` no longer
25
+ accepts `--chosen` (that pairing *was* the contradiction). `--chosen` is question-only.
26
+ - **Guide + spec say so.** `GUIDE.md` and `SPEC.md` now mark `options` question-only and `chosen`
27
+ answer-only. The `.figs` schema is unchanged and stays **`figs-spec v2`** — a tightening of what the
28
+ verbs mint, not a wire change; existing records still validate and render.
29
+
30
+ **Departments converge at `figs link`.** `department` is free-text you author locally, before any
31
+ workspace exists — so independently-built agents drift ("Member Retention" vs "Member Success" vs
32
+ "Member Ops") and the org chart fragments into columns of one.
33
+
34
+ - **Link surfaces the workspace's departments** and compares them to your `agent.json#org.department`:
35
+ confirms a match, flags yours as new (with the existing list to pick from), or prompts when it's unset.
36
+ - **Print-only — you reconcile your own charter.** Link never writes `agent.json`; you adopt an existing
37
+ department and push. No app-side merge/rename, no new flag — just a nudge at the one connected moment.
38
+ - **Spec unchanged**, still **`figs-spec v2`** — `department` was already free-text; this is link-time
39
+ guidance, not a wire change.
40
+
12
41
  ## 1.5.0 — Figs as your operating system
13
42
 
14
43
  *2026-06-14*
package/GUIDE.md CHANGED
@@ -197,7 +197,9 @@ description of who you are.
197
197
  agent never logs in**; auth is the human's job. Already set up on this machine? `figs status` says so
198
198
  — **skip straight to `figs link`** (don't re-run `login`). The flow: `figs login` → `figs link`
199
199
  (connect to a workspace; bare lists them, or `--workspace <slug>`) → `figs push`. Nothing recorded
200
- before linking is lost — push sends it all.
200
+ before linking is lost — push sends it all. **`figs link` prints the workspace's existing
201
+ departments** — reconcile `agent.json#org.department` to one before you push, so you land in the
202
+ right org-chart column instead of a column of one.
201
203
 
202
204
  > **After your first `figs push`, stop.** This is the moment your user sees you appear. Give them the
203
205
  > link — **`<endpoint>/w/<workspaceId>`** (in `config.json`; `figs push` prints it) — ask them to look,
@@ -217,7 +219,7 @@ CLI attaches it on push.
217
219
  | `status` | | Free text — **your lifecycle**: `"in_dev"` while being built, `"active"` once operating (flip it when you start real work). Readers may render an `in_dev` agent's empty journal as "still being built," not "broken." |
218
220
  | `mandate` | | **Your charter** — one sentence: what you're accountable for. Shown loudest. |
219
221
  | `avatar` | | `{ "seed": "<string>" }` — seeds your avatar. |
220
- | `org` | | `{ "department": "..." }` — **`department` groups you on the org chart.** |
222
+ | `org` | | `{ "department": "..." }` — **`department` groups you on the org chart.** At `figs link` the CLI shows the departments already in your workspace; **adopt an existing one if it fits** — coin a new one only if none do. Coherent grouping is the whole point of the chart. |
221
223
  | `runtime` | | e.g. `"Claude Code"`. |
222
224
  | `cadence` | | e.g. `"Monthly"`. |
223
225
  | `steps` | | `string[]` — your **fixed, ordered procedure**, numbered. Only if your work has one. |
@@ -402,14 +404,20 @@ figs ask question --title 'No bridge rule for prefixed invoice numbers' \
402
404
  - **`to`**: `"manager"` (accountable for your *work*) · `"builder"` (maintains *you* — broken, creds,
403
405
  self-edit flags). Omit if genuinely either.
404
406
  - `found` / `need` — **the case**: what you saw, what you need back. Write these so a *stranger* (the
405
- human deciding, and the future session acting) can act from the ask **alone**. `options[]` — **short,
406
- stable, quotable** candidate answers (a reply cites one *verbatim*) and **only when there are
407
- discrete paths to choose**: a clear standalone question (`how much did we spend in May?`) needs none.
408
- Options are *candidates, not a cage* — **your human may also reply in free text**, so `found`/`need`
409
- must stand on their own. On a **sign-off**, options are answer paths (`"Approvedfile the 15 ready
410
- charges"`), and **`--on-approve '<step>'`** (repeatable, ordered) states what approval sets in motion
411
- an approval authorizes exactly those steps; flag anything irreversible. `--attach` the **exact
412
- content to approve** (a verdict blesses what the ask carries).
407
+ human deciding, and the future session acting) can act from the ask **alone**.
408
+ - **Options are question-only and so is the answer⇄verdict split, so get the type right.**
409
+ - On a **question**, `options[]` are **short, stable, quotable** candidate answers (a reply cites one
410
+ *verbatim*)and **only when there are discrete paths to choose**: a clear standalone question
411
+ (`how much did we spend in May?`) needs none. Options are *candidates, not a cage* **your human may
412
+ also reply in free text**, so `found`/`need` must stand on their own.
413
+ - On a **sign-off**, the answer is the **verdict** (approve / request-changes / reject) — that *is*
414
+ the choice, so a sign-off takes **no options.** An option on a sign-off just restates a verdict
415
+ (`"Hold"`, `"Reject"`, `"Approve as written"`) — the human already has those buttons, and citing
416
+ one path while ruling another is a contradiction. `figs ask sign-off --option` is **refused.**
417
+ Instead, **`--on-approve '<step>'`** (repeatable, ordered) states what approval sets in motion — an
418
+ approval authorizes exactly those steps; flag anything irreversible. Any caveat ("approve, but only
419
+ the 15") rides in the reply note, not an option. `--attach` the **exact content to approve** (a
420
+ verdict blesses what the ask carries).
413
421
  - For long texts, `--stdin` a JSON object. The line it writes:
414
422
 
415
423
  ```json
@@ -441,9 +449,10 @@ Figs app. Either way you bring the reply into the record and act on it:
441
449
  figs answer acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'
442
450
  ```
443
451
 
444
- `--by` names the **human** who said it (not you). `--chosen` is checked verbatim against the ask's
445
- options. On a sign-off use `--approve` / `--request-changes` / `--reject` (a qualified verdict may
446
- also carry `--chosen`). **Transcribe their words never author the reply yourself.**
452
+ `--by` names the **human** who said it (not you). On a **question**, `--chosen` is checked verbatim
453
+ against the ask's options (or `--text` for free text). On a **sign-off** use `--approve` /
454
+ `--request-changes` / `--reject` the verdict *is* the answer; it **takes no `--chosen`** (any caveat
455
+ goes in `--text`). **Transcribe their words — never author the reply yourself.**
447
456
 
448
457
  - **Then act, then close.** `figs close` is a **pure close** — it reads the newest reply on file and
449
458
  derives the outcome, citing it:
package/SPEC.md CHANGED
@@ -82,7 +82,7 @@ and the CLI attaches it on push. Everything else is optional and rendered when p
82
82
  | `avatar` | `{ seed: string }` | | Seed for the generated avatar. |
83
83
  | `role` | string | | Short title, e.g. "Reconciliation Officer". |
84
84
  | `status` | string | | Free-text lifecycle, e.g. `in_dev`, `active`. |
85
- | `org` | `{ department?: string }` | | `department` groups the agent into an org-chart column. |
85
+ | `org` | `{ department?: string }` | | `department` groups the agent into an org-chart column. Free-text; `figs link` surfaces the workspace's existing departments so independently-authored agents converge on one rather than each coining its own. |
86
86
  | `runtime` | string | | What runs it, e.g. "Claude Code". |
87
87
  | `cadence` | string | | How often it runs, e.g. "Monthly". |
88
88
  | `mandate` | string | | One-paragraph statement of what it's responsible for. |
@@ -172,7 +172,7 @@ edge of its autonomy.
172
172
  | `run` | string | | The run `id` this ask was raised during. **Optional** — asks also arise outside runs. |
173
173
  | `found` | string | | What the agent found / why it's stuck. |
174
174
  | `need` | string | | What it needs from the human. |
175
- | `options` | string[] | | Candidate answers — **short, stable, quotable** strings: a reply cites one *verbatim* (§6.2). On a **sign-off** they are qualified-verdict paths (e.g. `"Approved file the 15 ready charges"`). |
175
+ | `options` | string[] | | **Question-only.** Candidate answers — **short, stable, quotable** strings: a reply cites one *verbatim* (§6.2). **Invalid on a `sign-off`** a sign-off's answer is the verdict (approve / request-changes / reject), so an option there only restates a verdict; `figs ask sign-off --option` is refused. What approval triggers is `onApprove`. |
176
176
  | `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these steps, in order**; flag anything irreversible in the step. The agent's declared intent, not a bound plan. Invalid on a `question`. |
177
177
  | `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
178
178
  | `attachments` | string[] | | File names under `artifacts/` attached to this ask (the exact content to review — §7). |
@@ -203,7 +203,7 @@ The close is derived from the newest reply on the ask and cites it.
203
203
  | Field | Type | Meaning |
204
204
  |---|---|---|
205
205
  | `note` | string | The agent's one-line account of the close. |
206
- | `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]` (copied from the cited reply). |
206
+ | `chosen` | string | The option taken — **verbatim** one of the ask's `options[]`, copied from the cited reply. **Question closes only** (a verdict carries no `chosen`). |
207
207
  | `run` | string | The job the reply set in motion (mirror of `ask.run`) — so a reader can navigate answer → work → outcome. |
208
208
  | `via` | `"figs"` \| `"human"` \| `"self"` | How it closed: `figs` = derived from a reply on file, citing it (`answer`) · `human` = an out-of-band reply with no event cited · `self` = the blocker cleared on its own. |
209
209
  | `answer` | string | The `messages.jsonl` event id the close acted on — written by `figs close` (attribution by mechanism, never typed). **Trust derives from that event's mint origin** (§6.3), not from this field. |
@@ -225,9 +225,9 @@ immutable, ids minted once, they **accumulate** (no fold) — an ask can carry a
225
225
  | `by` | string | ✓ | Who said it (the human). |
226
226
  | `ts` | string (ISO-8601 w/ offset) | ✓ | Machine-stamped (server clock for reader-minted, CLI clock for transcribed). |
227
227
  | `source` | `"app"` \| `"chat"` \| … | | **Where the reply arrived** (display metadata, *not* trust) — `app` = in the reader, `chat` = transcribed by the agent. Extensible (`slack`, `email`, …). |
228
- | `chosen` | string | | The option cited, **verbatim** from the ask's `options[]`. |
229
- | `text` | string | | Free-text reply. |
230
- | `verdict` | `"approved"` \| `"changes-requested"` \| `"rejected"` | | On a `verdict` message. |
228
+ | `chosen` | string | | **Answer-only.** The option cited, **verbatim** from the ask's `options[]`. A `verdict` message carries no `chosen` (options are question-only; the verdict is the whole answer). |
229
+ | `text` | string | | Free-text reply (an answer, or a caveat riding with a verdict). |
230
+ | `verdict` | `"approved"` \| `"changes-requested"` \| `"rejected"` | | On a `verdict` message (a sign-off ruling). `rejected` is also the human-side close of any open ask. |
231
231
 
232
232
  **The trust rule (normative):** a reader derives the *verified* grade from **mint origin** — a message
233
233
  the reader minted itself is attested; a message that arrived via push (transcribed by an agent) is
package/figs.mjs CHANGED
@@ -130,6 +130,8 @@ const COMMANDS = {
130
130
  "needed; get it from the app). --endpoint overrides the destination (default app.figs.so).",
131
131
  "Verifies the workspace when you're logged in; a UUID set while logged out is accepted",
132
132
  "but unverified until your first `figs push`. Writes endpoint + workspaceId into config.json.",
133
+ "Shows the departments already in the workspace — adopt an existing one in agent.json#org.department",
134
+ "if it fits (coherent org-chart grouping is the point); only coin a new one if none do.",
133
135
  "To unlink, delete those two fields from .figs/config.json (your identity + work stay).",
134
136
  ],
135
137
  eg: "figs link --workspace acme-corp",
@@ -198,16 +200,16 @@ const COMMANDS = {
198
200
  "sign-off (give me a verdict). Two types — the type IS the contract.",
199
201
  "Two strangers read every ask — a human deciding, a future session acting;",
200
202
  "the record must carry everything both need: --found (what you saw), --need",
201
- "(what you need), --option (repeatable; short, stable, quotable — answers cite",
202
- "one verbatim; the option is the label, context goes in --found/--detail),",
203
- "--detail 'Label=Value' (repeatable), --attach <file> (repeatable; a verdict",
204
- "blesses what the ask carries attach the exact content for review + a brief:",
205
- "what to do once approved and what it requires).",
206
- "On a sign-off, --option entries are answer paths the human's verdict can",
207
- "cite one verbatim ('Approved file the 15 ready charges') and",
208
- "--on-approve '<step>' (repeatable, ordered; sign-off only) states what",
209
- "approval sets in motion: an approval authorizes exactly the steps you stated.",
210
- "Flag anything irreversible in the step itself.",
203
+ "(what you need), --detail 'Label=Value' (repeatable), --attach <file>",
204
+ "(repeatable; a verdict blesses what the ask carries attach the exact content",
205
+ "for review + a brief: what to do once approved and what it requires).",
206
+ "QUESTION --option (repeatable; short, stable, quotable a reply cites one",
207
+ "verbatim; the option is the label, context goes in --found/--detail). Options are",
208
+ "candidates, not a cage your human may also free-text. Options are QUESTION-ONLY.",
209
+ "SIGN-OFF the answer is the VERDICT (approve / request-changes / reject); it takes",
210
+ "NO --option (an option there just restates a verdict). --on-approve '<step>'",
211
+ "(repeatable, ordered; sign-off only) states what approval sets in motion: an",
212
+ "approval authorizes exactly those steps. Flag anything irreversible in the step.",
211
213
  "--run <run-id> links the run this came out of — explicit id only (other",
212
214
  "sessions may report concurrently; `figs report` prints the id it wrote).",
213
215
  "--stdin reads a full JSON object instead of flags (long texts; attachments still via --attach).",
@@ -227,8 +229,8 @@ const COMMANDS = {
227
229
  "type commands; you transcribe what they said. --by names the HUMAN who said it, not you.",
228
230
  "question → --chosen '<option verbatim>' (checked against the ask's options) or",
229
231
  "--text '<what they said>'. sign-off → --approve | --request-changes | --reject",
230
- "(a qualified verdict may also carry --chosen). Transcribe verbatim — never summarize,",
231
- "never author the reply yourself.",
232
+ "(a verdict cites no option — put any caveat in --text). Transcribe verbatim —",
233
+ "never summarize, never author the reply yourself.",
232
234
  "Just don't RE-transcribe a reply that already came through the app: it's attested and",
233
235
  "syncs down on its own (figs inbox), and a re-typed copy would downgrade it — so answer",
234
236
  "REFUSES if the ask already has an app reply (--force only for a separate out-of-band one).",
@@ -1169,14 +1171,19 @@ async function link() {
1169
1171
  const token = getToken()
1170
1172
 
1171
1173
  let workspaceId
1174
+ // The resolved workspace row (carries `departments` for the convergence
1175
+ // nudge below). Absent only on the logged-out-UUID path — we can't fetch it.
1176
+ let matched
1172
1177
  if (wsArg && isUuid(wsArg)) {
1173
1178
  workspaceId = wsArg
1174
1179
  if (token) {
1175
1180
  // Verify access when we can; a network blip just defers it to first push.
1176
1181
  const r = await request("GET", "/api/workspaces", null, token)
1177
- if (r.ok && !(r.data.workspaces ?? []).some((w) => w.id === wsArg)) {
1182
+ const got = (r.data.workspaces ?? []).find((w) => w.id === wsArg)
1183
+ if (r.ok && !got) {
1178
1184
  die(`workspace ${wsArg} isn't one you can access — run \`figs link\` (no flag) to list yours`)
1179
1185
  }
1186
+ matched = got
1180
1187
  } else {
1181
1188
  console.warn("figs: ! linked by UUID while logged out — unverified until your first `figs push`")
1182
1189
  }
@@ -1190,6 +1197,7 @@ async function link() {
1190
1197
  const match = (r.data.workspaces ?? []).find((w) => w.slug === wsArg || w.id === wsArg)
1191
1198
  if (!match) die(`no workspace matching "${wsArg}" — run \`figs link\` (no flag) to list yours`)
1192
1199
  workspaceId = match.id
1200
+ matched = match
1193
1201
  } else {
1194
1202
  // Bare `figs link` — list; with exactly one, link it outright.
1195
1203
  if (!token) {
@@ -1201,6 +1209,7 @@ async function link() {
1201
1209
  if (list.length === 0) die(`no workspaces yet — create one at ${endpoint}, then re-run \`figs link\``)
1202
1210
  if (list.length === 1) {
1203
1211
  workspaceId = list[0].id
1212
+ matched = list[0]
1204
1213
  console.log(`figs: linking to ${list[0].slug} (${list[0].name})`)
1205
1214
  } else {
1206
1215
  console.log("figs: which workspace? re-run with one:")
@@ -1216,9 +1225,50 @@ async function link() {
1216
1225
  JSON.stringify({ agentId: config.agentId, endpoint, workspaceId }, null, 2) + "\n",
1217
1226
  )
1218
1227
  console.log(`figs: ✓ linked — workspace ${workspaceId} @ ${endpoint}`)
1228
+ printDepartmentGuidance(matched)
1219
1229
  console.log(" next: `figs push` publishes everything recorded so far")
1220
1230
  }
1221
1231
 
1232
+ /**
1233
+ * Steer department convergence at the one connected moment. `department` is
1234
+ * free-text the agent authors locally (account-free, before any workspace
1235
+ * exists) — so independently-authored agents drift ("Member Retention" vs
1236
+ * "Member Success" vs "Member Ops") and the org chart fragments. Link is where
1237
+ * we can finally compare the local charter against what the workspace already
1238
+ * uses and nudge the agent to adopt an existing department. Print-only: the
1239
+ * agent reconciles its own `agent.json` (link never writes the charter). Skips
1240
+ * silently when we couldn't fetch the workspace (logged-out UUID) or an older
1241
+ * app didn't return `departments`.
1242
+ */
1243
+ function printDepartmentGuidance(workspace) {
1244
+ if (!workspace || !Array.isArray(workspace.departments)) return
1245
+ const existing = workspace.departments.filter(Boolean)
1246
+ const raw = readJson(join(repoDir, "agent.json"), {})?.org?.department
1247
+ const mine = typeof raw === "string" ? raw.trim() : ""
1248
+ // A `<…>` stub or empty value isn't a real choice yet.
1249
+ const unset = !mine || mine.includes("<")
1250
+ const list = existing.join(" · ")
1251
+
1252
+ if (existing.length === 0) {
1253
+ console.log(
1254
+ unset
1255
+ ? " no departments here yet — you're the first; set agent.json#org.department to a broad team name"
1256
+ : ` first department here — you're starting "${mine}"`,
1257
+ )
1258
+ return
1259
+ }
1260
+ const hit = existing.find((d) => d.toLowerCase() === mine.toLowerCase())
1261
+ if (hit) {
1262
+ console.log(` ✓ department "${hit}" — grouping with the rest of the workspace`)
1263
+ } else if (!unset) {
1264
+ console.log(` ! department "${mine}" is new here. Existing: ${list}`)
1265
+ console.log(" → if one fits, set agent.json#org.department to match — coherent grouping is the point")
1266
+ } else {
1267
+ console.log(` departments here: ${list}`)
1268
+ console.log(" → set agent.json#org.department to one (or a new one if none fit)")
1269
+ }
1270
+ }
1271
+
1222
1272
  async function init() {
1223
1273
  // Purely local — `init` never touches the network, so it can never need an
1224
1274
  // account. The one gate is consent, not connectivity: Figs turns a repo into a
@@ -1708,6 +1758,14 @@ async function answerCmd() {
1708
1758
 
1709
1759
  const chosen = flag("--chosen")
1710
1760
  const text = flag("--text")
1761
+ // A verdict cites no option — options are question-only. On a sign-off the verdict
1762
+ // (approve / request-changes / reject) is the whole answer; pairing it with --chosen
1763
+ // is the "double verdict" (cite one path, rule another). Refuse it at the mint point.
1764
+ if (verdicts.length && chosen) {
1765
+ die(
1766
+ "a verdict cites no option — options are for questions. On a sign-off the verdict (approve / request-changes / reject) is the whole answer; put any caveat in --text '<note>'",
1767
+ )
1768
+ }
1711
1769
  const msg = {
1712
1770
  id: genId("msg"),
1713
1771
  kind: verdicts.length ? "verdict" : "answer",
@@ -2062,6 +2120,17 @@ async function askCmd() {
2062
2120
  if (!ask.to) ask.to = "manager"
2063
2121
  const options = flagAll("--option")
2064
2122
  if (options.length) ask.options = options
2123
+ // Options are question-only: they're candidate answers a reply cites. A sign-off's
2124
+ // answer is the VERDICT (approve / request-changes / reject) — never an option. An
2125
+ // option on a sign-off restates a verdict ("Hold", "Reject") and lets a reader cite
2126
+ // one verdict while clicking another. Refuse it at the authoring mint point. (Catches
2127
+ // --stdin options too — ask.options is the merged value.) What approval triggers goes
2128
+ // in --on-approve; any caveat rides in the reply note.
2129
+ if (ask.options?.length && ask.type === "sign-off") {
2130
+ die(
2131
+ "--option is sign-off-illegal: options are for questions (a reply cites one). A sign-off's answer is the verdict (approve / request-changes / reject) — put what approval sets in motion in --on-approve, and any caveat in the reply note",
2132
+ )
2133
+ }
2065
2134
  for (const o of ask.options ?? []) {
2066
2135
  if (o.length > 80) {
2067
2136
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.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": {