@nodii/dev 0.6.9 → 0.6.11

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/dist/docs.js CHANGED
@@ -93,12 +93,34 @@ function fencedLines(lines) {
93
93
  });
94
94
  }
95
95
 
96
+ /**
97
+ * The document's own clause id at the start of a line in a definition
98
+ * position (Dev issue 80), as Dev reads it: a bullet, text inside the bold,
99
+ * a bold paragraph, a heading, a bold table row or a numbered item. Ranges and
100
+ * lists (`**A-1 to A-4:**`, `**A-1, A-2:**`) are references. Null otherwise.
101
+ */
102
+ export function sourceIdAt(line, code) {
103
+ const m = /^\s*(?:([-*+])\s+|(\d+)\.\s+|(#{1,6})\s+|(\|)\s*)?(\*\*)?(.*)$/.exec(line);
104
+ if (!m) return null;
105
+ const [, bullet, numbered, heading, pipe, bold, rest = ""] = m;
106
+ if (!bold && !heading) return null;
107
+ const id = new RegExp(`^(${CLAUSE_ID})(?![\\d.]*\\d)`).exec(rest)?.[1];
108
+ // The document's own id: `TELE-3`, not the sibling code's `TELE-POR-3`.
109
+ if (!id || !new RegExp(`^${code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-S?\\d`).test(id)) return null;
110
+ const after = rest.slice(id.length);
111
+ // A list or range (`, X-2`, ` to X-4`, `–4`) is a reference, in or just after the bold; a dash before prose is not.
112
+ if (new RegExp(`^(?:\\*\\*)?\\s*(?:,|to\\s|and\\s|or\\s|[\u2013\u2014-])\\s*(?:\\*\\*)?(?:${CLAUSE_ID}|\\d)`).test(after)) return null;
113
+ const form = bullet && bold ? (/^\*\*(?=\s|$)/.test(after) ? "bullet" : "bullet-malformed") : heading ? "heading" : pipe ? "table-row" : numbered ? "numbered" : "bold-paragraph";
114
+ return { id, form };
115
+ }
116
+
96
117
  /** Sections, clause definitions and parked items, skipping fenced code, with 1-based lines of the whole file. */
97
- export function parseBody(content, bodyStartLine = 1) {
118
+ export function parseBody(content, bodyStartLine = 1, code = "") {
98
119
  const lines = content.split("\n");
99
120
  const sections = [];
100
121
  const clauses = [];
101
122
  const parked = [];
123
+ const sourceIds = [];
102
124
  const fenced = fencedLines(lines);
103
125
  let section = "";
104
126
  for (let i = bodyStartLine - 1; i < lines.length; i++) {
@@ -106,17 +128,43 @@ export function parseBody(content, bodyStartLine = 1) {
106
128
  if (fenced[i]) continue;
107
129
  const h2 = /^##\s+(.+?)\s*#*\s*$/.exec(line);
108
130
  if (h2 && !line.startsWith("###")) {
131
+ const heading = code ? sourceIdAt(line, code) : null;
132
+ if (heading) sourceIds.push({ ...heading, line: i + 1 });
109
133
  section = sectionTitle(h2[1]);
110
134
  sections.push({ title: section, line: i + 1 });
111
135
  continue;
112
136
  }
113
137
  // `- **TELE-32** Undecided: …` parks that clause; it is not a second definition of it.
114
138
  const p = parkedItem(line);
115
- if (p !== null) { parked.push({ text: p, line: i + 1 }); continue; }
139
+ if (p !== null) { parked.push({ text: p, line: i + 1, clauseId: PARKED.exec(line)?.[1] ?? null }); continue; }
140
+ const source = code ? sourceIdAt(line, code) : null;
141
+ if (source) sourceIds.push({ ...source, line: i + 1 });
116
142
  const clause = CLAUSE_DEF.exec(line);
117
143
  if (clause) clauses.push({ id: clause[1], section, line: i + 1 });
118
144
  }
119
- return { sections, clauses, parked };
145
+ return { sections, clauses, parked, sourceIds };
146
+ }
147
+
148
+ /** Dev issue 80: refuse every non-bullet clause form, then state the count invariant. */
149
+ export function clauseFormErrors(body) {
150
+ const defined = new Set(body.clauses.map((c) => c.id));
151
+ // A parked line names its clause; a `### X-12` group heading is defined by its sub-clauses.
152
+ // Only a heading groups sub-clauses: `**X-12** … MUST …` over `- **X-12.1**` is a clause of its own.
153
+ const known = (id, form) => defined.has(id) || body.parked.some((p) => p.clauseId === id) || (form === "heading" && body.clauses.some((c) => c.id.startsWith(`${id}.`)));
154
+ const missing = new Map();
155
+ const errors = [];
156
+ for (const s of body.sourceIds) {
157
+ if (known(s.id, s.form) || missing.has(s.id)) continue;
158
+ missing.set(s.id, s.line);
159
+ errors.push(s.form === "bullet-malformed"
160
+ ? { code: "CLAUSE_MALFORMED", message: `clause \`${s.id}\` has text inside its bold; write \`- **${s.id}** …\` with any tag such as \`[INTENDED]\` after the bold (Dev issue 80)`, line: s.line }
161
+ : { code: "CLAUSE_NOT_A_BULLET", message: `clause \`${s.id}\` is written as a ${s.form.replace("-", " ")}; Dev reads a clause only as a bullet, \`- **${s.id}** …\` (Dev issue 80)`, line: s.line });
162
+ }
163
+ if (missing.size) {
164
+ const source = defined.size + missing.size;
165
+ errors.push({ code: "CLAUSE_COUNT_MISMATCH", message: `Dev would store ${defined.size} clauses but the source defines ${source}; missing: ${[...missing.keys()].join(", ")} (Dev issue 80)` });
166
+ }
167
+ return errors;
120
168
  }
121
169
 
122
170
  /**
@@ -188,9 +236,10 @@ export function validateDocument(content) {
188
236
  else if (groundedAt && !GROUNDED_AT.test(groundedAt))
189
237
  errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: `grounded_at \`${groundedAt}\` must be \`<repo>@<sha>\``, line: 1 });
190
238
 
191
- const body = parseBody(text, bodyStartLine);
239
+ const body = parseBody(text, bodyStartLine, code);
192
240
  if (kind) errors.push(...sectionErrors(body.sections, kind));
193
241
  if (code) {
242
+ errors.push(...clauseFormErrors(body));
194
243
  const seen = new Map();
195
244
  for (const c of body.clauses) {
196
245
  if (!c.id.startsWith(`${code}-`)) { errors.push({ code: "CLAUSE_CODE_MISMATCH", message: `clause \`${c.id}\` does not start with the document code \`${code}-\` (M05-6)`, line: c.line }); continue; }
package/dist/lanes.js CHANGED
@@ -167,12 +167,50 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
167
167
  throw new CliError("USAGE", `Unknown lane action: ${action}`, 2);
168
168
  }
169
169
 
170
- export async function nextCommand(ref, flags, { request = devRequest } = {}) {
170
+ /**
171
+ * The files a lane touches, for path-tagged rules (M05-32): its branch and
172
+ * working-tree changes against origin/main, plus untracked files, read from
173
+ * its recorded worktree. Null when that worktree cannot be read here; Dev then
174
+ * serves every path-tagged clause rather than miss one.
175
+ */
176
+ export async function laneChangedPaths(lane) {
177
+ try {
178
+ const run = async (...args) => (await execFileAsync("git", ["-C", lane.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
179
+ const base = (await run("merge-base", "origin/main", "HEAD")).trim();
180
+ const changed = (await run("diff", "--name-only", base)).split("\n");
181
+ const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
182
+ return [...new Set([...changed, ...untracked].map((x) => x.trim()).filter(Boolean))].slice(0, 5000);
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ const rulesLines = (rules, key) => {
189
+ if (rules.error) return [`Rules: not served (${rules.error}). Until Dev serves them, read the clauses your brief cites; review delivers the rest (M05-35).`];
190
+ if (!rules.clauseCount) return [`Rules for this step: none served (no clause tagged for it yet, M05-37). Core: nodii-dev rules core`];
191
+ return [
192
+ `Rules for this step: ${rules.clauseCount} clause${rules.clauseCount === 1 ? "" : "s"}, ~${rules.tokens} tokens (DEC-0020). Full text: nodii-dev next ${key} --json`,
193
+ ...rules.clauses.map((c) => ` ${c.id} ${c.gist}${c.toolGap ? ` [TOOL GAP ${c.toolGap}]` : ""}\n -> ${c.pointer}`),
194
+ ];
195
+ };
196
+
197
+ export async function nextCommand(ref, flags, { request = devRequest, changedPaths = laneChangedPaths } = {}) {
171
198
  const lane = (await request("GET", `/api/v1/lanes/${encode(ref)}`)).data;
172
199
  const pending = lane.steps.filter((x) => ["waiting", "current", "blocked"].includes(x.state)).sort((a, b) => a.position - b.position);
173
200
  const steps = ["backend", "frontend"].map((side) => pending.find((x) => x.side === side)).filter(Boolean);
174
201
  const data = steps.map((step) => ({ side: step.side, step: step.stepKey, label: step.label, state: step.state, command: `nodii-dev lane step ${lane.key} --side ${step.side} --${step.state === "current" ? "done" : "start"} ${step.stepKey}` }));
175
- return { payload: { data }, human: data.length ? data.map((x) => `${x.side}: ${x.label}\n ${x.command}`).join("\n") : `All workflow steps are complete. Run: nodii-dev lane land ${lane.key}` };
202
+ // M05-32: the clauses Dev serves for this step. A failure here never hides the next step.
203
+ let rules = null;
204
+ if (data.length) {
205
+ const paths = flags.paths ? list(flags.paths) : await changedPaths(lane);
206
+ const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}) };
207
+ try { rules = (await request("POST", `/api/v1/lanes/${encode(lane.key)}/rules`, body)).data; }
208
+ catch (error) { rules = { error: error.code ?? error.message }; }
209
+ }
210
+ const human = data.length
211
+ ? [...data.map((x) => `${x.side}: ${x.label}\n ${x.command}`), ...rulesLines(rules, lane.key)].join("\n")
212
+ : `All workflow steps are complete. Run: nodii-dev lane land ${lane.key}`;
213
+ return { payload: { data, ...(rules ? { rules } : {}) }, human };
176
214
  }
177
215
 
178
216
  export async function builtCommand(action, args, flags, { request = devRequest } = {}) {
package/dist/oauth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { mkdir, open, readFile, rm, stat, utimes } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
@@ -45,73 +45,159 @@ function run(command, args, { input } = {}) {
45
45
  });
46
46
  }
47
47
 
48
- async function keychainStore(name, secret, os) {
48
+ // `security -i` reads each command line into a 4096-byte buffer. A longer line
49
+ // is cut there: the item is stored TRUNCATED and the rest of the line runs as a
50
+ // second, failing command (Dev issue 88: the access-token cache, once the
51
+ // JWT outgrew 4 KB, was stored cut short, every process read it as empty, and
52
+ // every command rotated the shared refresh token). Lines stay well under it.
53
+ export const SECURITY_LINE_MAX = 4000;
54
+ // A secret longer than one line is stored in parts, then a header naming the
55
+ // part count and the digest of the whole; a reader that sees a torn or partial
56
+ // set (a writer mid-way) gets nothing rather than a wrong secret.
57
+ const MAX_PARTS = 64;
58
+ const CHUNKED = "chunked:v1:";
59
+ const TOKEN_ALPHABET = /^[A-Za-z0-9._~+/=-]+$/;
60
+ const part = (name, i) => `${name}:part:${i}`;
61
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
62
+ const addLine = (name, svc, secret) => `add-generic-password -U -a ${name} -s ${svc} -w ${secret}`;
63
+ // Whether `security -i` takes the secret whole on one stdin line.
64
+ const fitsOneLine = (name, svc, secret) => TOKEN_ALPHABET.test(secret) && addLine(name, svc, secret).length <= SECURITY_LINE_MAX;
65
+ const checkName = (name) => {
66
+ if (!/^[A-Za-z0-9_.:-]+$/.test(name)) throw new CliError("KEYCHAIN_UNAVAILABLE", "The OAuth client id has characters the Keychain helper cannot take; nothing was stored.", 2);
67
+ };
68
+
69
+ async function keychainPut(name, secret, os, { exec, svc }) {
49
70
  if (os === "darwin") {
50
- if (!/^[A-Za-z0-9_.:-]+$/.test(name)) throw new CliError("KEYCHAIN_UNAVAILABLE", "The OAuth client id has characters the Keychain helper cannot take; nothing was stored.", 2);
71
+ checkName(name);
51
72
  // Through `security -i` on stdin, so the secret never appears in argv
52
73
  // (visible to `ps`). The interactive parser splits on whitespace and reads
53
74
  // quotes, so a secret outside a token's own alphabet falls back to argv
54
75
  // rather than being dropped (it may be a refresh token just rotated).
55
- if (/^[A-Za-z0-9._~+/=-]+$/.test(secret)) await run("security", ["-i"], { input: `add-generic-password -U -a ${name} -s ${service} -w ${secret}\n` });
56
- else await run("security", ["add-generic-password", "-U", "-a", name, "-s", service, "-w", secret]);
76
+ if (fitsOneLine(name, svc, secret)) await exec("security", ["-i"], { input: `${addLine(name, svc, secret)}\n` });
77
+ else await exec("security", ["add-generic-password", "-U", "-a", name, "-s", svc, "-w", secret]);
57
78
  return "macOS Keychain";
58
79
  }
59
80
  if (os === "linux") {
60
81
  try {
61
- await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", name], { input: secret });
82
+ await exec("secret-tool", ["store", "--label=nodii-dev OAuth", "service", svc, "account", name], { input: secret });
62
83
  return "Secret Service";
63
84
  } catch {}
64
85
  }
65
86
  throw new CliError("KEYCHAIN_UNAVAILABLE", "OAuth refresh tokens require macOS Keychain or Linux Secret Service. No credential was stored; re-run login when a keychain is available.", 2);
66
87
  }
67
88
 
68
- async function keychainLoad(name, os) {
69
- if (os === "darwin") return (await run("security", ["find-generic-password", "-a", name, "-s", service, "-w"])).trim();
70
- if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", name])).trim();
89
+ async function keychainGet(name, os, { exec, svc }) {
90
+ if (os === "darwin") return (await exec("security", ["find-generic-password", "-a", name, "-s", svc, "-w"])).trim();
91
+ if (os === "linux") return (await exec("secret-tool", ["lookup", "service", svc, "account", name])).trim();
71
92
  throw new Error("no keychain");
72
93
  }
73
94
 
74
- async function keychainDelete(name, os) {
95
+ async function removeItem(name, os, { exec, svc }) {
75
96
  try {
76
- if (os === "darwin") await run("security", ["delete-generic-password", "-a", name, "-s", service]);
77
- else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", name]);
78
- } catch {}
97
+ if (os === "darwin") await exec("security", ["delete-generic-password", "-a", name, "-s", svc]);
98
+ else if (os === "linux") await exec("secret-tool", ["clear", "service", svc, "account", name]);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // Parts are numbered from 1 without gaps, so the first one missing ends the
106
+ // sweep, whatever header (or none) the parts were written under.
107
+ async function removeParts(name, from, os, kc) {
108
+ if (os !== "darwin") return;
109
+ for (let i = from; i <= MAX_PARTS && (await removeItem(part(name, i), os, kc)); i++);
110
+ }
111
+
112
+ export async function keychainStore(name, secret, os = platform(), { exec = run, svc = service } = {}) {
113
+ const kc = { exec, svc };
114
+ if (os !== "darwin" || !TOKEN_ALPHABET.test(secret) || fitsOneLine(name, svc, secret)) {
115
+ const stored = await keychainPut(name, secret, os, kc);
116
+ await removeParts(name, 1, os, kc);
117
+ return stored;
118
+ }
119
+ checkName(name);
120
+ const size = SECURITY_LINE_MAX - addLine(part(name, MAX_PARTS), svc, "").length;
121
+ const parts = [];
122
+ if (size >= 256) for (let i = 0; i < secret.length && parts.length <= MAX_PARTS; i += size) parts.push(secret.slice(i, i + size));
123
+ if (!parts.length || parts.length > MAX_PARTS) throw new CliError("KEYCHAIN_UNAVAILABLE", "The secret is too long for the Keychain helper; nothing was stored.", 2);
124
+ // The parts in one `security` run, then, only once they are all in, the
125
+ // header that names them (`security -i` goes on past a failed line).
126
+ await exec("security", ["-i"], { input: `${parts.map((chunk, i) => addLine(part(name, i + 1), svc, chunk)).join("\n")}\n` });
127
+ await exec("security", ["-i"], { input: `${addLine(name, svc, `${CHUNKED}${parts.length}:${sha256(secret)}`)}\n` });
128
+ await removeParts(name, parts.length + 1, os, kc);
129
+ return "macOS Keychain";
79
130
  }
80
131
 
81
- export async function storeRefreshToken(clientId, token, os = platform()) {
82
- return keychainStore(account(clientId), token, os);
132
+ export async function keychainLoad(name, os = platform(), { exec = run, svc = service } = {}) {
133
+ const value = await keychainGet(name, os, { exec, svc });
134
+ if (!value.startsWith(CHUNKED)) return value;
135
+ const [count, digest] = value.slice(CHUNKED.length).split(":");
136
+ const n = Number.parseInt(count, 10);
137
+ if (!Number.isInteger(n) || n < 1 || n > MAX_PARTS) throw new Error("bad chunk header");
138
+ let secret = "";
139
+ for (let i = 1; i <= n; i++) secret += await keychainGet(part(name, i), os, { exec, svc });
140
+ if (sha256(secret) !== digest) throw new Error("chunks do not match their header");
141
+ return secret;
83
142
  }
84
143
 
85
- export async function loadRefreshToken(clientId, os = platform()) {
144
+ export async function keychainDelete(name, os = platform(), { exec = run, svc = service } = {}) {
145
+ const kc = { exec, svc };
146
+ await removeItem(name, os, kc);
147
+ await removeParts(name, 1, os, kc);
148
+ }
149
+
150
+ // Read back after writing: a store that "succeeds" but keeps something else (a
151
+ // truncated write) loses the rotated token, and the next refresh replays the
152
+ // old one, which revokes the whole chain.
153
+ export async function storeRefreshToken(clientId, token, os = platform(), keychain = {}) {
154
+ const stored = await keychainStore(account(clientId), token, os, keychain);
155
+ if ((await keychainLoad(account(clientId), os, keychain).catch(() => null)) !== token)
156
+ throw new CliError("KEYCHAIN_WRITE_FAILED", "The refresh token read back from the keychain differs from the one written.", 1);
157
+ return stored;
158
+ }
159
+
160
+ export async function loadRefreshToken(clientId, os = platform(), keychain = {}) {
86
161
  try {
87
- const token = await keychainLoad(account(clientId), os);
162
+ const token = await keychainLoad(account(clientId), os, keychain);
88
163
  if (token) return token;
89
164
  } catch {}
90
165
  throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
91
166
  }
92
167
 
93
- export async function deleteRefreshToken(clientId, os = platform()) {
94
- await keychainDelete(account(clientId), os);
168
+ export async function deleteRefreshToken(clientId, os = platform(), keychain = {}) {
169
+ await keychainDelete(account(clientId), os, keychain);
95
170
  }
96
171
 
97
172
  // The access token is cached beside the refresh token so that every nodii-dev
98
173
  // process on the machine shares one: without it each command rotated the
99
174
  // refresh token, and N lanes rotated it N times as often.
100
- export async function loadAccessToken(clientId, os = platform()) {
175
+ export async function loadAccessToken(clientId, os = platform(), keychain = {}) {
101
176
  try {
102
- const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os), "base64url").toString("utf8"));
177
+ const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os, keychain), "base64url").toString("utf8"));
103
178
  return typeof cached?.token === "string" && Number.isFinite(cached.expiresAt) ? cached : null;
104
179
  } catch {
105
180
  return null;
106
181
  }
107
182
  }
108
183
 
109
- export async function storeAccessToken(clientId, cached, os = platform()) {
110
- try { await keychainStore(accessAccount(clientId), Buffer.from(JSON.stringify(cached)).toString("base64url"), os); } catch {}
184
+ let cacheWarned = false;
185
+
186
+ // A cache that cannot be written is not fatal to this command, but every other
187
+ // process would then refresh too, so it is said once, on stderr.
188
+ export async function storeAccessToken(clientId, cached, os = platform(), keychain = {}) {
189
+ const value = Buffer.from(JSON.stringify(cached)).toString("base64url");
190
+ try {
191
+ await keychainStore(accessAccount(clientId), value, os, keychain);
192
+ if ((await keychainLoad(accessAccount(clientId), os, keychain)) === value) return;
193
+ } catch {}
194
+ if (cacheWarned) return;
195
+ cacheWarned = true;
196
+ process.stderr.write("nodii-dev: warning: the access token could not be cached in the keychain, so every nodii-dev process will refresh the sign-in.\n");
111
197
  }
112
198
 
113
- export async function deleteAccessToken(clientId, os = platform()) {
114
- await keychainDelete(accessAccount(clientId), os);
199
+ export async function deleteAccessToken(clientId, os = platform(), keychain = {}) {
200
+ await keychainDelete(accessAccount(clientId), os, keychain);
115
201
  }
116
202
 
117
203
  async function oauthPost(authUrl, path, fields, fetchImpl = fetch, timeoutMs = REFRESH_TIMEOUT_MS) {
@@ -326,7 +412,10 @@ export async function withRefreshLock(fn, { clientId, path = refreshLockPath(cli
326
412
  // grant, so the credential is kept.
327
413
  const DEAD_GRANT_ERRORS = new Set(["invalid_grant", "expired_token", "token_revoked"]);
328
414
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
329
- export const REFRESH_TIMEOUT_MS = 10_000;
415
+ // A refresh is never cut off early: once the request is sent, nodii-auth may
416
+ // rotate the token whether or not the answer arrives, so aborting it at 10 s
417
+ // lost the successor and the next run replayed the old token (Dev issue 88).
418
+ export const REFRESH_TIMEOUT_MS = 20_000;
330
419
  const REFRESH_ATTEMPTS = 4;
331
420
  const RETRY_AFTER_CAP_MS = 5_000;
332
421
  // The whole refresh, retries included, fits well inside the lock's wait
@@ -353,7 +442,10 @@ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs
353
442
  signal: AbortSignal.timeout(timeoutMs),
354
443
  });
355
444
  } catch (error) {
356
- return { ok: false, status: 0, body: {}, json: false, reason: error?.name === "TimeoutError" ? "timeout" : "unreachable" };
445
+ // Only a connection that never opened is known not to have reached the
446
+ // server; a timeout or a dropped connection may follow a rotation.
447
+ const reason = error?.name === "TimeoutError" ? "timeout" : UNSENT_CODES.has(error?.cause?.code ?? error?.code) ? "unsent" : "unreachable";
448
+ return { ok: false, status: 0, body: {}, json: false, reason };
357
449
  }
358
450
  let raw = "";
359
451
  try { raw = await response.text(); } catch {}
@@ -364,12 +456,44 @@ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs
364
456
  return { ok: response.ok, status: response.status, body, json, retryAfterMs: Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined };
365
457
  }
366
458
 
459
+ // Node's (undici) and Bun's codes for a connection that never opened.
460
+ const UNSENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "ConnectionRefused", "FailedToOpenSocket"]);
461
+ // The answers that show the token was not used, so it may be sent again: the
462
+ // edge's rate limit, nodii-auth failing before it commits (500, 503), and a
463
+ // non-JSON 4xx (a proxy or WAF refusing before nodii-auth; it answers JSON).
464
+ // Anything else that is not an answer (a gateway timeout, a 2xx without
465
+ // tokens, an HTML page) may follow a rotation, and is not sent again here.
466
+ const RESEND_STATUS = new Set([429, 500, 503]);
467
+ const mayResend = (result) => (result.status === 0 ? result.reason === "unsent" : RESEND_STATUS.has(result.status) || (result.status >= 400 && result.status < 500 && !result.json));
468
+
469
+ // From the moment a refresh token is presented until its successor is stored,
470
+ // the process must not die: nodii-auth has already marked the old token used,
471
+ // so losing the new one means the next refresh replays the old one and the
472
+ // server revokes the whole chain, signing out every process on the machine.
473
+ // SIGINT, SIGTERM and SIGHUP are held over that window and re-raised on
474
+ // release, which comes after the refresh lock is let go. SIGHUP is re-raised
475
+ // only with a terminal attached: under nohup (no terminal) it was meant to be
476
+ // ignored, and re-raising it would kill what nohup kept alive.
477
+ export function holdSignals() {
478
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
479
+ let caught = null;
480
+ const hold = (signal) => { caught ??= signal; };
481
+ for (const signal of signals) process.on(signal, hold);
482
+ return {
483
+ release() {
484
+ for (const signal of signals) process.off(signal, hold);
485
+ if (caught && (caught !== "SIGHUP" || process.stdin.isTTY || process.stdout.isTTY)) process.kill(process.pid, caught);
486
+ },
487
+ };
488
+ }
489
+
367
490
  const describe = (result) => result.status === 0 ? (result.reason === "timeout" ? "timed out" : "nodii-auth unreachable") : `HTTP ${result.status}${result.json && typeof result.body.error === "string" ? ` ${result.body.error}` : ""}`;
368
491
 
369
492
  export class OAuthAuthProvider {
370
493
  #accessToken;
371
494
  #expiresAt = 0;
372
495
  #refreshPromise;
496
+ #signals;
373
497
  constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock,
374
498
  loadAccess, storeAccess, deleteAccess, sleep = wait, timeoutMs = REFRESH_TIMEOUT_MS, budgetMs = REFRESH_BUDGET_MS } = {}) {
375
499
  // Injected token storage (tests) gets no shared access cache unless one is
@@ -397,7 +521,7 @@ export class OAuthAuthProvider {
397
521
  // rotated the refresh token and cached the access token it got.
398
522
  if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
399
523
  return this.#refresh(config, held ?? (async () => true));
400
- }, { clientId: config.clientId });
524
+ }, { clientId: config.clientId }).finally(() => this.#release());
401
525
  })().finally(() => { this.#refreshPromise = undefined; });
402
526
  return this.#refreshPromise;
403
527
  }
@@ -409,9 +533,15 @@ export class OAuthAuthProvider {
409
533
  // Never present the refresh token without the lock: a second holder
410
534
  // would present the same one, and the server revokes the chain on reuse.
411
535
  if (!(await held())) throw new CliError("OAUTH_REFRESH_BUSY", "Lost the refresh lock to another nodii-dev process; the saved sign-in was kept. Retry the command.", 1);
536
+ // Signals wait while a token is in flight; on success, until it is stored.
537
+ this.#signals ??= holdSignals();
412
538
  result = await postRefresh(config.authUrl, config.clientId, refresh, this.fetchImpl, Math.max(floor, Math.min(this.timeoutMs, deadline - Date.now())));
413
539
  const kind = classifyRefresh(result);
540
+ if (kind !== "ok") this.#release();
414
541
  if (kind !== "retry" || attempt >= REFRESH_ATTEMPTS) return { kind, result, attempts: attempt };
542
+ // A request that may have reached the server is not sent again with the
543
+ // same token: if it was rotated, that is a replay and revokes the chain.
544
+ if (!mayResend(result)) return { kind, result, attempts: attempt };
415
545
  const backoff = 500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);
416
546
  const pause = Math.min(Math.max(backoff, result.retryAfterMs ?? 0), RETRY_AFTER_CAP_MS);
417
547
  if (Date.now() + pause + floor > deadline) return { kind, result, attempts: attempt };
@@ -461,6 +591,11 @@ export class OAuthAuthProvider {
461
591
  await this.storeAccess(config.clientId, { token: this.#accessToken, expiresAt: this.#expiresAt });
462
592
  return this.#accessToken;
463
593
  }
594
+ #release() {
595
+ const signals = this.#signals;
596
+ this.#signals = undefined;
597
+ signals?.release();
598
+ }
464
599
  async headers() {
465
600
  const config = await readConfig();
466
601
  return { authorization: `Bearer ${await this.accessToken()}`, ...(config.agentId ? { "x-agent-id": config.agentId } : {}) };
package/dist/upload.js CHANGED
@@ -273,7 +273,8 @@ export async function rulebookCommand(action, args, flags, { request = devReques
273
273
  if (flags[name] === true) throw new CliError("USAGE", `--${name} needs a version, e.g. --${name} 1.0`, 2);
274
274
  if (action === "list") {
275
275
  const payload = await request("GET", `/api/v1/documents${query({ section: flags.section ?? (flags.all ? undefined : "rulebook"), kind: flags.kind, serviceId: flags.service, domain: flags.domain, status: flags.status, q: flags.q, limit: flags.limit ?? 500, offset: flags.offset })}`);
276
- return { payload, human: (p) => p.data.map((x) => `${x.code}\t${x.currentVersion}\t${x.status}\t${x.kind}\t${x.title}${x.parkedCount ? `\tparked=${x.parkedCount}` : ""}`).join("\n") || "No documents." };
276
+ // Dev issue 80: the clause count, so a document Dev reads as 0/44 shows at a glance.
277
+ return { payload, human: (p) => p.data.map((x) => `${x.code}\t${x.currentVersion}\t${x.status}\t${x.kind}\t${x.clauseCount ?? "?"} clause${x.clauseCount === 1 ? "" : "s"}\t${x.title}${x.parkedCount ? `\tparked=${x.parkedCount}` : ""}`).join("\n") || "No documents." };
277
278
  }
278
279
  if (action === "show") {
279
280
  const code = args[0];
@@ -289,3 +290,43 @@ export async function rulebookCommand(action, args, flags, { request = devReques
289
290
  }
290
291
  throw new CliError("USAGE", "rulebook list|show|diff", 2);
291
292
  }
293
+
294
+ const pad = (v, n) => String(v).padStart(n);
295
+
296
+ /**
297
+ * `nodii-dev rules core|report|cap` (DEC-0020; M05-33, M05-36): the generated
298
+ * core, the delivery report, and the operator's core token cap.
299
+ */
300
+ export async function rulesCommand(action, args, flags, { request = devRequest } = {}) {
301
+ if (action === "core") {
302
+ // Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
303
+ const payload = await request("GET", "/api/v1/rules/core");
304
+ return { payload, human: (p) => `${p.data.markdown.trimEnd()}\n\n${p.data.clauseCount} core clause${p.data.clauseCount === 1 ? "" : "s"}, ~${p.data.tokens} of ${p.data.cap} tokens.` };
305
+ }
306
+ if (action === "report") {
307
+ const payload = await request("GET", `/api/v1/rules/report${query({ section: flags.section, lane: flags.lane })}`);
308
+ return {
309
+ payload,
310
+ human: (p) => {
311
+ const d = p.data;
312
+ const row = (code, t) => `${code.padEnd(10)}${pad(t.clauses, 6)}${pad(t.must, 6)}${pad(t.enforced, 9)}${pad(t.served, 7)}${pad(t.core, 5)}${pad(t.untagged, 9)}${pad(t.untaggedMust, 14)}${pad(t.toolGap, 9)}`;
313
+ const lines = [
314
+ `${"document".padEnd(10)}${pad("all", 6)}${pad("MUST", 6)}${pad("enforced", 9)}${pad("served", 7)}${pad("core", 5)}${pad("untagged", 9)}${pad("untagged MUST", 14)}${pad("tool gap", 9)}`,
315
+ ...(flags.lane ? [] : d.documents.map((x) => row(x.code, x))),
316
+ row("TOTAL", d.totals),
317
+ `Core: ${d.core.clauseCount} clauses, ~${d.core.tokens} of ${d.core.cap} tokens${d.core.overCap ? " (OVER THE CAP)" : ""}.`,
318
+ ...(d.lanes.length ? ["Served per lane:"] : ["No lane has been served rules yet."]),
319
+ ...d.lanes.flatMap((l) => [` ${l.key} (${l.status}): ${l.clauseCount} clauses, ~${l.tokens} tokens`, ...l.steps.map((s) => ` ${s.side}/${s.step}: ${s.clauseCount} clauses, ~${s.tokens} tokens, served ${s.servedCount}x`)]),
320
+ ];
321
+ return lines.join("\n");
322
+ },
323
+ };
324
+ }
325
+ if (action === "cap") {
326
+ const cap = Number(args[0]);
327
+ if (!Number.isInteger(cap) || cap < 100 || cap > 100000) throw new CliError("USAGE", "rules cap needs a whole number of tokens, 100 to 100000 (M05-33)", 2);
328
+ const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
329
+ return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
330
+ }
331
+ throw new CliError("USAGE", "rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS", 2);
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {