@nodii/dev 0.6.35 → 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
@@ -145,6 +145,7 @@ nodii-dev gather tune start --cases all|ID,ID --corpus-ref REF [--trigger T] [--
145
145
  nodii-dev gather tune exec RUN_ID [--pickers shortlist-only,gemma-e4b,gemma-12b,gemma-26b]
146
146
  nodii-dev gather tune trial RUN_ID --case ID --picker P --iteration N [--documents A,B]
147
147
  nodii-dev gather tune finalize RUN_ID
148
+ nodii-dev gather timings [--model M] [--last N]
148
149
 
149
150
  nodii-dev open-questions list|show|answer
150
151
  nodii-dev feature-docs list|show|claim|release|status
@@ -154,7 +155,8 @@ nodii-dev libraries register-version LIBRARY_ID --version VERSION
154
155
  nodii-dev state diff
155
156
  nodii-dev heartbeat audit [SERVICE]
156
157
 
157
- nodii-dev run [--kind build|test_run|subagent|deploy] [--name TEXT] -- COMMAND [ARGS...]
158
+ nodii-dev run [--kind build|test_run|subagent|deploy] [--name TEXT] [--artifact PATH] -- COMMAND [ARGS...]
159
+ nodii-dev run --remote-ssm INSTANCE SCRIPT [--artifact PATH] [--quiet-minutes N] [--poll-seconds N] [--region R]
158
160
 
159
161
  nodii-dev design entries [--kind KIND] [--group GROUP] [--q TEXT]
160
162
  nodii-dev design entry KEY
@@ -325,6 +327,29 @@ nodii-dev hook nudge-pre # guideline nudges before a tool call; nev
325
327
  nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
326
328
  nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
327
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
328
353
 
329
354
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
330
355
  --side backend|frontend|both [--repo REPO] [--issue ISSUE]
@@ -371,7 +396,7 @@ nodii-dev rulebook diff CODE [--from V] [--to V]
371
396
  nodii-dev docs check PATH... # local M-05 checks only
372
397
  nodii-dev docs validate PATH... --class CLASS # Dev's dry run of the upload
373
398
  nodii-dev docs upload PATH... --class ruling|conformance|editorial
374
- [--base CODE=VERSION ...] [--decision DECISION.json] [--pending]
399
+ [--base CODE=VERSION ...] [--decision DECISION.json]
375
400
  [--conformance-ref PR|REPO@SHA] [--meaning-unchanged REASON]
376
401
  [--wait [--wait-minutes N]]
377
402
  nodii-dev docs ratify --domain KEY [--domain KEY ...] --decision DECISION.json [--wait]
@@ -620,10 +645,9 @@ Dev's error codes, and nothing is sent until there are none. Dev then runs the
620
645
  same checks, plus the ones that need its data, in one transaction.
621
646
 
622
647
  - `--class` is required on every write (M05-8). A `ruling` carries its
623
- decision (`--decision`, with `amends`). Agents must add `--pending`: Dev
624
- stores the ruling versions under a pending decision without activating
625
- them, and a signed-in staff person locks it later. Omitting `--pending`
626
- requests the staff-only immediate lock-and-amend action. An `editorial`
648
+ decision (`--decision`, with `amends`), and the decision locks as the change
649
+ lands (DEV-DEC-AS-COMMIT-1). `--pending` is still accepted but does nothing:
650
+ the CLI prints "decisions lock on upload" and doesn't send it. An `editorial`
627
651
  write that changes a Rules clause is refused unless
628
652
  `--meaning-unchanged REASON` is given (M05-10).
629
653
  - A new document is based on nothing. For a document Dev already has, pass
@@ -791,6 +815,41 @@ their output may be swallowed; the next visible call prints and acknowledges
791
815
  them. Unacknowledged amendments will join this preflight when the amendment API
792
816
  from AGENT-HARNESS-14 is available.
793
817
 
818
+ ### Result artifacts
819
+
820
+ A run that succeeds can attach one text artifact, such as a benchmark table as
821
+ JSON: `--artifact PATH`. It reaches the waiter whole, as the Result's
822
+ `artifact` evidence item. The note is the content, redacted like the log
823
+ tail. The ref is `NAME · BYTES bytes · sha256:HEX`. Dev keeps at most 10,000
824
+ characters in a note, so an artifact that is missing, empty or larger fails the
825
+ run (`Artifact not attached: …`). It never arrives cut.
826
+
827
+ ### Remote runs over SSM
828
+
829
+ `nodii-dev run --remote-ssm INSTANCE SCRIPT` runs a local shell script on an EC2
830
+ instance through SSM (`AWS-RunShellScript`), using your own `aws` CLI
831
+ credentials (`--region` passes through). It is a watched run like a local one.
832
+ It registers with `NODII_DEV_RUN_ID` as its waiter, heartbeats on every poll,
833
+ and ends with the exit code, the remote log tail and, on success, the artifact
834
+ (`--artifact` is a path on the instance, relative to the run directory).
835
+
836
+ The script runs detached on the instance in its own session, from
837
+ `/var/tmp/nodii-dev-runs/RUN_ID`, writing a log and then an exit-code file
838
+ there. Every `--poll-seconds` (default 30), a short SSM command reads the log's
839
+ size and tail and the exit code, and checks whether the process is alive. SSM
840
+ keeps only the first 24,000 characters of a command's output, so the script's
841
+ own invocation can't show progress. The run fails on:
842
+
843
+ - a non-zero exit;
844
+ - a process that is gone with no exit code (killed, or the instance restarted);
845
+ - a missing run directory;
846
+ - no new output, or no answer from the instance, for `--quiet-minutes`
847
+ (default 10).
848
+
849
+ A quiet script is cancelled with its whole process group. A long job should
850
+ print progress. Scripts are limited to 32 KB; copy larger code to the instance
851
+ first.
852
+
794
853
  ## Build lanes and repository setup
795
854
 
796
855
  `coordinator start --machine NAME` registers the machine's one watched
@@ -808,7 +867,10 @@ target agent and machine, fresh account-capacity telemetry, repository grants,
808
867
  lane limit, open issues and (when `--figma` is present) the authenticated Figma
809
868
  account. Only after that passes does the CLI create one worktree per repository,
810
869
  atomically register the lane and watched run, and launch the runtime with the
811
- single-line brief `You are lane KEY. Run nodii-dev lane gather.`. Ordinary
870
+ single-line brief `You are lane KEY. Run nodii-dev lane gather.`. It
871
+ dispatches an agent only onto a started lane: if Dev registers the lane as
872
+ anything but `building` (its start recorded), the CLI writes no marker, starts
873
+ no runtime, records the launch failure, and exits `LANE_NOT_STARTED`. Ordinary
812
874
  notes use `--notes`; a scope-narrowing note uses `--narrowing-note` and must
813
875
  also name the remainder's destination with `--narrowing-remainder`.
814
876
  The launched process uses the already-provisioned lowercase identity (for
@@ -846,6 +908,19 @@ acknowledge a changed Freeze before continuing, and compact SessionStart uses
846
908
  the same backstop. `lane freeze add|drop|swap` makes a reasoned, version-checked
847
909
  change; `show|history` reads the current contract or its append-only history.
848
910
 
911
+ A lane Gather warms the local picker (Gemma) as it starts, alongside Dev's
912
+ requests. Dev's requests share a 60 s deadline. The deadline stops while the
913
+ pick runs: the pick waits for the warm-up (up to 180 s), then the first pick
914
+ has 120 s once the model is warm (`PICKER_TIMEOUT` past it, and the model is
915
+ unloaded). Each pick is logged on this Mac (`~/.cache/nodii-dev/gather-timings.jsonl`,
916
+ or `NODII_DEV_GATHER_TIMINGS`). Gather prints the pick's warm-up and pick time
917
+ with this Mac's p50/p95, and `gather timings` prints the spread.
918
+ If this Mac has no picker (the model isn't installed, or no Ollama answers),
919
+ Gather falls back to the agent picker on the same shortlist. It prints
920
+ `picker: agent (gemma4:12b not installed; run nodii-dev setup)` and the
921
+ shortlist, then the agent records its pick with `--picker agent --shortlist ID
922
+ --documents …`. A wrong model digest or a timeout still fails.
923
+
849
924
  `lane start` creates and starts a lane. It infers the repository (by short
850
925
  name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
851
926
  required by Dev. Lane keys are passed directly to every lane and Built
@@ -944,9 +1019,15 @@ nodii-dev requests resolve UUID --commit-sha abc1234 --resolution-md "shipped"
944
1019
  nodii-dev open-questions answer UUID --decision-id D1420
945
1020
  ```
946
1021
 
947
- The CLI bundles Dev's OpenAPI document as the edge routes it (the edge does not
948
- serve Dev's own document), so `api list` works offline and lists only
949
- 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,
950
1031
  feature docs, contracts (gRPC, REST, outbox, sagas, tables), libraries,
951
1032
  workers, projects, slices, SQL lookups, issues, backlogs, builds, changes,
952
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 ?? {}))
@@ -162,6 +248,12 @@ export const ALIASES = {
162
248
 
163
249
  // Decisions a person makes in Dev (operator authority): nodii-dev never sends them.
164
250
  export const PERSON_ONLY = new Set(["approveDesignGuidelineChangeSet", "rejectDesignGuidelineChangeSet", "mergeDesignRelease"]);
251
+ // Signed-in staff only: Dev answers an agent with OPERATOR_REQUIRED by design.
252
+ // nodii-dev still sends them, for a person signed in to the CLI.
253
+ export const STAFF_ONLY = new Map([
254
+ ["ratifyAdDocument", "staff only: ratify on the document's Rules record page; an agent gets OPERATOR_REQUIRED"],
255
+ ]);
256
+
165
257
  export function refusePersonOnly(operationId) {
166
258
  if (PERSON_ONLY.has(operationId))
167
259
  throw new CliError("PERSON_ONLY", `${operationId} is a person's decision in Dev; nodii-dev does not send it.`, 2);
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
2
3
  import { stdin, stdout } from "node:process";
3
4
  import {
4
5
  CliError,
@@ -53,7 +54,7 @@ import {
53
54
  } from "./figma.js";
54
55
  export { figmaLock, submitMode } from "./figma.js";
55
56
  import { tuneCommand } from "./tune.js";
56
- import { TUNING_MODELS, measurePickerLatency } from "./gather.js";
57
+ import { PICKER_MODEL, TUNING_MODELS, measurePickerLatency, pickTimingStats, pickTimingText, pickTimingsPath, readPickTimings } from "./gather.js";
57
58
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
58
59
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
59
60
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
@@ -66,10 +67,12 @@ import {
66
67
  findFirstClass,
67
68
  flagsToOperation,
68
69
  refusePersonOnly,
69
- loadOpenApi,
70
+ describeCatalog,
71
+ loadCatalog,
70
72
  operationsFrom,
71
73
  parseParams,
72
74
  readBody,
75
+ STAFF_ONLY,
73
76
  } from "./api.js";
74
77
  import {
75
78
  agentWorkflowCommand,
@@ -91,6 +94,7 @@ import {
91
94
  showWaitingResults,
92
95
  superviseRun,
93
96
  } from "./runs.js";
97
+ import { awsCli, superviseRemoteRun } from "./remote.js";
94
98
 
95
99
  const HELP = `nodii-dev ${VERSION}
96
100
 
@@ -108,7 +112,7 @@ Usage: nodii-dev <command> [options] [--json]
108
112
  # blocked and wont_do need --reason; done needs --dev-check
109
113
  inbox list|show
110
114
  drift file | request file
111
- decisions list|get|search
115
+ decisions list|get|search # get DEC-n reads the AD decision with its amends
112
116
  design entries|entry|guide
113
117
  design guideline list [--drafted] [--all] | show ENTRY
114
118
  design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
@@ -148,7 +152,7 @@ Usage: nodii-dev <command> [options] [--json]
148
152
  watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
149
153
  watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees
150
154
  docs check|validate|upload <path...> --class ruling|conformance|editorial
151
- [--base CODE=VERSION] [--decision FILE] [--pending] [--conformance-ref REF]
155
+ [--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
152
156
  [--meaning-unchanged REASON] [--wait]
153
157
  docs ratify --domain KEY [--domain KEY ...] --decision FILE [--wait]
154
158
  docs parked [CODE] [--status pending|acknowledged]
@@ -169,6 +173,8 @@ Usage: nodii-dev <command> [options] [--json]
169
173
  gather tune finalize <run-id> # Dev scores the full 5-picker x 5-repeat matrix
170
174
  gather picker-latency [--picker gemma-12b|gemma-e4b|gemma-26b]
171
175
  # cold (model unloaded first) and warm pick latency, for the tuning record
176
+ gather timings [--model M] [--last N] # this Mac's lane Gather picks: warm-up and first-pick p50/p95
177
+ # (each lane gather logs one; the first pick has 120 s once warm)
172
178
 
173
179
  coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
174
180
  lane launch <key> --done-check TEXT --kind KIND --side SIDE
@@ -189,9 +195,14 @@ Usage: nodii-dev <command> [options] [--json]
189
195
  # recorded trigger shortlist; with --shortlist/--documents it records
190
196
  # the pick (picker: agent) and the Clause map
191
197
  lane gather show [<key>] [--gather ID] # the lane's Clause map (the Freeze references it, not its clauses)
192
- run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
198
+ run [--kind build|test_run|subagent|deploy] [--name TEXT] [--artifact PATH] -- <command> [args...]
193
199
  # watched command: registers with NODII_DEV_RUN_ID,
194
- # heartbeats, and records exit code + redacted log tail
200
+ # heartbeats, and records exit code + redacted log tail;
201
+ # --artifact attaches that file (text, <= 10,000 chars) to the Result
202
+ run --remote-ssm INSTANCE SCRIPT [--artifact PATH] [--quiet-minutes N] [--poll-seconds N] [--region R]
203
+ # SCRIPT runs detached on the EC2 instance through SSM (your aws CLI);
204
+ # fails on a non-zero exit, a vanished process, or no new output for
205
+ # N min (default 10, then cancelled); --artifact is a path there
195
206
 
196
207
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
197
208
  lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
@@ -220,7 +231,11 @@ Usage: nodii-dev <command> [options] [--json]
220
231
  agent lane-limit|grant|revoke
221
232
  people reveal <id> --reason TEXT | people refresh-names
222
233
 
223
- api list [--tag TAG]
234
+ api list [--tag TAG] # staff-only operations are marked (ratifyAdDocument:
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
224
239
  api <operationId> [--param k=v] [--body @file|-|JSON]
225
240
  <resource> <action> [ID ...] [--field VALUE]
226
241
 
@@ -479,6 +494,29 @@ export async function main(argv = process.argv.slice(2)) {
479
494
  return console.log(VERSION);
480
495
  if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
481
496
  return console.log(HELP);
497
+ if (argv[0] === "run" && argv.some((x) => x === "--remote-ssm" || x.startsWith("--remote-ssm="))) {
498
+ const { args: scriptArgs, flags: remoteFlags } = parseArgs(argv.slice(1));
499
+ const single = (flag) => {
500
+ const value = remoteFlags[flag];
501
+ if (Array.isArray(value) || value === true) throw new CliError("USAGE", `run --${flag} needs one value.`, 2);
502
+ return value;
503
+ };
504
+ if (scriptArgs.length !== 1)
505
+ throw new CliError("USAGE", "run --remote-ssm INSTANCE SCRIPT [--artifact PATH] [--quiet-minutes N] [--poll-seconds N] [--region R] [--kind K] [--name TEXT]", 2);
506
+ const number = (flag) => (single(flag) === undefined ? undefined : Number(single(flag)));
507
+ const result = await superviseRemoteRun({
508
+ instance: single("remote-ssm"),
509
+ script: scriptArgs[0],
510
+ artifact: single("artifact"),
511
+ quietMinutes: number("quiet-minutes"),
512
+ pollSeconds: number("poll-seconds"),
513
+ kind: single("kind"),
514
+ name: single("name"),
515
+ aws: awsCli({ region: single("region") }),
516
+ });
517
+ process.exitCode = result.exitCode;
518
+ return;
519
+ }
482
520
  if (["run", "__run-supervisor"].includes(argv[0])) {
483
521
  const boundary = argv.indexOf("--");
484
522
  if (boundary === -1 || boundary === argv.length - 1)
@@ -494,6 +532,9 @@ export async function main(argv = process.argv.slice(2)) {
494
532
  );
495
533
  const kind = runFlags.kind;
496
534
  const name = runFlags.name;
535
+ const artifact = runFlags.artifact;
536
+ if (Array.isArray(artifact) || artifact === true)
537
+ throw new CliError("USAGE", "run --artifact needs one path.", 2);
497
538
  if (Array.isArray(kind) || kind === true)
498
539
  throw new CliError("USAGE", "run --kind needs one value.", 2);
499
540
  if (Array.isArray(name) || name === true)
@@ -506,7 +547,7 @@ export async function main(argv = process.argv.slice(2)) {
506
547
  stdout.on("error", ignoreBrokenPipe);
507
548
  process.stderr.on("error", ignoreBrokenPipe);
508
549
  try {
509
- const result = await superviseRun({ command, kind, name });
550
+ const result = await superviseRun({ command, kind, name, artifact });
510
551
  process.exitCode = result.exitCode;
511
552
  return;
512
553
  } finally {
@@ -514,7 +555,7 @@ export async function main(argv = process.argv.slice(2)) {
514
555
  process.stderr.off("error", ignoreBrokenPipe);
515
556
  }
516
557
  }
517
- const exitCode = await launchRunSupervisor(command, { kind, name });
558
+ const exitCode = await launchRunSupervisor(command, { kind, name, artifact: artifact && resolve(artifact) });
518
559
  process.exitCode = exitCode;
519
560
  return;
520
561
  }
@@ -523,17 +564,23 @@ export async function main(argv = process.argv.slice(2)) {
523
564
  const [command, action, ...rest] = args;
524
565
  if (!command || f.help || command === "help") return console.log(HELP);
525
566
  if (command === "api") {
526
- 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
+ }
527
573
  if (action === "list") {
574
+ if (!json) console.error(`# ${describeCatalog(catalog)}`);
528
575
  const selected = f.tag === undefined || f.tag === true
529
576
  ? operations
530
577
  : operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
531
- return emit(selected.map(({ operationId, method, path, tags }) => ({ operationId, method, path, tags })), json,
532
- (rows) => rows.map((x) => `${x.operationId}\t${x.method}\t${x.path}\t${x.tags.join(",")}`).join("\n"));
578
+ return emit(selected.map(({ operationId, method, path, tags }) => ({ operationId, method, path, tags, ...(STAFF_ONLY.has(operationId) ? { staffOnly: true } : {}) })), json,
579
+ (rows) => rows.map((x) => `${x.operationId}\t${x.method}\t${x.path}\t${x.tags.join(",")}${x.staffOnly ? `\t# ${STAFF_ONLY.get(x.operationId)}` : ""}`).join("\n"));
533
580
  }
534
581
  if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
535
582
  const entry = operations.find((x) => x.operationId === action);
536
- 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);
537
584
  refusePersonOnly(entry.operationId);
538
585
  const parsed = await readBody(f.body);
539
586
  const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
@@ -599,6 +646,15 @@ export async function main(argv = process.argv.slice(2)) {
599
646
  const line = (label, x) => `${label} ${x.wallMs} ms (Ollama total ${x.totalMs ?? "?"} ms, model load ${x.loadMs ?? "?"} ms)`;
600
647
  return emit(result, json, (r) => `${r.model}\n${line("cold", r.cold)}\n${line("warm", r.warm)}`);
601
648
  }
649
+ if (command === "gather" && action === "timings") {
650
+ const last = f.last === undefined ? 50 : Number(f.last);
651
+ if (!Number.isInteger(last) || last < 1) throw new CliError("USAGE", "gather timings [--model M] [--last N]", 2);
652
+ const model = typeof f.model === "string" ? f.model : PICKER_MODEL;
653
+ const path = pickTimingsPath();
654
+ const entries = await readPickTimings({ path, model, last });
655
+ const stats = pickTimingStats(entries);
656
+ return emit({ model, path, ...stats, entries }, json, () => pickTimingText(stats, { model, path }));
657
+ }
602
658
  if (command === "gather" && action === "tune") {
603
659
  const result = await tuneCommand(rest[0], rest.slice(1), f);
604
660
  emit(result.payload, json, result.human);
@@ -786,6 +842,21 @@ export async function main(argv = process.argv.slice(2)) {
786
842
  f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
787
843
  }
788
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
+ );
789
860
  if (action === "get")
790
861
  return emit(
791
862
  await devRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
@@ -1038,7 +1109,8 @@ export async function main(argv = process.argv.slice(2)) {
1038
1109
  }
1039
1110
  }
1040
1111
  if (Object.hasOwn(GROUPS, command)) {
1041
- const operations = operationsFrom(await loadOpenApi());
1112
+ const catalog = await loadCatalog();
1113
+ const operations = operationsFrom(catalog.spec);
1042
1114
  let entry = findFirstClass(operations, command, action);
1043
1115
  if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
1044
1116
  entry = operations.find((x) => x.operationId === "acceptDrift");
@@ -1053,7 +1125,7 @@ export async function main(argv = process.argv.slice(2)) {
1053
1125
  if (!entry) {
1054
1126
  const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
1055
1127
  const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
1056
- 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);
1057
1129
  }
1058
1130
  const built = flagsToOperation(entry, rest, f);
1059
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.35";
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");