@nodii/dev 0.6.30 → 0.6.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -313,6 +313,9 @@ nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
313
313
  nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
314
314
  nodii-dev hook safety-pre # silent safety decision; blocks with exit 2
315
315
  nodii-dev hook safety-post # silently records resources created by successful commands
316
+ nodii-dev hook nudge-pre # guideline nudges before a tool call; never blocks
317
+ nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
318
+ nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
316
319
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
317
320
 
318
321
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
@@ -429,6 +432,38 @@ deploy. A coordinator's exceptional `--no-verify` also requires
429
432
  `NODII_DEV_NO_VERIFY_REASON`; the CLI records the reason in private Git
430
433
  metadata. Lanes and subagents can never use `--no-verify`.
431
434
 
435
+ ### Guideline nudges (AGENT-HARNESS-21)
436
+
437
+ `harness install` and `harness update` add two more `nodii-dev` hooks for
438
+ Claude Code and Codex. A nudge reminds a lane of a rule at the moment it is
439
+ about to be broken and **says nothing when the rule is met**. Nudges never
440
+ block a tool call, fail open on any error or unknown payload, and stay silent
441
+ in any session that is not a Dev lane. Each cites the clause it protects.
442
+
443
+ | Moment | Nudge | Silent when |
444
+ |---|---|---|
445
+ | Edit or write | A DS component or product page is used or edited and its guideline is not in the lane's gathered set (U07-19, DEC-0010) | the guideline is in the gathered set, or was read with `design guideline show` |
446
+ | Edit or write | `className` or `style` on a DS component (U07-19) | the tag is not a DS import, or has neither prop |
447
+ | Edit or write | A new migration: journal `when` = max + 1, then a Delta gather. A `_journal.json` write whose `when` values do not rise | the migration is covered by a Delta gather, or already exists |
448
+ | Edit or write | Code renders a person: `PiiText` without `self`, a raw `.email`/`.phone`/`.displayName`, or the page masks a value itself (R5, U05-25) | `PiiText` carries `self`, or no person is rendered |
449
+ | Figma read | A code lane reads by node id (`get_design_context`, `get_metadata`, `get_screenshot`, ...) instead of the published key (MW8-20) | a Figma lane, or no node id |
450
+ | `figma submit CS-n` | The unused-property, stale `Blocked on DS` placeholder and Dialog-without-body hand checks (F-34, CS-2453) | all three are recorded with `figma checked CS-n --unused-properties --stale-placeholders --dialog-body` |
451
+ | AskUserQuestion | The question is also an item in `docs/figma-ux-drive/qa-with-abhi/open.md` (R7) | open.md already holds the question |
452
+ | Stop | The lane is not `on_dev` but "done" was claimed (MW3); an "I couldn't" with no filed issue; Unconfirmed Freeze items still open; uncommitted work | none of those; a Stop that was already nudged once may stop |
453
+
454
+ The lane's gathered set is the private Git file `nodii-lane-knowledge.json`:
455
+ `lane gather --components a,b` adds the components it covered to `guidelines`,
456
+ `lane gather --delta` adds the paths it saw (`--paths`, else every path this
457
+ worktree changes against origin/main) to `deltaGathered`, and `design guideline
458
+ show` adds to `guidelinesRead`. A part's guideline is its root's (`dialog`
459
+ covers `dialog-content`). Edits are judged against the file they produce, so
460
+ an Edit or `apply_patch` that only changes an attribute is read in full.
461
+
462
+ The Stop nudge asks the session to continue once (`decision: block`, skipped
463
+ when `stop_hook_active`); Codex passes it through its managed dispatcher. A
464
+ nudge hook skips the amendment guard and the waiting-results banner, since
465
+ either would corrupt its JSON; it cannot run a Delta gather.
466
+
432
467
  ### DS conformance: `design roles` and `design check` (DEC-0034)
433
468
 
434
469
  Every UI role names exactly one DS component (MW6-14). Resolve your brief's
package/dist/cli.js CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  import { heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
24
24
  import { setupCommand } from "./setup.js";
25
25
  import { harnessCommand } from "./harness.js";
26
- import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
26
+ import { hookRuntime, recordHookFire, safetyContext, safetyPostHook, safetyPreHook } from "./safety.js";
27
+ import { SUBMIT_CHECK_FLAGS, changedPaths, nudgeCandidate, nudgePre, nudgeStop, recordGathered, recordGuidelineRead, recordSubmitChecks } from "./nudges.js";
27
28
  import { doctorCommand } from "./doctor.js";
28
29
  import { dispatchManagedHook } from "./managed-hooks.js";
29
30
  import { freezeCommand } from "./freeze.js";
@@ -126,6 +127,7 @@ Usage: nodii-dev <command> [options] [--json]
126
127
  figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
127
128
  figma check|log-write|submit|apply|abandon|release
128
129
  figma submit CHANGE_SET --file SUBMIT.json [--sync]
130
+ figma checked CHANGE_SET --unused-properties --stale-placeholders --dialog-body # the hand checks the submit nudge asks for
129
131
  # submit, resubmit and design check --change-set run as a job in Dev
130
132
  # and poll it (a large set outlasts the edge's timeout); --sync waits
131
133
  # on one request instead. A 502/504 is checked against the set, never resent
@@ -135,7 +137,7 @@ Usage: nodii-dev <command> [options] [--json]
135
137
  figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
136
138
  figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
137
139
  # register a Figma file in Dev so it can be locked
138
- hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
140
+ hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|nudge-pre|nudge-stop|session-start|managed-dispatch|print-settings
139
141
  setup [--yes|--check] # dry-run by default; installs/checks Ollama and pulls the Gather model
140
142
  harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
141
143
  # --system stages managed hooks and only prints operator-reviewed sudo commands
@@ -164,7 +166,7 @@ Usage: nodii-dev <command> [options] [--json]
164
166
  # authoritative preflight, one worktree per repo, watched launch
165
167
  # uses the provisioned agents/lane-<key>.json Dev identity
166
168
  lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
167
- lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT]
169
+ lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT] [--components a,b] [--paths a,b]
168
170
  # infers a launched worktree; explicit contract inputs support manual lane start
169
171
  run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
170
172
  # watched command: registers with NODII_DEV_RUN_ID,
@@ -444,7 +446,9 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
444
446
  export async function main(argv = process.argv.slice(2)) {
445
447
  // Amendment acknowledgement is a lane-wide backstop, including otherwise
446
448
  // local commands such as help and version.
447
- if (argv[0] !== "__run-supervisor") {
449
+ // A nudge hook is fail-open and writes only its own JSON: it cannot run a
450
+ // Delta gather, and a waiting-results banner would corrupt its output.
451
+ if (argv[0] !== "__run-supervisor" && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
448
452
  await amendmentGuard(argv);
449
453
  await showWaitingResults({ acknowledge: argv[0] !== "hook" });
450
454
  }
@@ -565,7 +569,12 @@ export async function main(argv = process.argv.slice(2)) {
565
569
  if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
566
570
  if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
567
571
  // Guideline change sets: approving or rejecting one is a person's action in Dev.
568
- if (command === "design" && action === "guideline") return emitCommand(await guidelineCommand(rest[0], rest.slice(1), f));
572
+ if (command === "design" && action === "guideline") {
573
+ const result = await guidelineCommand(rest[0], rest.slice(1), f);
574
+ // A guideline read in a lane counts toward the lane's gathered set (nudge hooks).
575
+ if (rest[0] === "show" && rest[1]) await recordGuidelineRead(process.cwd(), rest[1]).catch(() => {});
576
+ return emitCommand(result);
577
+ }
569
578
  if (command === "design" && action === "changeset") return emitCommand(await changeSetCommand(rest[0], rest.slice(1), f));
570
579
  if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
571
580
  if (command === "design" && action === "check") {
@@ -582,7 +591,13 @@ export async function main(argv = process.argv.slice(2)) {
582
591
  if (command === "coordinator") return emitCommand(await coordinatorCommand(action, f));
583
592
  if (command === "lane" && action === "launch") return emitCommand(await launchLaneCommand(rest[0], f));
584
593
  if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
585
- if (command === "lane" && action === "gather") return emitCommand(await gatherLaneCommand(rest[0], f));
594
+ if (command === "lane" && action === "gather") {
595
+ const result = await gatherLaneCommand(rest[0], f);
596
+ // What a gather covered is the lane's gathered set for the nudge hooks.
597
+ const asked = (value) => (typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
598
+ await (async () => recordGathered(process.cwd(), { guidelines: asked(f.components), deltaGathered: f.delta ? (f.paths ? asked(f.paths) : await changedPaths(process.cwd())) : [] }))().catch(() => {});
599
+ return emitCommand(result);
600
+ }
586
601
  if (command === "lane" && action === "freeze")
587
602
  return emitCommand(await freezeCommand(rest[0], rest.slice(1), f));
588
603
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
@@ -816,6 +831,13 @@ export async function main(argv = process.argv.slice(2)) {
816
831
  if (!result.data.allowed) process.exitCode = 3;
817
832
  return;
818
833
  }
834
+ if (action === "checked") {
835
+ const flagged = SUBMIT_CHECK_FLAGS.filter((check) => f[check]);
836
+ if (!isChangeSetId(rest[0]) || rest.length > 1 || !flagged.length)
837
+ throw new CliError("USAGE", `figma checked CHANGE_SET ${SUBMIT_CHECK_FLAGS.map((check) => `--${check}`).join(" ")} (at least one)`, 2);
838
+ await recordSubmitChecks(process.cwd(), rest[0], flagged);
839
+ return emit({ data: { changeSetId: rest[0], checked: flagged } }, json, `${rest[0]}: recorded ${flagged.join(", ")}.`);
840
+ }
819
841
  if (action === "log-write")
820
842
  return emit(await figmaLogWrite(rest[0], requireFlag(f, "tool"), requireFlag(f, "nodes").split(",").filter(Boolean)), json);
821
843
  if (["apply", "published", "abandon"].includes(action)) {
@@ -921,6 +943,27 @@ export async function main(argv = process.argv.slice(2)) {
921
943
  } catch {}
922
944
  return;
923
945
  }
946
+ // Guideline nudges (AGENT-HARNESS-21) never block: any error, a payload
947
+ // they don't know or a session outside a lane ends in no output.
948
+ if (action === "nudge-pre") {
949
+ try {
950
+ const payload = JSON.parse(await stdinText());
951
+ await recordHookFire(payload).catch(() => {});
952
+ if (!(await nudgeCandidate(payload))) return;
953
+ const nudges = await nudgePre(payload, await safetyContext(payload.cwd ?? process.cwd()));
954
+ if (nudges.length) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
955
+ } catch {}
956
+ return;
957
+ }
958
+ if (action === "nudge-stop") {
959
+ try {
960
+ const payload = JSON.parse(await stdinText());
961
+ await recordHookFire(payload).catch(() => {});
962
+ const nudges = await nudgeStop(payload, await safetyContext(payload.cwd ?? process.cwd()));
963
+ if (nudges.length) console.log(JSON.stringify({ decision: "block", reason: nudges.join("\n") }));
964
+ } catch {}
965
+ return;
966
+ }
924
967
  if (action === "lane-heartbeat") {
925
968
  const result = await heartbeatCurrentLane();
926
969
  return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.30";
7
+ export const VERSION = "0.6.31";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
package/dist/harness.js CHANGED
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { promisify } from "node:util";
12
12
  import { CliError, devRequest, query } from "./core.js";
13
13
  import { managedHookBundle, nodiiCommandArgs, writeManagedHookBundle } from "./managed-hooks.js";
14
+ import { nudgeSettingsSnippet } from "./nudges.js";
14
15
  import { codexSafetyHooksSnippet, regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
15
16
 
16
17
  /** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
@@ -198,6 +199,7 @@ export function withSafetyHooks(harness) {
198
199
  const parsed = JSON.parse(settings.content);
199
200
  let hooks = addHookEntries(parsed.hooks, safetySettingsSnippet().hooks);
200
201
  hooks = addHookEntries(hooks, regroundingHooksSnippet().hooks);
202
+ hooks = addHookEntries(hooks, nudgeSettingsSnippet().hooks);
201
203
  assertNodiiHooks(hooks, ".claude/settings.json");
202
204
  settings.content = JSON.stringify({ ...parsed, hooks });
203
205
  settings.sha256 = sha(settings.content);
@@ -135,7 +135,12 @@ export async function dispatchManagedHook(event, payloadText, deps = {}) {
135
135
  }
136
136
  const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
137
137
  const plain = successful.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
138
- if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
138
+ // A Stop nudge asks the session to continue once; any other Stop output is dropped.
139
+ if (event === "Stop") {
140
+ const block = messages.find((message) => message.decision === "block" && typeof message.reason === "string");
141
+ return block ? `${JSON.stringify({ decision: "block", reason: block.reason })}\n` : "{}\n";
142
+ }
143
+ if (event === "SubagentStop") return "{}\n";
139
144
  return [...structured, ...plain].join("\n") + ([...structured, ...plain].length ? "\n" : "");
140
145
  } catch {
141
146
  return emptyResponse(event);
package/dist/nudges.js ADDED
@@ -0,0 +1,441 @@
1
+ // Guideline nudge hooks (AGENT-HARNESS-21, agent-harness-design.md "Hooks").
2
+ //
3
+ // A nudge reminds a lane of a rule at the moment it is about to break it, and
4
+ // says nothing when the rule is already met. Nothing here blocks a tool call;
5
+ // the one hard stop in a session (Stop) is asked at most once. Every entry
6
+ // point fails open: an error, an unknown payload or no lane yields no nudge.
7
+ // Each nudge names the clause it protects, so an unused one can be retired.
8
+ import { execFile } from "node:child_process";
9
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { isFigmaTool, isReadTool } from "./hooks.js";
13
+ import { patchPaths } from "./safety.js";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]);
17
+ const SHELL_TOOLS = new Set(["Bash", "shell", "exec_command"]);
18
+ export const LANE_KNOWLEDGE = "nodii-lane-knowledge.json";
19
+ export const SUBMIT_CHECKS = "nodii-submit-checks.json";
20
+ export const SUBMIT_CHECK_FLAGS = ["unused-properties", "stale-placeholders", "dialog-body"];
21
+ const OPEN_MD = "docs/figma-ux-drive/qa-with-abhi/open.md";
22
+ const DS_SOURCE = /["'](@cognion-nucleus\/nodii-(?:design-system|blocks)(?:\/[^"']*)?)["']/;
23
+ const MAX_LISTED = 6;
24
+
25
+ // A file a nudge cannot read is the same as one that is not there.
26
+ async function optional(path) {
27
+ try { return await readFile(path, "utf8"); }
28
+ catch { return null; }
29
+ }
30
+
31
+ async function git(cwd, ...args) {
32
+ return (await execFileAsync("git", args, { cwd, encoding: "utf8" })).stdout.trim();
33
+ }
34
+
35
+ const privatePath = (cwd, name) => git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
36
+
37
+ async function readPrivateJson(cwd, name) {
38
+ try {
39
+ const text = await optional(await privatePath(cwd, name));
40
+ return text ? JSON.parse(text) : {};
41
+ } catch { return {}; }
42
+ }
43
+
44
+ async function writePrivateJson(cwd, name, update) {
45
+ const path = await privatePath(cwd, name);
46
+ const before = await optional(path).then((text) => (text ? JSON.parse(text) : {})).catch(() => ({}));
47
+ const next = update(before);
48
+ await mkdir(dirname(path), { recursive: true });
49
+ const temporary = `${path}.${process.pid}.tmp`;
50
+ await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
51
+ await rename(temporary, path);
52
+ return next;
53
+ }
54
+
55
+ /**
56
+ * What the lane has already read. Gather writes `guidelines` (the lane's
57
+ * gathered set) and `deltaGathered` (paths a Delta gather covered); reading a
58
+ * guideline with `design guideline show` adds to `guidelinesRead`.
59
+ */
60
+ export const laneKnowledge = (cwd) => readPrivateJson(cwd, LANE_KNOWLEDGE);
61
+
62
+ /** What `lane gather` covered: the components asked for, and on a Delta gather the paths it saw. */
63
+ export const recordGathered = (cwd, { guidelines = [], deltaGathered = [] }) =>
64
+ writePrivateJson(cwd, LANE_KNOWLEDGE, (state) => ({
65
+ ...state,
66
+ guidelines: [...new Set([...(state.guidelines ?? []), ...guidelines])],
67
+ deltaGathered: [...new Set([...(state.deltaGathered ?? []), ...deltaGathered])],
68
+ }));
69
+
70
+ /** The paths this worktree changes against origin/main, committed or not. */
71
+ export async function changedPaths(cwd) {
72
+ const base = await git(cwd, "merge-base", "origin/main", "HEAD");
73
+ const lines = [await git(cwd, "diff", "--name-only", base), await git(cwd, "ls-files", "--others", "--exclude-standard", "--full-name")];
74
+ return [...new Set(lines.join("\n").split("\n").map((line) => line.trim()).filter(Boolean))].slice(0, 5000);
75
+ }
76
+
77
+ export const recordGuidelineRead = (cwd, key) =>
78
+ writePrivateJson(cwd, LANE_KNOWLEDGE, (state) => ({ ...state, guidelinesRead: [...new Set([...(state.guidelinesRead ?? []), key])] }));
79
+
80
+ /** The checks attested for one Figma change set before its submit. */
81
+ export const recordSubmitChecks = (cwd, changeSet, checks) =>
82
+ writePrivateJson(cwd, SUBMIT_CHECKS, (state) => ({ ...state, [changeSet]: { ...state[changeSet], ...Object.fromEntries(checks.map((check) => [check, new Date().toISOString()])) } }));
83
+
84
+ // ─── reading the edit ──────────────────────────────────────────────────────
85
+
86
+ const cleanPath = (path) => String(path).split("\\").join("/").replace(/^\.\//, "");
87
+
88
+ function editedPaths(payload) {
89
+ const input = payload.tool_input ?? {};
90
+ if (payload.tool_name === "apply_patch") return patchPaths(input.command ?? input.input);
91
+ return [input.file_path, input.path, input.notebook_path].filter((x) => typeof x === "string" && x);
92
+ }
93
+
94
+ const lf = (text) => String(text).replace(/\r\n/g, "\n");
95
+ const replaceOnce = (text, from, to, all = false) => {
96
+ if (!from || !text.includes(from)) return null;
97
+ return all ? text.split(from).join(to) : text.replace(from, () => to);
98
+ };
99
+
100
+ /** One v4A patch section applied to `text`; a hunk that does not apply adds its new lines at the end. */
101
+ function patchedText(text, section) {
102
+ let result = text;
103
+ for (const hunk of section.split(/^@@.*$/m)) {
104
+ const oldLines = [];
105
+ const newLines = [];
106
+ const lines = hunk.split("\n");
107
+ while (lines.length && lines.at(-1) === "") lines.pop();
108
+ for (const line of lines) {
109
+ if (line === "") { oldLines.push(""); newLines.push(""); }
110
+ else if (line.startsWith("+")) newLines.push(line.slice(1));
111
+ else if (line.startsWith("-")) oldLines.push(line.slice(1));
112
+ else if (line.startsWith(" ")) { oldLines.push(line.slice(1)); newLines.push(line.slice(1)); }
113
+ }
114
+ if (!oldLines.length && !newLines.length) continue;
115
+ const next = oldLines.length ? replaceOnce(result, oldLines.join("\n"), newLines.join("\n")) : null;
116
+ result = next ?? `${result}\n${newLines.join("\n")}`;
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /** Each file the call writes, before and after it, as best the payload shows. */
122
+ async function editedFiles(payload, cwd) {
123
+ const input = payload.tool_input ?? {};
124
+ const absolute = (path) => (isAbsolute(path) ? path : resolve(cwd, path));
125
+ if (payload.tool_name === "apply_patch") {
126
+ const sections = String(input.command ?? input.input ?? "").split(/^\*\*\* (?=(?:Add|Update|Delete) File: )/m).slice(1);
127
+ return Promise.all(sections.map(async (section) => {
128
+ const [head, ...body] = section.split("\n");
129
+ const [, kind, name] = /^(Add|Update|Delete) File: (.+)$/.exec(head) ?? [];
130
+ const from = absolute(name?.trim() ?? "");
131
+ const moved = /^\*\*\* Move to: (.+)$/m.exec(body.join("\n"))?.[1]?.trim();
132
+ const path = moved ? absolute(moved) : from;
133
+ const disk = await optional(from);
134
+ const before = kind === "Add" ? "" : lf(disk ?? "");
135
+ const text = body.join("\n");
136
+ const after = kind === "Add" ? text.split("\n").filter((line) => line.startsWith("+")).map((line) => line.slice(1)).join("\n") : patchedText(before, lf(text));
137
+ return { kind, path, existed: (moved ? await optional(path) : disk) !== null, before, after };
138
+ })).then((files) => files.filter((file) => file.kind !== "Delete"));
139
+ }
140
+ const paths = editedPaths(payload).map(absolute).slice(0, 1);
141
+ return Promise.all(paths.map(async (path) => {
142
+ const disk = await optional(path);
143
+ const before = lf(disk ?? "");
144
+ let after;
145
+ if (typeof input.content === "string") after = lf(input.content);
146
+ else if (typeof input.new_source === "string") after = lf(input.new_source);
147
+ else if (Array.isArray(input.edits)) after = input.edits.reduce((text, edit) => replaceOnce(text, lf(edit?.old_string ?? ""), lf(edit?.new_string ?? ""), edit?.replace_all) ?? `${text}\n${lf(edit?.new_string ?? "")}`, before);
148
+ else after = replaceOnce(before, lf(input.old_string ?? ""), lf(input.new_string ?? ""), input.replace_all) ?? `${before}\n${lf(input.new_string ?? "")}`;
149
+ return { path, existed: disk !== null, before, after };
150
+ }));
151
+ }
152
+
153
+ const lineCounts = (text) => {
154
+ const counts = new Map();
155
+ for (const line of String(text).split("\n")) counts.set(line.trim(), (counts.get(line.trim()) ?? 0) + 1);
156
+ return counts;
157
+ };
158
+ /** Lines of `after` that `before` does not have. */
159
+ function changedLines(before, after) {
160
+ const had = lineCounts(before);
161
+ return String(after).split("\n").filter((line) => {
162
+ const key = line.trim();
163
+ if (!key) return false;
164
+ if (had.get(key) > 0) { had.set(key, had.get(key) - 1); return false; }
165
+ return true;
166
+ });
167
+ }
168
+
169
+ const kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
170
+
171
+ /** Local name → DS export, for PascalCase components imported from the DS or its blocks. */
172
+ export function dsImports(text) {
173
+ const out = new Map();
174
+ for (const match of String(text).matchAll(/import\s+(?!type\b)\{([^}]*)\}\s+from\s+(["'][^"']+["'])/g)) {
175
+ if (!DS_SOURCE.test(match[2])) continue;
176
+ for (const part of match[1].split(",")) {
177
+ const spec = part.trim();
178
+ if (!spec || /^type\s/.test(spec)) continue;
179
+ const [imported, local = imported] = spec.split(/\s+as\s+/);
180
+ if (/^[A-Z]/.test(imported) && !/(?:Props|Variants?|Context)$/.test(imported)) out.set(local.trim(), imported.trim());
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Every `<Tag …>` opening tag: its name, its source (`raw`), and the attribute
188
+ * text outside `{…}` and quotes. An expression is skipped by brace depth alone,
189
+ * so an apostrophe or a regex inside one cannot swallow the rest of the file.
190
+ */
191
+ export function jsxOpenTags(text) {
192
+ const tags = [];
193
+ const source = String(text);
194
+ for (const match of source.matchAll(/<([A-Z][\w.]*)(?=[\s/>])/g)) {
195
+ let depth = 0;
196
+ let quote = null;
197
+ let attrs = "";
198
+ let end = source.length;
199
+ for (let at = match.index + match[0].length; at < source.length; at += 1) {
200
+ const char = source[at];
201
+ if (quote) { attrs += char === quote ? char : " "; if (char === quote) quote = null; continue; }
202
+ if (char === "{") { depth += 1; attrs += " "; continue; }
203
+ if (char === "}") { depth = Math.max(0, depth - 1); continue; }
204
+ if (depth > 0) continue;
205
+ if (char === '"' || char === "'") { quote = char; attrs += char; continue; }
206
+ if (char === ">") { end = at; break; }
207
+ attrs += char;
208
+ }
209
+ tags.push({ tag: match[1], attrs, raw: source.slice(match.index, end + 1) });
210
+ }
211
+ return tags;
212
+ }
213
+
214
+ /** Opening tags in `after` that `before` does not already have verbatim. */
215
+ function changedTags(before, after) {
216
+ const had = new Map();
217
+ for (const { raw } of jsxOpenTags(before)) had.set(raw, (had.get(raw) ?? 0) + 1);
218
+ return jsxOpenTags(after).filter(({ raw }) => {
219
+ if (had.get(raw) > 0) { had.set(raw, had.get(raw) - 1); return false; }
220
+ return true;
221
+ });
222
+ }
223
+
224
+ const withoutTags = (text) => jsxOpenTags(text).reduce((out, { raw }) => out.replace(raw, " "), String(text));
225
+ const uiFile = (path) => /\.(?:tsx|jsx)$/.test(path) && !/\.(?:test|spec|stories)\.[jt]sx?$/.test(path);
226
+
227
+ // ─── the nudges ────────────────────────────────────────────────────────────
228
+
229
+ // A part's guideline is its root's: `dialog` covers `dialog-content`, but `alert` does not cover `alert-dialog`.
230
+ const PARTS = /^(?:content|header|footer|title|description|trigger|item|items|list|group|label|separator|body|row|cell|head|caption|action|cancel|close|overlay|portal|value|indicator|viewport|sub|text|root)$/;
231
+ const covers = (known, key) => known.some((entry) => key === entry || (key.startsWith(`${entry}-`) && key.slice(entry.length + 1).split("-").every((part) => PARTS.test(part))));
232
+ const listed = (items) => items.slice(0, MAX_LISTED).join(", ") + (items.length > MAX_LISTED ? `, +${items.length - MAX_LISTED} more` : "");
233
+ const PAGE_NOISE = /^(?:index|types?|utils?|constants?|use-.*|.*-(?:types?|utils?|constants?|mocks?|fixtures?|helpers?|hooks?))$/;
234
+
235
+ /** DS component or product page used or edited without its guideline in the lane's gathered set. */
236
+ export function guidelineNudges({ rel, before, after }, knowledge) {
237
+ const known = [...(knowledge.guidelines ?? []), ...(knowledge.guidelinesRead ?? [])].map(kebab);
238
+ const keys = new Set();
239
+ const component = /(?:^|\/)packages\/design-system\/src\/components\/([a-z0-9-]+)\.tsx$/.exec(rel);
240
+ if (component) keys.add(component[1]);
241
+ const page = /(?:^|\/)product-pages\/(?:.*\/)?([a-z0-9-]+?)(?:\.figma)?\.(?:tsx|ts)$/.exec(rel);
242
+ if (page && page[1].includes("-") && !PAGE_NOISE.test(page[1])) keys.add(page[1]);
243
+ if (uiFile(rel)) {
244
+ const imports = dsImports(after);
245
+ for (const { tag } of changedTags(before, after)) {
246
+ const root = tag.split(".")[0];
247
+ if (imports.has(root)) keys.add(kebab(imports.get(root)));
248
+ }
249
+ }
250
+ const missing = [...keys].filter((key) => !covers(known, key));
251
+ if (!missing.length) return [];
252
+ return [`nodii-dev nudge: no guideline for ${listed(missing)} in this lane's gathered set. Read it before using or editing it: ${missing.slice(0, 3).map((key) => `\`nodii-dev design guideline show ${key}\``).join(", ")} (U07-19, DEC-0010).`];
253
+ }
254
+
255
+ /** className or style passed to a DS component (U07-19). */
256
+ export function restyleNudges({ rel, before, after }) {
257
+ if (!uiFile(rel)) return [];
258
+ const imports = dsImports(after);
259
+ const hits = new Set();
260
+ for (const { tag, attrs } of changedTags(before, after)) {
261
+ if (!imports.has(tag.split(".")[0])) continue;
262
+ for (const prop of ["className", "style"]) if (new RegExp(`(?:^|\\s)${prop}\\s*=`).test(attrs)) hits.add(`<${tag} ${prop}>`);
263
+ }
264
+ if (!hits.size) return [];
265
+ return [`nodii-dev nudge: ${listed([...hits])}: a consumer MUST NOT pass className or style to a DS component (U07-19). Set only props, variants, slots and children; a missing capability is a DS gap (U07-20), filed as a DS issue.`];
266
+ }
267
+
268
+ const PERSON_FIELD = /(?<!\$)\{\s*[\w$.?]+\??\.(?:email|phone|mobile|displayName|fullName|firstName|lastName|personName)\s*\}/;
269
+ const MASKING = /\bmask(?:ed)?(?:Name|Email|Phone|Pii|Person)?\s*\(|\b[A-Za-z]\*{3,}/;
270
+
271
+ /** Code that renders a person: through PiiText, never masking the viewer (R5, U05-25). */
272
+ export function selfPiiNudges({ rel, before, after }) {
273
+ if (!uiFile(rel)) return [];
274
+ const notes = [];
275
+ for (const { tag, raw } of changedTags(before, after))
276
+ if (tag === "PiiText" && /\bkind\s*=\s*\{?["']?(?:name|email|phone)/.test(raw) && !/\bself\b/.test(raw))
277
+ notes.push("PiiText shows a person without `self`: if this can be the viewer pass `self={isSelf}`");
278
+ if (PERSON_FIELD.test(changedLines(withoutTags(before), withoutTags(after)).join("\n"))) notes.push("a person's name, email or phone is rendered without PiiText");
279
+ if (MASKING.test(changedLines(before, after).join("\n"))) notes.push("the page masks a value itself (a page never masks or reveals; PiiText does)");
280
+ if (!notes.length) return [];
281
+ return [`nodii-dev nudge: ${notes.join("; ")}. Ask: is this the viewer? Self is never masked (R5, D967, U05-25); every other person goes through PiiText.`];
282
+ }
283
+
284
+ /** `when` values of a Drizzle journal, in file order. */
285
+ const journalWhens = (text) => (JSON.parse(text).entries ?? []).map((entry) => entry.when);
286
+
287
+ /** A new migration, or a journal edit that breaks the `when` rule. */
288
+ export async function migrationNudges({ path, rel, existed, after }, knowledge) {
289
+ const notes = [];
290
+ if (/(?:^|\/)(?:migrations|drizzle)\/(?:.*\/)?[^/]+\.sql$/.test(rel) && !existed) {
291
+ const covered = (knowledge.deltaGathered ?? []).map((item) => cleanPath(item).replace(/\/$/, ""));
292
+ if (!covered.some((item) => rel === item || rel.startsWith(`${item}/`))) {
293
+ let newest = "";
294
+ try { const whens = journalWhens(await optional(resolve(dirname(path), "meta/_journal.json")) ?? "{}"); if (whens.length) newest = ` (the journal's newest is ${Math.max(...whens)}, so the next \`when\` is ${Math.max(...whens) + 1})`; } catch {}
295
+ notes.push(`${rel} is a new migration. Its journal entry needs when = max(existing when) + 1${newest} with idx and tag strictly increasing (never drizzle-kit's now()); then run \`nodii-dev lane gather --delta\` (scope drift)`);
296
+ }
297
+ }
298
+ if (/(?:^|\/)meta\/_journal\.json$/.test(rel)) {
299
+ try {
300
+ const whens = journalWhens(after);
301
+ const bad = whens.findIndex((when, at) => at > 0 && !(when > whens[at - 1]));
302
+ if (bad !== -1) notes.push(`${rel}: journal entry ${bad} has when ${whens[bad]}, not above the one before (${whens[bad - 1]}); Drizzle silently skips a migration whose when is not newer`);
303
+ } catch {}
304
+ }
305
+ return notes.map((note) => `nodii-dev nudge: ${note}.`);
306
+ }
307
+
308
+ const NODE_ID_TOOLS = /(?:get_design_context|get_metadata|get_screenshot|get_variable_defs|get_motion_context)$/;
309
+
310
+ /** A code lane reading Figma by node id (MW8-20). */
311
+ export function figmaReadNudges(payload, lane) {
312
+ const name = payload.tool_name ?? "";
313
+ if (!isFigmaTool(name) || !isReadTool(name) || !NODE_ID_TOOLS.test(name)) return [];
314
+ if (/figma|design/i.test(`${lane.kind ?? ""} ${lane.side ?? ""}`)) return [];
315
+ const input = payload.tool_input ?? {};
316
+ const byUrl = typeof input.url === "string" && /[?&]node-id=/.test(input.url);
317
+ if (!(typeof input.nodeId === "string" || typeof input.node_id === "string" || byUrl)) return [];
318
+ return ["nodii-dev nudge: this code lane reads Figma by node id. Find the surface by its published component-set key (`figma:keys:refresh`), never a node id; a node id is not a stable reference across hops (MW8-20, MW8-17)."];
319
+ }
320
+
321
+ const SUBMIT_COMMAND = /(?:nodii-dev|@nodii\/dev(?:@\S+)?)\s+figma\s+submit\b([^;&|\n]*)/;
322
+ const CHECKED_COMMAND = /(?:nodii-dev|@nodii\/dev(?:@\S+)?)\s+figma\s+checked\s+(CS-[\w-]+)([^;&|\n]*)/g;
323
+
324
+ /** `nodii-dev figma submit CS-n`: the hand checks Dev's gate cannot see. */
325
+ export async function submitNudges(command, { cwd }) {
326
+ const text = String(command ?? "");
327
+ const submit = SUBMIT_COMMAND.exec(text);
328
+ const changeSet = /\b(CS-[\w-]+)/.exec(submit?.[1] ?? "")?.[1];
329
+ if (!changeSet) return [];
330
+ const done = { ...(await readPrivateJson(cwd, SUBMIT_CHECKS))[changeSet] };
331
+ // The same command line may record them first: `figma checked CS-n … && figma submit CS-n …`.
332
+ for (const [, id, flags] of text.matchAll(CHECKED_COMMAND)) if (id === changeSet) for (const check of SUBMIT_CHECK_FLAGS) if (flags.includes(`--${check}`)) done[check] = true;
333
+ const label = { "unused-properties": "every component property is used (F-34)", "stale-placeholders": 'every "Blocked on DS: <part>" placeholder is re-checked against the published library (CS-2453)', "dialog-body": "a Dialog with no body is an AlertDialog" };
334
+ const missing = SUBMIT_CHECK_FLAGS.filter((check) => !done[check]);
335
+ if (!missing.length) return [];
336
+ return [`nodii-dev nudge: before submitting ${changeSet}, check that ${missing.map((check) => label[check]).join("; ")}. Then record it: \`nodii-dev figma checked ${changeSet} ${missing.map((check) => `--${check}`).join(" ")}\` (DEC-0034 hand check).`];
337
+ }
338
+
339
+ /** AskUserQuestion: the ask also lives as an item in open.md (R7). */
340
+ export async function askNudges(payload, { root }) {
341
+ if (payload.tool_name !== "AskUserQuestion") return [];
342
+ const questions = (payload.tool_input?.questions ?? []).map((item) => String(item?.question ?? "")).filter(Boolean);
343
+ const openMd = (await optional(resolve(root, OPEN_MD)))?.toLowerCase().replace(/\s+/g, " ");
344
+ const unrecorded = questions.filter((question) => !openMd?.includes(question.toLowerCase().replace(/\s+/g, " ").slice(0, 60)));
345
+ if (questions.length && !unrecorded.length) return [];
346
+ return [`nodii-dev nudge: a question for the operator is also an item in ${OPEN_MD} (R7), with its Q section; chat alone is not where an ask lives. Add it, and say "item #n in open.md" here.`];
347
+ }
348
+
349
+ const SIGNALS = /@cognion-nucleus|PiiText|\bmask\w*\(|\b[A-Za-z]\*{3,}|\.(?:email|phone|mobile|displayName|fullName|firstName|lastName|personName)\b/;
350
+
351
+ /** Whether any nudge could apply to this call, so most calls never resolve the lane. */
352
+ export async function nudgeCandidate(payload) {
353
+ const tool = payload.tool_name ?? "";
354
+ if (isFigmaTool(tool)) return isReadTool(tool) && NODE_ID_TOOLS.test(tool);
355
+ if (tool === "AskUserQuestion") return true;
356
+ if (SHELL_TOOLS.has(tool)) return /\bfigma\s+submit\b/.test(String(payload.tool_input?.command ?? ""));
357
+ if (!EDIT_TOOLS.has(tool)) return false;
358
+ const cwd = payload.cwd ?? process.cwd();
359
+ return (await editedFiles(payload, cwd)).some(({ path, before, after }) => {
360
+ const p = cleanPath(path);
361
+ if (/\.sql$|_journal\.json$|product-pages\/|design-system\/src\/components\//.test(p)) return true;
362
+ if (!uiFile(p)) return false;
363
+ return SIGNALS.test(changedLines(before, after).join("\n")) || (changedTags(before, after).length > 0 && SIGNALS.test(after));
364
+ });
365
+ }
366
+
367
+ /** PreToolUse nudges for one tool call. Returns [] outside a lane. */
368
+ export async function nudgePre(payload, context, { knowledge = laneKnowledge } = {}) {
369
+ const lane = context?.lane;
370
+ if (!lane) return [];
371
+ const tool = payload.tool_name ?? "";
372
+ const root = context.root;
373
+ const cwd = payload.cwd ?? context.cwd ?? root;
374
+ if (isFigmaTool(tool)) return figmaReadNudges(payload, lane);
375
+ if (tool === "AskUserQuestion") return askNudges(payload, { root });
376
+ if (SHELL_TOOLS.has(tool)) return submitNudges(payload.tool_input?.command, { cwd });
377
+ if (!EDIT_TOOLS.has(tool)) return [];
378
+ const known = await knowledge(cwd);
379
+ const out = [];
380
+ for (const file of await editedFiles(payload, cwd)) {
381
+ const edit = { ...file, rel: cleanPath(relative(root, file.path)) };
382
+ out.push(...guidelineNudges(edit, known), ...restyleNudges(edit), ...selfPiiNudges(edit), ...(await migrationNudges(edit, known)));
383
+ }
384
+ return out;
385
+ }
386
+
387
+ // ─── Stop ──────────────────────────────────────────────────────────────────
388
+
389
+ // A claim that the work is finished, judged a sentence at a time so "the docs are not done" cannot hide "it is done".
390
+ const DONE_CLAIM = /\b(?:all|everything|work|lane|task|ticket|issue|build|it|this|that|i'm|we're|i am|we are)\s*(?:is|are|'s|'m|'re)?\s*(?:now\s+)?(?:done|complete[d]?|finished)\b|^\W*(?:done|all done|complete)\b/i;
391
+ const SHOUTED_DONE = /\bDONE\b/;
392
+ const NOT_DONE = /\b(?:not|isn't|aren't|wasn't|never|until|nothing is|n't)\s+(?:yet\s+)?(?:done|complete[d]?|finished)\b|\b(?:unfinished|incomplete)\b/i;
393
+ const claimsDone = (text) => String(text).split(/(?<=[.!?\n])\s+/).some((sentence) => (DONE_CLAIM.test(sentence) || SHOUTED_DONE.test(sentence)) && !NOT_DONE.test(sentence));
394
+ const COULDNT = /\b(?:I|we)\s+(?:couldn't|could not|can't|cannot|was unable to|were unable to|wasn't able to|am unable to)\b/i;
395
+ const FILED = /\b[A-Z][A-Z0-9]+(?:-[A-Z0-9]+)*-\d+\b|\bfiled\b|\bissue\s+#?\d+|\bopen\.md\b/;
396
+
397
+ /** The last assistant message: the Stop payload's own, else the transcript's final one. */
398
+ export async function lastAssistantText(payload) {
399
+ if (typeof payload.last_assistant_message === "string") return payload.last_assistant_message;
400
+ if (!payload.transcript_path) return "";
401
+ try {
402
+ const lines = String(await optional(payload.transcript_path) ?? "").trim().split("\n");
403
+ for (const line of lines.reverse()) {
404
+ const entry = JSON.parse(line);
405
+ if ((entry.type ?? entry.message?.role) !== "assistant") continue;
406
+ const content = entry.message?.content;
407
+ const text = Array.isArray(content) ? content.filter((part) => part?.type === "text").map((part) => part.text).join("\n") : String(content ?? "");
408
+ if (text.trim()) return text;
409
+ }
410
+ } catch {}
411
+ return "";
412
+ }
413
+
414
+ const openUnconfirmed = (lane) => [lane.freeze?.unconfirmed, lane.unconfirmed].flat().filter(Boolean).filter((item) =>
415
+ typeof item === "string" || !(item?.resolvedAt || item?.confirmedAt || ["resolved", "confirmed", "dropped"].includes(item?.status)));
416
+
417
+ /** Stop nudges. A lane that has just been nudged once is left to stop (stop_hook_active). */
418
+ export async function nudgeStop(payload, context, { lastText = lastAssistantText, status = (root) => git(root, "status", "--porcelain") } = {}) {
419
+ const lane = context?.lane;
420
+ if (!lane || payload.stop_hook_active) return [];
421
+ const notes = [];
422
+ const text = await lastText(payload);
423
+ if (claimsDone(text) && lane.status !== "on_dev")
424
+ notes.push(`${lane.key} reads ${lane.status}, not on_dev: done means merged, deployed and verified live on dev (MW3). Run \`nodii-dev lane show ${lane.key}\`, and say what is left instead of "done"`);
425
+ if (COULDNT.test(text) && !FILED.test(text))
426
+ notes.push('you said you couldn\'t do something and cited no filed issue; file it (`nodii-dev issues create`, or a request or drift entry) so it is not lost');
427
+ const unconfirmed = openUnconfirmed(lane);
428
+ if (unconfirmed.length)
429
+ notes.push(`${unconfirmed.length} Unconfirmed item${unconfirmed.length === 1 ? " is" : "s are"} still open in ${lane.key}'s Freeze; confirm or drop each with a reason (\`nodii-dev lane freeze\`)`);
430
+ const dirty = (await status(context.root).catch(() => "")).split("\n").filter(Boolean);
431
+ if (dirty.length)
432
+ notes.push(`${dirty.length} uncommitted path${dirty.length === 1 ? "" : "s"} in ${context.root}: commit in your worktree (push on commit, so a death loses nothing)`);
433
+ return notes.map((note) => `nodii-dev nudge: ${note}.`);
434
+ }
435
+
436
+ export const nudgeSettingsSnippet = () => ({
437
+ hooks: {
438
+ PreToolUse: [{ matcher: "^(AskUserQuestion|mcp__(?:figma|claude_ai_Figma|figma-dev-mode)__.*|Bash|Edit|Write|MultiEdit|NotebookEdit)$", hooks: [{ type: "command", command: "nodii-dev hook nudge-pre" }] }],
439
+ Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook nudge-stop" }] }],
440
+ },
441
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.30",
3
+ "version": "0.6.31",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {