@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.
- package/SPEC.md +10 -4
- package/figs.mjs +58 -5
- 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 */ ]
|
|
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({
|
|
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
|
-
|
|
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
|
)
|