@nodii/dev 0.6.28 → 0.6.30

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/README.md CHANGED
@@ -87,7 +87,7 @@ nodii-dev login [--no-browser] [--scope KEYS] [--force]
87
87
  nodii-dev logout
88
88
  nodii-dev whoami # OAuth identity plus the registered agent
89
89
  nodii-dev config show
90
- nodii-dev doctor [--figma-file KEY] [--json] # preflight: everything a lane needs, read-only
90
+ nodii-dev doctor [--figma-file KEY] [--json] # preflight: Dev, hooks, local Ollama + Gather model, optional Figma
91
91
 
92
92
  nodii-dev api list [--tag TAG] [--json]
93
93
  nodii-dev api OPERATION_ID [--param name=value ...] [--body @file|-|JSON]
@@ -97,6 +97,8 @@ nodii-dev agent heartbeat
97
97
  nodii-dev agents spawn SERVICE [--repo-root DIR]
98
98
  nodii-dev agents cowork-tick [--json]
99
99
  nodii-dev agents cowork-tick --plist [--interval SECONDS]
100
+ nodii-dev watchdog install [--interval SECONDS]
101
+ nodii-dev watchdog sweep
100
102
 
101
103
  nodii-dev issues list [--service ID] [--project KEY] [--status STATUS]
102
104
  [--kind KIND] [--priority PRIORITY] [--claimed true|false]
@@ -127,6 +129,15 @@ nodii-dev decisions get D123
127
129
  nodii-dev decisions search WORDS
128
130
  nodii-dev decisions create|lock|supersede|amend
129
131
 
132
+ nodii-dev lane gather [KEY] [--delta] [--objective TEXT] [--done-check TEXT]
133
+ [--paths A,B] [--components K,L]
134
+ nodii-dev lane freeze add KEY --section SECTION --file ITEM.json --reason TEXT
135
+ nodii-dev lane freeze drop KEY --section SECTION --item ID --reason TEXT
136
+ nodii-dev lane freeze swap KEY --section SECTION [--item ID] --file VALUE.json --reason TEXT
137
+ nodii-dev lane freeze show KEY
138
+ nodii-dev lane freeze history KEY
139
+ nodii-dev rules find "QUESTION" [--limit N]
140
+
130
141
  nodii-dev open-questions list|show|answer
131
142
  nodii-dev feature-docs list|show|claim|release|status
132
143
  nodii-dev contracts grpc|rest|outbox|saga|table publish|status
@@ -312,6 +323,7 @@ nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP [--r
312
323
  nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF
313
324
  nodii-dev lane subagent-quota KEY N
314
325
  nodii-dev lane heartbeat KEY [--note TEXT]
326
+ nodii-dev lane resume KEY
315
327
  nodii-dev lane review KEY --file REVIEW.json
316
328
  nodii-dev lane reviews KEY
317
329
  nodii-dev lane land KEY
@@ -358,8 +370,22 @@ nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run] [--s
358
370
  nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
359
371
  nodii-dev harness check [--json] # 0 current, 1 differs from Dev, 2 no harness
360
372
  nodii-dev harness update --check # the same check
373
+ nodii-dev watchdog install [--interval SECONDS] # install/update com.nodii.watchdog in ~/Library/LaunchAgents
374
+ nodii-dev watchdog sweep # launchd entrypoint; normally not run by hand
361
375
  ```
362
376
 
377
+ The per-Mac watchdog runs outside agent sessions. Run `watchdog install` once
378
+ with the machine coordinator's config; it refuses a lane or unassigned agent.
379
+ Every sweep reports three host facts to Dev: watched run processes that exited
380
+ without ending their run, `/tmp/cc-socks/<pid>.sock` sockets whose process is
381
+ gone, and watched worktrees whose HEAD has commits absent from its configured
382
+ remote branch. Each observation is posted to the watched run by the machine
383
+ coordinator; `process_dead` transitions it to explicit `orphan` immediately.
384
+ Run launchers and wrappers persist one owner-only record per process under
385
+ `~/.config/nodii-dev/watchdog/runs/`; the plist itself contains only absolute
386
+ command/config paths, its schedule, and log paths, while OAuth credentials
387
+ remain in the machine keychain.
388
+
363
389
  `harness install` and `harness update` merge the safety hooks into both
364
390
  `.claude/settings.json` and `.codex/hooks.json`; every installed hook invokes
365
391
  only `nodii-dev`. The
@@ -718,6 +744,42 @@ from AGENT-HARNESS-14 is available.
718
744
 
719
745
  ## Build lanes and repository setup
720
746
 
747
+ `coordinator start --machine NAME` registers the machine's one watched
748
+ coordinator run and shows its running lanes, orphaned runs and unclaimed
749
+ results. A live second coordinator is refused; a coordinator whose heartbeat
750
+ watchdog has recorded it orphaned is recorded as the predecessor of the
751
+ takeover.
752
+
753
+ `lane launch KEY` replaces the manual worktree/start/process sequence. It
754
+ requires the issue (whose title supplies both lane title and objective), done
755
+ check, build kind and side, runtime profile and model, subagent quota, and
756
+ repeated `--repo NAME=/SOURCE` plus
757
+ `--branch NAME=BRANCH` pairs. The Hub preflight checks the coordinator waiter,
758
+ target agent and machine, fresh account-capacity telemetry, repository grants,
759
+ lane limit, open issues and (when `--figma` is present) the authenticated Figma
760
+ account. Only after that passes does the CLI create one worktree per repository,
761
+ atomically register the lane and watched run, and launch the runtime with the
762
+ single-line brief `You are lane KEY. Run nodii-dev lane gather.`. Ordinary
763
+ notes use `--notes`; a scope-narrowing note uses `--narrowing-note` and must
764
+ also name the remainder's destination with `--narrowing-remainder`.
765
+ The launched process uses the already-provisioned lowercase identity (for
766
+ example `~/.config/nodii-dev/agents/lane-ah-12.json`), never the coordinator's
767
+ Dev identity.
768
+
769
+ Objective, scope and rule changes are durable `lane amend` records, never chat
770
+ messages. Every CLI call from a launched worktree checks the current watched
771
+ run's acknowledgement version. A pending change is printed and blocks the
772
+ call until `lane gather --delta` serves the current rules and acknowledges the
773
+ newest displayed version. `NODII_DEV_RUN_ID` makes this check per subagent run,
774
+ so one child cannot acknowledge for its siblings.
775
+
776
+ Gather also persists the lane's bounded Freeze: the objective and done check,
777
+ strict repository/Figma boundaries, the current pinned clauses, and the lane's
778
+ lookups, decisions, and unconfirmed questions. Ordinary CLI calls print and
779
+ acknowledge a changed Freeze before continuing, and compact SessionStart uses
780
+ the same backstop. `lane freeze add|drop|swap` makes a reasoned, version-checked
781
+ change; `show|history` reads the current contract or its append-only history.
782
+
721
783
  `lane start` creates and starts a lane. It infers the repository (by short
722
784
  name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
723
785
  required by Dev. Lane keys are passed directly to every lane and Built
@@ -733,6 +795,17 @@ slot; Dev refuses another registration with `SUBAGENT_QUOTA_REACHED` when the
733
795
  lane is full. A `done`, `handed_off`, or `failed` subagent run no longer counts,
734
796
  so the watchdog marking a killed process failed frees the slot.
735
797
 
798
+ `lane resume` asks Dev to reserve a fresh run for an orphaned lane, then launches
799
+ the allowlisted Codex or Claude runtime as a detached headless process in the
800
+ same worktree. Dev keeps the lane's finished steps intact and returns the next
801
+ unfinished step together with its bounded recovery packet. The CLI sends that
802
+ packet over the new process's stdin (never its argv), saves its exact PID and
803
+ append-only log under `~/.config/nodii-dev/runs/<run-id>/`, and reports a local
804
+ spawn failure back to Dev so the reservation is not left running. Human output
805
+ identifies the replacement run, PID, step and last pushed commit without
806
+ echoing the dead run's log tail; `--json` retains the complete server response.
807
+ Dev refuses a third attempt after two identical deaths.
808
+
736
809
  `setup` installs the Claude Code and Codex workflow harnesses in the current repository.
737
810
  It writes `.claude/skills/nodii-build-workflow/SKILL.md`, merges SessionStart,
738
811
  Stop and tool hooks into `.claude/settings.json` and `.codex/hooks.json`, and maintains one
package/dist/api.js CHANGED
@@ -99,7 +99,8 @@ export const GROUPS = {
99
99
  "approval-kinds": "ApprovalKinds", "notification-registry": "Notification registry", skills: "Skills", "open-questions": "OpenQuestions",
100
100
  decisions: "Decisions", drift: "Drift", requests: "Requests", pipelines: "Pipelines", slices: "Slices", "sql-lookups": "SqlLookups",
101
101
  runners: "Runners", routing: "Routing", usage: "Usage", audit: "AuditLog", reconciliation: "Reconciliation", cron: "Cron", health: "Health",
102
- composite: "Composite", "service-edges": "ServiceEdges", lanes: "Lanes",
102
+ composite: "Composite", "service-edges": "ServiceEdges", lanes: "Lanes", runs: "Runs",
103
+ releases: "Releases", operate: "Operate",
103
104
  swaps: "Swaps", ideas: "Ideas", terminology: "Terminology", credentials: "Credentials",
104
105
  rulebook: "ad-documents", "ad-decisions": "ad-decisions", domains: "ad-domains", standardizations: "standardizations",
105
106
  archive: "archive", harness: "harness", utils: "utils",
@@ -154,6 +155,8 @@ export const ALIASES = {
154
155
  "standardizations:list": "listStandardizations", "standardizations:show": "getStandardization", "standardizations:update": "updateStandardization",
155
156
  "archive:search": "searchPreDevArchive", "archive:resolve": "resolvePreCitation",
156
157
  "harness:show": "generateHarness", "harness:methodology": "getHarnessMethodologyVersion",
158
+ "runs:register": "registerRun", "runs:show": "getRun", "runs:heartbeat": "heartbeatRun", "runs:end": "endRun",
159
+ "runs:results": "listRunResults", "runs:acknowledge": "acknowledgeResult",
157
160
  "terminology:list": "listTerminology", "terminology:show": "getTerminologyTerm", "terminology:create": "createTerminologyTerm", "terminology:update": "updateTerminologyTerm",
158
161
  };
159
162
 
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { harnessCommand } from "./harness.js";
26
26
  import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
27
27
  import { doctorCommand } from "./doctor.js";
28
28
  import { dispatchManagedHook } from "./managed-hooks.js";
29
+ import { freezeCommand } from "./freeze.js";
29
30
  import {
30
31
  figmaChangeSetShow,
31
32
  figmaChangeSetText,
@@ -53,6 +54,7 @@ export { figmaLock, submitMode } from "./figma.js";
53
54
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
54
55
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
55
56
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
57
+ import { watchdogCommand } from "./watchdog.js";
56
58
  import {
57
59
  ALIASES,
58
60
  GROUPS,
@@ -74,6 +76,13 @@ import {
74
76
  nextCommand,
75
77
  peopleCommand,
76
78
  } from "./lanes.js";
79
+ import {
80
+ amendmentGuard,
81
+ amendLaneCommand,
82
+ coordinatorCommand,
83
+ gatherLaneCommand,
84
+ launchLaneCommand,
85
+ } from "./starting.js";
77
86
  import {
78
87
  launchRunSupervisor,
79
88
  showWaitingResults,
@@ -87,8 +96,8 @@ Usage: nodii-dev <command> [options] [--json]
87
96
  login [--force] | logout | whoami | config show
88
97
  # login says so when the machine is already signed in; --force
89
98
  # replaces that one sign-in (never from a lane)
90
- doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots
91
- # and, given a key (or NODII_DEV_FIGMA_FILE), that Figma file's registration
99
+ doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots,
100
+ # local Ollama + pinned Gather model, and optional Figma registration
92
101
  agent register --name NAME | agent heartbeat
93
102
  issues list|show|create|update|claim|release
94
103
  issues comment ISSUE --body TEXT # or --body-file FILE; issues update --comment TEXT|--comment-file FILE adds one too
@@ -127,11 +136,13 @@ Usage: nodii-dev <command> [options] [--json]
127
136
  figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
128
137
  # register a Figma file in Dev so it can be locked
129
138
  hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
130
- setup [--yes|--check] # dry-run by default
139
+ setup [--yes|--check] # dry-run by default; installs/checks Ollama and pulls the Gather model
131
140
  harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
132
141
  # --system stages managed hooks and only prints operator-reviewed sudo commands
133
142
  harness check # 0 current, 1 differs from Dev, 2 not installed
134
143
  harness update --check # the same check
144
+ watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
145
+ watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees
135
146
  docs check|validate|upload <path...> --class ruling|conformance|editorial
136
147
  [--base CODE=VERSION] [--decision FILE] [--pending] [--conformance-ref REF]
137
148
  [--meaning-unchanged REASON] [--wait]
@@ -140,8 +151,21 @@ Usage: nodii-dev <command> [options] [--json]
140
151
  rulebook list|show CODE [--version V]|diff CODE
141
152
  rules core # the generated core (M05-33); refused over the cap
142
153
  rules report [--section S] [--lane KEY] # delivery totals per document; rules served per lane
154
+ rules find "QUESTION" [--limit N] # answer an ad-hoc rules question from the indexed sections
143
155
  rules cap TOKENS # operator: set the core token cap (default 3000)
144
156
 
157
+ coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
158
+ lane launch <key> --done-check TEXT --kind KIND --side SIDE
159
+ --issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
160
+ --machine NAME --runtime claude|codex --profile PATH --model MODEL --subagents N
161
+ [--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
162
+ [--figma FILE[=COMPONENT_SET[,SET]]] [--notes TEXT]
163
+ [--narrowing-note TEXT --narrowing-remainder DESTINATION]
164
+ # authoritative preflight, one worktree per repo, watched launch
165
+ # uses the provisioned agents/lane-<key>.json Dev identity
166
+ lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
167
+ lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT]
168
+ # infers a launched worktree; explicit contract inputs support manual lane start
145
169
  run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
146
170
  # watched command: registers with NODII_DEV_RUN_ID,
147
171
  # heartbeats, and records exit code + redacted log tail
@@ -151,15 +175,21 @@ Usage: nodii-dev <command> [options] [--json]
151
175
  # --block --reason on a blocked step records its new reason
152
176
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
153
177
  lane subagent-quota <key> <n> # coordinator assigns 0-100 subagent slots
154
- lane heartbeat|land|verify|prioritize|deprioritize|stop <key>
178
+ lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
155
179
  lane review <key> --file REVIEW.json # base/head, outcome and clause-cited findings
156
180
  lane reviews <key> # stored review history for the lane
181
+ lane freeze add <key> --section SECTION --file ITEM.json --reason TEXT
182
+ lane freeze drop <key> --section SECTION --item ID --reason TEXT
183
+ lane freeze swap <key> --section SECTION [--item ID] --file VALUE.json --reason TEXT
184
+ lane freeze show|history <key> # durable compact-safe contract and all versions
157
185
  lane non-issue <key> --reason TEXT --evidence TEXT --reroute KEY[,KEY]|none
158
186
  --branch carried:KEY|dropped[:REASON] [--commits SHA[,SHA]]
159
187
  # DEC-0011: terminal non_issue; issue -> wont_do; slot frees
160
188
  lane list [--kind KIND] [--status STATUS] [--mine] [--cursor CURSOR]
161
189
  lane show <key> | next <key> [--paths A,B] [--components K,L] [--role R]
162
190
  # next step, plus the rules Dev serves for it (DEC-0020)
191
+ lane gather [key] [--delta] [--paths A,B] [--components K,L]
192
+ # local document pick; prints Lane contract then Clause map
163
193
  built list | built ack <key...>
164
194
  machine list|show|set-coordinator|assign|report
165
195
  agent lane-limit|grant|revoke
@@ -412,8 +442,12 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
412
442
  }
413
443
 
414
444
  export async function main(argv = process.argv.slice(2)) {
415
- if (argv[0] !== "__run-supervisor")
445
+ // Amendment acknowledgement is a lane-wide backstop, including otherwise
446
+ // local commands such as help and version.
447
+ if (argv[0] !== "__run-supervisor") {
448
+ await amendmentGuard(argv);
416
449
  await showWaitingResults({ acknowledge: argv[0] !== "hook" });
450
+ }
417
451
  if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
418
452
  return console.log(VERSION);
419
453
  if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
@@ -523,6 +557,10 @@ export async function main(argv = process.argv.slice(2)) {
523
557
  if (rest.length) throw new CliError("USAGE", `harness ${action} takes no arguments, got: ${rest.join(" ")}. Write a flag value as --include-drafts=false.`, 2);
524
558
  return emitCommand(await harnessCommand(action, f));
525
559
  }
560
+ if (command === "watchdog") {
561
+ if (rest.length) throw new CliError("USAGE", `watchdog ${action ?? ""} takes no arguments, got: ${rest.join(" ")}.`, 2);
562
+ return emitCommand(await watchdogCommand(action, f));
563
+ }
526
564
  if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
527
565
  if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
528
566
  if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
@@ -541,6 +579,12 @@ export async function main(argv = process.argv.slice(2)) {
541
579
  }
542
580
  return;
543
581
  }
582
+ if (command === "coordinator") return emitCommand(await coordinatorCommand(action, f));
583
+ if (command === "lane" && action === "launch") return emitCommand(await launchLaneCommand(rest[0], f));
584
+ if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
585
+ if (command === "lane" && action === "gather") return emitCommand(await gatherLaneCommand(rest[0], f));
586
+ if (command === "lane" && action === "freeze")
587
+ return emitCommand(await freezeCommand(rest[0], rest.slice(1), f));
544
588
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
545
589
  if (command === "next") return emitCommand(await nextCommand(action, f));
546
590
  if (command === "built") return emitCommand(await builtCommand(action, rest, f));
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.28";
7
+ export const VERSION = "0.6.30";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -119,9 +119,30 @@ const staleCode = (raw) => {
119
119
  }
120
120
  };
121
121
 
122
- async function devFetch(method, path, bodyText, contentType, accept, extra = {}) {
123
- const config = await readConfig();
124
- const auth = await authProvider();
122
+ const withSignal = (promise, signal) => {
123
+ if (!signal) return promise;
124
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
125
+ return new Promise((resolvePromise, rejectPromise) => {
126
+ const abort = () => rejectPromise(signal.reason ?? new Error("aborted"));
127
+ signal.addEventListener("abort", abort, { once: true });
128
+ Promise.resolve(promise).then(
129
+ (value) => { signal.removeEventListener("abort", abort); resolvePromise(value); },
130
+ (error) => { signal.removeEventListener("abort", abort); rejectPromise(error); },
131
+ );
132
+ });
133
+ };
134
+
135
+ async function devFetch(method, path, bodyText, contentType, accept, extra = {}, signal) {
136
+ const wait = async (promise) => {
137
+ try {
138
+ return await withSignal(promise, signal);
139
+ } catch (error) {
140
+ if (signal?.aborted) throw new CliError("DEV_TIMEOUT", `Dev request timed out: ${method} ${path}`, 1);
141
+ throw error;
142
+ }
143
+ };
144
+ const config = await wait(readConfig());
145
+ const auth = await wait(authProvider());
125
146
  const url = requestUrl(config, path);
126
147
  // The edge refuses a token minted before the principal's claims epoch with
127
148
  // 401 token_stale (refresh and retry once). The request never reached Dev,
@@ -132,26 +153,28 @@ async function devFetch(method, path, bodyText, contentType, accept, extra = {})
132
153
  for (;;) {
133
154
  const headers = { accept, ...extra };
134
155
  if (bodyText !== undefined) headers["content-type"] = contentType;
135
- Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
156
+ Object.assign(headers, await wait(auth.headers({ method, path, body: bodyText ?? "" })));
136
157
  let response;
137
158
  try {
138
- response = await fetch(url, { method, headers, body: bodyText });
159
+ response = await wait(fetch(url, { method, headers, body: bodyText, signal }));
139
160
  } catch (error) {
161
+ if (error?.code === "DEV_TIMEOUT") throw error;
162
+ if (signal?.aborted) throw new CliError("DEV_TIMEOUT", `Dev request timed out: ${method} ${path}`, 1);
140
163
  throw new CliError("DEV_UNREACHABLE", `Cannot reach Dev at ${new URL(url).origin}: ${error.message}`);
141
164
  }
142
- const raw = await response.text();
165
+ const raw = await wait(response.text());
143
166
  if (response.status !== 401 || typeof auth.refreshStale !== "function" || !staleCode(raw)) return { response, raw };
144
167
  if (refreshed) return { response, raw };
145
168
  // A cached token that is stale too is not adopted twice: the next one is refreshed.
146
169
  const used = String(headers.authorization ?? "").replace(/^Bearer /, "");
147
- if ((await auth.refreshStale(used, { force: adopted })) === "refreshed") refreshed = true;
170
+ if ((await wait(auth.refreshStale(used, { force: adopted }))) === "refreshed") refreshed = true;
148
171
  else adopted = true;
149
172
  }
150
173
  }
151
174
 
152
175
  /** A JSON call that keeps the status: `{ status, payload }`. Errors throw as devRawRequest's do. */
153
- export async function devExchange(method, path, bodyText, { contentType = "application/json", headers = {} } = {}) {
154
- const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json", headers);
176
+ export async function devExchange(method, path, bodyText, { contentType = "application/json", headers = {}, signal } = {}) {
177
+ const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json", headers, signal);
155
178
  let payload;
156
179
  try {
157
180
  payload = raw ? JSON.parse(raw) : {};
@@ -168,8 +191,8 @@ export async function devExchange(method, path, bodyText, { contentType = "appli
168
191
  return { status: response.status, payload };
169
192
  }
170
193
 
171
- export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
172
- return (await devExchange(method, path, bodyText, { contentType })).payload;
194
+ export async function devRawRequest(method, path, bodyText, contentType = "application/json", options = {}) {
195
+ return (await devExchange(method, path, bodyText, { ...options, contentType })).payload;
173
196
  }
174
197
 
175
198
  /** A Dev route that answers with text (e.g. a document body as text/markdown). Errors are Dev's JSON. */
@@ -181,8 +204,8 @@ export async function devTextRequest(method, path) {
181
204
  throw new CliError(payload.code ?? `HTTP_${response.status}`, payload.message ?? `Dev returned HTTP ${response.status}`, 1, payload.details);
182
205
  }
183
206
 
184
- export const devRequest = (method, path, body) =>
185
- devRawRequest(method, path, body === undefined ? undefined : JSON.stringify(body));
207
+ export const devRequest = (method, path, body, options) =>
208
+ devRawRequest(method, path, body === undefined ? undefined : JSON.stringify(body), "application/json", options);
186
209
 
187
210
  /** A JSON call sent with `Prefer: respond-async` (RFC 7240): `{ status, payload }`, 202 when Dev took it as a job. */
188
211
  export const devAsyncRequest = async (method, path, body) =>
@@ -199,7 +222,7 @@ export async function checkLogin() {
199
222
  export const encode = (value) => encodeURIComponent(value);
200
223
  // Switches never take the next token as their value, so
201
224
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
202
- const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system"]);
225
+ const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta"]);
203
226
  export function parseArgs(argv) {
204
227
  const flags = {};
205
228
  const args = [];