@lovrozagar/grunt 0.2.0 → 0.3.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.
Files changed (55) hide show
  1. package/.agents/agents/grunt.md +4 -2
  2. package/.agents/agents/orchestrator.md +2 -2
  3. package/.agents/hooks.json +12 -1
  4. package/.agents/skills/commit/SKILL.md +21 -0
  5. package/.agents/skills/commit-and-push/SKILL.md +15 -0
  6. package/.agents/skills/explain/SKILL.md +14 -0
  7. package/.agents/skills/handoff/SKILL.md +84 -0
  8. package/.agents/skills/parent/SKILL.md +11 -0
  9. package/.agents/skills/solo/SKILL.md +21 -0
  10. package/.claude/agents/grunt.md +2 -0
  11. package/.claude/agents/orchestrator.md +3 -3
  12. package/.claude/settings.json +34 -1
  13. package/.claude/skills/commit/SKILL.md +21 -0
  14. package/.claude/skills/commit-and-push/SKILL.md +15 -0
  15. package/.claude/skills/explain/SKILL.md +14 -0
  16. package/.claude/skills/handoff/SKILL.md +84 -0
  17. package/.claude/skills/parent/SKILL.md +11 -0
  18. package/.claude/skills/solo/SKILL.md +21 -0
  19. package/.codex/agents/grunt.toml +4 -2
  20. package/.codex/agents/orchestrator.toml +2 -2
  21. package/.codex/hooks.json +34 -1
  22. package/.grok/agents/grunt.md +4 -2
  23. package/.grok/agents/orchestrator.md +2 -2
  24. package/.grok/hooks/orchestrate-parent.js +263 -29
  25. package/.grok/skills/commit/SKILL.md +21 -0
  26. package/.grok/skills/commit-and-push/SKILL.md +15 -0
  27. package/.grok/skills/handoff/SKILL.md +84 -0
  28. package/.grok/skills/solo/SKILL.md +21 -0
  29. package/.rulesync/hooks.jsonc +11 -3
  30. package/.rulesync/reference/cascade.md +4 -3
  31. package/.rulesync/reference/hooks.md +5 -1
  32. package/.rulesync/reference/map.md +18 -3
  33. package/.rulesync/rules/CLAUDE.md +2 -1
  34. package/.rulesync/rules/overview.md +2 -1
  35. package/.rulesync/skills/commit/SKILL.md +22 -0
  36. package/.rulesync/skills/commit-and-push/SKILL.md +16 -0
  37. package/.rulesync/skills/handoff/SKILL.md +84 -0
  38. package/.rulesync/skills/solo/SKILL.md +21 -0
  39. package/.rulesync/subagents/grunt.md +6 -2
  40. package/.rulesync/subagents/implementer.md +2 -0
  41. package/.rulesync/subagents/orchestrator.md +4 -2
  42. package/.rulesync/subagents/thinker.md +2 -0
  43. package/AGENTS.md +2 -1
  44. package/CLAUDE.md +2 -1
  45. package/README.md +2 -2
  46. package/cli/init.mjs +105 -6
  47. package/cli/init.test.ts +303 -6
  48. package/package.json +12 -6
  49. package/scripts/emit-agent-shell-tools.mjs +118 -0
  50. package/scripts/emit-gemini.mjs +219 -0
  51. package/scripts/emit-mcp-policy.mjs +23 -0
  52. package/scripts/gate-fat-tools.mjs +64 -0
  53. package/scripts/hooks-union.mjs +391 -0
  54. package/scripts/persist-handoff.mjs +231 -0
  55. package/scripts/scrub-spawn-prompt.mjs +37 -17
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Durable driver overlay after `rulesync generate -f hooks`.
4
+ * Union, never replace: keep Stop / UserPromptSubmit / SubagentStop / mcp deny.
5
+ *
6
+ * Claude/Codex native event is UserPromptSubmit (mapped from SoT beforeSubmitPrompt).
7
+ * Antigravity has neither beforeSubmitPrompt nor subagentStop.
8
+ * Grok SessionStart stays empty on orchestrate-parent.json.
9
+ *
10
+ * Consumer extras (if those scripts exist): SessionStart, check-behind,
11
+ * validate+sim, platform/** deny. Gemini: SessionStart + AfterTool only;
12
+ * check-behind / path deny stay caveats.
13
+ *
14
+ * node scripts/hooks-union.mjs apply then check
15
+ * node scripts/hooks-union.mjs --check keep-list only
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+
21
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
22
+
23
+ export const PULL = "node scripts/daily-pull-check.mjs";
24
+ export const RTK = "node scripts/rtk-check.mjs";
25
+ export const FLEET = "node scripts/daily-fleet-check.mjs";
26
+ export const BEHIND = "node scripts/check-behind.mjs --hook";
27
+ export const VALIDATE = "node scripts/validate.mjs --hook";
28
+ export const SIM = "node scripts/sim.mjs --hook";
29
+ export const SCRUB =
30
+ 'node "${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/scrub-spawn-prompt.mjs"';
31
+ export const GATE_FAT =
32
+ 'node "${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/gate-fat-tools.mjs"';
33
+
34
+ export const AUTORUN_NEEDLES = [
35
+ "daily-pull-check.mjs",
36
+ "rtk-check.mjs",
37
+ "daily-fleet-check.mjs",
38
+ "check-behind.mjs",
39
+ "validate.mjs",
40
+ "sim.mjs",
41
+ ];
42
+ export const GRUNT_NEEDLES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs"];
43
+ export const PLATFORM_DENY = ["Write(platform/**)", "Edit(platform/**)"];
44
+ export const GIT_HOOKS = [
45
+ "scripts/git-hooks/pre-commit",
46
+ "scripts/git-hooks/pre-commit.mjs",
47
+ "scripts/git-hooks/pre-push",
48
+ "scripts/git-hooks/pre-push.mjs",
49
+ ];
50
+
51
+ const CAVEAT_BEHIND =
52
+ "NOT ported: PreToolUse check-behind.mjs. Gemini has no confirmed before-tool deny event.";
53
+ const CAVEAT_PERMS =
54
+ "NOT ported: Write/Edit(platform/**). Gemini tool controls are name-scoped, not path-scoped.";
55
+
56
+ export function parseArgv(argv) {
57
+ const args = Array.isArray(argv) ? argv : [];
58
+ let check = false;
59
+ for (const a of args) {
60
+ if (a === "--check") {
61
+ check = true;
62
+ continue;
63
+ }
64
+ return { ok: false, error: `unknown flag: ${a}` };
65
+ }
66
+ return { ok: true, check };
67
+ }
68
+
69
+ function isPlainObject(v) {
70
+ return v != null && typeof v === "object" && !Array.isArray(v);
71
+ }
72
+
73
+ function resolveRoot(opts) {
74
+ if (typeof opts === "string") return opts;
75
+ if (opts && typeof opts.workspaceRoot === "string" && opts.workspaceRoot) {
76
+ return opts.workspaceRoot;
77
+ }
78
+ return process.cwd();
79
+ }
80
+
81
+ function hasScript(root, name) {
82
+ return fs.existsSync(path.join(root, "scripts", name));
83
+ }
84
+
85
+ function hasPlatform(root) {
86
+ return fs.existsSync(path.join(root, "platform"));
87
+ }
88
+
89
+ function presentAutorun(root) {
90
+ return AUTORUN_NEEDLES.filter((n) => hasScript(root, n));
91
+ }
92
+
93
+ function hookCmd(command, timeout) {
94
+ const h = { type: "command", command };
95
+ if (timeout != null) h.timeout = timeout;
96
+ return h;
97
+ }
98
+
99
+ function gruntPreToolUse() {
100
+ return [
101
+ {
102
+ matcher: "spawn_subagent|Task|Agent|spawn_agent",
103
+ hooks: [hookCmd(SCRUB, 5)],
104
+ },
105
+ {
106
+ matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
107
+ hooks: [hookCmd(GATE_FAT, 5)],
108
+ },
109
+ ];
110
+ }
111
+
112
+ function commandsIn(group) {
113
+ if (!isPlainObject(group)) return [];
114
+ const out = [];
115
+ if (typeof group.command === "string") out.push(group.command);
116
+ const hooks = Array.isArray(group.hooks) ? group.hooks : [];
117
+ for (const h of hooks) {
118
+ if (isPlainObject(h) && typeof h.command === "string") out.push(h.command);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function needleOf(group) {
124
+ for (const c of commandsIn(group)) {
125
+ const m = String(c).match(/([\w.-]+\.mjs|orchestrate-parent\.js)/);
126
+ if (m) return m[1];
127
+ }
128
+ return commandsIn(group)[0] || JSON.stringify(group);
129
+ }
130
+
131
+ function upsertGroups(existing, desired) {
132
+ const prev = Array.isArray(existing) ? existing : [];
133
+ const desiredNeedles = new Set(desired.map(needleOf));
134
+ const extras = prev.filter((g) => !desiredNeedles.has(needleOf(g)));
135
+ return [...desired, ...extras];
136
+ }
137
+
138
+ function uniq(arr) {
139
+ const seen = new Set();
140
+ const out = [];
141
+ for (const x of arr) {
142
+ if (seen.has(x)) continue;
143
+ seen.add(x);
144
+ out.push(x);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function loadJson(p) {
150
+ if (!fs.existsSync(p)) return {};
151
+ const raw = fs.readFileSync(p, "utf8");
152
+ if (!String(raw).trim()) return {};
153
+ const v = JSON.parse(raw);
154
+ return isPlainObject(v) ? v : {};
155
+ }
156
+
157
+ function saveJson(p, obj) {
158
+ fs.mkdirSync(path.dirname(p), { recursive: true });
159
+ fs.writeFileSync(p, `${JSON.stringify(obj, null, 2)}\n`);
160
+ }
161
+
162
+ function desiredPre(root) {
163
+ const pre = [];
164
+ if (hasScript(root, "check-behind.mjs")) {
165
+ pre.push({ matcher: "Write|Edit", hooks: [hookCmd(BEHIND)] });
166
+ }
167
+ pre.push(...gruntPreToolUse());
168
+ return pre;
169
+ }
170
+
171
+ function desiredSession(root) {
172
+ const hooks = [];
173
+ if (hasScript(root, "daily-pull-check.mjs")) hooks.push(hookCmd(PULL));
174
+ if (hasScript(root, "rtk-check.mjs")) hooks.push(hookCmd(RTK));
175
+ if (hasScript(root, "daily-fleet-check.mjs")) hooks.push(hookCmd(FLEET));
176
+ return hooks.length ? [{ hooks }] : null;
177
+ }
178
+
179
+ function desiredPost(root) {
180
+ const hooks = [];
181
+ if (hasScript(root, "validate.mjs")) hooks.push(hookCmd(VALIDATE));
182
+ if (hasScript(root, "sim.mjs")) hooks.push(hookCmd(SIM));
183
+ return hooks.length ? [{ matcher: "Write|Edit", hooks }] : null;
184
+ }
185
+
186
+ function consumerNamed(root) {
187
+ const named = {};
188
+ if (hasScript(root, "daily-pull-check.mjs")) {
189
+ named["daily-pull-all"] = { event: "SessionStart", command: PULL };
190
+ }
191
+ if (hasScript(root, "rtk-check.mjs")) {
192
+ named["rtk-check"] = { event: "SessionStart", command: RTK };
193
+ }
194
+ if (hasScript(root, "daily-fleet-check.mjs")) {
195
+ named["daily-fleet-digest"] = { event: "SessionStart", command: FLEET };
196
+ }
197
+ if (hasScript(root, "check-behind.mjs")) {
198
+ named["check-behind"] = { event: "PreToolUse", matcher: "Write|Edit", command: BEHIND };
199
+ }
200
+ named["scrub-spawn-prompt"] = {
201
+ event: "PreToolUse",
202
+ matcher: "spawn_subagent|Task|Agent|spawn_agent",
203
+ command: SCRUB,
204
+ timeout: 5,
205
+ };
206
+ named["gate-fat-tools"] = {
207
+ event: "PreToolUse",
208
+ matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
209
+ command: GATE_FAT,
210
+ timeout: 5,
211
+ };
212
+ if (hasScript(root, "validate.mjs")) {
213
+ named["validate-artifact"] = { event: "PostToolUse", matcher: ".*", command: VALIDATE };
214
+ }
215
+ if (hasScript(root, "sim.mjs")) {
216
+ named["simulate-artifact"] = { event: "PostToolUse", matcher: ".*", command: SIM };
217
+ }
218
+ return named;
219
+ }
220
+
221
+ function applyClaude(root) {
222
+ const abs = path.join(root, ".claude/settings.json");
223
+ const live = loadJson(abs);
224
+ const hooks = isPlainObject(live.hooks) ? { ...live.hooks } : {};
225
+ hooks.PreToolUse = upsertGroups(hooks.PreToolUse, desiredPre(root));
226
+ const session = desiredSession(root);
227
+ if (session) hooks.SessionStart = session;
228
+ const post = desiredPost(root);
229
+ if (post) hooks.PostToolUse = upsertGroups(hooks.PostToolUse, post);
230
+ const permissions = isPlainObject(live.permissions) ? { ...live.permissions } : {};
231
+ const deny = Array.isArray(permissions.deny) ? [...permissions.deny] : [];
232
+ permissions.deny = hasPlatform(root) ? uniq([...deny, ...PLATFORM_DENY]) : deny;
233
+ const next = { ...live, permissions, hooks };
234
+ saveJson(abs, next);
235
+ }
236
+
237
+ function applyCodex(root) {
238
+ const abs = path.join(root, ".codex/hooks.json");
239
+ const live = loadJson(abs);
240
+ const nested = isPlainObject(live.hooks);
241
+ const cur = nested ? { ...live.hooks } : { ...live };
242
+ cur.PreToolUse = upsertGroups(cur.PreToolUse, desiredPre(root));
243
+ const session = desiredSession(root);
244
+ if (session) cur.SessionStart = session;
245
+ const post = desiredPost(root);
246
+ if (post) {
247
+ const codexPost = post.map((g) => ({ hooks: g.hooks }));
248
+ cur.PostToolUse = upsertGroups(cur.PostToolUse, codexPost);
249
+ }
250
+ if (nested) saveJson(abs, { ...live, hooks: cur });
251
+ else saveJson(abs, { ...live, ...cur });
252
+ }
253
+
254
+ function applyAgents(root) {
255
+ const abs = path.join(root, ".agents/hooks.json");
256
+ const live = loadJson(abs);
257
+ const rulesync = isPlainObject(live.rulesync) ? { ...live.rulesync } : {};
258
+ rulesync.PreToolUse = upsertGroups(rulesync.PreToolUse, desiredPre(root));
259
+ const session = desiredSession(root);
260
+ if (session) {
261
+ rulesync.SessionStart = [{ matcher: ".*", hooks: session[0].hooks }];
262
+ }
263
+ const post = desiredPost(root);
264
+ if (post) {
265
+ rulesync.PostToolUse = [{ matcher: ".*", hooks: post[0].hooks }];
266
+ }
267
+ const named = presentAutorun(root).length ? consumerNamed(root) : {};
268
+ saveJson(abs, { ...live, ...named, rulesync });
269
+ }
270
+
271
+ function applyGemini(root) {
272
+ if (!presentAutorun(root).length) return;
273
+ const abs = path.join(root, ".gemini/settings.json");
274
+ const live = loadJson(abs);
275
+ const session = desiredSession(root);
276
+ const post = desiredPost(root);
277
+ const hooks = isPlainObject(live.hooks) ? { ...live.hooks } : {};
278
+ if (session) hooks.SessionStart = session;
279
+ if (post) {
280
+ hooks.AfterTool = [{ matcher: "write_file|replace", hooks: post[0].hooks }];
281
+ }
282
+ saveJson(abs, {
283
+ ...live,
284
+ _caveat_check_behind: CAVEAT_BEHIND,
285
+ _caveat_permissions: CAVEAT_PERMS,
286
+ hooks,
287
+ });
288
+ }
289
+
290
+ export function applyUnion(opts = {}) {
291
+ const root = resolveRoot(opts);
292
+ const check = typeof opts === "object" && opts && opts.check === true;
293
+ if (check) {
294
+ const failures = checkUnion({ workspaceRoot: root });
295
+ if (failures.length) {
296
+ return { ok: false, check: true, error: `drift: ${failures.join("; ")}`, failures };
297
+ }
298
+ return { ok: true, check: true, failures: [] };
299
+ }
300
+ applyClaude(root);
301
+ applyCodex(root);
302
+ applyAgents(root);
303
+ applyGemini(root);
304
+ return { ok: true, check: false };
305
+ }
306
+
307
+ export function checkUnion(opts = {}) {
308
+ const root = resolveRoot(opts);
309
+ const failures = [];
310
+ const autorun = presentAutorun(root);
311
+ const files = [
312
+ {
313
+ id: "claude",
314
+ path: ".claude/settings.json",
315
+ needles: [
316
+ ...GRUNT_NEEDLES,
317
+ ...autorun,
318
+ ...(hasPlatform(root) ? PLATFORM_DENY : []),
319
+ ],
320
+ },
321
+ {
322
+ id: "codex",
323
+ path: ".codex/hooks.json",
324
+ needles: [...GRUNT_NEEDLES, ...autorun],
325
+ },
326
+ {
327
+ id: "agents",
328
+ path: ".agents/hooks.json",
329
+ needles: [...GRUNT_NEEDLES, ...autorun],
330
+ },
331
+ ];
332
+ if (autorun.length) {
333
+ files.push({
334
+ id: "gemini",
335
+ path: ".gemini/settings.json",
336
+ needles: [
337
+ ...autorun.filter((n) => n !== "check-behind.mjs"),
338
+ "_caveat_check_behind",
339
+ "_caveat_permissions",
340
+ ],
341
+ });
342
+ }
343
+ for (const spec of files) {
344
+ const p = path.join(root, spec.path);
345
+ if (!fs.existsSync(p)) {
346
+ failures.push(`${spec.id}: missing ${spec.path}`);
347
+ continue;
348
+ }
349
+ const text = fs.readFileSync(p, "utf8");
350
+ for (const n of spec.needles) {
351
+ if (!text.includes(n)) failures.push(`${spec.id}: missing ${n}`);
352
+ }
353
+ }
354
+ const gitDir = path.join(root, "scripts/git-hooks");
355
+ if (fs.existsSync(gitDir)) {
356
+ for (const rel of GIT_HOOKS) {
357
+ if (!fs.existsSync(path.join(root, rel))) failures.push(`git-hook missing: ${rel}`);
358
+ }
359
+ }
360
+ return failures;
361
+ }
362
+
363
+ function main() {
364
+ try {
365
+ const parsed = parseArgv(process.argv.slice(2));
366
+ if (!parsed.ok) {
367
+ process.stderr.write((parsed.error || "invalid argv") + "\n");
368
+ return 1;
369
+ }
370
+ if (!parsed.check) {
371
+ applyUnion({ workspaceRoot: process.cwd() });
372
+ process.stdout.write("hooks-union: applied\n");
373
+ }
374
+ const failures = checkUnion({ workspaceRoot: process.cwd() });
375
+ if (failures.length) {
376
+ for (const f of failures) process.stderr.write(`hooks-union FAIL ${f}\n`);
377
+ return 1;
378
+ }
379
+ process.stdout.write("hooks-union: check ok\n");
380
+ return 0;
381
+ } catch (err) {
382
+ process.stderr.write(String(err && err.message ? err.message : err) + "\n");
383
+ return 1;
384
+ }
385
+ }
386
+
387
+ const thisFile = fileURLToPath(import.meta.url);
388
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
389
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
390
+ process.exit(main());
391
+ }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /** Allocate/slugify/validate/write a session handoff under .tmp/grunt/handoffs/. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import {
7
+ CREATED_RE,
8
+ DATETIME_RE,
9
+ SERIAL_LINE_RE,
10
+ compactCreatedStamp,
11
+ ensureGitignore,
12
+ nextSerial,
13
+ slugify,
14
+ utcDateTime,
15
+ } from "./persist-plan.mjs";
16
+
17
+ export const HANDOFF_DIR = ".tmp/grunt/handoffs";
18
+ /** `{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md`; stamp required for new writes. */
19
+ export const FILENAME_RE = /^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*-\d{8}T\d{6}Z\.md$/;
20
+ export const STATUS_RE = /^(open|resumed|done)$/;
21
+ export const FILENAME_STAMP_RE = /-\d{8}T\d{6}Z$/;
22
+ export const REQUIRED_H2 = ["Goal", "State", "Context", "Next", "Watch-outs"];
23
+ const BOX_RE = /^( {0,2})(\d+(?:\.\d+){0,2}) \[([ xX])\] (.+)$/;
24
+ const LEAF_ID_RE = /^\d+\.\d+(?:\.\d+)?$/;
25
+
26
+ export function slugFromFilename(filename) {
27
+ return String(filename)
28
+ .replace(/\.md$/, "")
29
+ .replace(/^[0-9]+-/, "")
30
+ .replace(FILENAME_STAMP_RE, "");
31
+ }
32
+
33
+ export function extractHandoffName(content) {
34
+ const m = String(content).match(/^\s*HANDOFF_NAME:\s*(.+?)\s*$/m);
35
+ return m ? m[1].trim() : "";
36
+ }
37
+
38
+ export function stripHandoffName(content) {
39
+ return String(content).replace(/^\s*HANDOFF_NAME:\s*.+\s*\n/, "");
40
+ }
41
+
42
+ export function extractH1(content) {
43
+ const m = String(content).match(/^#\s+(.+?)\s*$/m);
44
+ return m ? m[1].trim() : "";
45
+ }
46
+
47
+ function stripFrontmatter(text) {
48
+ const s = String(text).replace(/^\uFEFF/, "");
49
+ if (!s.startsWith("---\n")) return s;
50
+ const end = s.indexOf("\n---\n", 4);
51
+ if (end === -1) return s;
52
+ return s.slice(end + 5);
53
+ }
54
+
55
+ export function injectFrontmatter({ serial, slug, source, body, created }) {
56
+ const src = String(source || "").slice(0, 120);
57
+ let rest = stripFrontmatter(body).replace(/^\s+/, "");
58
+ if (!rest.startsWith("# ")) {
59
+ rest = `# ${slug}\n\n` + rest;
60
+ } else {
61
+ rest = rest.replace(/^# .+/, `# ${slug}`);
62
+ }
63
+ return `---
64
+ serial: ${serial}
65
+ name: ${slug}
66
+ status: open
67
+ created: ${created || utcDateTime()}
68
+ source: ${JSON.stringify(src)}
69
+ ---
70
+
71
+ ${rest}`;
72
+ }
73
+
74
+ function sectionBodies(text) {
75
+ const h2 = [...text.matchAll(/^## (.+)$/gm)].map((m) => ({
76
+ title: m[1].trim(),
77
+ index: m.index,
78
+ }));
79
+ const bodies = {};
80
+ for (let i = 0; i < h2.length; i++) {
81
+ const start = h2[i].index + h2[i].title.length + 4;
82
+ const end = i + 1 < h2.length ? h2[i + 1].index : text.length;
83
+ bodies[h2[i].title] = text.slice(start, end);
84
+ }
85
+ return { order: h2.map((h) => h.title), bodies };
86
+ }
87
+
88
+ function parseFrontmatter(text) {
89
+ if (!text.startsWith("---\n")) return { error: "missing frontmatter" };
90
+ const end = text.indexOf("\n---\n", 4);
91
+ if (end === -1) return { error: "unterminated frontmatter" };
92
+ const raw = text.slice(4, end);
93
+ const map = {};
94
+ for (const line of raw.split("\n")) {
95
+ const m = line.match(/^([a-z]+):(?:\s+(.*))?$/);
96
+ if (!m) continue;
97
+ let v = m[2] ?? "";
98
+ if (v.startsWith('"') && v.endsWith('"')) {
99
+ try {
100
+ v = JSON.parse(v);
101
+ } catch {
102
+ v = v.slice(1, -1);
103
+ }
104
+ }
105
+ map[m[1]] = v;
106
+ }
107
+ return { map, body: text.slice(end + 5) };
108
+ }
109
+
110
+ export function validateHandoff(filename, text) {
111
+ const errors = [];
112
+ if (!FILENAME_RE.test(filename)) errors.push("filename");
113
+ const fm = parseFrontmatter(text);
114
+ if (fm.error) {
115
+ errors.push(fm.error);
116
+ return errors;
117
+ }
118
+ const { map, body } = fm;
119
+ const serialLine = text.split("\n").find((l) => l.startsWith("serial:"));
120
+ if (!serialLine || !SERIAL_LINE_RE.test(serialLine)) errors.push("serial line");
121
+ for (const k of ["serial", "name", "status", "created", "source"]) {
122
+ if (map[k] == null || map[k] === "") errors.push(`missing ${k}`);
123
+ }
124
+ if (map.status && !STATUS_RE.test(map.status)) errors.push("status");
125
+ if (map.created && !CREATED_RE.test(map.created)) errors.push("created");
126
+ if (typeof map.source === "string" && map.source.length > 120) {
127
+ errors.push("source length");
128
+ }
129
+ const slug = map.name;
130
+ if (slug && slugFromFilename(filename) !== slug) errors.push("name/filename slug");
131
+ if (!body.trim().startsWith(`# ${slug}`)) errors.push("h1 slug");
132
+ const { order, bodies } = sectionBodies(body);
133
+ const firstFive = order.slice(0, REQUIRED_H2.length);
134
+ if (REQUIRED_H2.some((h, i) => firstFive[i] !== h)) errors.push("heading order");
135
+ const extra = order.filter((h) => !REQUIRED_H2.includes(h));
136
+ if (extra.length) errors.push("extra h2");
137
+ const goal = (bodies.Goal || "").trim();
138
+ const sentences = goal.split(/(?<=[.!?])(?:\s+|$)/).filter((s) => s.trim());
139
+ if (!goal || sentences.length < 1 || sentences.length > 3) errors.push("goal");
140
+ for (const h of ["State", "Context", "Watch-outs"]) {
141
+ const b = (bodies[h] || "").trim();
142
+ if (!b) errors.push(`${h} empty`);
143
+ else if (!/^(?:[-*] |\(none\))/m.test(b)) errors.push(`${h} bullets`);
144
+ }
145
+ const boxes = [];
146
+ for (const line of (bodies.Next || "").split("\n")) {
147
+ const m = line.match(BOX_RE);
148
+ if (!m) continue;
149
+ if (m[3] !== " ") errors.push("fresh [x]");
150
+ boxes.push({ id: m[2], text: m[4] });
151
+ }
152
+ const leaves = boxes.filter((b) => LEAF_ID_RE.test(b.id));
153
+ if (leaves.length < 1) errors.push("no N.M leaf");
154
+ const phases = boxes.filter((b) => /^\d+$/.test(b.id));
155
+ for (const p of phases) {
156
+ if (!boxes.some((b) => b.id.startsWith(p.id + "."))) errors.push(`phase ${p.id} leaf`);
157
+ }
158
+ return [...new Set(errors)];
159
+ }
160
+
161
+ export function persistHandoff({ workspaceRoot, content, source, created } = {}) {
162
+ const ws = workspaceRoot || process.cwd();
163
+ const raw = String(content || "");
164
+ const name =
165
+ extractHandoffName(raw) || extractH1(stripFrontmatter(stripHandoffName(raw)));
166
+ const slug = slugify(name);
167
+ const body = stripHandoffName(raw);
168
+ const dir = path.join(ws, HANDOFF_DIR);
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ const createdAt = created && DATETIME_RE.test(created) ? created : utcDateTime();
171
+ const stamp = compactCreatedStamp(createdAt);
172
+ let serial = nextSerial(dir);
173
+ let filename = `${serial}-${slug}-${stamp}.md`;
174
+ let dest = path.join(dir, filename);
175
+ let tries = 0;
176
+ while (fs.existsSync(dest) && tries < 5) {
177
+ serial += 1;
178
+ filename = `${serial}-${slug}-${stamp}.md`;
179
+ dest = path.join(dir, filename);
180
+ tries += 1;
181
+ }
182
+ if (fs.existsSync(dest)) return { ok: false, error: "serial collision" };
183
+ const goalMatch = stripFrontmatter(body).match(/## Goal\n([\s\S]*?)\n## /);
184
+ const sourceText = String(
185
+ source ||
186
+ name ||
187
+ (goalMatch ? goalMatch[1].trim().replace(/\s+/g, " ") : slug),
188
+ ).slice(0, 120);
189
+ const full = injectFrontmatter({
190
+ serial,
191
+ slug,
192
+ source: sourceText,
193
+ body,
194
+ created: createdAt,
195
+ });
196
+ const errors = validateHandoff(filename, full);
197
+ if (errors.length) return { ok: false, error: errors.join("; "), errors };
198
+ ensureGitignore(ws);
199
+ fs.writeFileSync(dest, full);
200
+ return { ok: true, path: dest, serial, slug, content: full, filename };
201
+ }
202
+
203
+ function main() {
204
+ try {
205
+ const wsIdx = process.argv.indexOf("--workspace");
206
+ const workspaceRoot = wsIdx >= 0 ? process.argv[wsIdx + 1] : process.cwd();
207
+ const content = fs.readFileSync(0, "utf8");
208
+ const result = persistHandoff({ workspaceRoot, content });
209
+ if (!result.ok) {
210
+ process.stderr.write((result.error || "invalid handoff") + "\n");
211
+ return 1;
212
+ }
213
+ process.stdout.write(
214
+ JSON.stringify({
215
+ serial: result.serial,
216
+ path: result.path,
217
+ filename: result.filename,
218
+ }) + "\n",
219
+ );
220
+ return 0;
221
+ } catch (err) {
222
+ process.stderr.write(String(err && err.message ? err.message : err) + "\n");
223
+ return 1;
224
+ }
225
+ }
226
+
227
+ const thisFile = fileURLToPath(import.meta.url);
228
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
229
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
230
+ process.exit(main());
231
+ }