@nodii/dev 0.6.29 → 0.6.31

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]
@@ -129,6 +129,15 @@ nodii-dev decisions get D123
129
129
  nodii-dev decisions search WORDS
130
130
  nodii-dev decisions create|lock|supersede|amend
131
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
+
132
141
  nodii-dev open-questions list|show|answer
133
142
  nodii-dev feature-docs list|show|claim|release|status
134
143
  nodii-dev contracts grpc|rest|outbox|saga|table publish|status
@@ -304,6 +313,9 @@ nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
304
313
  nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
305
314
  nodii-dev hook safety-pre # silent safety decision; blocks with exit 2
306
315
  nodii-dev hook safety-post # silently records resources created by successful commands
316
+ nodii-dev hook nudge-pre # guideline nudges before a tool call; never blocks
317
+ nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
318
+ nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
307
319
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
308
320
 
309
321
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
@@ -420,6 +432,38 @@ deploy. A coordinator's exceptional `--no-verify` also requires
420
432
  `NODII_DEV_NO_VERIFY_REASON`; the CLI records the reason in private Git
421
433
  metadata. Lanes and subagents can never use `--no-verify`.
422
434
 
435
+ ### Guideline nudges (AGENT-HARNESS-21)
436
+
437
+ `harness install` and `harness update` add two more `nodii-dev` hooks for
438
+ Claude Code and Codex. A nudge reminds a lane of a rule at the moment it is
439
+ about to be broken and **says nothing when the rule is met**. Nudges never
440
+ block a tool call, fail open on any error or unknown payload, and stay silent
441
+ in any session that is not a Dev lane. Each cites the clause it protects.
442
+
443
+ | Moment | Nudge | Silent when |
444
+ |---|---|---|
445
+ | Edit or write | A DS component or product page is used or edited and its guideline is not in the lane's gathered set (U07-19, DEC-0010) | the guideline is in the gathered set, or was read with `design guideline show` |
446
+ | Edit or write | `className` or `style` on a DS component (U07-19) | the tag is not a DS import, or has neither prop |
447
+ | Edit or write | A new migration: journal `when` = max + 1, then a Delta gather. A `_journal.json` write whose `when` values do not rise | the migration is covered by a Delta gather, or already exists |
448
+ | Edit or write | Code renders a person: `PiiText` without `self`, a raw `.email`/`.phone`/`.displayName`, or the page masks a value itself (R5, U05-25) | `PiiText` carries `self`, or no person is rendered |
449
+ | Figma read | A code lane reads by node id (`get_design_context`, `get_metadata`, `get_screenshot`, ...) instead of the published key (MW8-20) | a Figma lane, or no node id |
450
+ | `figma submit CS-n` | The unused-property, stale `Blocked on DS` placeholder and Dialog-without-body hand checks (F-34, CS-2453) | all three are recorded with `figma checked CS-n --unused-properties --stale-placeholders --dialog-body` |
451
+ | AskUserQuestion | The question is also an item in `docs/figma-ux-drive/qa-with-abhi/open.md` (R7) | open.md already holds the question |
452
+ | Stop | The lane is not `on_dev` but "done" was claimed (MW3); an "I couldn't" with no filed issue; Unconfirmed Freeze items still open; uncommitted work | none of those; a Stop that was already nudged once may stop |
453
+
454
+ The lane's gathered set is the private Git file `nodii-lane-knowledge.json`:
455
+ `lane gather --components a,b` adds the components it covered to `guidelines`,
456
+ `lane gather --delta` adds the paths it saw (`--paths`, else every path this
457
+ worktree changes against origin/main) to `deltaGathered`, and `design guideline
458
+ show` adds to `guidelinesRead`. A part's guideline is its root's (`dialog`
459
+ covers `dialog-content`). Edits are judged against the file they produce, so
460
+ an Edit or `apply_patch` that only changes an attribute is read in full.
461
+
462
+ The Stop nudge asks the session to continue once (`decision: block`, skipped
463
+ when `stop_hook_active`); Codex passes it through its managed dispatcher. A
464
+ nudge hook skips the amendment guard and the waiting-results banner, since
465
+ either would corrupt its JSON; it cannot run a Delta gather.
466
+
423
467
  ### DS conformance: `design roles` and `design check` (DEC-0034)
424
468
 
425
469
  Every UI role names exactly one DS component (MW6-14). Resolve your brief's
@@ -764,6 +808,13 @@ call until `lane gather --delta` serves the current rules and acknowledges the
764
808
  newest displayed version. `NODII_DEV_RUN_ID` makes this check per subagent run,
765
809
  so one child cannot acknowledge for its siblings.
766
810
 
811
+ Gather also persists the lane's bounded Freeze: the objective and done check,
812
+ strict repository/Figma boundaries, the current pinned clauses, and the lane's
813
+ lookups, decisions, and unconfirmed questions. Ordinary CLI calls print and
814
+ acknowledge a changed Freeze before continuing, and compact SessionStart uses
815
+ the same backstop. `lane freeze add|drop|swap` makes a reasoned, version-checked
816
+ change; `show|history` reads the current contract or its append-only history.
817
+
767
818
  `lane start` creates and starts a lane. It infers the repository (by short
768
819
  name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
769
820
  required by Dev. Lane keys are passed directly to every lane and Built
package/dist/cli.js CHANGED
@@ -23,9 +23,11 @@ import {
23
23
  import { heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
24
24
  import { setupCommand } from "./setup.js";
25
25
  import { harnessCommand } from "./harness.js";
26
- import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
26
+ import { hookRuntime, recordHookFire, safetyContext, safetyPostHook, safetyPreHook } from "./safety.js";
27
+ import { SUBMIT_CHECK_FLAGS, changedPaths, nudgeCandidate, nudgePre, nudgeStop, recordGathered, recordGuidelineRead, recordSubmitChecks } from "./nudges.js";
27
28
  import { doctorCommand } from "./doctor.js";
28
29
  import { dispatchManagedHook } from "./managed-hooks.js";
30
+ import { freezeCommand } from "./freeze.js";
29
31
  import {
30
32
  figmaChangeSetShow,
31
33
  figmaChangeSetText,
@@ -95,8 +97,8 @@ Usage: nodii-dev <command> [options] [--json]
95
97
  login [--force] | logout | whoami | config show
96
98
  # login says so when the machine is already signed in; --force
97
99
  # replaces that one sign-in (never from a lane)
98
- doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots
99
- # and, given a key (or NODII_DEV_FIGMA_FILE), that Figma file's registration
100
+ doctor [--figma-file KEY] [--json] # preflight: sign-in, agent, permissions, machine, repo, lane slots,
101
+ # local Ollama + pinned Gather model, and optional Figma registration
100
102
  agent register --name NAME | agent heartbeat
101
103
  issues list|show|create|update|claim|release
102
104
  issues comment ISSUE --body TEXT # or --body-file FILE; issues update --comment TEXT|--comment-file FILE adds one too
@@ -125,6 +127,7 @@ Usage: nodii-dev <command> [options] [--json]
125
127
  figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
126
128
  figma check|log-write|submit|apply|abandon|release
127
129
  figma submit CHANGE_SET --file SUBMIT.json [--sync]
130
+ figma checked CHANGE_SET --unused-properties --stale-placeholders --dialog-body # the hand checks the submit nudge asks for
128
131
  # submit, resubmit and design check --change-set run as a job in Dev
129
132
  # and poll it (a large set outlasts the edge's timeout); --sync waits
130
133
  # on one request instead. A 502/504 is checked against the set, never resent
@@ -134,8 +137,8 @@ Usage: nodii-dev <command> [options] [--json]
134
137
  figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
135
138
  figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
136
139
  # register a Figma file in Dev so it can be locked
137
- hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
138
- setup [--yes|--check] # dry-run by default
140
+ hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|nudge-pre|nudge-stop|session-start|managed-dispatch|print-settings
141
+ setup [--yes|--check] # dry-run by default; installs/checks Ollama and pulls the Gather model
139
142
  harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
140
143
  # --system stages managed hooks and only prints operator-reviewed sudo commands
141
144
  harness check # 0 current, 1 differs from Dev, 2 not installed
@@ -150,6 +153,7 @@ Usage: nodii-dev <command> [options] [--json]
150
153
  rulebook list|show CODE [--version V]|diff CODE
151
154
  rules core # the generated core (M05-33); refused over the cap
152
155
  rules report [--section S] [--lane KEY] # delivery totals per document; rules served per lane
156
+ rules find "QUESTION" [--limit N] # answer an ad-hoc rules question from the indexed sections
153
157
  rules cap TOKENS # operator: set the core token cap (default 3000)
154
158
 
155
159
  coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
@@ -162,7 +166,8 @@ Usage: nodii-dev <command> [options] [--json]
162
166
  # authoritative preflight, one worktree per repo, watched launch
163
167
  # uses the provisioned agents/lane-<key>.json Dev identity
164
168
  lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
165
- lane gather [<key>] [--delta] # infers a launched worktree; Delta required after an amendment
169
+ lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT] [--components a,b] [--paths a,b]
170
+ # infers a launched worktree; explicit contract inputs support manual lane start
166
171
  run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
167
172
  # watched command: registers with NODII_DEV_RUN_ID,
168
173
  # heartbeats, and records exit code + redacted log tail
@@ -175,12 +180,18 @@ Usage: nodii-dev <command> [options] [--json]
175
180
  lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
176
181
  lane review <key> --file REVIEW.json # base/head, outcome and clause-cited findings
177
182
  lane reviews <key> # stored review history for the lane
183
+ lane freeze add <key> --section SECTION --file ITEM.json --reason TEXT
184
+ lane freeze drop <key> --section SECTION --item ID --reason TEXT
185
+ lane freeze swap <key> --section SECTION [--item ID] --file VALUE.json --reason TEXT
186
+ lane freeze show|history <key> # durable compact-safe contract and all versions
178
187
  lane non-issue <key> --reason TEXT --evidence TEXT --reroute KEY[,KEY]|none
179
188
  --branch carried:KEY|dropped[:REASON] [--commits SHA[,SHA]]
180
189
  # DEC-0011: terminal non_issue; issue -> wont_do; slot frees
181
190
  lane list [--kind KIND] [--status STATUS] [--mine] [--cursor CURSOR]
182
191
  lane show <key> | next <key> [--paths A,B] [--components K,L] [--role R]
183
192
  # next step, plus the rules Dev serves for it (DEC-0020)
193
+ lane gather [key] [--delta] [--paths A,B] [--components K,L]
194
+ # local document pick; prints Lane contract then Clause map
184
195
  built list | built ack <key...>
185
196
  machine list|show|set-coordinator|assign|report
186
197
  agent lane-limit|grant|revoke
@@ -435,7 +446,9 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
435
446
  export async function main(argv = process.argv.slice(2)) {
436
447
  // Amendment acknowledgement is a lane-wide backstop, including otherwise
437
448
  // local commands such as help and version.
438
- if (argv[0] !== "__run-supervisor") {
449
+ // A nudge hook is fail-open and writes only its own JSON: it cannot run a
450
+ // Delta gather, and a waiting-results banner would corrupt its output.
451
+ if (argv[0] !== "__run-supervisor" && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
439
452
  await amendmentGuard(argv);
440
453
  await showWaitingResults({ acknowledge: argv[0] !== "hook" });
441
454
  }
@@ -556,7 +569,12 @@ export async function main(argv = process.argv.slice(2)) {
556
569
  if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
557
570
  if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
558
571
  // Guideline change sets: approving or rejecting one is a person's action in Dev.
559
- if (command === "design" && action === "guideline") return emitCommand(await guidelineCommand(rest[0], rest.slice(1), f));
572
+ if (command === "design" && action === "guideline") {
573
+ const result = await guidelineCommand(rest[0], rest.slice(1), f);
574
+ // A guideline read in a lane counts toward the lane's gathered set (nudge hooks).
575
+ if (rest[0] === "show" && rest[1]) await recordGuidelineRead(process.cwd(), rest[1]).catch(() => {});
576
+ return emitCommand(result);
577
+ }
560
578
  if (command === "design" && action === "changeset") return emitCommand(await changeSetCommand(rest[0], rest.slice(1), f));
561
579
  if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
562
580
  if (command === "design" && action === "check") {
@@ -573,7 +591,15 @@ export async function main(argv = process.argv.slice(2)) {
573
591
  if (command === "coordinator") return emitCommand(await coordinatorCommand(action, f));
574
592
  if (command === "lane" && action === "launch") return emitCommand(await launchLaneCommand(rest[0], f));
575
593
  if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
576
- if (command === "lane" && action === "gather") return emitCommand(await gatherLaneCommand(rest[0], f));
594
+ if (command === "lane" && action === "gather") {
595
+ const result = await gatherLaneCommand(rest[0], f);
596
+ // What a gather covered is the lane's gathered set for the nudge hooks.
597
+ const asked = (value) => (typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
598
+ await (async () => recordGathered(process.cwd(), { guidelines: asked(f.components), deltaGathered: f.delta ? (f.paths ? asked(f.paths) : await changedPaths(process.cwd())) : [] }))().catch(() => {});
599
+ return emitCommand(result);
600
+ }
601
+ if (command === "lane" && action === "freeze")
602
+ return emitCommand(await freezeCommand(rest[0], rest.slice(1), f));
577
603
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
578
604
  if (command === "next") return emitCommand(await nextCommand(action, f));
579
605
  if (command === "built") return emitCommand(await builtCommand(action, rest, f));
@@ -805,6 +831,13 @@ export async function main(argv = process.argv.slice(2)) {
805
831
  if (!result.data.allowed) process.exitCode = 3;
806
832
  return;
807
833
  }
834
+ if (action === "checked") {
835
+ const flagged = SUBMIT_CHECK_FLAGS.filter((check) => f[check]);
836
+ if (!isChangeSetId(rest[0]) || rest.length > 1 || !flagged.length)
837
+ throw new CliError("USAGE", `figma checked CHANGE_SET ${SUBMIT_CHECK_FLAGS.map((check) => `--${check}`).join(" ")} (at least one)`, 2);
838
+ await recordSubmitChecks(process.cwd(), rest[0], flagged);
839
+ return emit({ data: { changeSetId: rest[0], checked: flagged } }, json, `${rest[0]}: recorded ${flagged.join(", ")}.`);
840
+ }
808
841
  if (action === "log-write")
809
842
  return emit(await figmaLogWrite(rest[0], requireFlag(f, "tool"), requireFlag(f, "nodes").split(",").filter(Boolean)), json);
810
843
  if (["apply", "published", "abandon"].includes(action)) {
@@ -910,6 +943,27 @@ export async function main(argv = process.argv.slice(2)) {
910
943
  } catch {}
911
944
  return;
912
945
  }
946
+ // Guideline nudges (AGENT-HARNESS-21) never block: any error, a payload
947
+ // they don't know or a session outside a lane ends in no output.
948
+ if (action === "nudge-pre") {
949
+ try {
950
+ const payload = JSON.parse(await stdinText());
951
+ await recordHookFire(payload).catch(() => {});
952
+ if (!(await nudgeCandidate(payload))) return;
953
+ const nudges = await nudgePre(payload, await safetyContext(payload.cwd ?? process.cwd()));
954
+ if (nudges.length) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
955
+ } catch {}
956
+ return;
957
+ }
958
+ if (action === "nudge-stop") {
959
+ try {
960
+ const payload = JSON.parse(await stdinText());
961
+ await recordHookFire(payload).catch(() => {});
962
+ const nudges = await nudgeStop(payload, await safetyContext(payload.cwd ?? process.cwd()));
963
+ if (nudges.length) console.log(JSON.stringify({ decision: "block", reason: nudges.join("\n") }));
964
+ } catch {}
965
+ return;
966
+ }
913
967
  if (action === "lane-heartbeat") {
914
968
  const result = await heartbeatCurrentLane();
915
969
  return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
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.29";
7
+ export const VERSION = "0.6.31";
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) =>