@nodii/dev 0.6.36 → 0.6.37

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
@@ -327,6 +327,29 @@ nodii-dev hook nudge-pre # guideline nudges before a tool call; nev
327
327
  nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
328
328
  nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
329
329
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
330
+ ```
331
+
332
+ `hook safety-pre` (installed by `harness install` for Claude Code and Codex)
333
+ blocks, silently when satisfied (AGENT-HARNESS-15):
334
+
335
+ - **Everyone, in a lane or not:** pattern kills (`pkill`, `killall`,
336
+ `kill $(pgrep …)`, `ps … | xargs kill`, `kill -1`). Kill only a PID you
337
+ started and saved.
338
+ - **In a lane:** `git stash`; checking out or creating another branch;
339
+ `git add -A`; `git reset` onto another branch; `git worktree remove --force`;
340
+ a force push (`-f`, `--force`, `--force-with-lease`, `+ref`); a push without
341
+ a review receipt for HEAD and origin/main; `--no-verify` and its aliases
342
+ (`LEFTHOOK=0`, `HUSKY=0`, `core.hooksPath`); `nodii-dev login`, `logout`,
343
+ `agent register`; `nodii/prod` secrets, `NODE_ENV=test`, `skipAuth`;
344
+ `docker compose down -v` or volume deletes on stacks the run didn't create;
345
+ deploy/apply without a `GO deploy <sha>` grant; shell writes outside the
346
+ worktree (redirects, `tee`, `cp`, `mv`, `rm`, `mkdir`, `touch` …; temp space
347
+ is fine) and mutating `git -C` into another checkout; Edit/Write outside the
348
+ worktree, outside owned paths or on generated files.
349
+ - **A coordinator** may use `--no-verify` only with `NODII_DEV_NO_VERIFY_REASON`
350
+ set; the reason is recorded in private Git metadata.
351
+
352
+ ```bash
330
353
 
331
354
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
332
355
  --side backend|frontend|both [--repo REPO] [--issue ISSUE]
@@ -996,9 +1019,15 @@ nodii-dev requests resolve UUID --commit-sha abc1234 --resolution-md "shipped"
996
1019
  nodii-dev open-questions answer UUID --decision-id D1420
997
1020
  ```
998
1021
 
999
- The CLI bundles Dev's OpenAPI document as the edge routes it (the edge does not
1000
- serve Dev's own document), so `api list` works offline and lists only
1001
- operations the edge exposes. The first-class resource families cover every tag: services,
1022
+ `api` and the resource commands read Dev's live catalog through the edge
1023
+ (`GET /devops/api/v1/openapi.json`), so an operation Dev adds after a CLI
1024
+ release is callable at once. The catalog is cached for 15 minutes in
1025
+ `~/.cache/nodii-dev/api-catalog.json` (`NODII_DEV_API_CATALOG_CACHE`),
1026
+ stamped with Dev's version and the document's sha256. When Dev cannot be read
1027
+ the CLI uses the last good copy, else the OpenAPI document bundled at release
1028
+ (the edge's routes only, so it works offline). `api catalog [--refresh]` shows
1029
+ which one is in use and why; `NODII_DEV_API_CATALOG=bundled` never asks Dev.
1030
+ The first-class resource families cover every tag: services,
1002
1031
  feature docs, contracts (gRPC, REST, outbox, sagas, tables), libraries,
1003
1032
  workers, projects, slices, SQL lookups, issues, backlogs, builds, changes,
1004
1033
  runtime, people, agents, inbox/gates, design, runbooks,
package/dist/api.js CHANGED
@@ -1,16 +1,102 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
  import { stdin } from "node:process";
3
- import { CliError, devRawRequest, encode } from "./core.js";
6
+ import { CliError, VERSION, devExchange, devRawRequest, encode, readConfig } from "./core.js";
4
7
 
5
8
  const methods = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
6
- // The Dev API as the platform edge routes it, bundled with the package (the
7
- // edge does not serve Dev's own document). Refresh: `npm run openapi:snapshot`.
9
+ // The Dev API as the platform edge routes it, bundled with the package: the
10
+ // fallback when Dev's live catalog cannot be read. Refresh: `npm run openapi:snapshot`.
8
11
  let bundled;
9
12
  export async function loadOpenApi() {
10
13
  bundled ??= JSON.parse(await readFile(new URL("./devops-openapi.json", import.meta.url), "utf8"));
11
14
  return bundled;
12
15
  }
13
16
 
17
+ // Dev issue 99: an operation Dev added after this release is still callable.
18
+ // The live catalog is read through the edge, cached for CATALOG_TTL_MS per
19
+ // edge and stamped with Dev's version and the document's sha256. A refused or
20
+ // malformed read is cached too, so a Dev without the route costs one request
21
+ // per TTL. Unreachable or signed out: the last good copy, else the bundled one.
22
+ export const LIVE_CATALOG_PATH = "/api/v1/openapi.json";
23
+ export const CATALOG_TTL_MS = 15 * 60 * 1000;
24
+ const CATALOG_TIMEOUT_MS = 4000;
25
+ const TRANSIENT = new Set(["DEV_UNREACHABLE", "DEV_TIMEOUT", "NOT_LOGGED_IN", "LOGIN_REQUIRED", "OAUTH_LOGIN_REQUIRED", "KEYCHAIN_UNAVAILABLE"]);
26
+ export const catalogCachePath = (env = process.env) =>
27
+ env.NODII_DEV_API_CATALOG_CACHE || join(homedir(), ".cache", "nodii-dev", "api-catalog.json");
28
+
29
+ const isSpec = (value) => value && typeof value === "object" && typeof value.openapi === "string" && value.paths && typeof value.paths === "object";
30
+ // Only Dev's own API is reachable through the edge's /devops prefix.
31
+ const edgeReachable = (spec) => ({ ...spec, paths: Object.fromEntries(Object.entries(spec.paths).filter(([path]) => path.startsWith("/api/v1/"))) });
32
+
33
+ async function readCache(path) {
34
+ try {
35
+ return JSON.parse(await readFile(path, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function writeCache(path, value) {
42
+ try {
43
+ await mkdir(dirname(path), { recursive: true });
44
+ const tmp = `${path}.${process.pid}.tmp`;
45
+ await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
46
+ await rename(tmp, path);
47
+ } catch {
48
+ // A cache that cannot be written only costs a refetch next time.
49
+ }
50
+ }
51
+
52
+ const bundledCatalog = async (reason) => ({ spec: await loadOpenApi(), source: "bundled", version: `@nodii/dev ${VERSION}`, reason });
53
+ const cachedCatalog = (good, source, reason) => ({ spec: good.spec, source, version: good.version, sha256: good.sha256, fetchedAt: good.fetchedAt, reason });
54
+
55
+ /**
56
+ * The operation catalog: `{ spec, source: live|cache|stale|bundled, version, sha256?, fetchedAt?, reason? }`.
57
+ * NODII_DEV_API_CATALOG=bundled skips Dev; `refresh` ignores the TTL.
58
+ */
59
+ export async function loadCatalog({ refresh = false, env = process.env, now = Date.now() } = {}) {
60
+ if (env.NODII_DEV_API_CATALOG === "bundled") return bundledCatalog("NODII_DEV_API_CATALOG=bundled");
61
+ const config = await readConfig(false).catch(() => null);
62
+ if (!config?.edgeUrl) return bundledCatalog("not signed in");
63
+ const path = catalogCachePath(env);
64
+ const cache = await readCache(path);
65
+ const mine = cache?.edgeUrl === config.edgeUrl ? cache : null;
66
+ const fresh = mine && now - Date.parse(mine.checkedAt) < CATALOG_TTL_MS;
67
+ if (fresh && !refresh)
68
+ return mine.failure
69
+ ? mine.good ? cachedCatalog(mine.good, "stale", mine.failure) : bundledCatalog(mine.failure)
70
+ : cachedCatalog(mine.good, "cache");
71
+
72
+ let failure;
73
+ try {
74
+ const { payload } = await devExchange("GET", LIVE_CATALOG_PATH, undefined, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
75
+ if (!isSpec(payload)) failure = `Dev's ${LIVE_CATALOG_PATH} is not an OpenAPI document`;
76
+ else {
77
+ const spec = edgeReachable(payload);
78
+ const sha256 = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
79
+ const good = { spec, version: `Dev ${payload.info?.version ?? "unknown"}`, sha256, fetchedAt: new Date(now).toISOString() };
80
+ await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: good.fetchedAt, good, failure: null });
81
+ return cachedCatalog(good, "live");
82
+ }
83
+ } catch (error) {
84
+ const reason = `${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`;
85
+ // Signed out, offline, timed out or a 5xx says nothing about the route: not cached.
86
+ if (TRANSIENT.has(error?.code) || /^HTTP_5\d\d$/.test(error?.code ?? "") || !(error instanceof CliError))
87
+ return mine?.good ? cachedCatalog(mine.good, "stale", reason) : bundledCatalog(reason);
88
+ failure = reason;
89
+ }
90
+ await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: new Date(now).toISOString(), good: mine?.good ?? null, failure });
91
+ return mine?.good ? cachedCatalog(mine.good, "stale", failure) : bundledCatalog(failure);
92
+ }
93
+
94
+ export function describeCatalog(catalog) {
95
+ const stamp = [catalog.version, catalog.sha256 && `sha256 ${catalog.sha256.slice(0, 12)}`, catalog.fetchedAt && `fetched ${catalog.fetchedAt}`].filter(Boolean).join(", ");
96
+ const why = catalog.reason ? `; live catalog unavailable: ${catalog.reason}` : "";
97
+ return `catalog: ${catalog.source} (${stamp})${why}`;
98
+ }
99
+
14
100
  export function operationsFrom(spec) {
15
101
  const out = [];
16
102
  for (const [path, item] of Object.entries(spec.paths ?? {}))
package/dist/cli.js CHANGED
@@ -67,7 +67,8 @@ import {
67
67
  findFirstClass,
68
68
  flagsToOperation,
69
69
  refusePersonOnly,
70
- loadOpenApi,
70
+ describeCatalog,
71
+ loadCatalog,
71
72
  operationsFrom,
72
73
  parseParams,
73
74
  readBody,
@@ -111,7 +112,7 @@ Usage: nodii-dev <command> [options] [--json]
111
112
  # blocked and wont_do need --reason; done needs --dev-check
112
113
  inbox list|show
113
114
  drift file | request file
114
- decisions list|get|search
115
+ decisions list|get|search # get DEC-n reads the AD decision with its amends
115
116
  design entries|entry|guide
116
117
  design guideline list [--drafted] [--all] | show ENTRY
117
118
  design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
@@ -232,6 +233,9 @@ Usage: nodii-dev <command> [options] [--json]
232
233
 
233
234
  api list [--tag TAG] # staff-only operations are marked (ratifyAdDocument:
234
235
  # agents get OPERATOR_REQUIRED by design)
236
+ api catalog [--refresh] # which catalog api and resource commands use: Dev's live one
237
+ # (cached 15 min, version + sha256 stamped) or the bundled
238
+ # fallback, and why; NODII_DEV_API_CATALOG=bundled skips Dev
235
239
  api <operationId> [--param k=v] [--body @file|-|JSON]
236
240
  <resource> <action> [ID ...] [--field VALUE]
237
241
 
@@ -560,8 +564,14 @@ export async function main(argv = process.argv.slice(2)) {
560
564
  const [command, action, ...rest] = args;
561
565
  if (!command || f.help || command === "help") return console.log(HELP);
562
566
  if (command === "api") {
563
- const operations = operationsFrom(await loadOpenApi());
567
+ const catalog = await loadCatalog({ refresh: action === "catalog" && f.refresh === true });
568
+ const operations = operationsFrom(catalog.spec);
569
+ if (action === "catalog") {
570
+ const { spec: _spec, ...stamp } = catalog;
571
+ return emit({ ...stamp, operations: operations.length }, json, `${describeCatalog(catalog)}\n${operations.length} operations`);
572
+ }
564
573
  if (action === "list") {
574
+ if (!json) console.error(`# ${describeCatalog(catalog)}`);
565
575
  const selected = f.tag === undefined || f.tag === true
566
576
  ? operations
567
577
  : operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
@@ -570,7 +580,7 @@ export async function main(argv = process.argv.slice(2)) {
570
580
  }
571
581
  if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
572
582
  const entry = operations.find((x) => x.operationId === action);
573
- if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
583
+ if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action} (${describeCatalog(catalog)})`, 2);
574
584
  refusePersonOnly(entry.operationId);
575
585
  const parsed = await readBody(f.body);
576
586
  const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
@@ -832,6 +842,21 @@ export async function main(argv = process.argv.slice(2)) {
832
842
  f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
833
843
  }
834
844
  if (command === "decisions") {
845
+ // A DEC-* id is an AD-era decision: getAdDecision carries its amends; the
846
+ // pre-dev /decisions route knows none of them.
847
+ if (action === "get" && /^DEC-\d+$/i.test(rest[0] ?? ""))
848
+ return emit(
849
+ await devRequest("GET", `/api/v1/ad-decisions/${encode(rest[0].toUpperCase())}`),
850
+ json,
851
+ ({ data: d }) => [
852
+ `${d.id}\t${d.status}\t${d.title}`,
853
+ ...(d.ruling ? ["", d.ruling] : []),
854
+ "",
855
+ d.amends?.length
856
+ ? `Amends (${d.amends.length}):\n${d.amends.map((a) => ` ${a.clauseId ?? a.docCode}\t${a.effect}\t${a.docCode} ${a.previousVersion ?? "-"} -> ${a.version}${a.documentTitle ? `\t${a.documentTitle}` : ""}`).join("\n")}`
857
+ : "Amends: none",
858
+ ].join("\n"),
859
+ );
835
860
  if (action === "get")
836
861
  return emit(
837
862
  await devRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
@@ -1084,7 +1109,8 @@ export async function main(argv = process.argv.slice(2)) {
1084
1109
  }
1085
1110
  }
1086
1111
  if (Object.hasOwn(GROUPS, command)) {
1087
- const operations = operationsFrom(await loadOpenApi());
1112
+ const catalog = await loadCatalog();
1113
+ const operations = operationsFrom(catalog.spec);
1088
1114
  let entry = findFirstClass(operations, command, action);
1089
1115
  if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
1090
1116
  entry = operations.find((x) => x.operationId === "acceptDrift");
@@ -1099,7 +1125,7 @@ export async function main(argv = process.argv.slice(2)) {
1099
1125
  if (!entry) {
1100
1126
  const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
1101
1127
  const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
1102
- throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
1128
+ throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available} (${describeCatalog(catalog)})`, 2);
1103
1129
  }
1104
1130
  const built = flagsToOperation(entry, rest, f);
1105
1131
  if (command === "requests" && action === "answer")
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.36";
7
+ export const VERSION = "0.6.37";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
package/dist/nodii-dev.js CHANGED
@@ -2,6 +2,9 @@
2
2
  import { fail, main } from "./cli.js";
3
3
  import { regroundCurrentLane, shouldReground } from "./freeze.js";
4
4
  import { isHookDenial } from "./hooks.js";
5
+ import { drainStdout, routeStdout } from "./stdout.js";
6
+
7
+ routeStdout();
5
8
 
6
9
  // Hooks run in the agent's environment, which may be incomplete or may not be
7
10
  // a git worktree. An unavailable hook must never turn into a refused tool call.
@@ -16,11 +19,13 @@ async function run() {
16
19
  await main(argv);
17
20
  }
18
21
 
19
- run().catch((error) => {
20
- const denial = isHookDenial(error?.code);
21
- if (argv[0] === "hook" && !denial) {
22
- process.exitCode = 0;
23
- return;
24
- }
25
- fail(error);
26
- });
22
+ run()
23
+ .catch((error) => {
24
+ const denial = isHookDenial(error?.code);
25
+ if (argv[0] === "hook" && !denial) {
26
+ process.exitCode = 0;
27
+ return;
28
+ }
29
+ fail(error);
30
+ })
31
+ .finally(drainStdout);
package/dist/safety.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
@@ -109,7 +110,103 @@ function createsBranch(after) {
109
110
  return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
110
111
  }
111
112
 
112
- const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify"));
113
+ // A hook bypass by another name is still --no-verify: lefthook's and husky's
114
+ // off switches, and pointing Git's hooks somewhere else.
115
+ const HOOK_BYPASS = /(?:^|[\s;&|(])(?:export\s+|env\s+)?(?:LEFTHOOK\s*=\s*["']?(?:0|false|off)\b|HUSKY\s*=\s*["']?0\b)|\bcore\.hooks[Pp]ath\b/;
116
+ const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify")) || HOOK_BYPASS.test(command);
117
+
118
+ // Operator, 29 Oct: a pattern kill on macOS once killed every agent on the
119
+ // machine. Only a PID the caller started (and saved) may be killed.
120
+ const AT_COMMAND = String.raw`(?:^|[;&|(\x60{\n]|\$\()\s*(?:(?:sudo|exec|nohup|time|command)(?:\s+-\S+)*\s+)*`;
121
+ const PATTERN_KILLS = [
122
+ new RegExp(`${AT_COMMAND}(?:\\S*/)?(?:pkill|killall)\\b`),
123
+ /(?:^|[;&|(\s])kill\b[^;&|\n]*(?:\$\(|`)\s*(?:\S*\/)?(?:pgrep|pidof|ps|lsof|jobs)\b/,
124
+ /\b(?:pgrep|pidof|ps|lsof)\b[^;&\n]*\|(?:[^;&\n]*\|)?\s*xargs\b[^;&|\n]*\bkill\b/,
125
+ /(?:^|[;&|(\s])kill\s+(?:-(?:\d+|[A-Z]+|s\s+\S+)\s+)*(?:--\s+)?-1\b/,
126
+ ];
127
+
128
+ /** Blocks that hold for every caller, in a lane or not, and before any lane discovery. */
129
+ export function machineViolations(command) {
130
+ if (PATTERN_KILLS.some((pattern) => pattern.test(command)))
131
+ return [{ code: "SAFETY_PATTERN_KILL", message: "Pattern kills (pkill, killall, kill $(pgrep ...), ps | xargs kill, kill -1) can kill every agent on this machine. Kill only a PID you started and saved to a .pid file." }];
132
+ return [];
133
+ }
134
+
135
+ function forcePush(invocation) {
136
+ if (invocation.verb !== "push") return false;
137
+ return invocation.args.some((arg) => ["-f", "--force", "--force-if-includes", "--mirror"].includes(arg) || arg.startsWith("--force-with-lease") || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg) || /^\+[^\s]/.test(arg));
138
+ }
139
+
140
+ // Writes a lane may make outside its worktree: temporary and scratch space.
141
+ const WRITABLE_ELSEWHERE = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders", "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/tty"];
142
+ const WRITE_COMMANDS = new Map([["cp", "last"], ["mv", "last"], ["ln", "last"], ["install", "last"], ["rsync", "last"], ["rm", "all"], ["rmdir", "all"], ["touch", "all"], ["mkdir", "all"], ["truncate", "all"], ["chmod", "rest"], ["chown", "rest"], ["tee", "all"]]);
143
+ const READ_ONLY_GIT = new Set(["status", "diff", "log", "show", "rev-parse", "rev-list", "branch", "ls-files", "ls-tree", "cat-file", "describe", "blame", "grep", "merge-base", "config", "remote", "fetch", "worktree", "for-each-ref", "symbolic-ref", "shortlog", "reflog", "name-rev", "check-ignore", "var", "help", "version"]);
144
+
145
+ /** Shell words with their quoting kept, split on control operators, for write detection. */
146
+ function segments(command) {
147
+ const out = [[]];
148
+ const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|\n])|([^\s"';|&]+(?:&(?![&])[^\s"';|&]*)*)|(&)/g;
149
+ for (const match of command.matchAll(re)) {
150
+ if (match[3] || match[5]) out.push([]);
151
+ else out.at(-1).push({ text: match[1] ?? match[2] ?? match[4], quoted: match[4] === undefined });
152
+ }
153
+ return out.filter((x) => x.length);
154
+ }
155
+
156
+ function writeTargets(command) {
157
+ const targets = [];
158
+ for (const segment of segments(command)) {
159
+ for (let i = 0; i < segment.length; i += 1) {
160
+ const { text, quoted } = segment[i];
161
+ if (quoted) continue;
162
+ const redirect = /^(?:\d*|&)>{1,2}\|?(.*)$/.exec(text);
163
+ if (redirect) {
164
+ const target = redirect[1] || segment[i + 1]?.text;
165
+ if (target && !target.startsWith("&")) targets.push(target);
166
+ }
167
+ }
168
+ const words = segment.filter((x, i) => !(i === 0 && /^[A-Z_][A-Z0-9_]*=/.test(x.text)));
169
+ const at = words.findIndex((x) => !["sudo", "command", "exec", "nohup"].includes(x.text));
170
+ const name = basename(words[at]?.text ?? "");
171
+ const mode = WRITE_COMMANDS.get(name);
172
+ if (!mode) continue;
173
+ const args = words.slice(at + 1).map((x) => x.text).filter((x) => !x.startsWith("-") && !/^\d*>/.test(x));
174
+ if (mode === "last") targets.push(...args.slice(-1));
175
+ else if (mode === "rest") targets.push(...args.slice(1));
176
+ else targets.push(...args);
177
+ }
178
+ return targets;
179
+ }
180
+
181
+ function outsideWorktree(target, context, relativeKnown) {
182
+ let path = target;
183
+ if (path === "~" || path.startsWith("~/")) path = join(homedir(), path.slice(1));
184
+ else if (/^\$\{?HOME\}?(?:\/|$)/.test(path)) path = join(homedir(), path.replace(/^\$\{?HOME\}?/, ""));
185
+ if (path.includes("$") || path.includes("`")) return false;
186
+ if (!isAbsolute(path)) {
187
+ if (!relativeKnown) return false;
188
+ path = resolve(context.cwd, path);
189
+ }
190
+ const inside = (root) => { const rel = relative(root, path); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
191
+ return !inside(context.root) && !WRITABLE_ELSEWHERE().some(inside);
192
+ }
193
+
194
+ function gitOutsideWorktree(tokens, context) {
195
+ for (let at = 0; at < tokens.length; at += 1) {
196
+ if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
197
+ let cursor = at + 1;
198
+ let target = null;
199
+ while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
200
+ const option = tokens[cursor];
201
+ if (["-C", "--git-dir", "--work-tree"].includes(option)) target = tokens[cursor + 1] ?? null;
202
+ else if (/^--(?:git-dir|work-tree)=/.test(option)) target = option.slice(option.indexOf("=") + 1);
203
+ cursor += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option) ? 2 : 1;
204
+ }
205
+ const verb = tokens[cursor];
206
+ if (target && verb && !READ_ONLY_GIT.has(verb) && outsideWorktree(target, context, true)) return { target, verb };
207
+ }
208
+ return null;
209
+ }
113
210
 
114
211
  function deployCommand(tokens, command) {
115
212
  if (commandHas(tokens, "tofu", "apply")) return true;
@@ -189,6 +286,16 @@ export function commandViolations(command, context) {
189
286
  }
190
287
  if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
191
288
  block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
289
+ if (gitCommands.some(forcePush))
290
+ block("SAFETY_FORCE_PUSH", "A force push rewrites history others may hold; a lane pushes fast-forward only. Ask the coordinator.");
291
+ if (context.root) {
292
+ const other = gitOutsideWorktree(tokens, context);
293
+ if (other) block("SAFETY_OTHER_WORKTREE", `git ${other.verb} in ${other.target} touches a checkout outside this lane's worktree ${context.root}.`);
294
+ const relativeKnown = !/(?:^|[;&|(\s])(?:cd|pushd)\s/.test(command);
295
+ const outside = writeTargets(command).filter((target) => outsideWorktree(target, context, relativeKnown));
296
+ if (outside.length)
297
+ block("SAFETY_WRITE_OUTSIDE_WORKTREE", `Write blocked outside the lane worktree ${context.root}: ${[...new Set(outside)].join(", ")}. Use the worktree or a temp/scratch directory.`);
298
+ }
192
299
 
193
300
  if (usesNoVerify(command)) {
194
301
  if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
@@ -387,6 +494,10 @@ function blocked(violation) {
387
494
  export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
388
495
  const tool = payload.tool_name ?? "";
389
496
  if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
497
+ if (SHELL_TOOLS.has(tool)) {
498
+ const machine = machineViolations(String(payload.tool_input?.command ?? ""))[0];
499
+ if (machine) blocked(machine);
500
+ }
390
501
  const cwd = payload.cwd ?? process.cwd();
391
502
  const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
392
503
  context.cwd = await realpath(cwd).catch(() => resolve(cwd));
package/dist/stdout.js ADDED
@@ -0,0 +1,26 @@
1
+ import { format } from "node:util";
2
+
3
+ // Dev issue 37: on Bun, once any module imports from `node:process`,
4
+ // console.log to a pipe stops at 65,536 bytes and the rest is lost at exit, so
5
+ // a large `--json` reached `jq` cut off. process.stdout.write delivers it all.
6
+ // The executable routes console.log through it and waits for the stream to
7
+ // drain before it returns. A reader that closes early (`| head -c`) is not an
8
+ // error.
9
+ let installed = false;
10
+
11
+ export function routeStdout(stream = process.stdout) {
12
+ if (installed) return;
13
+ installed = true;
14
+ stream.on("error", (error) => {
15
+ if (error?.code !== "EPIPE") throw error;
16
+ stream.destroy();
17
+ });
18
+ console.log = (...values) => {
19
+ if (!stream.destroyed) stream.write(`${format(...values)}\n`);
20
+ };
21
+ }
22
+
23
+ export function drainStdout(stream = process.stdout) {
24
+ if (stream.destroyed || !stream.writable) return Promise.resolve();
25
+ return new Promise((resolve) => stream.write("", () => resolve()));
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.36",
3
+ "version": "0.6.37",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {