@lovrozagar/grunt 0.9.4 → 0.10.0

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.
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Machine-local live agent board. One JSON file per session under ~/.grunt/board/.
4
+ * A read prints lines whose host process is still alive and deletes the rest.
5
+ * Fail-open: a probe or IO error skips the write and prints nothing.
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const WORK_CAP = 80;
14
+ const MAX_WALK = 4;
15
+ const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ash", "busybox", "cmd", "powershell", "pwsh"]);
16
+
17
+ function field(value) {
18
+ const s = String(value ?? "").replace(/\s+/g, "-").trim();
19
+ return s || "-";
20
+ }
21
+
22
+ function oneLine(value, cap) {
23
+ const s = String(value ?? "").replace(/\s+/g, " ").trim();
24
+ const cut = cap ? s.slice(0, cap).trim() : s;
25
+ return cut || "-";
26
+ }
27
+
28
+ function commBase(comm) {
29
+ const base = String(comm || "").split(/[/\\]/).pop() || "";
30
+ return base.replace(/\.exe$/i, "").toLowerCase();
31
+ }
32
+
33
+ function safe(value) {
34
+ return field(value).replace(/[^\w.@+-]/g, "_") || "-";
35
+ }
36
+
37
+ export function formatLine(row) {
38
+ return [
39
+ field(row.time),
40
+ field(row.provider),
41
+ field(row.model),
42
+ field(row.effort),
43
+ field(row.session),
44
+ oneLine(row.cwd),
45
+ oneLine(row.work, WORK_CAP),
46
+ ].join(" ");
47
+ }
48
+
49
+ export function boardHome(env = process.env) {
50
+ return env.GRUNT_BOARD_HOME || os.homedir();
51
+ }
52
+
53
+ export function boardDir(home) {
54
+ return path.join(home, ".grunt", "board");
55
+ }
56
+
57
+ function rowPath(dir, provider, session) {
58
+ return path.join(dir, `${safe(provider)}-${safe(session)}.json`);
59
+ }
60
+
61
+ function readRow(file) {
62
+ try {
63
+ const row = JSON.parse(fs.readFileSync(file, "utf8"));
64
+ return row && typeof row === "object" ? row : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function ensureDir(dir) {
71
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ try {
73
+ fs.chmodSync(dir, 0o700);
74
+ } catch {
75
+ // Windows cannot always chmod a directory.
76
+ }
77
+ }
78
+
79
+ function atomicWrite(file, text) {
80
+ const tmp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
81
+ fs.writeFileSync(tmp, text, { mode: 0o600 });
82
+ fs.renameSync(tmp, file);
83
+ try {
84
+ fs.chmodSync(file, 0o600);
85
+ } catch {
86
+ // Windows cannot always chmod a file.
87
+ }
88
+ }
89
+
90
+ export function readBootId({ readFile = fs.readFileSync, platform = process.platform } = {}) {
91
+ if (platform !== "linux") return "-";
92
+ try {
93
+ return String(readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim() || "-";
94
+ } catch {
95
+ return "-";
96
+ }
97
+ }
98
+
99
+ /** Linux /proc/pid/stat. Field 22 is starttime. comm sits between the first "(" and the last ")". */
100
+ export function parseProcStat(text) {
101
+ const s = String(text ?? "");
102
+ const end = s.lastIndexOf(")");
103
+ const open = s.indexOf("(");
104
+ if (open < 0 || end < open) return null;
105
+ const comm = s.slice(open + 1, end);
106
+ const rest = s.slice(end + 1).trim().split(/\s+/);
107
+ if (rest.length < 20) return null;
108
+ const ppid = Number(rest[1]);
109
+ if (!rest[19] || !Number.isFinite(ppid)) return null;
110
+ return { comm, state: rest[0], ppid, start: rest[19] };
111
+ }
112
+
113
+ export function probeProcess(pid, deps = {}) {
114
+ const n = Number(pid);
115
+ if (!Number.isInteger(n) || n <= 1) return null;
116
+ const platform = deps.platform || process.platform;
117
+ const readFile = deps.readFile || fs.readFileSync;
118
+ const spawn = deps.spawn || spawnSync;
119
+ if (platform === "linux") {
120
+ try {
121
+ const parsed = parseProcStat(readFile(`/proc/${n}/stat`, "utf8"));
122
+ if (!parsed || parsed.state === "Z") return null;
123
+ return { pid: n, start: parsed.start, comm: parsed.comm, ppid: parsed.ppid };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ if (platform === "darwin") {
129
+ const r = spawn("ps", ["-o", "ppid=,lstart=,comm=", "-p", String(n)], { encoding: "utf8" });
130
+ if (!r || r.status !== 0) return null;
131
+ const line = String(r.stdout || "").trim();
132
+ const m = line.match(/^(\d+)\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s*$/);
133
+ if (!m) return null;
134
+ return { pid: n, start: m[2], comm: commBase(m[3]), ppid: Number(m[1]) };
135
+ }
136
+ if (platform === "win32") {
137
+ const script = [
138
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${n}"`,
139
+ "if (-not $p) { exit 1 }",
140
+ "$p.ParentProcessId",
141
+ "$p.Name",
142
+ "$p.CreationDate.ToUniversalTime().ToString('o')",
143
+ ].join("; ");
144
+ const r = spawn("powershell.exe", ["-NoProfile", "-Command", script], { encoding: "utf8" });
145
+ if (!r || r.status !== 0) return null;
146
+ const parts = String(r.stdout || "").trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
147
+ if (parts.length < 3) return null;
148
+ const ppid = Number(parts[0]);
149
+ if (!Number.isFinite(ppid) || !parts[2]) return null;
150
+ return { pid: n, start: parts[2], comm: parts[1], ppid };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export function hostIdentity({ pid = process.pid, probe = probeProcess } = {}) {
156
+ let current = probe(pid)?.ppid;
157
+ for (let i = 0; i < MAX_WALK && current && current > 1; i++) {
158
+ const info = probe(current);
159
+ if (!info || !(info.pid > 1) || info.start == null || info.start === "") return null;
160
+ const name = commBase(info.comm);
161
+ if (!SHELLS.has(name)) return { pid: info.pid, start: String(info.start) };
162
+ if (!(info.ppid > 1) || info.ppid === current) return null;
163
+ current = info.ppid;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ export function rowAlive(row, { boot, probe = probeProcess } = {}) {
169
+ if (!row || row.pid == null || row.start == null || row.start === "") return false;
170
+ if (String(row.boot ?? "") !== String(boot ?? "")) return false;
171
+ let info;
172
+ try {
173
+ info = probe(row.pid);
174
+ } catch {
175
+ return false;
176
+ }
177
+ if (!info || info.start == null || info.start === "") return false;
178
+ return String(info.start) === String(row.start);
179
+ }
180
+
181
+ export function sweep({ home, boot, probe = probeProcess } = {}) {
182
+ const dir = boardDir(home);
183
+ if (!fs.existsSync(dir)) return [];
184
+ const kept = [];
185
+ for (const name of fs.readdirSync(dir)) {
186
+ const file = path.join(dir, name);
187
+ if (!name.endsWith(".json")) continue;
188
+ const row = readRow(file);
189
+ if (!row || !rowAlive(row, { boot, probe })) {
190
+ fs.rmSync(file, { force: true });
191
+ continue;
192
+ }
193
+ kept.push(row);
194
+ }
195
+ kept.sort((a, b) => String(a.time).localeCompare(String(b.time)));
196
+ return kept;
197
+ }
198
+
199
+ function nextWork(prev, opts) {
200
+ if (opts.forceWork) return { work: oneLine(opts.work, WORK_CAP), sticky: true };
201
+ if (prev?.sticky && prev.work) return { work: String(prev.work), sticky: true };
202
+ if (opts.work != null && String(opts.work).trim()) return { work: oneLine(opts.work, WORK_CAP), sticky: false };
203
+ if (prev?.work) return { work: String(prev.work), sticky: false };
204
+ return { work: "active", sticky: false };
205
+ }
206
+
207
+ export function note(opts = {}) {
208
+ const session = String(opts.session ?? "").trim();
209
+ if (!session || session === "default") return null;
210
+ if (opts.pid == null || opts.start == null || opts.start === "") return null;
211
+ const home = opts.home ?? boardHome(opts.env);
212
+ const dir = boardDir(home);
213
+ ensureDir(dir);
214
+ const provider = field(opts.provider);
215
+ const file = rowPath(dir, provider, session);
216
+ const prev = readRow(file);
217
+ const chosen = nextWork(prev, opts);
218
+ const row = {
219
+ time: opts.now instanceof Date ? opts.now.toISOString().replace(/\.\d{3}Z$/, "Z") : opts.now ? String(opts.now) : new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
220
+ provider,
221
+ model: opts.model == null || String(opts.model).trim() === "" ? "-" : String(opts.model),
222
+ effort: opts.effort == null || String(opts.effort).trim() === "" ? "-" : String(opts.effort),
223
+ session,
224
+ cwd: oneLine(opts.cwd),
225
+ work: chosen.work,
226
+ sticky: chosen.sticky,
227
+ pid: Number(opts.pid),
228
+ start: String(opts.start),
229
+ boot: String(opts.boot ?? ""),
230
+ };
231
+ atomicWrite(file, `${JSON.stringify(row)}\n`);
232
+ sweep({ home, boot: row.boot, probe: opts.probe });
233
+ if (!fs.existsSync(file)) return null;
234
+ return row;
235
+ }
236
+
237
+ export function lines(opts = {}) {
238
+ const home = opts.home ?? boardHome(opts.env);
239
+ const boot = opts.boot ?? readBootId();
240
+ return sweep({ home, boot, probe: opts.probe }).map(formatLine);
241
+ }
242
+
243
+ export function digest(opts = {}) {
244
+ const home = opts.home ?? boardHome(opts.env);
245
+ const boot = opts.boot ?? readBootId();
246
+ const selfP = field(opts.provider);
247
+ const selfS = field(opts.session);
248
+ return sweep({ home, boot, probe: opts.probe })
249
+ .filter((row) => !(field(row.provider) === selfP && field(row.session) === selfS))
250
+ .sort((a, b) => String(b.time).localeCompare(String(a.time)))
251
+ .slice(0, opts.cap ?? 8)
252
+ .map(formatLine)
253
+ .join("\n");
254
+ }
255
+
256
+ function providerOf(data, env) {
257
+ if (env.GRUNT_BOARD_PROVIDER) return env.GRUNT_BOARD_PROVIDER;
258
+ if (env.GROK_WORKSPACE_ROOT || env.GROK_SESSION_ID || env.GROK_HOOK_EVENT) return "grok";
259
+ if (env.CLAUDE_PROJECT_DIR) return "claude";
260
+ if (data && data.model) return "codex";
261
+ return "-";
262
+ }
263
+
264
+ function modelOf(data) {
265
+ if (!data || data.model == null || String(data.model).trim() === "") return "-";
266
+ return String(data.model);
267
+ }
268
+
269
+ function effortOf(data, env) {
270
+ const level = data && data.effort && typeof data.effort === "object" ? data.effort.level : data?.effort;
271
+ if (level != null && String(level).trim() !== "") return String(level);
272
+ if (env.CLAUDE_EFFORT && String(env.CLAUDE_EFFORT).trim() !== "") return String(env.CLAUDE_EFFORT);
273
+ return "-";
274
+ }
275
+
276
+ function sessionOf(data, env) {
277
+ const value = (data && (data.session_id || data.sessionId)) || env.GROK_SESSION_ID || "";
278
+ return String(value).trim();
279
+ }
280
+
281
+ function cwdOf(data, env) {
282
+ return (
283
+ env.GROK_WORKSPACE_ROOT ||
284
+ (data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
285
+ env.CLAUDE_PROJECT_DIR ||
286
+ process.cwd()
287
+ );
288
+ }
289
+
290
+ function promptOf(data) {
291
+ if (!data || typeof data !== "object") return "";
292
+ return String(data.prompt ?? data.userPrompt ?? data.user_prompt ?? "");
293
+ }
294
+
295
+ export function recordHook(data, env = process.env, deps = {}) {
296
+ try {
297
+ const session = sessionOf(data, env);
298
+ if (!session || session === "default") return null;
299
+ const ident = deps.identity || hostIdentity({ probe: deps.probe || probeProcess });
300
+ if (!ident) return null;
301
+ const boot = deps.boot || readBootId();
302
+ return note({
303
+ home: boardHome(env),
304
+ env,
305
+ provider: providerOf(data, env),
306
+ model: modelOf(data),
307
+ effort: effortOf(data, env),
308
+ session,
309
+ cwd: cwdOf(data, env),
310
+ work: promptOf(data),
311
+ pid: ident.pid,
312
+ start: ident.start,
313
+ boot,
314
+ now: deps.now,
315
+ probe: deps.probe,
316
+ });
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ export function otherLines(data, env = process.env, deps = {}) {
323
+ try {
324
+ return digest({
325
+ home: boardHome(env),
326
+ env,
327
+ provider: providerOf(data, env),
328
+ session: sessionOf(data, env),
329
+ boot: deps.boot || readBootId(),
330
+ probe: deps.probe,
331
+ });
332
+ } catch {
333
+ return "";
334
+ }
335
+ }
336
+
337
+ function parseArgs(argv) {
338
+ const out = { set: false, work: null, home: null };
339
+ for (let i = 0; i < argv.length; i++) {
340
+ const arg = argv[i];
341
+ if (arg === "set") out.set = true;
342
+ else if (arg === "--work") out.work = argv[++i] ?? "";
343
+ else if (arg.startsWith("--work=")) out.work = arg.slice("--work=".length);
344
+ else if (arg === "--home") out.home = argv[++i] ?? "";
345
+ else if (arg.startsWith("--home=")) out.home = arg.slice("--home=".length);
346
+ }
347
+ return out;
348
+ }
349
+
350
+ function main() {
351
+ try {
352
+ const args = parseArgs(process.argv.slice(2));
353
+ const env = { ...process.env };
354
+ if (args.home) env.GRUNT_BOARD_HOME = args.home;
355
+ const home = boardHome(env);
356
+ if (args.set) {
357
+ const ident = hostIdentity();
358
+ if (!ident) return 0;
359
+ const provider = providerOf({}, env);
360
+ const session =
361
+ env.GRUNT_BOARD_SESSION || env.GROK_SESSION_ID || env.CLAUDE_SESSION_ID || String(ident.pid);
362
+ note({
363
+ home,
364
+ env,
365
+ provider: provider === "-" ? "antigravity" : provider,
366
+ model: "-",
367
+ effort: env.CLAUDE_EFFORT || "-",
368
+ session,
369
+ cwd: cwdOf({}, env),
370
+ work: args.work ?? "",
371
+ forceWork: true,
372
+ pid: ident.pid,
373
+ start: ident.start,
374
+ boot: readBootId(),
375
+ });
376
+ return 0;
377
+ }
378
+ const printed = lines({ home, env });
379
+ if (printed.length) process.stdout.write(`${printed.join("\n")}\n`);
380
+ return 0;
381
+ } catch {
382
+ return 0;
383
+ }
384
+ }
385
+
386
+ const thisFile = fileURLToPath(import.meta.url);
387
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
388
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
389
+ process.exit(main());
390
+ }
@@ -16,6 +16,7 @@ import {
16
16
  detectPackageManager,
17
17
  runScriptLine,
18
18
  } from "./package-manager.mjs";
19
+ import { selectedPacks } from "./feature-packs.mjs";
19
20
 
20
21
  export const CHROMIUM_BINS = [
21
22
  "chromium",
@@ -229,6 +230,19 @@ function isFile(abs) {
229
230
  }
230
231
  }
231
232
 
233
+ export function jevDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
234
+ if (String(env.TYPESAFE_API_KEY || "").trim()) return { ok: true, extra: "env" };
235
+ try {
236
+ const parsed = JSON.parse(fs.readFileSync(path.join(home, ".grunt", "jev.json"), "utf8"));
237
+ if (String(parsed && parsed.apiKey ? parsed.apiKey : "").trim()) {
238
+ return { ok: true, extra: "config" };
239
+ }
240
+ } catch {
241
+ /* absent */
242
+ }
243
+ return { ok: false, extra: "" };
244
+ }
245
+
232
246
  export function speakDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
233
247
  const names = [];
234
248
  if (String(env.ELEVENLABS_API_KEY || "").trim()) names.push("elevenlabs");
@@ -377,20 +391,31 @@ export function runDoctor({
377
391
  lines.push(rulesync ? row("rulesync", "ok", rulesync) : row("rulesync", "missing"));
378
392
  lines.push(lightpanda ? row("lightpanda", "ok", lightpanda) : row("lightpanda", "missing"));
379
393
  lines.push(chromium ? row("chromium", "ok", chromium) : row("chromium", "missing"));
394
+ const enabled = new Set(selectedPacks(cwd));
380
395
  lines.push(gh ? row("gh", "ok", gh) : row("gh", "missing (optional)"));
381
- lines.push(clasp ? row("clasp", "ok", clasp) : row("clasp", "missing (optional)"));
382
- const workspace = workspaceDoctorStatus({ home });
383
- lines.push(
384
- workspace.ok
385
- ? row("google-workspace", "ok", workspace.extra)
386
- : row("google-workspace", "missing (optional)"),
387
- );
388
- lines.push(ffmpeg ? row("ffmpeg", "ok", ffmpeg) : row("ffmpeg", "missing (optional)"));
389
- lines.push(
390
- whisperCli ? row("whisper-cli", "ok", whisperCli) : row("whisper-cli", "missing (optional)"),
391
- );
392
- const speak = speakDoctorStatus({ env, home });
393
- lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
396
+ if (enabled.has("clasp")) {
397
+ lines.push(clasp ? row("clasp", "ok", clasp) : row("clasp", "missing (optional)"));
398
+ }
399
+ if (enabled.has("google-workspace")) {
400
+ const workspace = workspaceDoctorStatus({ home });
401
+ lines.push(
402
+ workspace.ok
403
+ ? row("google-workspace", "ok", workspace.extra)
404
+ : row("google-workspace", "missing (optional)"),
405
+ );
406
+ }
407
+ if (enabled.has("listen")) {
408
+ lines.push(ffmpeg ? row("ffmpeg", "ok", ffmpeg) : row("ffmpeg", "missing (optional)"));
409
+ lines.push(
410
+ whisperCli ? row("whisper-cli", "ok", whisperCli) : row("whisper-cli", "missing (optional)"),
411
+ );
412
+ }
413
+ if (enabled.has("speak")) {
414
+ const speak = speakDoctorStatus({ env, home });
415
+ lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
416
+ }
417
+ const jev = jevDoctorStatus({ env, home });
418
+ lines.push(jev.ok ? row("jev", "ok", jev.extra) : row("jev", "missing (optional)"));
394
419
 
395
420
  const missingRequired = REQUIRED.filter((k) => (k === "node" ? !nodeOk : !found[k]));
396
421
  if (!pmOk) missingRequired.push("package-manager");
@@ -444,7 +469,17 @@ export function runDoctor({
444
469
  return { code: missingRequired.length || missingMaps.length ? 1 : 0, stdout, stderr: "" };
445
470
  }
446
471
 
447
- function main() {
472
+ /** Load dest `scripts/lib/env.mjs` `loadEnv` when that file exists next to this script. */
473
+ export async function loadOptionalConsumerEnv(baseHref = import.meta.url) {
474
+ const file = fileURLToPath(new URL("./lib/env.mjs", baseHref));
475
+ if (!fs.existsSync(file)) return false;
476
+ const { loadEnv } = await import(pathToFileURL(file).href);
477
+ if (typeof loadEnv === "function") loadEnv();
478
+ return true;
479
+ }
480
+
481
+ async function main() {
482
+ await loadOptionalConsumerEnv();
448
483
  const r = runDoctor();
449
484
  if (r.stdout) process.stdout.write(r.stdout);
450
485
  if (r.stderr) process.stderr.write(r.stderr);
@@ -454,5 +489,8 @@ function main() {
454
489
  const thisFile = fileURLToPath(import.meta.url);
455
490
  const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
456
491
  if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
457
- main();
492
+ main().catch((err) => {
493
+ process.stderr.write((err && err.message ? err.message : String(err)) + "\n");
494
+ process.exit(1);
495
+ });
458
496
  }
@@ -0,0 +1,123 @@
1
+ /** Optional installer packs. The npm package still ships them. Init writes only the selected ones. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const FEATURES_REL = ".rulesync/grunt.features.jsonc";
6
+
7
+ export const OPTIONAL_PACKS = [
8
+ { name: "google-workspace", skill: "google-workspace", ref: "google-workspace.md", script: "google-workspace.mjs" },
9
+ { name: "listen", skill: "listen", ref: "listen.md", script: "listen.mjs" },
10
+ { name: "speak", skill: "speak", ref: "speak.md", script: "speak.mjs" },
11
+ { name: "clasp", skill: "clasp", ref: "clasp.md", script: "" },
12
+ ];
13
+
14
+ export const PACK_OPTIONS = OPTIONAL_PACKS.map((pack) => ({ value: pack.name, label: pack.name }));
15
+
16
+ const SKILL_DIRS = [".rulesync/skills", ".grok/skills", ".claude/skills", ".agents/skills"];
17
+
18
+ function partsOf(rel) {
19
+ return String(rel || "").split(/[/\\]/).filter((part) => part && part !== ".");
20
+ }
21
+
22
+ function knownSet() {
23
+ return new Set(OPTIONAL_PACKS.map((pack) => pack.name));
24
+ }
25
+
26
+ function inTableOrder(names) {
27
+ const on = new Set(names);
28
+ return OPTIONAL_PACKS.map((pack) => pack.name).filter((name) => on.has(name));
29
+ }
30
+
31
+ export function normalizePacks(list) {
32
+ const known = knownSet();
33
+ const seen = [];
34
+ for (const raw of list || []) {
35
+ const name = String(raw || "").trim();
36
+ if (!name || seen.includes(name)) continue;
37
+ if (!known.has(name)) throw new Error(`unknown pack ${name}`);
38
+ seen.push(name);
39
+ }
40
+ return inTableOrder(seen);
41
+ }
42
+
43
+ export function parsePacksFlag(value) {
44
+ const text = String(value ?? "").trim();
45
+ if (!text || text === "none") return [];
46
+ return normalizePacks(text.split(","));
47
+ }
48
+
49
+ function stripJsonc(text) {
50
+ return String(text)
51
+ .replace(/\/\*[\s\S]*?\*\//g, "")
52
+ .replace(/^\s*\/\/.*$/gm, "");
53
+ }
54
+
55
+ /** null when the file is missing. Bad JSON or an empty list is `{ packs: [] }`. */
56
+ export function readPacks(dest) {
57
+ const abs = path.join(dest, FEATURES_REL);
58
+ if (!fs.existsSync(abs)) return null;
59
+ try {
60
+ const data = JSON.parse(stripJsonc(fs.readFileSync(abs, "utf8")));
61
+ const raw = Array.isArray(data?.packs) ? data.packs : [];
62
+ const known = knownSet();
63
+ return { packs: inTableOrder(raw.map((name) => String(name).trim()).filter((name) => known.has(name))) };
64
+ } catch {
65
+ return { packs: [] };
66
+ }
67
+ }
68
+
69
+ export function selectedPacks(dest) {
70
+ const saved = readPacks(dest);
71
+ return saved ? saved.packs : [];
72
+ }
73
+
74
+ export function writePacks(dest, packs) {
75
+ const selected = normalizePacks(packs);
76
+ const abs = path.join(dest, FEATURES_REL);
77
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
78
+ fs.writeFileSync(abs, `${JSON.stringify({ packs: selected }, null, 2)}\n`);
79
+ }
80
+
81
+ /** An array, including `[]`, is an answer. Otherwise use the saved file, or nothing. */
82
+ export function resolvePackSelection(dest, packs) {
83
+ if (Array.isArray(packs)) return { packs: normalizePacks(packs), answered: true };
84
+ const saved = readPacks(dest);
85
+ if (saved) return { packs: saved.packs, answered: false };
86
+ return { packs: [], answered: false };
87
+ }
88
+
89
+ export function isUnselectedPackPath(rel, selected) {
90
+ const on = selected instanceof Set ? selected : new Set(selected || []);
91
+ const parts = partsOf(rel);
92
+ if (parts.length >= 3 && parts[1] === "skills") {
93
+ const pack = OPTIONAL_PACKS.find((item) => item.skill === parts[2]);
94
+ if (pack && !on.has(pack.name)) return true;
95
+ }
96
+ if (parts[0] === ".rulesync" && parts[1] === "reference" && parts.length >= 3) {
97
+ const pack = OPTIONAL_PACKS.find((item) => item.ref === parts[2]);
98
+ if (pack && !on.has(pack.name)) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ export function unselectedPackRels(selected) {
104
+ const on = selected instanceof Set ? selected : new Set(selected || []);
105
+ const rels = [];
106
+ for (const pack of OPTIONAL_PACKS) {
107
+ if (on.has(pack.name)) continue;
108
+ for (const dir of SKILL_DIRS) rels.push(`${dir}/${pack.skill}`);
109
+ if (pack.ref) rels.push(`.rulesync/reference/${pack.ref}`);
110
+ if (pack.script) rels.push(`scripts/${pack.script}`);
111
+ }
112
+ return rels;
113
+ }
114
+
115
+ export function unselectedRefNames(selected) {
116
+ const on = selected instanceof Set ? selected : new Set(selected || []);
117
+ return new Set(OPTIONAL_PACKS.filter((pack) => pack.ref && !on.has(pack.name)).map((pack) => pack.ref));
118
+ }
119
+
120
+ export function unselectedScriptNames(selected) {
121
+ const on = selected instanceof Set ? selected : new Set(selected || []);
122
+ return new Set(OPTIONAL_PACKS.filter((pack) => pack.script && !on.has(pack.name)).map((pack) => pack.script));
123
+ }
@@ -111,6 +111,34 @@ export function mergeGuardedContent(existingText, gruntBody) {
111
111
  return composeGuardedMarkdown(gruntBody, user, nl);
112
112
  }
113
113
 
114
+ /** `##` blocks in `existing` whose titles are not in `incoming`. */
115
+ export function extraH2Sections(existing, incoming) {
116
+ const incomingTitles = new Set(
117
+ [...String(incoming ?? "").matchAll(/^## (.+)$/gm)].map((m) => m[1].trim()),
118
+ );
119
+ const text = String(existing ?? "").replace(/\r\n/g, "\n");
120
+ const matches = [...text.matchAll(/^## /gm)];
121
+ if (!matches.length) return "";
122
+ const chunks = [];
123
+ for (let i = 0; i < matches.length; i++) {
124
+ const start = matches[i].index;
125
+ const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
126
+ const block = text.slice(start, end).replace(/(?:\n)+$/, "");
127
+ const title = block.match(/^## (.+)$/m)?.[1].trim();
128
+ if (title && !incomingTitles.has(title)) chunks.push(block);
129
+ }
130
+ return chunks.join("\n\n");
131
+ }
132
+
133
+ /** Grunt body plus consumer-only `##` sections. No sentinels (INDEX inlines law.md). */
134
+ export function mergeReferenceMarkdown(existing, incoming) {
135
+ const src = String(incoming ?? "");
136
+ const extras = extraH2Sections(existing ?? "", src);
137
+ const base = src.replace(/(?:\r?\n)+$/, "");
138
+ if (!extras.trim()) return `${base}\n`;
139
+ return `${base}\n\n${extras.replace(/(?:\n)+$/, "")}\n`;
140
+ }
141
+
114
142
  export function writeMergedGuardedFile(destPath, gruntBody) {
115
143
  if (!fs.existsSync(destPath)) {
116
144
  fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody));