@nodii/dev 0.4.1 → 0.6.1

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/dist/docs.js ADDED
@@ -0,0 +1,204 @@
1
+ // Local checks for AD documents (M-05) before anything is sent to Dev. They
2
+ // mirror Dev's structure checks (AD-era contract §1.3) rule for rule, so a
3
+ // document that passes here fails in Dev only on checks that need Dev's data:
4
+ // `pre:` and DEC resolution, versions, retired clause ids. Dev stays the
5
+ // authority; this exists so an upload fails fast, offline, with every problem
6
+ // in one report, using Dev's error codes. Warnings name text Dev reads
7
+ // differently from what its author probably meant.
8
+
9
+ export const CHANGE_CLASSES = ["ruling", "conformance", "editorial"];
10
+
11
+ // Contract §1.4.
12
+ export const RULEBOOK_SECTIONS = ["Summary", "Scope", "Rules", "Rationale", "Edge cases and examples", "Enforcement", "Relations", "Sources", "Open questions for ratification", "Change history"];
13
+ export const FEATURE_SECTIONS = ["Summary", "Scope and non-goals", "Actors and permissions", "Rules", "Behaviour", "Data and contracts", "Edge cases and failure modes", "Rationale", "Enforcement and tests", "Relations", "Sources", "Known gaps", "Open questions for ratification", "Change history"];
14
+ export const OVERVIEW_SECTIONS = ["Summary", "Purpose and users", "Capability map", "Component map", "Peer services", "Deployment and environments", "State of the service", "Known gaps", "Open questions for ratification", "Change history"];
15
+
16
+ // Contract §1.2: front matter `kind` as written → Dev's kind.
17
+ const KINDS = {
18
+ principles: "principles", methodology: "methodology", standard: "standard", standards: "standard",
19
+ doctrine: "doctrine", doctrines: "doctrine", "ux-guideline": "ux-guideline", "ux guideline": "ux-guideline",
20
+ "ux-guidelines": "ux-guideline", "ux guidelines": "ux-guideline", ux: "ux-guideline", "know-how": "know-how",
21
+ feature: "feature-doc", "feature-doc": "feature-doc",
22
+ "feature-overview": "service-overview", "feature-doc-overview": "service-overview", "service-overview": "service-overview", overview: "service-overview",
23
+ "capability-plan": "capability-plan", "feature-plan": "capability-plan", "feature-doc-plan": "capability-plan",
24
+ };
25
+ const ANATOMY = { "feature-doc": FEATURE_SECTIONS, "service-overview": OVERVIEW_SECTIONS, "capability-plan": ["Change history"] };
26
+ const FRONT_MATTER = ["id", "code", "title", "kind", "era", "status", "version", "owner", "incorporates"];
27
+ const CODE_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/;
28
+ const GROUNDED_AT = /^[a-z0-9][a-z0-9._-]*@[0-9a-f]{7,40}$/;
29
+ const VERSION = /^(\d{1,4})(?:\.(\d{1,4}))?$/;
30
+
31
+ export const devKind = (label) => KINDS[String(label ?? "").trim().toLowerCase()] ?? null;
32
+
33
+ const unquote = (raw) => (raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) ? raw.slice(1, -1) : raw);
34
+ function flowList(raw) {
35
+ const items = [];
36
+ let depth = 0;
37
+ let current = "";
38
+ for (const ch of raw.slice(1, -1)) {
39
+ if (ch === "(" || ch === "[") depth++;
40
+ if (ch === ")" || ch === "]") depth--;
41
+ if (ch === "," && depth === 0) { items.push(current); current = ""; continue; }
42
+ current += ch;
43
+ }
44
+ items.push(current);
45
+ return items.map((x) => unquote(x.trim())).filter(Boolean);
46
+ }
47
+
48
+ /** Flat `key: value` front matter with `[a, b]` flow lists, as Dev reads it. */
49
+ export function parseFrontMatter(content) {
50
+ const lines = content.split("\n");
51
+ if (lines[0]?.trim() !== "---") return null;
52
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
53
+ if (end === -1) return null;
54
+ const fields = {};
55
+ const blockLists = [];
56
+ for (const [i, line] of lines.slice(1, end).entries()) {
57
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
58
+ if (!m) {
59
+ if (/^\s+-\s/.test(line) && i > 0) blockLists.push(i + 2);
60
+ continue;
61
+ }
62
+ const value = m[2].trim();
63
+ fields[m[1]] = value.startsWith("[") && value.endsWith("]") ? flowList(value) : unquote(value);
64
+ }
65
+ return { fields, bodyStartLine: end + 2, blockLists };
66
+ }
67
+
68
+ const sectionTitle = (heading) => heading.replace(/^\d+(\.\d+)*\.?\s+/, "").trim();
69
+ // Dev's definitions (src/lib/ad/markdown.ts), exactly.
70
+ // A clause id: `<code>-<n>`, a Scope clause `<code>-S<n>`, sub-clauses `.m`.
71
+ const CLAUSE_ID = "[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-S?\\d+(?:\\.\\d+)*";
72
+ const CLAUSE_DEF = new RegExp(`^\\s*[-*+]\\s+\\*\\*(${CLAUSE_ID})\\*\\*(?=\\s|$)(.*)$`);
73
+ // A parked item (M05-11.1): Undecided: … · **Undecided:** … · **Undecided** …
74
+ // · **Undecided (PAYC-26):** … · **Undecided: … (MW1-12.4).** · - **TELE-32** Undecided: …
75
+ const PARKED = new RegExp(`^\\s*(?:[-*+]\\s+|\\d+\\.\\s+)?(?:\\*\\*(${CLAUSE_ID})\\*\\*\\s+)?(\\*\\*)?Undecided(?:\\s*\\(([^)]*)\\))?(?:\\*\\*\\s*:|:\\s*\\*\\*|:|\\*\\*(?=\\s))\\s*(.*)$`);
76
+ // A bare `D` id needs two or more digits (a lone `D1` is Cloudflare's
77
+ // database); namespaced ids (`billing-D7`) are always decisions.
78
+ const DECISION_ID = /\b(?:[a-z][a-z0-9_]*(?:-[a-z0-9_]+)*-D\d+|D\d{2,})\b/g;
79
+
80
+ /** One line as a parked item, as Dev stores it; null when it is not one. */
81
+ export function parkedItem(line) {
82
+ const m = PARKED.exec(line);
83
+ if (!m) return null;
84
+ return `${m[3] ? `(${m[3]}) ` : ""}${(m[4] ?? "").trim()}`.replace(/\*\*\s*$/, "").trim();
85
+ }
86
+
87
+ /** Which lines are fenced code (the fence lines included). */
88
+ function fencedLines(lines) {
89
+ let open = false;
90
+ return lines.map((line) => {
91
+ if (/^\s*(```|~~~)/.test(line)) { open = !open; return true; }
92
+ return open;
93
+ });
94
+ }
95
+
96
+ /** Sections, clause definitions and parked items, skipping fenced code, with 1-based lines of the whole file. */
97
+ export function parseBody(content, bodyStartLine = 1) {
98
+ const lines = content.split("\n");
99
+ const sections = [];
100
+ const clauses = [];
101
+ const parked = [];
102
+ const fenced = fencedLines(lines);
103
+ let section = "";
104
+ for (let i = bodyStartLine - 1; i < lines.length; i++) {
105
+ const line = lines[i] ?? "";
106
+ if (fenced[i]) continue;
107
+ const h2 = /^##\s+(.+?)\s*#*\s*$/.exec(line);
108
+ if (h2 && !line.startsWith("###")) {
109
+ section = sectionTitle(h2[1]);
110
+ sections.push({ title: section, line: i + 1 });
111
+ continue;
112
+ }
113
+ // `- **TELE-32** Undecided: …` parks that clause; it is not a second definition of it.
114
+ const p = parkedItem(line);
115
+ if (p !== null) { parked.push({ text: p, line: i + 1 }); continue; }
116
+ const clause = CLAUSE_DEF.exec(line);
117
+ if (clause) clauses.push({ id: clause[1], section, line: i + 1 });
118
+ }
119
+ return { sections, clauses, parked };
120
+ }
121
+
122
+ /**
123
+ * Bare pre-dev decision ids (M05-5): a `D12`+ or `billing-D7` in prose, not
124
+ * right after `pre:`. Fenced code and inline code spans are skipped, as Dev does.
125
+ */
126
+ export function bareCitations(content) {
127
+ const out = [];
128
+ const lines = content.split("\n");
129
+ const fenced = fencedLines(lines);
130
+ lines.forEach((line, i) => {
131
+ if (fenced[i]) return;
132
+ const prose = line.replace(/`[^`]*`/g, (span) => " ".repeat(span.length));
133
+ for (const m of prose.matchAll(DECISION_ID))
134
+ if (prose.slice(Math.max(0, m.index - 4), m.index) !== "pre:") out.push({ citation: m[0], line: i + 1 });
135
+ });
136
+ return out;
137
+ }
138
+
139
+ // "The capability map" is the "Capability map" section.
140
+ const startsWithTitle = (actual, expected) => actual.replace(/^the\s+/i, "").toLowerCase().startsWith(expected.toLowerCase());
141
+
142
+ function sectionErrors(sections, kind) {
143
+ const titles = sections.map((s) => s.title);
144
+ if (kind === "feature-doc" && titles.some((t) => startsWithTitle(t, "Capability map")) && titles.some((t) => startsWithTitle(t, "Purpose and users")))
145
+ return [{ code: "LOOKS_LIKE_OVERVIEW", message: "this document has the service-overview sections; set `kind: service-overview`", line: 1 }];
146
+ const errors = [];
147
+ const required = ANATOMY[kind] ?? RULEBOOK_SECTIONS;
148
+ let cursor = 0;
149
+ for (const expected of required) {
150
+ const at = sections.findIndex((s) => startsWithTitle(s.title, expected));
151
+ if (at === -1) { errors.push({ code: "SECTION_MISSING", message: `section \`## ${expected}\` is required for kind \`${kind}\`` }); continue; }
152
+ if (at < cursor) { errors.push({ code: "SECTION_ORDER", message: `section \`## ${expected}\` is out of order (expected: ${required.join(" · ")})`, line: sections[at].line }); continue; }
153
+ cursor = at;
154
+ }
155
+ return errors;
156
+ }
157
+
158
+ const scalar = (v) => (v === undefined ? "" : Array.isArray(v) ? v.join(", ") : v);
159
+
160
+ /**
161
+ * Validate one AD document. Returns { meta, errors, warnings, parked, clauses };
162
+ * each error is { code, message, line? } with Dev's codes (contract §1.3).
163
+ */
164
+ export function validateDocument(content) {
165
+ const text = content.replace(/\r\n/g, "\n");
166
+ const errors = [];
167
+ const warnings = [];
168
+ const parsed = parseFrontMatter(text);
169
+ if (!parsed) {
170
+ errors.push({ code: "FRONT_MATTER_MISSING", message: "the document must open with a `---` front matter block (M05-15)", line: 1 });
171
+ return { meta: {}, errors, warnings, parked: [], clauses: [] };
172
+ }
173
+ const { fields, bodyStartLine, blockLists } = parsed;
174
+ for (const key of FRONT_MATTER)
175
+ if (fields[key] === undefined || (scalar(fields[key]).length === 0 && !(key === "incorporates" && Array.isArray(fields[key]))))
176
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: `front matter field \`${key}\` is required (M05-15)`, line: 1 });
177
+ if (blockLists.length)
178
+ warnings.push({ message: "front matter uses a YAML block list (` - item`); Dev reads only `key: value` and `[a, b]` lists, so write it as `[a, b]`", line: blockLists[0] });
179
+ const kind = scalar(fields.kind) ? devKind(scalar(fields.kind)) : null;
180
+ if (scalar(fields.kind) && !kind) errors.push({ code: "KIND_UNKNOWN", message: `kind \`${scalar(fields.kind)}\` is not a rulebook kind or a feature-doc kind`, line: 1 });
181
+ const code = scalar(fields.code);
182
+ if (code && !CODE_PATTERN.test(code)) errors.push({ code: "CODE_INVALID", message: `code \`${code}\` must be upper-case letters, digits and hyphens (e.g. PII, M05, AUTH-SES)`, line: 1 });
183
+ const version = scalar(fields.version);
184
+ if (version && !VERSION.test(version.trim())) errors.push({ code: "VERSION_INVALID", message: `version \`${version}\` must be \`major.minor\` (e.g. 0.2, 1.0)`, line: 1 });
185
+ const groundedAt = fields.grounded_at === undefined ? null : scalar(fields.grounded_at);
186
+ if ((kind === "feature-doc" || kind === "service-overview") && !groundedAt)
187
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: "front matter field `grounded_at` (`<repo>@<sha>`) is required on feature docs (M05-15)", line: 1 });
188
+ else if (groundedAt && !GROUNDED_AT.test(groundedAt))
189
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: `grounded_at \`${groundedAt}\` must be \`<repo>@<sha>\``, line: 1 });
190
+
191
+ const body = parseBody(text, bodyStartLine);
192
+ if (kind) errors.push(...sectionErrors(body.sections, kind));
193
+ if (code) {
194
+ const seen = new Map();
195
+ for (const c of body.clauses) {
196
+ if (!c.id.startsWith(`${code}-`)) { errors.push({ code: "CLAUSE_CODE_MISMATCH", message: `clause \`${c.id}\` does not start with the document code \`${code}-\` (M05-6)`, line: c.line }); continue; }
197
+ if (seen.has(c.id)) errors.push({ code: "CLAUSE_ID_DUPLICATE", message: `clause \`${c.id}\` is defined twice (first at line ${seen.get(c.id)}) (M05-6)`, line: c.line });
198
+ else seen.set(c.id, c.line);
199
+ }
200
+ }
201
+ for (const b of bareCitations(text))
202
+ errors.push({ code: "BARE_DECISION_ID", message: `\`${b.citation}\` is a bare pre-dev decision id; cite it as \`pre:${b.citation}\` (M05-5)`, line: b.line });
203
+ return { meta: { ...fields, devKind: kind }, errors, warnings, parked: body.parked, clauses: body.clauses };
204
+ }
@@ -0,0 +1,343 @@
1
+ // `nodii-dev harness install|update`: write the agent harness Dev generates
2
+ // for one repository from the ratified methodology (AD-era contract §7).
3
+ // Nothing about the rules is authored here. This module places Dev's files
4
+ // (Dev stamps each with its methodology version) and keeps an ownership
5
+ // record, so it never touches a file, block or hook entry it did not write.
6
+ import { execFile } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { dirname, join, normalize, sep } from "node:path";
10
+ import { promisify } from "node:util";
11
+ import { CliError, devRequest, query } from "./core.js";
12
+
13
+ /** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
14
+ export const STATE_PATH = ".nodii/harness-state.json";
15
+ export const BEGIN = "<!-- nodii-dev:harness:begin -->";
16
+ export const END = "<!-- nodii-dev:harness:end -->";
17
+ const MODES = new Set(["managed-block", "replace", "merge-json"]);
18
+
19
+ const sha = (text) => createHash("sha256").update(text).digest("hex");
20
+ // Key-order independent, so a tool that rewrites settings.json does not hide an entry.
21
+ const stable = (value) => JSON.stringify(value, (_, v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v));
22
+
23
+ // Dev's stamp file carries the time of each request; a new time alone is not a change.
24
+ function sameStamp(current, content) {
25
+ try {
26
+ const strip = (text) => stable({ ...JSON.parse(text), generatedAt: null });
27
+ return strip(current) === strip(content);
28
+ } catch { return false; }
29
+ }
30
+
31
+ async function optional(path) {
32
+ try { return await readFile(path, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
33
+ }
34
+
35
+ // The only files Dev generates (AD-era contract §7). Anything else, such as a
36
+ // git hook or a CI workflow, is refused whatever the disk's case rules.
37
+ const HARNESS_PATHS = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^\.claude\/settings\.json$/, /^\.nodii\/harness\.json$/, /^\.claude\/skills\/nodii-[a-z0-9][a-z0-9._-]*\/SKILL\.md$/];
38
+
39
+ /** A generated path must be one of the harness files the contract names. */
40
+ export function safeRelative(path) {
41
+ const raw = String(path ?? "");
42
+ const clean = normalize(raw).split(sep).join("/");
43
+ if (clean !== raw || !HARNESS_PATHS.some((p) => p.test(clean)))
44
+ throw new CliError("HARNESS_INVALID", `Dev sent a path that is not a harness file: ${path}`, 1);
45
+ return clean;
46
+ }
47
+
48
+ // ─── managed blocks ─────────────────────────────────────────────────────────
49
+ const blockPattern = new RegExp(`${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
50
+ export const blockOf = (content) => {
51
+ const trimmed = content.trim();
52
+ return `${trimmed.startsWith(BEGIN) ? trimmed : `${BEGIN}\n${trimmed}\n${END}`}\n`;
53
+ };
54
+ export const currentBlock = (text) => blockPattern.exec(text ?? "")?.[0] ?? null;
55
+ // An editor may drop the final newline or check the file out with CRLF; neither is an edit.
56
+ const blockSha = (block) => sha(block.replace(/\r\n/g, "\n").replace(/\n?$/, "\n"));
57
+ export function withBlock(text, block) {
58
+ if (text === null || text.trim() === "") return block;
59
+ if (blockPattern.test(text)) return text.replace(blockPattern, () => block);
60
+ return `${text.trimEnd()}\n\n${block}`;
61
+ }
62
+ // Only the gap the block sat in is closed; the rest of the file is left as it was.
63
+ export function withoutBlock(text) {
64
+ const match = blockPattern.exec(text);
65
+ if (!match) return text;
66
+ const before = text.slice(0, match.index).replace(/\n+$/, "");
67
+ const after = text.slice(match.index + match[0].length).replace(/^\n+/, "");
68
+ const out = before && after ? `${before}\n\n${after}` : before || after;
69
+ return out.trim() === "" ? "" : `${out.trimEnd()}\n`;
70
+ }
71
+ // A CRLF checkout of a file nodii-dev wrote is not an edit.
72
+ const fileSha = (text) => sha(text.replace(/\r\n/g, "\n"));
73
+
74
+ // ─── merged JSON (Claude Code settings hooks) ───────────────────────────────
75
+ function parseJson(text, path) {
76
+ try { return text === null || text.trim() === "" ? {} : JSON.parse(text); }
77
+ catch { throw new CliError("HARNESS_SETTINGS_INVALID", `${path} is not valid JSON; fix it before installing the harness.`, 2); }
78
+ }
79
+ /**
80
+ * Remove exactly the hook entries nodii-dev added before (`added`), then add
81
+ * the new fragment's entries that are not already there. Returns the merged
82
+ * settings and the entries this merge owns: the ones it added now, plus the
83
+ * ones it added before and still wants. An entry the user had first is never
84
+ * owned, so a later update never removes it.
85
+ */
86
+ export function mergeHooks(current, added = {}, fragment = {}) {
87
+ const hooks = Object.fromEntries(Object.entries(current.hooks ?? {}).map(([e, list]) => [e, [...list]]));
88
+ const owned = {};
89
+ const wanted = (event, entry) => (fragment.hooks?.[event] ?? []).some((x) => stable(x) === stable(entry));
90
+ for (const [event, entries] of Object.entries(added.hooks ?? {}))
91
+ for (const entry of entries) {
92
+ if (wanted(event, entry) && (hooks[event] ?? []).some((x) => stable(x) === stable(entry))) { (owned[event] ??= []).push(entry); continue; }
93
+ // Only the one copy nodii-dev added goes; an identical copy the user added stays.
94
+ const list = hooks[event] ?? [];
95
+ const at = list.findLastIndex((x) => stable(x) === stable(entry));
96
+ if (at !== -1) list.splice(at, 1);
97
+ if (!list.length) delete hooks[event];
98
+ }
99
+ for (const [event, entries] of Object.entries(fragment.hooks ?? {}))
100
+ for (const entry of entries) {
101
+ const list = (hooks[event] ??= []);
102
+ if (list.some((x) => stable(x) === stable(entry))) continue;
103
+ list.push(entry);
104
+ (owned[event] ??= []).push(entry);
105
+ }
106
+ const out = { ...current };
107
+ if (Object.keys(hooks).length) out.hooks = hooks;
108
+ else delete out.hooks;
109
+ return { merged: out, owned: { hooks: owned } };
110
+ }
111
+
112
+ /** The first event whose recorded entry is no longer in the settings exactly as written, or null. */
113
+ export function missingHooks(current, added = {}) {
114
+ for (const [event, entries] of Object.entries(added.hooks ?? {}))
115
+ if (entries.some((entry) => !(current.hooks?.[event] ?? []).some((x) => stable(x) === stable(entry)))) return event;
116
+ return null;
117
+ }
118
+
119
+ export async function readState(root) {
120
+ const text = await optional(join(root, STATE_PATH));
121
+ if (text === null) return null;
122
+ try { return JSON.parse(text); }
123
+ catch { throw new CliError("HARNESS_STATE_INVALID", `${STATE_PATH} is not valid JSON; restore it from git before updating.`, 2); }
124
+ }
125
+
126
+ /** Check Dev's response before anything is planned from it. */
127
+ export function checkHarness(harness) {
128
+ if (!harness?.methodologyVersion || !Array.isArray(harness.files)) throw new CliError("HARNESS_INVALID", "Dev's harness response has no methodologyVersion or files.", 1);
129
+ const seen = new Set();
130
+ return harness.files.map((file) => {
131
+ const path = safeRelative(file.path);
132
+ if (seen.has(path)) throw new CliError("HARNESS_INVALID", `Dev sent ${path} twice.`, 1);
133
+ seen.add(path);
134
+ if (!MODES.has(file.mode)) throw new CliError("HARNESS_INVALID", `Dev sent ${path} with unknown mode ${file.mode}.`, 1);
135
+ const content = String(file.content ?? "");
136
+ if (file.sha256 && sha(content) !== file.sha256) throw new CliError("HARNESS_INVALID", `${path} does not match the sha256 Dev sent with it.`, 1);
137
+ if (file.mode === "merge-json") {
138
+ try { JSON.parse(content); } catch { throw new CliError("HARNESS_INVALID", `Dev sent ${path} with content that is not JSON.`, 1); }
139
+ }
140
+ return { path, mode: file.mode, kind: file.kind, content };
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Compare Dev's harness with the repository. `adopt` names existing files the
146
+ * operator lets nodii-dev take over (on install, or when an update adds a file). Returns the planned
147
+ * writes, the new ownership state, and every reason the plan cannot apply.
148
+ */
149
+ export async function planHarness(root, harness, { install, adopt = [], request = {} }) {
150
+ const files = checkHarness(harness);
151
+ const state = await readState(root);
152
+ if (install && state) throw new CliError("HARNESS_INSTALLED", `This repository already has the nodii-dev harness for ${state.repo} (methodology ${state.methodologyVersion}). Run \`nodii-dev harness update\`.`, 2);
153
+ if (!install && !state) throw new CliError("HARNESS_NOT_INSTALLED", "No nodii-dev harness here. Run `nodii-dev harness install` first.", 2);
154
+ if (state && (state.repo !== harness.repo || (state.serviceId ?? null) !== (harness.serviceId ?? null)))
155
+ throw new CliError("HARNESS_SERVICE_MISMATCH", `This repository's harness is for ${state.repo}${state.serviceId ? ` (${state.serviceId})` : ""}, not ${harness.repo}${harness.serviceId ? ` (${harness.serviceId})` : ""}. One service per repository.`, 2);
156
+ const owned = state?.files ?? {};
157
+ const adopted = new Set(adopt.map((path) => {
158
+ try { return safeRelative(path); }
159
+ catch { throw new CliError("USAGE", `--adopt ${path} is not a harness file (CLAUDE.md, AGENTS.md, .claude/settings.json, .claude/skills/nodii-CODE/SKILL.md).`, 2); }
160
+ }));
161
+ const changes = [];
162
+ const conflicts = [];
163
+ const next = {};
164
+ for (const file of files) {
165
+ const before = owned[file.path];
166
+ const current = await optional(join(root, file.path));
167
+ const unowned = current !== null && !before && !adopted.has(file.path);
168
+ if (file.mode === "replace") {
169
+ if (unowned) { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev" }); continue; }
170
+ if (current !== null && before && fileSha(current) !== before.sha256) { conflicts.push({ path: file.path, reason: "edited since nodii-dev wrote it" }); continue; }
171
+ if (current !== null && file.kind === "stamp" && sameStamp(current, file.content)) { next[file.path] = { mode: file.mode, sha256: fileSha(current) }; continue; }
172
+ next[file.path] = { mode: file.mode, sha256: sha(file.content) };
173
+ // A CRLF checkout of the same text is not a change.
174
+ if (current === null || fileSha(current) !== sha(file.content)) changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : "update", text: file.content });
175
+ } else if (file.mode === "managed-block") {
176
+ const block = blockOf(file.content);
177
+ const existing = currentBlock(current);
178
+ // A BEGIN marker without its END: appending would later swallow the text between them.
179
+ if (!existing && (current ?? "").includes(BEGIN)) { conflicts.push({ path: file.path, reason: `has a ${BEGIN} line without its ${END}; restore the end marker or remove the begin marker` }); continue; }
180
+ if (unowned && current.trim() !== "") { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev; adopt it to add the managed block" }); continue; }
181
+ if (before && existing && blockSha(existing) !== before.sha256) { conflicts.push({ path: file.path, reason: "its nodii-dev block was edited since nodii-dev wrote it" }); continue; }
182
+ next[file.path] = { mode: file.mode, sha256: blockSha(block) };
183
+ const text = withBlock(current, block);
184
+ // The same block with CRLF line endings or no final newline is not a change.
185
+ if (text !== current && !(existing && blockSha(existing) === blockSha(block))) changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : existing ? "update" : "add block", text });
186
+ } else {
187
+ const fragment = JSON.parse(file.content);
188
+ if (unowned) { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev; adopt it to merge the hooks" }); continue; }
189
+ const parsed = parseJson(current, file.path);
190
+ const edited = current !== null && missingHooks(parsed, before?.added);
191
+ if (edited) { conflicts.push({ path: file.path, reason: `the hook entry nodii-dev added under ${edited} was edited or removed since nodii-dev wrote it` }); continue; }
192
+ const { merged, owned: added } = mergeHooks(parsed, before?.added, fragment);
193
+ next[file.path] = { mode: file.mode, added };
194
+ const text = `${JSON.stringify(merged, null, 2)}\n`;
195
+ // Compare content, not formatting: another indent or key order is not a change.
196
+ if (current === null || stable(merged) !== stable(parsed))
197
+ changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : "merge", text });
198
+ }
199
+ }
200
+ for (const [path, before] of Object.entries(owned)) {
201
+ if (next[path] || files.some((f) => f.path === path)) continue;
202
+ const current = await optional(join(root, path));
203
+ if (current === null) continue;
204
+ if (before.mode === "replace") {
205
+ if (fileSha(current) !== before.sha256) conflicts.push({ path, reason: "edited since nodii-dev wrote it; Dev no longer generates it" });
206
+ else changes.push({ path, mode: before.mode, action: "remove" });
207
+ } else if (before.mode === "managed-block") {
208
+ const existing = currentBlock(current);
209
+ if (existing && blockSha(existing) !== before.sha256) conflicts.push({ path, reason: "its nodii-dev block was edited; Dev no longer generates it" });
210
+ else if (existing) {
211
+ const text = withoutBlock(current);
212
+ changes.push(text === "" ? { path, mode: before.mode, action: "remove" } : { path, mode: before.mode, action: "remove block", text });
213
+ }
214
+ } else {
215
+ const parsed = parseJson(current, path);
216
+ const edited = missingHooks(parsed, before.added);
217
+ if (edited) { conflicts.push({ path, reason: `the hook entry nodii-dev added under ${edited} was edited or removed; Dev no longer generates it` }); continue; }
218
+ const { merged } = mergeHooks(parsed, before.added, {});
219
+ changes.push({ path, mode: before.mode, action: "unmerge", text: `${JSON.stringify(merged, null, 2)}\n` });
220
+ }
221
+ }
222
+ const nextState = {
223
+ repo: harness.repo,
224
+ serviceId: harness.serviceId ?? null,
225
+ methodologyVersion: harness.methodologyVersion,
226
+ generatedAt: harness.generatedAt ?? null,
227
+ sources: harness.sources ?? [],
228
+ // What install asked Dev for, so update asks the same.
229
+ request,
230
+ files: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))),
231
+ };
232
+ return { state, nextState, changes, conflicts };
233
+ }
234
+
235
+ export async function applyHarness(root, plan) {
236
+ if (plan.conflicts.length) throw new CliError("HARNESS_CONFLICT", "Refusing to apply a harness plan with conflicts.", 1);
237
+ const writeState = async (state) => {
238
+ await mkdir(dirname(join(root, STATE_PATH)), { recursive: true });
239
+ await writeFile(join(root, STATE_PATH), `${JSON.stringify(state, null, 2)}\n`);
240
+ };
241
+ const done = [];
242
+ try {
243
+ for (const change of plan.changes) {
244
+ const target = join(root, change.path);
245
+ if (change.action === "remove") await rm(target, { force: true });
246
+ else {
247
+ await mkdir(dirname(target), { recursive: true });
248
+ await writeFile(target, change.text);
249
+ }
250
+ done.push(change.path);
251
+ }
252
+ } catch (error) {
253
+ // Record ownership of what was written before the failure, so the next
254
+ // run neither disowns those files nor reports them as edited.
255
+ const files = { ...(plan.state?.files ?? {}) };
256
+ for (const path of done) if (plan.nextState.files[path]) files[path] = plan.nextState.files[path]; else delete files[path];
257
+ await writeState({ ...(plan.state ?? plan.nextState), files }).catch(() => {});
258
+ throw error;
259
+ }
260
+ await writeState(plan.nextState);
261
+ return plan.nextState;
262
+ }
263
+
264
+ /** Which methodology sources moved between two states: "M05 0.2 → 1.0", "+M10 1.0", "-M04". */
265
+ export function sourceChanges(before = [], after = []) {
266
+ const from = new Map(before.map((s) => [s.code, s.version]));
267
+ const to = new Map(after.map((s) => [s.code, s.version]));
268
+ const out = [];
269
+ for (const [code, version] of to)
270
+ if (!from.has(code)) out.push(`+${code} ${version}`);
271
+ else if (from.get(code) !== version) out.push(`${code} ${from.get(code)} → ${version}`);
272
+ for (const code of from.keys()) if (!to.has(code)) out.push(`-${code}`);
273
+ return out;
274
+ }
275
+
276
+ // ─── the command ────────────────────────────────────────────────────────────
277
+ const execFileAsync = promisify(execFile);
278
+
279
+ /** The repository root and Dev's `repo` key: the git remote's name, else the folder's. */
280
+ export async function repoContext(cwd = process.cwd()) {
281
+ let root;
282
+ try { root = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim(); }
283
+ catch { throw new CliError("GIT_REQUIRED", "Run harness commands inside the repository's git checkout, or pass --root DIR --repo NAME.", 2); }
284
+ let name;
285
+ try { name = (await execFileAsync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" })).stdout.trim(); }
286
+ catch { name = root; }
287
+ return { root, repo: name.replace(/\.git$/, "").split(/[/:]/).filter(Boolean).at(-1).toLowerCase() };
288
+ }
289
+
290
+ const flagList = (value) => (value === undefined || value === true ? [] : [value].flat().flatMap((x) => String(x).split(",")).filter(Boolean));
291
+ const changeLine = (c) => `${{ create: "+", remove: "-" }[c.action] ?? "~"} ${c.path}${["create", "update", "remove"].includes(c.action) ? "" : ` (${c.action})`}`;
292
+
293
+ export async function harnessCommand(action, flags, { request = devRequest, context = repoContext } = {}) {
294
+ if (!["install", "update"].includes(action)) throw new CliError("USAGE", "harness install|update [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check]", 2);
295
+ if (flags.fleet || flags.all) throw new CliError("USAGE", "The harness is installed one service at a time, in that service's repository. There is no fleet mode.", 2);
296
+ if (flags.root === true || flags.repo === true) throw new CliError("USAGE", "--root and --repo need a value.", 2);
297
+ const ctx = flags.root ? { root: flags.root, repo: flags.repo } : await context();
298
+ const repo = flags.repo && flags.repo !== true ? String(flags.repo) : ctx.repo;
299
+ if (!repo) throw new CliError("USAGE", "Pass --repo NAME.", 2);
300
+ const state = await readState(ctx.root);
301
+ // Update asks Dev for what install asked for, unless told otherwise.
302
+ const saved = action === "update" ? (state?.request ?? {}) : {};
303
+ const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
304
+ const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
305
+ const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
306
+
307
+ if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
308
+ if (action === "update" && flags.check) {
309
+ if (!state) throw new CliError("HARNESS_NOT_INSTALLED", "No nodii-dev harness here. Run `nodii-dev harness install` first.", 2);
310
+ // Compare the files Dev would write now, not only the version: the
311
+ // CLAUDE.md block also changes when the service's domain becomes AD.
312
+ const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
313
+ const plan = await planHarness(ctx.root, harness, { install: false });
314
+ const moved = sourceChanges(state.sources, harness.sources);
315
+ const behind = harness.methodologyVersion !== state.methodologyVersion || plan.changes.length > 0 || plan.conflicts.length > 0;
316
+ const payload = { data: { repo, current: !behind, installed: state.methodologyVersion, available: harness.methodologyVersion, sources: moved, changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })), conflicts: plan.conflicts } };
317
+ if (behind) process.exitCode = 1;
318
+ return { payload, human: behind ? [`Harness is behind: ${state.methodologyVersion} → ${harness.methodologyVersion}`, ...moved.map((x) => ` source ${x}`), ...plan.changes.map(changeLine), ...plan.conflicts.map((c) => ` conflict ${c.path}: ${c.reason}`), "Run `nodii-dev harness update`."].join("\n") : `Harness is current (${state.methodologyVersion}).` };
319
+ }
320
+
321
+ const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
322
+ const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
323
+ const summary = {
324
+ repo: harness.repo, serviceId: harness.serviceId ?? null,
325
+ from: state?.methodologyVersion ?? null, to: harness.methodologyVersion,
326
+ sources: sourceChanges(state?.sources, harness.sources),
327
+ changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })),
328
+ conflicts: plan.conflicts, warnings: harness.warnings ?? [],
329
+ };
330
+ const lines = [
331
+ `${harness.repo}${harness.serviceId ? ` (${harness.serviceId})` : ""}: methodology ${summary.from ? `${summary.from} → ` : ""}${summary.to}`,
332
+ ...summary.sources.map((x) => ` source ${x}`),
333
+ ...plan.changes.map(changeLine),
334
+ ...summary.warnings.map((w) => `warning: ${w}`),
335
+ ];
336
+ if (plan.conflicts.length)
337
+ throw new CliError("HARNESS_CONFLICT",
338
+ `Nothing written. These files are not nodii-dev's to change:\n${plan.conflicts.map((c) => ` ${c.path}: ${c.reason}`).join("\n")}\n` +
339
+ "Restore files nodii-dev wrote (git checkout, or remove your edits). For your own files, remove them or let nodii-dev take them over with --adopt PATH[,PATH].", 1, summary);
340
+ if (flags["dry-run"]) return { payload: { data: { ...summary, dryRun: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? "Dry run: nothing written." : "No changes."}` };
341
+ await applyHarness(ctx.root, plan);
342
+ return { payload: { data: { ...summary, applied: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? `${action === "install" ? "Installed" : "Updated"}: ${plan.changes.length} change${plan.changes.length === 1 ? "" : "s"}.` : "Harness already current."}` };
343
+ }
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CliError, hubRequest } from "./core.js";
1
+ import { CliError, devRequest } from "./core.js";
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
 
@@ -73,7 +73,7 @@ export function extractNodeIds(value, out = new Set()) {
73
73
  return [...out];
74
74
  }
75
75
 
76
- export async function preHook(payload, request = hubRequest) {
76
+ export async function preHook(payload, request = devRequest) {
77
77
  const name = payload.tool_name ?? "";
78
78
  if (!isFigmaTool(name) || isReadTool(name))
79
79
  return { allowed: true, ignored: true };
@@ -109,7 +109,7 @@ export async function preHook(payload, request = hubRequest) {
109
109
  return { allowed: true, fileKey, changeSetId: response.data.changeSetId };
110
110
  }
111
111
 
112
- export async function postHook(payload, request = hubRequest) {
112
+ export async function postHook(payload, request = devRequest) {
113
113
  const name = payload.tool_name ?? "";
114
114
  if (!isFigmaTool(name) || isReadTool(name))
115
115
  return { logged: false, ignored: true };
@@ -156,7 +156,7 @@ export const settingsSnippet = () => ({
156
156
  },
157
157
  });
158
158
 
159
- export async function heartbeatCurrentLane(request = hubRequest) {
159
+ export async function heartbeatCurrentLane(request = devRequest) {
160
160
  let branch;
161
161
  try { branch = (await execFileAsync("git", ["branch", "--show-current"], { encoding: "utf8" })).stdout.trim(); }
162
162
  catch { return { heartbeated: false, reason: "not a git worktree" }; }
package/dist/lanes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { CliError, encode, hubRequest, query, readConfig, requireFlag } from "./core.js";
3
+ import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
4
4
 
5
5
  const execFileAsync = promisify(execFile);
6
6
  const via = { via: "nodii-dev" };
@@ -42,7 +42,7 @@ function evidence(flags) {
42
42
  return { kind: flag === "figma" ? "figma_frame" : flag.replace("-", "_"), ...(URL.canParse(value) ? { url: value } : { ref: value }), ...via };
43
43
  }
44
44
 
45
- export async function laneCommand(action, args, flags, { request = hubRequest, config = readConfig, gitContext = inferGitContext } = {}) {
45
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext } = {}) {
46
46
  if (action === "list") {
47
47
  const c = flags.mine ? await config() : null;
48
48
  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 })}`);
@@ -105,7 +105,7 @@ export async function laneCommand(action, args, flags, { request = hubRequest, c
105
105
  throw new CliError("USAGE", `Unknown lane action: ${action}`, 2);
106
106
  }
107
107
 
108
- export async function nextCommand(ref, flags, { request = hubRequest } = {}) {
108
+ export async function nextCommand(ref, flags, { request = devRequest } = {}) {
109
109
  const lane = (await request("GET", `/api/v1/lanes/${encode(ref)}`)).data;
110
110
  const pending = lane.steps.filter((x) => ["waiting", "current", "blocked"].includes(x.state)).sort((a, b) => a.position - b.position);
111
111
  const steps = ["backend", "frontend"].map((side) => pending.find((x) => x.side === side)).filter(Boolean);
@@ -113,7 +113,7 @@ export async function nextCommand(ref, flags, { request = hubRequest } = {}) {
113
113
  return { payload: { data }, human: data.length ? data.map((x) => `${x.side}: ${x.label}\n ${x.command}`).join("\n") : `All workflow steps are complete. Run: nodii-dev lane land ${lane.key}` };
114
114
  }
115
115
 
116
- export async function builtCommand(action, args, flags, { request = hubRequest } = {}) {
116
+ export async function builtCommand(action, args, flags, { request = devRequest } = {}) {
117
117
  if (action === "list") {
118
118
  const payload = await request("GET", `/api/v1/built${query({ cursor: flags.cursor, limit: flags.limit, includeAcknowledged: flags["include-acknowledged"] })}`);
119
119
  return { payload, human: (p) => p.data.map(laneLine).join("\n") || "No built lanes awaiting acknowledgement." };
@@ -126,7 +126,7 @@ export async function builtCommand(action, args, flags, { request = hubRequest }
126
126
  throw new CliError("USAGE", `Unknown built action: ${action}`, 2);
127
127
  }
128
128
 
129
- export async function machineCommand(action, args, flags, { request = hubRequest } = {}) {
129
+ export async function machineCommand(action, args, flags, { request = devRequest } = {}) {
130
130
  if (action === "list") return { payload: await request("GET", "/api/v1/machines"), human: (p) => p.data.map((x) => `${x.id}\t${x.name}\tcoordinator=${x.coordinatorAgentId}`).join("\n") || "No machines." };
131
131
  const id = need(args[0], `machine ${action} needs <machine>`);
132
132
  if (action === "show") return { payload: await request("GET", `/api/v1/machines/${encode(id)}`) };
@@ -136,7 +136,7 @@ export async function machineCommand(action, args, flags, { request = hubRequest
136
136
  throw new CliError("USAGE", `Unknown machine action: ${action}`, 2);
137
137
  }
138
138
 
139
- export async function peopleCommand(action, args, flags, { request = hubRequest } = {}) {
139
+ export async function peopleCommand(action, args, flags, { request = devRequest } = {}) {
140
140
  if (action === "reveal") {
141
141
  const id = need(args[0], "people reveal needs <person>");
142
142
  return { payload: await request("POST", `/api/v1/people/${encode(id)}/reveal-name`, { reason: requireFlag(flags, "reason") }), human: (p) => p.data.value ?? p.data.hint ?? "Name unavailable." };
@@ -145,7 +145,7 @@ export async function peopleCommand(action, args, flags, { request = hubRequest
145
145
  throw new CliError("USAGE", `Unknown people action: ${action}`, 2);
146
146
  }
147
147
 
148
- export async function agentWorkflowCommand(action, args, flags, { request = hubRequest } = {}) {
148
+ export async function agentWorkflowCommand(action, args, flags, { request = devRequest } = {}) {
149
149
  const agent = need(args[0], `agent ${action} needs <agent>`);
150
150
  if (action === "lane-limit") return { payload: await request("PUT", `/api/v1/agents/${encode(agent)}/lane-limit`, { laneLimit: Number(need(args[1], "agent lane-limit needs <agent> <n>")) }), human: "Lane limit updated." };
151
151
  if (action === "grant") return { payload: await request("POST", `/api/v1/agents/${encode(agent)}/repo-grants`, { repo: need(args[1], `agent ${action} needs <agent> <repo>`) }), human: "Repository grant added." };