@nodii/dev 0.6.23 → 0.6.24
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 +24 -0
- package/dist/cli.js +27 -1
- package/dist/core.js +1 -1
- package/dist/harness.js +31 -2
- package/dist/hooks.js +8 -4
- package/dist/lanes.js +10 -2
- package/dist/safety.js +397 -0
- package/dist/setup.js +13 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -298,6 +298,8 @@ style check as a submit (`CHANGE_SET_IMAGE_STYLE`).
|
|
|
298
298
|
```bash
|
|
299
299
|
nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
|
|
300
300
|
nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
|
|
301
|
+
nodii-dev hook safety-pre # silent safety decision; blocks with exit 2
|
|
302
|
+
nodii-dev hook safety-post # silently records resources created by successful commands
|
|
301
303
|
nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
|
|
302
304
|
|
|
303
305
|
nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
|
|
@@ -354,6 +356,28 @@ nodii-dev harness check [--json] # 0 current, 1 differs from Dev,
|
|
|
354
356
|
nodii-dev harness update --check # the same check
|
|
355
357
|
```
|
|
356
358
|
|
|
359
|
+
`harness install` and `harness update` merge the two safety hooks into
|
|
360
|
+
`.claude/settings.json`; every installed hook invokes only `nodii-dev`. The
|
|
361
|
+
pre-hook blocks edits outside a lane's optional `ownedPaths`, generated UI/API
|
|
362
|
+
files, unsafe Git operations, stale or missing review receipts, lane identity
|
|
363
|
+
changes, production-secret/test-auth bypasses, cleanup of Docker resources the
|
|
364
|
+
run did not create, and deploy/apply commands without an exact grant. Allowed
|
|
365
|
+
calls produce no output.
|
|
366
|
+
|
|
367
|
+
Until Dev exposes a lane's owned paths (AGENT-HARNESS-10), an edit with no
|
|
368
|
+
ownership field emits a nudge instead of claiming it is safe or blocking an
|
|
369
|
+
unproven violation. Likewise, a deploy with no `NODII_DEV_GO_FILE` source
|
|
370
|
+
nudges for AGENT-HARNESS-13; when the source exists, a missing exact grant is
|
|
371
|
+
blocked. A Compose project whose normalized name is the lane key counts as
|
|
372
|
+
lane-owned, as does a project recorded by a successful `docker compose up`.
|
|
373
|
+
|
|
374
|
+
`lane review` writes its receipt to private Git metadata, pinning both `HEAD`
|
|
375
|
+
and `origin/main`; it never adds a repository file. Set `NODII_DEV_GO_FILE` to
|
|
376
|
+
the coordinator grant file so an exact `GO deploy <HEAD>` line can authorize a
|
|
377
|
+
deploy. A coordinator's exceptional `--no-verify` also requires
|
|
378
|
+
`NODII_DEV_NO_VERIFY_REASON`; the CLI records the reason in private Git
|
|
379
|
+
metadata. Lanes and subagents can never use `--no-verify`.
|
|
380
|
+
|
|
357
381
|
### DS conformance: `design roles` and `design check` (DEC-0034)
|
|
358
382
|
|
|
359
383
|
Every UI role names exactly one DS component (MW6-14). Resolve your brief's
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { heartbeatCurrentLane, postHook, postHookFailure, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
24
24
|
import { setupCommand } from "./setup.js";
|
|
25
25
|
import { harnessCommand } from "./harness.js";
|
|
26
|
+
import { safetyPostHook, safetyPreHook } from "./safety.js";
|
|
26
27
|
import { doctorCommand } from "./doctor.js";
|
|
27
28
|
import {
|
|
28
29
|
figmaChangeSetShow,
|
|
@@ -119,7 +120,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
119
120
|
figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
|
|
120
121
|
figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
|
|
121
122
|
# register a Figma file in Dev so it can be locked
|
|
122
|
-
hook claude-figma-pre|claude-figma-post|print-settings
|
|
123
|
+
hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|print-settings
|
|
123
124
|
setup [--yes|--check] # dry-run by default
|
|
124
125
|
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run]
|
|
125
126
|
harness check # 0 current, 1 differs from Dev, 2 not installed
|
|
@@ -771,6 +772,31 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
771
772
|
);
|
|
772
773
|
}
|
|
773
774
|
}
|
|
775
|
+
if (action === "safety-pre") {
|
|
776
|
+
// A malformed or unavailable safety check must not turn into permission.
|
|
777
|
+
try {
|
|
778
|
+
const result = await safetyPreHook(JSON.parse(await stdinText()));
|
|
779
|
+
for (const nudge of result.nudges ?? []) console.log(nudge);
|
|
780
|
+
return;
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw new CliError(
|
|
783
|
+
error?.code ?? "SAFETY_HOOK_FAILED",
|
|
784
|
+
error?.message ?? String(error),
|
|
785
|
+
2,
|
|
786
|
+
error?.details,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (action === "safety-post") {
|
|
791
|
+
try {
|
|
792
|
+
await safetyPostHook(JSON.parse(await stdinText()));
|
|
793
|
+
} catch (error) {
|
|
794
|
+
// A post-hook cannot undo a completed command; report state-tracking
|
|
795
|
+
// failure without making Claude treat the completed command as failed.
|
|
796
|
+
console.error(`nodii-dev safety post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`);
|
|
797
|
+
}
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
774
800
|
if (action === "lane-heartbeat") {
|
|
775
801
|
const result = await heartbeatCurrentLane();
|
|
776
802
|
return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.24";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
package/dist/harness.js
CHANGED
|
@@ -9,6 +9,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
9
9
|
import { dirname, join, normalize, sep } from "node:path";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { CliError, devRequest, query } from "./core.js";
|
|
12
|
+
import { safetySettingsSnippet } from "./safety.js";
|
|
12
13
|
|
|
13
14
|
/** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
|
|
14
15
|
export const STATE_PATH = ".nodii/harness-state.json";
|
|
@@ -141,6 +142,30 @@ export function checkHarness(harness) {
|
|
|
141
142
|
});
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
// Safety enforcement is executable CLI behaviour, not methodology prose. Add
|
|
146
|
+
// its nodii-dev-only hook entries to the settings fragment Dev generated so a
|
|
147
|
+
// normal harness install/update always installs the matching CLI hooks.
|
|
148
|
+
export function withSafetyHooks(harness) {
|
|
149
|
+
checkHarness(harness);
|
|
150
|
+
const copy = { ...harness, files: harness.files.map((file) => ({ ...file })) };
|
|
151
|
+
let settings = copy.files.find((file) => file.path === ".claude/settings.json");
|
|
152
|
+
if (!settings) {
|
|
153
|
+
settings = { path: ".claude/settings.json", mode: "merge-json", kind: "settings", content: "{}" };
|
|
154
|
+
copy.files.push(settings);
|
|
155
|
+
}
|
|
156
|
+
if (settings.mode !== "merge-json")
|
|
157
|
+
throw new CliError("HARNESS_INVALID", "Dev must generate .claude/settings.json as merge-json so nodii-dev can install safety hooks.", 1);
|
|
158
|
+
const parsed = JSON.parse(settings.content);
|
|
159
|
+
const hooks = { ...(parsed.hooks ?? {}) };
|
|
160
|
+
for (const [event, entries] of Object.entries(safetySettingsSnippet().hooks)) {
|
|
161
|
+
hooks[event] = [...(hooks[event] ?? [])];
|
|
162
|
+
for (const entry of entries) if (!hooks[event].some((existing) => stable(existing) === stable(entry))) hooks[event].push(entry);
|
|
163
|
+
}
|
|
164
|
+
settings.content = JSON.stringify({ ...parsed, hooks });
|
|
165
|
+
settings.sha256 = sha(settings.content);
|
|
166
|
+
return copy;
|
|
167
|
+
}
|
|
168
|
+
|
|
144
169
|
/**
|
|
145
170
|
* Compare Dev's harness with the repository. `adopt` names existing files the
|
|
146
171
|
* operator lets nodii-dev take over (on install, or when an update adds a file). Returns the planned
|
|
@@ -358,12 +383,16 @@ export async function harnessCommand(action, flags, { request = devRequest, cont
|
|
|
358
383
|
const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
|
|
359
384
|
const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
|
|
360
385
|
const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
|
|
386
|
+
const fetchHarness = async () => {
|
|
387
|
+
const response = await request("GET", `/api/v1/harness${query(params)}`);
|
|
388
|
+
return { ...response, data: withSafetyHooks(response.data) };
|
|
389
|
+
};
|
|
361
390
|
|
|
362
391
|
if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
|
|
363
392
|
if (action === "check" && (flags["dry-run"] || flags.adopt)) throw new CliError("USAGE", "harness check writes nothing; --dry-run and --adopt go with install and update.", 2);
|
|
364
|
-
if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state,
|
|
393
|
+
if (action === "check" || (action === "update" && flags.check)) return checkCommand(ctx.root, repo, state, fetchHarness);
|
|
365
394
|
|
|
366
|
-
const harness = (await
|
|
395
|
+
const harness = (await fetchHarness()).data;
|
|
367
396
|
const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
|
|
368
397
|
const summary = {
|
|
369
398
|
repo: harness.repo, serviceId: harness.serviceId ?? null,
|
package/dist/hooks.js
CHANGED
|
@@ -2,6 +2,7 @@ import { CliError, devRequest } from "./core.js";
|
|
|
2
2
|
import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { safetySettingsSnippet } from "./safety.js";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
@@ -177,8 +178,9 @@ export function postHookFailure(error) {
|
|
|
177
178
|
return { exitCode: 0, message: `nodii-dev post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}` };
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
export const settingsSnippet = () =>
|
|
181
|
-
hooks
|
|
181
|
+
export const settingsSnippet = () => {
|
|
182
|
+
const safety = safetySettingsSnippet().hooks;
|
|
183
|
+
return { hooks: {
|
|
182
184
|
PreToolUse: [
|
|
183
185
|
{
|
|
184
186
|
matcher: "^mcp__(figma|claude_ai_Figma|figma-dev-mode)__.*$",
|
|
@@ -186,6 +188,7 @@ export const settingsSnippet = () => ({
|
|
|
186
188
|
{ type: "command", command: "nodii-dev hook claude-figma-pre" },
|
|
187
189
|
],
|
|
188
190
|
},
|
|
191
|
+
...safety.PreToolUse,
|
|
189
192
|
],
|
|
190
193
|
PostToolUse: [
|
|
191
194
|
{
|
|
@@ -194,9 +197,10 @@ export const settingsSnippet = () => ({
|
|
|
194
197
|
{ type: "command", command: "nodii-dev hook claude-figma-post" },
|
|
195
198
|
],
|
|
196
199
|
},
|
|
200
|
+
...safety.PostToolUse,
|
|
197
201
|
],
|
|
198
|
-
}
|
|
199
|
-
}
|
|
202
|
+
} };
|
|
203
|
+
};
|
|
200
204
|
|
|
201
205
|
export async function heartbeatCurrentLane(request = devRequest) {
|
|
202
206
|
let branch;
|
package/dist/lanes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
|
|
4
|
+
import { recordReviewReceipt } from "./safety.js";
|
|
4
5
|
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const via = { via: "nodii-dev" };
|
|
@@ -98,7 +99,7 @@ export async function branchCommits(lane) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits } = {}) {
|
|
102
|
+
export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, reviewReceipt = recordReviewReceipt } = {}) {
|
|
102
103
|
if (action === "list") {
|
|
103
104
|
const c = flags.mine ? await config() : null;
|
|
104
105
|
const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
|
|
@@ -168,7 +169,14 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
168
169
|
return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/heartbeat`, { ...(flags.note !== undefined ? { note: flags.note } : {}), ...via }), human: `${ref}: heartbeat recorded.` };
|
|
169
170
|
}
|
|
170
171
|
if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
|
|
171
|
-
if (action === "review")
|
|
172
|
+
if (action === "review") {
|
|
173
|
+
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, via);
|
|
174
|
+
try { await reviewReceipt(); }
|
|
175
|
+
catch (error) {
|
|
176
|
+
throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} from its worktree.`, 1);
|
|
177
|
+
}
|
|
178
|
+
return { payload, human: `${ref}: review recorded for the current commit and origin/main.` };
|
|
179
|
+
}
|
|
172
180
|
if (action === "verify") {
|
|
173
181
|
const value = requireFlag(flags, "dev-check");
|
|
174
182
|
return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/verify`, { ...(URL.canParse(value) ? { url: value } : { ref: value }), ...via }), human: `${ref}: verified on dev.` };
|
package/dist/safety.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const ACTIVE = new Set(["queued", "building", "built", "review", "on_main"]);
|
|
9
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
10
|
+
const GENERATED_MARK = /(?:do not edit|auto-?generated|generated (?:file|code)|@generated)/i;
|
|
11
|
+
const REVIEW_RECEIPT = "nodii-review-receipt.json";
|
|
12
|
+
const SAFETY_STATE = "nodii-safety-state.json";
|
|
13
|
+
const SAFETY_AUDIT = "nodii-safety-audit.jsonl";
|
|
14
|
+
|
|
15
|
+
async function optional(path) {
|
|
16
|
+
try { return await readFile(path, "utf8"); }
|
|
17
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function git(cwd, ...args) {
|
|
21
|
+
return (await execFileAsync("git", args, { cwd, encoding: "utf8" })).stdout.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function gitPrivatePath(cwd, name) {
|
|
25
|
+
return git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cleanPath = (path) => String(path).split(sep).join("/").replace(/^\.\//, "");
|
|
29
|
+
const quotePattern = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
30
|
+
function globPattern(glob) {
|
|
31
|
+
const source = quotePattern(cleanPath(glob)).replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*");
|
|
32
|
+
return new RegExp(`^${source}(?:/.*)?$`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function words(command) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|([^\s]+)/g;
|
|
38
|
+
for (const match of command.matchAll(re)) out.push(match[1] ?? match[2] ?? match[3]);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function commandHas(tokens, ...sequence) {
|
|
43
|
+
return tokens.some((_, at) => sequence.every((part, offset) => tokens[at + offset] === part));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandAfter(tokens, ...sequence) {
|
|
47
|
+
const at = tokens.findIndex((_, index) => sequence.every((part, offset) => tokens[index + offset] === part));
|
|
48
|
+
return at === -1 ? [] : tokens.slice(at + sequence.length);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function composeProject(tokens, cwd) {
|
|
52
|
+
const after = commandAfter(tokens, "docker", "compose");
|
|
53
|
+
for (let i = 0; i < after.length; i += 1) {
|
|
54
|
+
if (["-p", "--project-name"].includes(after[i])) return after[i + 1] ?? null;
|
|
55
|
+
if (after[i].startsWith("--project-name=")) return after[i].slice("--project-name=".length);
|
|
56
|
+
}
|
|
57
|
+
const assigned = tokens.find((x) => x.startsWith("COMPOSE_PROJECT_NAME="));
|
|
58
|
+
return assigned?.slice("COMPOSE_PROJECT_NAME=".length) || basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function gitInvocations(tokens) {
|
|
62
|
+
const commands = [];
|
|
63
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
64
|
+
if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
|
|
65
|
+
let cursor = at + 1;
|
|
66
|
+
while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
|
|
67
|
+
const option = tokens[cursor];
|
|
68
|
+
cursor += 1;
|
|
69
|
+
if (["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option)) cursor += 1;
|
|
70
|
+
}
|
|
71
|
+
if (tokens[cursor]) commands.push({ verb: tokens[cursor], args: tokens.slice(cursor + 1) });
|
|
72
|
+
}
|
|
73
|
+
return commands;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function commandRef(after, verb) {
|
|
77
|
+
if (!after.length) return null;
|
|
78
|
+
if (["checkout", "reset"].includes(verb) && after[0] === "--") return null;
|
|
79
|
+
let skip = false;
|
|
80
|
+
for (const item of after) {
|
|
81
|
+
if (skip) { skip = false; continue; }
|
|
82
|
+
if (["-b", "-B", "--orphan", "--pathspec-from-file"].includes(item)) { skip = true; continue; }
|
|
83
|
+
if (item.startsWith("-")) continue;
|
|
84
|
+
return item;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createsBranch(after) {
|
|
90
|
+
return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify"));
|
|
94
|
+
|
|
95
|
+
function deployCommand(tokens, command) {
|
|
96
|
+
if (commandHas(tokens, "tofu", "apply")) return true;
|
|
97
|
+
if (commandHas(tokens, "terraform", "apply")) return true;
|
|
98
|
+
if (commandHas(tokens, "gh", "workflow", "run")) return /deploy|release|promote/i.test(command);
|
|
99
|
+
return /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?[^\s]*deploy[^\s]*\b/i.test(command) || /\b(?:make|just)\s+[^\s]*deploy[^\s]*\b/i.test(command);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function volumeNames(tokens) {
|
|
103
|
+
return commandAfter(tokens, "docker", "volume", "rm").filter((x) => !x.startsWith("-"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function generatedByPath(path) {
|
|
107
|
+
const p = cleanPath(path);
|
|
108
|
+
return /(?:^|\/)(?:components\/ui|__generated__|generated)(?:\/|$)/i.test(p) ||
|
|
109
|
+
/(?:^|\/)(?:devops-)?openapi\.json$/i.test(p) ||
|
|
110
|
+
/(?:^|\/)[^/]*(?:\.gen|\.generated)\.[^/]+$/i.test(p);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function generatedFile(path, context) {
|
|
114
|
+
const p = cleanPath(path);
|
|
115
|
+
if (generatedByPath(p) || context.generatedPaths.some((x) => globPattern(x).test(p))) return true;
|
|
116
|
+
const text = await optional(resolve(context.root, p));
|
|
117
|
+
return text !== null && GENERATED_MARK.test(text.slice(0, 4096));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function exactDeployGrant(context) {
|
|
121
|
+
return context.deployGrants.some((x) => x === context.head);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Data-dependent checks nudge, rather than silently passing, when their source is not available yet. */
|
|
125
|
+
export function commandNudges(command, context) {
|
|
126
|
+
const tokens = words(command);
|
|
127
|
+
const nudges = [];
|
|
128
|
+
if (deployCommand(tokens, command) && !context.deployGrantKnown)
|
|
129
|
+
nudges.push("nodii-dev safety: can't verify deploy authorization: no GO grant source is configured (follow-up AGENT-HARNESS-13).");
|
|
130
|
+
return nudges;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every costly or irreversible shell mistake AGENT-HARNESS-15 blocks. */
|
|
134
|
+
export function commandViolations(command, context) {
|
|
135
|
+
const tokens = words(command);
|
|
136
|
+
const gitCommands = gitInvocations(tokens);
|
|
137
|
+
const violations = [];
|
|
138
|
+
const lane = context.role !== "coordinator";
|
|
139
|
+
const block = (code, message) => violations.push({ code, message });
|
|
140
|
+
|
|
141
|
+
if (gitCommands.some((x) => x.verb === "stash") && lane)
|
|
142
|
+
block("SAFETY_GIT_STASH", "git stash is shared by every worktree; lanes and subagents use a work-in-progress commit.");
|
|
143
|
+
|
|
144
|
+
for (const verb of ["checkout", "switch"]) {
|
|
145
|
+
const invocation = gitCommands.find((x) => x.verb === verb);
|
|
146
|
+
if (!invocation) continue;
|
|
147
|
+
if (createsBranch(invocation.args)) {
|
|
148
|
+
block("SAFETY_GIT_BRANCH", `git ${verb} would create and leave the lane branch ${context.branch}.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const ref = commandRef(invocation.args, verb);
|
|
152
|
+
if (ref && ref !== context.branch)
|
|
153
|
+
block("SAFETY_GIT_BRANCH", `git ${verb} would leave the lane branch ${context.branch}.`);
|
|
154
|
+
}
|
|
155
|
+
if (gitCommands.some((x) => x.verb === "add" && x.args.some((arg) => arg === "-A" || arg === "--all")))
|
|
156
|
+
block("SAFETY_GIT_ADD_ALL", "git add -A/--all can stage work the lane does not own; stage owned paths by name.");
|
|
157
|
+
const reset = gitCommands.find((x) => x.verb === "reset");
|
|
158
|
+
if (reset) {
|
|
159
|
+
const ref = commandRef(reset.args, "reset");
|
|
160
|
+
if (ref && !/^HEAD(?:[~^].*)?$/.test(ref) && ref !== context.branch)
|
|
161
|
+
block("SAFETY_GIT_RESET_BRANCH", `git reset onto ${ref} is not allowed from ${context.branch}.`);
|
|
162
|
+
}
|
|
163
|
+
if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
|
|
164
|
+
block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
|
|
165
|
+
|
|
166
|
+
if (usesNoVerify(command)) {
|
|
167
|
+
if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
|
|
168
|
+
else if (!context.noVerifyReason) block("SAFETY_NO_VERIFY_REASON", "A coordinator may use --no-verify only with a recorded reason.");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (gitCommands.some((x) => x.verb === "push")) {
|
|
172
|
+
const receipt = context.reviewReceipt;
|
|
173
|
+
if (!receipt) block("SAFETY_REVIEW_REQUIRED", `Push blocked: no review receipt for ${context.head}. Run nodii-dev lane review after the gates are green.`);
|
|
174
|
+
else if (receipt.commit !== context.head)
|
|
175
|
+
block("SAFETY_REVIEW_COMMIT", `Push blocked: review covers ${receipt.commit}, not ${context.head}.`);
|
|
176
|
+
else if (receipt.base !== context.base)
|
|
177
|
+
block("SAFETY_REVIEW_BASE", `Push blocked: review used base ${receipt.base}, but origin/main is ${context.base}. Re-review the current diff.`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (lane && /(?:^|[\s/])(?:nodii-dev|@nodii\/dev(?:@[^\s]+)?)\s+(?:login|logout|agent\s+register)(?:\s|$)/.test(command))
|
|
181
|
+
block("SAFETY_IDENTITY", "A lane never runs nodii-dev login, logout or agent register; ask the coordinator.");
|
|
182
|
+
if (/\bnodii\/prod(?:\/|\b)/i.test(command))
|
|
183
|
+
block("SAFETY_PROD_SECRET", "nodii/prod secrets are outside lane authority.");
|
|
184
|
+
if (/(?:^|[;&|]\s*)(?:(?:export|env)\s+)?NODE_ENV\s*=\s*["']?test\b/i.test(command))
|
|
185
|
+
block("SAFETY_NODE_ENV_TEST", "NODE_ENV=test disables production-shaped behaviour and is forbidden in lane tests.");
|
|
186
|
+
if (/(?:--skipAuth\b|\bskipAuth\s*=\s*(?:true|1)\b|["']skipAuth["']\s*:\s*true\b)/i.test(command))
|
|
187
|
+
block("SAFETY_SKIP_AUTH", "skipAuth is forbidden; real-stack tests keep production authentication on.");
|
|
188
|
+
|
|
189
|
+
if (commandHas(tokens, "docker", "compose") && commandAfter(tokens, "docker", "compose").includes("down") && tokens.some((x) => x === "-v" || x === "--volumes")) {
|
|
190
|
+
const project = composeProject(tokens, context.cwd);
|
|
191
|
+
if (!project || !context.createdStacks.includes(project))
|
|
192
|
+
block("SAFETY_COMPOSE_OWNERSHIP", `docker compose down -v is allowed only for a stack this run created${project ? `; ${project} is not recorded as owned` : ""}.`);
|
|
193
|
+
}
|
|
194
|
+
if (commandHas(tokens, "docker", "volume", "rm")) {
|
|
195
|
+
const foreign = volumeNames(tokens).filter((x) => !context.createdVolumes.includes(x));
|
|
196
|
+
if (!volumeNames(tokens).length || foreign.length)
|
|
197
|
+
block("SAFETY_VOLUME_OWNERSHIP", `Named-volume deletion is allowed only for volumes this run created${foreign.length ? `; not owned: ${foreign.join(", ")}` : ""}.`);
|
|
198
|
+
}
|
|
199
|
+
if (commandHas(tokens, "docker", "volume", "prune"))
|
|
200
|
+
block("SAFETY_VOLUME_OWNERSHIP", "docker volume prune cannot prove ownership of every volume; remove only a named volume this run created.");
|
|
201
|
+
if (deployCommand(tokens, command) && context.deployGrantKnown && !exactDeployGrant(context))
|
|
202
|
+
block("SAFETY_DEPLOY_GRANT", `Deploy/apply blocked: no recorded GO deploy ${context.head} grant.`);
|
|
203
|
+
|
|
204
|
+
return violations;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Edit-tool ownership and generated-file blocks. */
|
|
208
|
+
export async function editViolations(paths, context) {
|
|
209
|
+
const violations = [];
|
|
210
|
+
for (const path of paths) {
|
|
211
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(context.cwd, path);
|
|
212
|
+
const rel = cleanPath(relative(context.root, absolute));
|
|
213
|
+
if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
|
|
214
|
+
violations.push({ code: "SAFETY_EDIT_OUTSIDE_REPO", message: `Edit blocked outside the lane repository: ${path}.` });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (context.ownedPaths.length && !context.ownedPaths.some((x) => globPattern(x).test(rel))) {
|
|
218
|
+
violations.push({ code: "SAFETY_EDIT_OUTSIDE_SCOPE", message: `Edit blocked outside the lane's owned paths: ${rel}.` });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (await generatedFile(rel, context))
|
|
222
|
+
violations.push({ code: "SAFETY_EDIT_GENERATED", message: `Edit blocked for generated file ${rel}; change its source and regenerate it.` });
|
|
223
|
+
}
|
|
224
|
+
return violations;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function editPaths(payload) {
|
|
228
|
+
const input = payload.tool_input ?? {};
|
|
229
|
+
return [input.file_path, input.path, input.notebook_path, ...(Array.isArray(input.paths) ? input.paths : [])].filter((x) => typeof x === "string" && x);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function harnessGenerated(root) {
|
|
233
|
+
const text = await optional(resolve(root, ".nodii/harness-state.json"));
|
|
234
|
+
if (!text) return [];
|
|
235
|
+
try { return Object.keys(JSON.parse(text).files ?? {}); }
|
|
236
|
+
catch { return [".nodii/harness-state.json"]; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function laneOwnership(lane, root) {
|
|
240
|
+
const direct = lane && Object.hasOwn(lane, "ownedPaths");
|
|
241
|
+
const frozen = lane?.freeze?.boundaries && Object.hasOwn(lane.freeze.boundaries, "ownedPaths");
|
|
242
|
+
const candidates = direct ? lane.ownedPaths : frozen ? lane.freeze.boundaries.ownedPaths : [];
|
|
243
|
+
const paths = [candidates].flat().filter((x) => typeof x === "string" && x).map((x) => {
|
|
244
|
+
if (!isAbsolute(x)) return cleanPath(x);
|
|
245
|
+
const rel = cleanPath(relative(root, x));
|
|
246
|
+
return rel.startsWith("../") ? x : rel;
|
|
247
|
+
});
|
|
248
|
+
return { known: Boolean(direct || frozen), paths };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const laneStack = (key) => String(key ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
252
|
+
|
|
253
|
+
async function currentLane(cwd, request, config) {
|
|
254
|
+
if (!config?.agentId) return null;
|
|
255
|
+
const branch = await git(cwd, "branch", "--show-current");
|
|
256
|
+
const root = await git(cwd, "rev-parse", "--show-toplevel");
|
|
257
|
+
const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
|
|
258
|
+
return lanes.find((x) => x.agentId === config.agentId && x.branch === branch && resolve(x.worktree ?? root) === resolve(root) && ACTIVE.has(x.status)) ?? null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function deployGrantsFrom(text) {
|
|
262
|
+
return String(text ?? "").split(/\r?\n/).map((line) => /^GO deploy ([0-9a-f]{7,64})\s*$/.exec(line)?.[1]).filter(Boolean);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Resolve the facts a hook needs without creating a second lane record. */
|
|
266
|
+
export async function safetyContext(cwd = process.cwd(), { request = devRequest, configReader = readConfig, env = process.env } = {}) {
|
|
267
|
+
const root = await git(cwd, "rev-parse", "--show-toplevel");
|
|
268
|
+
const branch = await git(cwd, "branch", "--show-current");
|
|
269
|
+
const head = await git(cwd, "rev-parse", "HEAD");
|
|
270
|
+
const base = await git(cwd, "rev-parse", "origin/main");
|
|
271
|
+
let config = null;
|
|
272
|
+
try { config = await configReader(false); } catch {}
|
|
273
|
+
const configuredPath = env.NODII_DEV_CONFIG_PATH ?? configPath();
|
|
274
|
+
let role = basename(configuredPath).startsWith("lane-") ? "lane" : "unknown";
|
|
275
|
+
if (role === "unknown" && config?.agentId) {
|
|
276
|
+
try { role = (await request("GET", `/api/v1/agents/${encode(config.agentId)}`)).data?.role ?? role; } catch {}
|
|
277
|
+
}
|
|
278
|
+
let lane = null;
|
|
279
|
+
try { lane = await currentLane(cwd, request, config); } catch {}
|
|
280
|
+
const receiptPath = await gitPrivatePath(cwd, REVIEW_RECEIPT);
|
|
281
|
+
const statePath = await gitPrivatePath(cwd, SAFETY_STATE);
|
|
282
|
+
const receiptText = await optional(receiptPath);
|
|
283
|
+
const stateText = await optional(statePath);
|
|
284
|
+
const reviewReceipt = receiptText ? JSON.parse(receiptText) : null;
|
|
285
|
+
const state = stateText ? JSON.parse(stateText) : {};
|
|
286
|
+
const goText = env.NODII_DEV_GO_FILE ? await optional(env.NODII_DEV_GO_FILE) : null;
|
|
287
|
+
const ownership = laneOwnership(lane, root);
|
|
288
|
+
const laneProject = laneStack(lane?.key);
|
|
289
|
+
return {
|
|
290
|
+
cwd: payloadCwd(cwd), root, branch, head, base, role, lane,
|
|
291
|
+
ownedPaths: ownership.paths,
|
|
292
|
+
ownershipKnown: role === "coordinator" || ownership.known,
|
|
293
|
+
generatedPaths: [...await harnessGenerated(root), ...(state.generatedPaths ?? [])],
|
|
294
|
+
reviewReceipt,
|
|
295
|
+
createdStacks: [...new Set([...(state.createdStacks ?? []), ...(laneProject ? [laneProject] : [])])],
|
|
296
|
+
createdVolumes: state.createdVolumes ?? [],
|
|
297
|
+
deployGrants: deployGrantsFrom(goText),
|
|
298
|
+
deployGrantKnown: goText !== null,
|
|
299
|
+
noVerifyReason: env.NODII_DEV_NO_VERIFY_REASON?.trim() || null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const payloadCwd = (cwd) => resolve(cwd);
|
|
304
|
+
|
|
305
|
+
function blocked(violation) {
|
|
306
|
+
throw new CliError(violation.code, violation.message, 2);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Claude PreToolUse entrypoint. Silent return means allowed. */
|
|
310
|
+
export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
|
|
311
|
+
const tool = payload.tool_name ?? "";
|
|
312
|
+
if (tool !== "Bash" && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
|
|
313
|
+
const cwd = payload.cwd ?? process.cwd();
|
|
314
|
+
const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
|
|
315
|
+
context.cwd = resolve(cwd);
|
|
316
|
+
if (tool === "Bash") {
|
|
317
|
+
const command = String(payload.tool_input?.command ?? "");
|
|
318
|
+
const violation = commandViolations(command, context)[0];
|
|
319
|
+
if (violation) blocked(violation);
|
|
320
|
+
if (usesNoVerify(command) && context.role === "coordinator" && context.noVerifyReason)
|
|
321
|
+
await recordNoVerifyReason(cwd, context.noVerifyReason);
|
|
322
|
+
return { allowed: true, nudges: commandNudges(command, context) };
|
|
323
|
+
}
|
|
324
|
+
const paths = editPaths(payload);
|
|
325
|
+
if (!paths.length) blocked({ code: "SAFETY_EDIT_PATH_MISSING", message: `Edit blocked: ${tool} supplied no file path.` });
|
|
326
|
+
const violation = (await editViolations(paths, context))[0];
|
|
327
|
+
if (violation) blocked(violation);
|
|
328
|
+
return {
|
|
329
|
+
allowed: true,
|
|
330
|
+
nudges: context.role !== "coordinator" && !context.ownershipKnown
|
|
331
|
+
? ["nodii-dev safety: can't verify owned paths: the lane has no Dev ownedPaths field yet (follow-up AGENT-HARNESS-10)."]
|
|
332
|
+
: [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function writeSafetyState(cwd, update) {
|
|
337
|
+
const path = await gitPrivatePath(cwd, SAFETY_STATE);
|
|
338
|
+
const before = await optional(path);
|
|
339
|
+
const state = before ? JSON.parse(before) : {};
|
|
340
|
+
const next = update({ createdStacks: [], createdVolumes: [], ...state });
|
|
341
|
+
await mkdir(dirname(path), { recursive: true });
|
|
342
|
+
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function successful(payload) {
|
|
347
|
+
const response = payload.tool_response ?? {};
|
|
348
|
+
const code = response.exitCode ?? response.exit_code ?? response.status;
|
|
349
|
+
return code === 0 || (code === undefined && response.interrupted === false && response.is_error !== true);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Record only successful resource creation; says nothing when satisfied. */
|
|
353
|
+
export async function safetyPostHook(payload) {
|
|
354
|
+
if (payload.tool_name !== "Bash" || !successful(payload)) return { recorded: false };
|
|
355
|
+
const command = String(payload.tool_input?.command ?? "");
|
|
356
|
+
const tokens = words(command);
|
|
357
|
+
const cwd = payload.cwd ?? process.cwd();
|
|
358
|
+
if (commandHas(tokens, "docker", "compose") && commandAfter(tokens, "docker", "compose").includes("up")) {
|
|
359
|
+
const project = composeProject(tokens, cwd);
|
|
360
|
+
await writeSafetyState(cwd, (state) => ({ ...state, createdStacks: [...new Set([...state.createdStacks, project])] }));
|
|
361
|
+
return { recorded: true, kind: "stack", name: project };
|
|
362
|
+
}
|
|
363
|
+
if (commandHas(tokens, "docker", "volume", "create")) {
|
|
364
|
+
const name = commandAfter(tokens, "docker", "volume", "create").filter((x) => !x.startsWith("-")).at(-1);
|
|
365
|
+
if (name) {
|
|
366
|
+
await writeSafetyState(cwd, (state) => ({ ...state, createdVolumes: [...new Set([...state.createdVolumes, name])] }));
|
|
367
|
+
return { recorded: true, kind: "volume", name };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { recorded: false };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Pin the exact commit and origin/main base Dev's review transition covers. */
|
|
374
|
+
export async function recordReviewReceipt(cwd = process.cwd()) {
|
|
375
|
+
const path = await gitPrivatePath(cwd, REVIEW_RECEIPT);
|
|
376
|
+
const receipt = {
|
|
377
|
+
commit: await git(cwd, "rev-parse", "HEAD"),
|
|
378
|
+
base: await git(cwd, "rev-parse", "origin/main"),
|
|
379
|
+
recordedAt: new Date().toISOString(),
|
|
380
|
+
};
|
|
381
|
+
await mkdir(dirname(path), { recursive: true });
|
|
382
|
+
await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
383
|
+
return receipt;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Record the exceptional coordinator reason without placing it in the repository. */
|
|
387
|
+
export async function recordNoVerifyReason(cwd, reason) {
|
|
388
|
+
const path = await gitPrivatePath(cwd, SAFETY_AUDIT);
|
|
389
|
+
await appendFile(path, `${JSON.stringify({ at: new Date().toISOString(), action: "--no-verify", reason })}\n`, { mode: 0o600 });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export const safetySettingsSnippet = () => ({
|
|
393
|
+
hooks: {
|
|
394
|
+
PreToolUse: [{ matcher: "^(Bash|Edit|Write|MultiEdit|NotebookEdit)$", hooks: [{ type: "command", command: "nodii-dev hook safety-pre" }] }],
|
|
395
|
+
PostToolUse: [{ matcher: "^Bash$", hooks: [{ type: "command", command: "nodii-dev hook safety-post" }] }],
|
|
396
|
+
},
|
|
397
|
+
});
|
package/dist/setup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
|
+
import { safetySettingsSnippet } from "./safety.js";
|
|
4
5
|
|
|
5
6
|
export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
|
|
6
7
|
const START = "<!-- nodii-dev build workflow:start -->";
|
|
@@ -43,11 +44,18 @@ export const managedBlock = `${START}
|
|
|
43
44
|
Follow [the nodii build workflow skill](${SKILL_PATH}) for all build work. Drive lane state, evidence, heartbeats, landing, dev verification, and Built acknowledgement through \`nodii-dev\`.
|
|
44
45
|
${END}`;
|
|
45
46
|
|
|
46
|
-
export const workflowHooks = () =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
47
|
+
export const workflowHooks = () => {
|
|
48
|
+
const safety = safetySettingsSnippet().hooks;
|
|
49
|
+
return {
|
|
50
|
+
SessionStart: [{ hooks: [{ type: "command", command: "nodii-dev lane list --mine --json" }] }],
|
|
51
|
+
Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook lane-heartbeat" }] }],
|
|
52
|
+
PreToolUse: safety.PreToolUse,
|
|
53
|
+
PostToolUse: [
|
|
54
|
+
...safety.PostToolUse,
|
|
55
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "nodii-dev hook workflow-post" }] },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
};
|
|
51
59
|
|
|
52
60
|
const stable = (x) => JSON.stringify(x);
|
|
53
61
|
function mergeHookList(current = [], additions = []) {
|