@nodii/dev 0.6.29 → 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 +52 -1
- package/dist/cli.js +63 -9
- package/dist/core.js +37 -14
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +18 -1
- package/dist/freeze.js +328 -0
- package/dist/gather.js +241 -0
- package/dist/harness.js +2 -0
- package/dist/hooks.js +16 -42
- package/dist/lanes.js +170 -9
- package/dist/managed-hooks.js +6 -1
- package/dist/nodii-dev.js +12 -2
- package/dist/nudges.js +441 -0
- package/dist/setup.js +23 -6
- package/dist/starting.js +52 -23
- package/dist/upload.js +11 -1
- package/package.json +1 -1
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/dist/setup.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
4
|
import { codexHooksFromClaude } from "./harness.js";
|
|
5
5
|
import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
|
|
6
|
+
import { PICKER_MODEL, setupOllama } from "./gather.js";
|
|
6
7
|
|
|
7
8
|
export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
|
|
8
9
|
const START = "<!-- nodii-dev build workflow:start -->";
|
|
@@ -118,21 +119,37 @@ export async function setupPlan(root = process.cwd()) {
|
|
|
118
119
|
return changes;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin } = {}) {
|
|
122
|
+
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin, pickerSetup = setupOllama } = {}) {
|
|
123
|
+
if (flags.yes && flags.check) throw new CliError("USAGE", "setup accepts either --yes or --check, not both.", 2);
|
|
122
124
|
const changes = await setupPlan(root);
|
|
123
125
|
let authenticated = true;
|
|
124
126
|
try { await authCheck(); } catch { authenticated = false; }
|
|
127
|
+
let pickerReady = true;
|
|
128
|
+
let pickerStatus;
|
|
129
|
+
try { pickerStatus = await pickerSetup({ check: !flags.yes }); }
|
|
130
|
+
catch (error) {
|
|
131
|
+
pickerReady = false;
|
|
132
|
+
pickerStatus = { code: error?.code ?? "PICKER_UNAVAILABLE", message: error?.message ?? String(error) };
|
|
133
|
+
}
|
|
125
134
|
if (flags.check) {
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
const drift = [
|
|
136
|
+
...(changes.length ? [`Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`] : []),
|
|
137
|
+
...(!pickerReady ? [`Gather picker: ${pickerStatus.message}`] : []),
|
|
138
|
+
];
|
|
139
|
+
if (drift.length) throw new CliError("SETUP_DRIFT", drift.join("; "), 1);
|
|
140
|
+
return { payload: { ok: true, changed: [], authenticated, picker: pickerStatus }, human: authenticated ? "Workflow harness and Gather picker are current." : "Workflow harness and Gather picker are current. Not logged in; run `nodii-dev login`." };
|
|
128
141
|
}
|
|
129
142
|
if (!flags.yes) {
|
|
130
|
-
const preview =
|
|
131
|
-
|
|
143
|
+
const preview = [
|
|
144
|
+
...(changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`) : ["No workflow changes."]),
|
|
145
|
+
pickerReady ? `Gather picker: ${PICKER_MODEL} is ready.` : `+ install Ollama and pull ${PICKER_MODEL}`,
|
|
146
|
+
].join("\n");
|
|
147
|
+
return { payload: { dryRun: true, changed: [...changes.map((x) => x.relative), ...(!pickerReady ? [`ollama:${PICKER_MODEL}`] : [])], authenticated, picker: pickerStatus }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
|
|
132
148
|
}
|
|
149
|
+
if (!pickerReady) throw new CliError(pickerStatus.code, pickerStatus.message, 1);
|
|
133
150
|
for (const change of changes) {
|
|
134
151
|
await mkdir(dirname(change.path), { recursive: true });
|
|
135
152
|
await writeFile(change.path, change.content);
|
|
136
153
|
}
|
|
137
|
-
return { payload: { applied: true, changed: changes.map((x) => x.relative), authenticated }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
|
|
154
|
+
return { payload: { applied: true, changed: [...changes.map((x) => x.relative), ...(pickerStatus.changed ?? []).map((x) => `ollama:${x}`)], authenticated, picker: pickerStatus }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\nGather picker: ${PICKER_MODEL} ready on Ollama ${pickerStatus.version}.\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
|
|
138
155
|
}
|
package/dist/starting.js
CHANGED
|
@@ -4,12 +4,26 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
|
|
7
|
-
import {
|
|
7
|
+
import { GATHER_TIMEOUT_MS } from "./gather.js";
|
|
8
|
+
import { laneCommand, nextCommand } from "./lanes.js";
|
|
8
9
|
|
|
9
10
|
const execFile = promisify(execFileCallback);
|
|
10
11
|
const via = { via: "nodii-dev" };
|
|
11
12
|
const values = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
12
13
|
|
|
14
|
+
const withSignal = (promise, signal) => {
|
|
15
|
+
if (!signal) return promise;
|
|
16
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
17
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
18
|
+
const abort = () => rejectPromise(signal.reason ?? new Error("aborted"));
|
|
19
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
20
|
+
Promise.resolve(promise).then(
|
|
21
|
+
(value) => { signal.removeEventListener("abort", abort); resolvePromise(value); },
|
|
22
|
+
(error) => { signal.removeEventListener("abort", abort); rejectPromise(error); },
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
13
27
|
function pairs(value, flag) {
|
|
14
28
|
const out = new Map();
|
|
15
29
|
for (const raw of values(value)) {
|
|
@@ -380,27 +394,42 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
380
394
|
throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run nodii-dev lane gather ${lane.laneKey} --delta.`, 1, { amendments: pending.data });
|
|
381
395
|
}
|
|
382
396
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
397
|
+
const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
|
|
398
|
+
|
|
399
|
+
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
|
|
402
|
+
const signal = controller.signal;
|
|
403
|
+
const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
|
|
404
|
+
try {
|
|
405
|
+
const lane = await withSignal(marker(), signal);
|
|
406
|
+
key ??= lane?.laneKey;
|
|
407
|
+
if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
|
|
408
|
+
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
409
|
+
if (lane && lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather for another lane cannot run inside this lane's registered worktree.", 1);
|
|
410
|
+
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
411
|
+
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
412
|
+
if (!pending.data.length) {
|
|
413
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
414
|
+
return { ...gathered, human: `${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nNo pending amendments.` };
|
|
415
|
+
}
|
|
416
|
+
const version = Math.max(...pending.data.map((item) => item.version));
|
|
417
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
418
|
+
const acknowledgement = await withSignal(acknowledge(key, {
|
|
419
|
+
...flags,
|
|
420
|
+
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
421
|
+
}, { request: timedRequest }), signal);
|
|
422
|
+
const acknowledgedVersion = acknowledgement.payload?.rules?.amendmentAcknowledgement?.version;
|
|
423
|
+
if (acknowledgedVersion !== version)
|
|
424
|
+
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
425
|
+
return {
|
|
426
|
+
payload: { ...gathered.payload, rules: acknowledgement.payload?.rules, amendments: pending.data, acknowledgedVersion },
|
|
427
|
+
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
|
|
428
|
+
};
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
|
|
431
|
+
throw error;
|
|
432
|
+
} finally {
|
|
433
|
+
clearTimeout(deadline);
|
|
393
434
|
}
|
|
394
|
-
const version = Math.max(...pending.data.map((item) => item.version));
|
|
395
|
-
const gathered = await gather(key, {
|
|
396
|
-
...flags,
|
|
397
|
-
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
398
|
-
}, { request });
|
|
399
|
-
const acknowledgedVersion = gathered.payload?.rules?.amendmentAcknowledgement?.version;
|
|
400
|
-
if (acknowledgedVersion !== version)
|
|
401
|
-
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
402
|
-
return {
|
|
403
|
-
payload: { ...gathered.payload, amendments: pending.data, acknowledgedVersion },
|
|
404
|
-
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
|
|
405
|
-
};
|
|
406
435
|
}
|
package/dist/upload.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
|
|
10
10
|
import { CHANGE_CLASSES, validateDocument } from "./docs.js";
|
|
11
|
+
import { rulesFindText } from "./gather.js";
|
|
11
12
|
|
|
12
13
|
/** Exit code when Dev is waiting on the operator to acknowledge parked items. */
|
|
13
14
|
export const EXIT_PARKED = 4;
|
|
@@ -307,6 +308,15 @@ const pad = (v, n) => String(v).padStart(n);
|
|
|
307
308
|
* core, the delivery report, and the operator's core token cap.
|
|
308
309
|
*/
|
|
309
310
|
export async function rulesCommand(action, args, flags, { request = devRequest } = {}) {
|
|
311
|
+
if (action === "find") {
|
|
312
|
+
const question = args.join(" ").trim();
|
|
313
|
+
if (!question) throw new CliError("USAGE", 'rules find needs a question, for example: nodii-dev rules find "may this route use offset paging?"', 2);
|
|
314
|
+
if (question.length > 20_000) throw new CliError("USAGE", "rules find questions are at most 20,000 characters", 2);
|
|
315
|
+
const limit = flags.limit === undefined ? 10 : Number(flags.limit);
|
|
316
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new CliError("USAGE", "rules find --limit needs a whole number from 1 to 100", 2);
|
|
317
|
+
const payload = await request("GET", `/api/v1/rules/find${query({ question, limit })}`);
|
|
318
|
+
return { payload, human: rulesFindText };
|
|
319
|
+
}
|
|
310
320
|
if (action === "core") {
|
|
311
321
|
// Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
|
|
312
322
|
const payload = await request("GET", "/api/v1/rules/core");
|
|
@@ -337,5 +347,5 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
|
|
|
337
347
|
const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
|
|
338
348
|
return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
|
|
339
349
|
}
|
|
340
|
-
throw new CliError("USAGE", "rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS
|
|
350
|
+
throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
|
|
341
351
|
}
|