@lovrozagar/grunt 0.5.0 → 0.6.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.
Files changed (153) hide show
  1. package/.agents/agents/grunt.md +2 -23
  2. package/.agents/agents/orchestrator.md +5 -45
  3. package/.agents/skills/ask/SKILL.md +11 -0
  4. package/.agents/skills/auto/SKILL.md +13 -0
  5. package/.agents/skills/browser/SKILL.md +4 -7
  6. package/.agents/skills/clasp/SKILL.md +11 -0
  7. package/.agents/skills/commit/SKILL.md +3 -1
  8. package/.agents/skills/commit-push/SKILL.md +2 -0
  9. package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
  10. package/.agents/skills/commit-push-release/SKILL.md +2 -0
  11. package/.agents/skills/explain/SKILL.md +7 -13
  12. package/.agents/skills/google-workspace/SKILL.md +13 -0
  13. package/.agents/skills/handoff/SKILL.md +7 -11
  14. package/.agents/skills/implement-plan/SKILL.md +5 -108
  15. package/.agents/skills/listen/SKILL.md +15 -0
  16. package/.agents/skills/pickup/SKILL.md +6 -12
  17. package/.agents/skills/speak/SKILL.md +13 -0
  18. package/.agents/skills/tmp/SKILL.md +54 -0
  19. package/.agents/skills/write-plan/SKILL.md +5 -74
  20. package/.claude/agents/grunt.md +2 -25
  21. package/.claude/agents/orchestrator.md +12 -45
  22. package/.claude/skills/ask/SKILL.md +11 -0
  23. package/.claude/skills/auto/SKILL.md +13 -0
  24. package/.claude/skills/browser/SKILL.md +4 -7
  25. package/.claude/skills/clasp/SKILL.md +11 -0
  26. package/.claude/skills/commit/SKILL.md +3 -1
  27. package/.claude/skills/commit-push/SKILL.md +2 -0
  28. package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
  29. package/.claude/skills/commit-push-release/SKILL.md +2 -0
  30. package/.claude/skills/explain/SKILL.md +7 -13
  31. package/.claude/skills/google-workspace/SKILL.md +13 -0
  32. package/.claude/skills/handoff/SKILL.md +7 -11
  33. package/.claude/skills/implement-plan/SKILL.md +5 -108
  34. package/.claude/skills/listen/SKILL.md +15 -0
  35. package/.claude/skills/pickup/SKILL.md +6 -12
  36. package/.claude/skills/speak/SKILL.md +13 -0
  37. package/.claude/skills/tmp/SKILL.md +54 -0
  38. package/.claude/skills/write-plan/SKILL.md +5 -74
  39. package/.codex/agents/grunt.toml +2 -25
  40. package/.codex/agents/orchestrator.toml +4 -45
  41. package/.grok/agents/grunt.md +2 -23
  42. package/.grok/agents/orchestrator.md +4 -43
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +278 -228
  45. package/.grok/skills/ask/SKILL.md +11 -0
  46. package/.grok/skills/auto/SKILL.md +13 -0
  47. package/.grok/skills/browser/SKILL.md +4 -7
  48. package/.grok/skills/clasp/SKILL.md +11 -0
  49. package/.grok/skills/commit/SKILL.md +3 -1
  50. package/.grok/skills/commit-push/SKILL.md +2 -0
  51. package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
  52. package/.grok/skills/commit-push-release/SKILL.md +2 -0
  53. package/.grok/skills/explain/SKILL.md +7 -13
  54. package/.grok/skills/google-workspace/SKILL.md +13 -0
  55. package/.grok/skills/handoff/SKILL.md +7 -11
  56. package/.grok/skills/implement-plan/SKILL.md +5 -108
  57. package/.grok/skills/listen/SKILL.md +15 -0
  58. package/.grok/skills/pickup/SKILL.md +6 -12
  59. package/.grok/skills/speak/SKILL.md +13 -0
  60. package/.grok/skills/tmp/SKILL.md +54 -0
  61. package/.grok/skills/write-plan/SKILL.md +5 -74
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -17
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -84
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -9
  70. package/.rulesync/reference/implementation-format.md +113 -0
  71. package/.rulesync/reference/law.md +5 -3
  72. package/.rulesync/reference/listen.md +80 -0
  73. package/.rulesync/reference/map.md +39 -31
  74. package/.rulesync/reference/output.md +3 -41
  75. package/.rulesync/reference/plan-format.md +14 -12
  76. package/.rulesync/reference/refs-map.md +12 -5
  77. package/.rulesync/reference/rtk.md +3 -2
  78. package/.rulesync/reference/scope.md +37 -0
  79. package/.rulesync/reference/setup.md +27 -0
  80. package/.rulesync/reference/skills-map.md +13 -9
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -44
  83. package/.rulesync/rules/overview.md +9 -42
  84. package/.rulesync/skills/ask/SKILL.md +11 -0
  85. package/.rulesync/skills/auto/SKILL.md +11 -0
  86. package/.rulesync/skills/browser/SKILL.md +4 -8
  87. package/.rulesync/skills/clasp/SKILL.md +9 -0
  88. package/.rulesync/skills/commit/SKILL.md +3 -1
  89. package/.rulesync/skills/commit-push/SKILL.md +2 -0
  90. package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
  91. package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
  92. package/.rulesync/skills/explain/SKILL.md +7 -14
  93. package/.rulesync/skills/google-workspace/SKILL.md +11 -0
  94. package/.rulesync/skills/handoff/SKILL.md +7 -11
  95. package/.rulesync/skills/implement-plan/SKILL.md +5 -116
  96. package/.rulesync/skills/listen/SKILL.md +13 -0
  97. package/.rulesync/skills/pickup/SKILL.md +6 -12
  98. package/.rulesync/skills/speak/SKILL.md +13 -0
  99. package/.rulesync/skills/tmp/SKILL.md +54 -0
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -80
  101. package/.rulesync/subagents/grunt.md +2 -23
  102. package/.rulesync/subagents/orchestrator.md +14 -54
  103. package/AGENTS.md +9 -42
  104. package/CHANGELOG.md +89 -0
  105. package/CLAUDE.md +1 -44
  106. package/README.md +97 -88
  107. package/cli/grunt.mjs +29 -13
  108. package/cli/grunt.test.ts +62 -20
  109. package/cli/init.mjs +218 -31
  110. package/cli/init.test.ts +669 -46
  111. package/cli/prompt.mjs +8 -0
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +13 -1
  114. package/scripts/browser.mjs +98 -5
  115. package/scripts/doctor.mjs +82 -1
  116. package/scripts/emit-gemini.mjs +1 -1
  117. package/scripts/emit-mcp-policy.mjs +1 -1
  118. package/scripts/gate-fat-tools.mjs +223 -173
  119. package/scripts/google-workspace.mjs +713 -0
  120. package/scripts/grunt-job.mjs +453 -69
  121. package/scripts/listen.mjs +829 -0
  122. package/scripts/parse-need.mjs +21 -2
  123. package/scripts/persist-implementation.mjs +278 -0
  124. package/scripts/persist-plan.mjs +17 -2
  125. package/scripts/persist-tmp.mjs +123 -0
  126. package/scripts/scrub-spawn-prompt.mjs +4 -51
  127. package/scripts/setup.mjs +746 -0
  128. package/scripts/speak.mjs +291 -0
  129. package/.agents/agents/implementer.md +0 -32
  130. package/.agents/agents/thinker.md +0 -28
  131. package/.agents/skills/cascade/SKILL.md +0 -26
  132. package/.agents/skills/parent/SKILL.md +0 -6
  133. package/.agents/skills/solo/SKILL.md +0 -23
  134. package/.claude/agents/implementer.md +0 -37
  135. package/.claude/agents/thinker.md +0 -35
  136. package/.claude/skills/cascade/SKILL.md +0 -26
  137. package/.claude/skills/parent/SKILL.md +0 -6
  138. package/.claude/skills/solo/SKILL.md +0 -23
  139. package/.codex/agents/implementer.toml +0 -26
  140. package/.codex/agents/thinker.toml +0 -25
  141. package/.grok/agents/implementer.md +0 -27
  142. package/.grok/agents/thinker.md +0 -26
  143. package/.grok/parent.md +0 -1
  144. package/.grok/roles/implementer.toml +0 -5
  145. package/.grok/roles/thinker.toml +0 -5
  146. package/.grok/skills/cascade/SKILL.md +0 -26
  147. package/.grok/skills/parent/SKILL.md +0 -6
  148. package/.grok/skills/solo/SKILL.md +0 -23
  149. package/.rulesync/skills/cascade/SKILL.md +0 -26
  150. package/.rulesync/skills/parent/SKILL.md +0 -6
  151. package/.rulesync/skills/solo/SKILL.md +0 -23
  152. package/.rulesync/subagents/implementer.md +0 -49
  153. package/.rulesync/subagents/thinker.md +0 -48
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
- export const NEED_JOBS = new Set(["search", "exec", "web", "test"]);
7
+ export const NEED_JOBS = new Set(["search", "exec", "web", "test", "slice", "fetch"]);
8
8
 
9
9
  export function parseNeed(text) {
10
10
  const s = String(text ?? "");
@@ -28,10 +28,20 @@ export function parseNeed(text) {
28
28
  }
29
29
  const job = String(item.job ?? "").trim();
30
30
  const query = String(item.query ?? "").trim();
31
- if (!NEED_JOBS.has(job) || !query) {
31
+ const stash = item.stash != null ? String(item.stash).trim() : "";
32
+ if (!NEED_JOBS.has(job)) {
33
+ return { ok: false, error: "invalid job or query" };
34
+ }
35
+ if (job === "slice") {
36
+ if (!stash && !query) {
37
+ return { ok: false, error: "invalid job or query" };
38
+ }
39
+ } else if (!query) {
32
40
  return { ok: false, error: "invalid job or query" };
33
41
  }
34
42
  const row = { job, query };
43
+ if (job === "slice" && stash) row.stash = stash;
44
+ else if (stash) row.stash = stash;
35
45
  if (item.path != null && String(item.path).trim()) {
36
46
  row.path = String(item.path).trim();
37
47
  }
@@ -43,6 +53,15 @@ export function parseNeed(text) {
43
53
  ? item.glob.map((g) => String(g))
44
54
  : [String(item.glob)];
45
55
  }
56
+ if (item.from != null && String(item.from).trim()) {
57
+ row.from = String(item.from).trim();
58
+ }
59
+ if (item.to != null && String(item.to).trim()) {
60
+ row.to = String(item.to).trim();
61
+ }
62
+ if (job === "slice" && !row.stash && query) {
63
+ row.stash = query;
64
+ }
46
65
  jobs.push(row);
47
66
  }
48
67
  return { ok: true, jobs };
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ /** Allocate/slugify/validate/write a session journal under .tmp/grunt/implementations/. */
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 IMPLEMENTATION_DIR = ".tmp/grunt/implementations";
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 = /^(in-progress|done|blocked)$/;
21
+ export const PLAN_VALUE_RE = /^(none|[1-9][0-9]*)$/;
22
+ export const FILENAME_STAMP_RE = /-\d{8}T\d{6}Z$/;
23
+ export const REQUIRED_H2 = ["Done", "Issue", "Files", "Log", "Blockers", "Notes"];
24
+ const META_LINE_RE = /^\s*IMPL_(NAME|PLAN):\s*.+\s*$/;
25
+ const NAME_RE = /^\s*IMPL_NAME:\s*(.+?)\s*$/m;
26
+ const PLAN_RE = /^\s*IMPL_PLAN:\s*(.+?)\s*$/m;
27
+
28
+ export function slugFromFilename(filename) {
29
+ return String(filename)
30
+ .replace(/\.md$/, "")
31
+ .replace(/^[0-9]+-/, "")
32
+ .replace(FILENAME_STAMP_RE, "");
33
+ }
34
+
35
+ export function extractImplName(content) {
36
+ const m = String(content).match(NAME_RE);
37
+ return m ? m[1].trim() : "";
38
+ }
39
+
40
+ export function extractImplPlan(content) {
41
+ const m = String(content).match(PLAN_RE);
42
+ if (!m) return "none";
43
+ const v = m[1].trim().toLowerCase();
44
+ if (v === "none" || v === "") return "none";
45
+ if (/^[1-9][0-9]*$/.test(v)) return v;
46
+ return "";
47
+ }
48
+
49
+ export function stripImplMeta(content) {
50
+ return String(content)
51
+ .replace(/^\uFEFF/, "")
52
+ .split(/\r?\n/)
53
+ .filter((line) => !META_LINE_RE.test(line))
54
+ .join("\n");
55
+ }
56
+
57
+ export function extractH1(content) {
58
+ const m = String(content).match(/^#\s+(.+?)\s*$/m);
59
+ return m ? m[1].trim() : "";
60
+ }
61
+
62
+ export function isFullImplementationBody(content) {
63
+ const s = String(content || "");
64
+ return /^---\n/.test(s) || /^# /m.test(s) || /^## Done\b/m.test(s);
65
+ }
66
+
67
+ function stripFrontmatter(text) {
68
+ const s = String(text).replace(/^\uFEFF/, "");
69
+ if (!s.startsWith("---\n")) return s;
70
+ const end = s.indexOf("\n---\n", 4);
71
+ if (end === -1) return s;
72
+ return s.slice(end + 5);
73
+ }
74
+
75
+ export function injectFrontmatter({ serial, slug, source, body, created, plan }) {
76
+ const src = String(source || "").slice(0, 120);
77
+ const planVal = PLAN_VALUE_RE.test(String(plan || "")) ? String(plan) : "none";
78
+ let rest = stripFrontmatter(body).replace(/^\s+/, "");
79
+ if (!rest.startsWith("# ")) {
80
+ rest = `# ${slug}\n\n` + rest;
81
+ } else {
82
+ rest = rest.replace(/^# .+/, `# ${slug}`);
83
+ }
84
+ return `---
85
+ serial: ${serial}
86
+ plan: ${planVal}
87
+ name: ${slug}
88
+ status: in-progress
89
+ created: ${created || utcDateTime()}
90
+ source: ${JSON.stringify(src)}
91
+ ---
92
+
93
+ ${rest}`;
94
+ }
95
+
96
+ function sectionBodies(text) {
97
+ const h2 = [...text.matchAll(/^## (.+)$/gm)].map((m) => ({
98
+ title: m[1].trim(),
99
+ index: m.index,
100
+ }));
101
+ const bodies = {};
102
+ for (let i = 0; i < h2.length; i++) {
103
+ const start = h2[i].index + h2[i].title.length + 4;
104
+ const end = i + 1 < h2.length ? h2[i + 1].index : text.length;
105
+ bodies[h2[i].title] = text.slice(start, end);
106
+ }
107
+ return { order: h2.map((h) => h.title), bodies };
108
+ }
109
+
110
+ function parseFrontmatter(text) {
111
+ if (!text.startsWith("---\n")) return { error: "missing frontmatter" };
112
+ const end = text.indexOf("\n---\n", 4);
113
+ if (end === -1) return { error: "unterminated frontmatter" };
114
+ const raw = text.slice(4, end);
115
+ const map = {};
116
+ for (const line of raw.split("\n")) {
117
+ const m = line.match(/^([a-z]+):(?:\s+(.*))?$/);
118
+ if (!m) continue;
119
+ let v = m[2] ?? "";
120
+ if (v.startsWith('"') && v.endsWith('"')) {
121
+ try {
122
+ v = JSON.parse(v);
123
+ } catch {
124
+ v = v.slice(1, -1);
125
+ }
126
+ }
127
+ map[m[1]] = v;
128
+ }
129
+ return { map, body: text.slice(end + 5) };
130
+ }
131
+
132
+ function bulletsOrNone(body, label) {
133
+ const b = String(body || "").trim();
134
+ if (!b) return `${label} empty`;
135
+ if (b === "(none)") return "";
136
+ const lines = b.split("\n").map((l) => l.trim()).filter(Boolean);
137
+ if (!lines.length) return `${label} empty`;
138
+ for (const line of lines) {
139
+ if (line === "(none)") continue;
140
+ if (label === "Log" && /^\d+\.\d+(?:\.\d+)? \[[ xX]\] /.test(line)) continue;
141
+ if (!/^[-*] /.test(line)) return `${label} bullets`;
142
+ }
143
+ return "";
144
+ }
145
+
146
+ function filesError(body) {
147
+ const b = String(body || "").trim();
148
+ if (!b) return "Files empty";
149
+ if (b === "(none)") return "";
150
+ for (const line of b.split("\n")) {
151
+ const t = line.trim();
152
+ if (!t || t === "(none)") continue;
153
+ if (!/^[-*] /.test(t)) return "Files bullets";
154
+ const p = t.replace(/^[-*] /, "").trim();
155
+ if (!p) return "Files bullets";
156
+ if (p.startsWith("/") || /^[A-Za-z]:/.test(p)) return "Files abs";
157
+ if (p.startsWith(".tmp/") || p.includes("/.tmp/")) return "Files tmp";
158
+ }
159
+ return "";
160
+ }
161
+
162
+ export function validateImplementation(filename, text) {
163
+ const errors = [];
164
+ if (!FILENAME_RE.test(filename)) errors.push("filename");
165
+ const fm = parseFrontmatter(text);
166
+ if (fm.error) {
167
+ errors.push(fm.error);
168
+ return errors;
169
+ }
170
+ const { map, body } = fm;
171
+ const serialLine = text.split("\n").find((l) => l.startsWith("serial:"));
172
+ if (!serialLine || !SERIAL_LINE_RE.test(serialLine)) errors.push("serial line");
173
+ for (const k of ["serial", "plan", "name", "status", "created", "source"]) {
174
+ if (map[k] == null || map[k] === "") errors.push(`missing ${k}`);
175
+ }
176
+ if (map.status && !STATUS_RE.test(map.status)) errors.push("status");
177
+ if (map.plan && !PLAN_VALUE_RE.test(map.plan)) errors.push("plan");
178
+ if (map.created && !CREATED_RE.test(map.created)) errors.push("created");
179
+ if (typeof map.source === "string" && map.source.length > 120) {
180
+ errors.push("source length");
181
+ }
182
+ const slug = map.name;
183
+ if (slug && slugFromFilename(filename) !== slug) errors.push("name/filename slug");
184
+ if (!body.trim().startsWith(`# ${slug}`)) errors.push("h1 slug");
185
+ const { order, bodies } = sectionBodies(body);
186
+ const first = order.slice(0, REQUIRED_H2.length);
187
+ if (REQUIRED_H2.some((h, i) => first[i] !== h)) errors.push("heading order");
188
+ const extra = order.filter((h) => !REQUIRED_H2.includes(h));
189
+ if (extra.length) errors.push("extra h2");
190
+ for (const h of ["Done", "Issue", "Log", "Blockers", "Notes"]) {
191
+ const err = bulletsOrNone(bodies[h], h);
192
+ if (err) errors.push(err);
193
+ }
194
+ const ferr = filesError(bodies.Files);
195
+ if (ferr) errors.push(ferr);
196
+ const blockers = (bodies.Blockers || "").trim();
197
+ if (map.status === "blocked" && blockers === "(none)") errors.push("blocked blockers");
198
+ return [...new Set(errors)];
199
+ }
200
+
201
+ export function persistImplementation({
202
+ workspaceRoot,
203
+ content,
204
+ source,
205
+ created,
206
+ } = {}) {
207
+ const ws = workspaceRoot || process.cwd();
208
+ const raw = String(content || "");
209
+ const name = extractImplName(raw) || extractH1(stripFrontmatter(stripImplMeta(raw)));
210
+ const slug = slugify(name);
211
+ const dir = path.join(ws, IMPLEMENTATION_DIR);
212
+ fs.mkdirSync(dir, { recursive: true });
213
+ const plan = extractImplPlan(raw);
214
+ if (raw.match(PLAN_RE) && !plan) return { ok: false, error: "plan", errors: ["plan"] };
215
+ const body = stripImplMeta(raw);
216
+ const createdAt = created && DATETIME_RE.test(created) ? created : utcDateTime();
217
+ const stamp = compactCreatedStamp(createdAt);
218
+ let serial = nextSerial(dir);
219
+ let filename = `${serial}-${slug}-${stamp}.md`;
220
+ let dest = path.join(dir, filename);
221
+ let tries = 0;
222
+ while (fs.existsSync(dest) && tries < 5) {
223
+ serial += 1;
224
+ filename = `${serial}-${slug}-${stamp}.md`;
225
+ dest = path.join(dir, filename);
226
+ tries += 1;
227
+ }
228
+ if (fs.existsSync(dest)) return { ok: false, error: "serial collision" };
229
+ const doneMatch = stripFrontmatter(body).match(/## Done\n([\s\S]*?)\n## /);
230
+ const sourceText = String(
231
+ source ||
232
+ name ||
233
+ (doneMatch ? doneMatch[1].trim().replace(/\s+/g, " ") : slug),
234
+ ).slice(0, 120);
235
+ const full = injectFrontmatter({
236
+ serial,
237
+ slug,
238
+ source: sourceText,
239
+ body,
240
+ created: createdAt,
241
+ plan,
242
+ });
243
+ const errors = validateImplementation(filename, full);
244
+ if (errors.length) return { ok: false, error: errors.join("; "), errors };
245
+ ensureGitignore(ws);
246
+ fs.writeFileSync(dest, full);
247
+ return { ok: true, path: dest, serial, slug, content: full, filename, plan };
248
+ }
249
+
250
+ function main() {
251
+ try {
252
+ const wsIdx = process.argv.indexOf("--workspace");
253
+ const workspaceRoot = wsIdx >= 0 ? process.argv[wsIdx + 1] : process.cwd();
254
+ const content = fs.readFileSync(0, "utf8");
255
+ const result = persistImplementation({ workspaceRoot, content });
256
+ if (!result.ok) {
257
+ process.stderr.write((result.error || "invalid implementation") + "\n");
258
+ return 1;
259
+ }
260
+ process.stdout.write(
261
+ JSON.stringify({
262
+ serial: result.serial,
263
+ path: result.path,
264
+ filename: result.filename,
265
+ }) + "\n",
266
+ );
267
+ return 0;
268
+ } catch (err) {
269
+ process.stderr.write(String(err && err.message ? err.message : err) + "\n");
270
+ return 1;
271
+ }
272
+ }
273
+
274
+ const thisFile = fileURLToPath(import.meta.url);
275
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
276
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
277
+ process.exit(main());
278
+ }
@@ -1,10 +1,24 @@
1
1
  #!/usr/bin/env node
2
- /** Allocate/slugify/validate/write a local plan under .tmp/plans/. */
2
+ /** Allocate/slugify/validate/write a local plan under .tmp/grunt/plans/. */
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
- export const PLAN_DIR = ".tmp/plans";
7
+ export const PLAN_DIR = ".tmp/grunt/plans";
8
+ /** One-shot rename source; drop next release. */
9
+ export const LEGACY_PLAN_DIR = ".tmp/plans";
10
+
11
+ /** If `.tmp/plans` exists and `.tmp/grunt/plans` is missing, rename. Both exist: leave old. */
12
+ export function migrateLegacyPlansDir(workspaceRoot) {
13
+ const ws = workspaceRoot || process.cwd();
14
+ const legacy = path.join(ws, LEGACY_PLAN_DIR);
15
+ const next = path.join(ws, PLAN_DIR);
16
+ if (fs.existsSync(legacy) && !fs.existsSync(next)) {
17
+ fs.mkdirSync(path.dirname(next), { recursive: true });
18
+ fs.renameSync(legacy, next);
19
+ }
20
+ return next;
21
+ }
8
22
  /** Old unstamped names and new `{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md`. */
9
23
  export const FILENAME_RE =
10
24
  /^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*(-\d{8}T\d{6}Z)?\.md$/;
@@ -237,6 +251,7 @@ export function persistPlan({
237
251
  source ||
238
252
  (raw.match(/^\s*source:\s*(.+)$/m) ? undefined : planName);
239
253
  const body = stripPlanName(raw);
254
+ migrateLegacyPlansDir(ws);
240
255
  const plansDir = path.join(ws, PLAN_DIR);
241
256
  fs.mkdirSync(plansDir, { recursive: true });
242
257
  const createdAt =
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /** Allocate/slugify/write a one-off convo artifact under .tmp/grunt/. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import {
7
+ DATETIME_RE,
8
+ compactCreatedStamp,
9
+ ensureGitignore,
10
+ slugify,
11
+ utcDateTime,
12
+ } from "./persist-plan.mjs";
13
+
14
+ export const TMP_DIR = ".tmp/grunt";
15
+ /** `{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.{ext}`; stamp required. */
16
+ export const FILENAME_RE =
17
+ /^[1-9][0-9]*-[a-z0-9]+(-[a-z0-9]+)*-\d{8}T\d{6}Z\.[a-z0-9]+$/;
18
+ export const FILENAME_STAMP_RE = /-\d{8}T\d{6}Z$/;
19
+ const META_LINE_RE = /^\s*TMP_(NAME|EXT):\s*.+\s*$/;
20
+ const NAME_RE = /^\s*TMP_NAME:\s*(.+?)\s*$/m;
21
+ const EXT_RE = /^\s*TMP_EXT:\s*(.+?)\s*$/m;
22
+
23
+ export function extractTmpName(content) {
24
+ const m = String(content).match(NAME_RE);
25
+ return m ? m[1].trim() : "";
26
+ }
27
+
28
+ export function extractTmpExt(content) {
29
+ const m = String(content).match(EXT_RE);
30
+ return m ? m[1].trim() : "";
31
+ }
32
+
33
+ export function stripTmpMeta(content) {
34
+ return String(content)
35
+ .replace(/^\uFEFF/, "")
36
+ .split(/\r?\n/)
37
+ .filter((line) => !META_LINE_RE.test(line))
38
+ .join("\n");
39
+ }
40
+
41
+ export function normalizeTmpExt(raw) {
42
+ let e = String(raw || "")
43
+ .trim()
44
+ .toLowerCase();
45
+ if (e.startsWith(".")) e = e.slice(1);
46
+ if (!/^[a-z0-9]+$/.test(e) || e.length > 16) return "";
47
+ return e;
48
+ }
49
+
50
+ function nextTmpSerial(dir) {
51
+ let next = 1;
52
+ if (!fs.existsSync(dir)) return next;
53
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
54
+ if (!ent.isFile()) continue;
55
+ const m = ent.name.match(/^([0-9]+)-/);
56
+ if (!m) continue;
57
+ const n = parseInt(m[1], 10);
58
+ if (Number.isFinite(n) && n >= next) next = n + 1;
59
+ }
60
+ return next;
61
+ }
62
+
63
+ export function persistTmp({ workspaceRoot, content, created } = {}) {
64
+ const ws = workspaceRoot || process.cwd();
65
+ const raw = String(content || "");
66
+ if (raw.includes("\0")) return { ok: false, error: "binary" };
67
+ const name = extractTmpName(raw);
68
+ if (!name) return { ok: false, error: "missing TMP_NAME" };
69
+ const extRaw = extractTmpExt(raw);
70
+ const ext = extRaw ? normalizeTmpExt(extRaw) : "md";
71
+ if (!ext) return { ok: false, error: "ext" };
72
+ const slug = slugify(name);
73
+ const body = stripTmpMeta(raw);
74
+ const dir = path.join(ws, TMP_DIR);
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ const createdAt = created && DATETIME_RE.test(created) ? created : utcDateTime();
77
+ const stamp = compactCreatedStamp(createdAt);
78
+ let serial = nextTmpSerial(dir);
79
+ let filename = `${serial}-${slug}-${stamp}.${ext}`;
80
+ let dest = path.join(dir, filename);
81
+ let tries = 0;
82
+ while (fs.existsSync(dest) && tries < 5) {
83
+ serial += 1;
84
+ filename = `${serial}-${slug}-${stamp}.${ext}`;
85
+ dest = path.join(dir, filename);
86
+ tries += 1;
87
+ }
88
+ if (fs.existsSync(dest)) return { ok: false, error: "serial collision" };
89
+ if (!FILENAME_RE.test(filename)) return { ok: false, error: "filename" };
90
+ ensureGitignore(ws);
91
+ fs.writeFileSync(dest, body);
92
+ return { ok: true, path: dest, serial, slug, ext, content: body, filename };
93
+ }
94
+
95
+ function main() {
96
+ try {
97
+ const wsIdx = process.argv.indexOf("--workspace");
98
+ const workspaceRoot = wsIdx >= 0 ? process.argv[wsIdx + 1] : process.cwd();
99
+ const content = fs.readFileSync(0, "utf8");
100
+ const result = persistTmp({ workspaceRoot, content });
101
+ if (!result.ok) {
102
+ process.stderr.write((result.error || "invalid tmp") + "\n");
103
+ return 1;
104
+ }
105
+ process.stdout.write(
106
+ JSON.stringify({
107
+ serial: result.serial,
108
+ path: result.path,
109
+ filename: result.filename,
110
+ }) + "\n",
111
+ );
112
+ return 0;
113
+ } catch (err) {
114
+ process.stderr.write(String(err && err.message ? err.message : err) + "\n");
115
+ return 1;
116
+ }
117
+ }
118
+
119
+ const thisFile = fileURLToPath(import.meta.url);
120
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
121
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
122
+ process.exit(main());
123
+ }
@@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { scrubText } from "./scrub-text-lib.mjs";
7
7
  import { denyResponse } from "./gate-fat-tools.mjs";
8
8
 
9
- const ALLOWED_TYPES = new Set(["grunt", "implementer", "thinker"]);
9
+ const ALLOWED_TYPES = new Set(["grunt"]);
10
10
  export const MAX_PROMPT_CHARS = 100000;
11
11
  export const TRUNCATE_SUFFIX = "…[truncated]";
12
12
  /** `router` is a legacy transcript prefix; keep matching it to strip old pastes. */
@@ -14,17 +14,12 @@ const TRANSCRIPT_PREFIX =
14
14
  /^\s*(?:\[(?:orchestrator|router|implementer|thinker|grunt)\]:|Human:|Assistant:)\s*/i;
15
15
  const YOU_ARE_SUBAGENT =
16
16
  /^\s*You are (?:grunt|implementer|thinker) subagent\b/;
17
- const VERDICT_LINE = /^\s*verdict:\s*/;
18
17
 
19
18
  export function stripTranscripts(text) {
20
19
  const lines = String(text).split(/\n/);
21
20
  const out = [];
22
21
  let keptYouAre = false;
23
22
  for (const line of lines) {
24
- if (VERDICT_LINE.test(line)) {
25
- out.push(line);
26
- continue;
27
- }
28
23
  if (TRANSCRIPT_PREFIX.test(line)) continue;
29
24
  if (YOU_ARE_SUBAGENT.test(line)) {
30
25
  if (keptYouAre) continue;
@@ -35,44 +30,6 @@ export function stripTranscripts(text) {
35
30
  return out.join("\n");
36
31
  }
37
32
 
38
- export function extractVerdictBlocks(text) {
39
- const rawLines = String(text).split(/\n/);
40
- const lines = [];
41
- for (const line of rawLines) {
42
- const idx = line.search(/(?:^|\s)verdict:\s/);
43
- if (idx > 0) {
44
- const before = line.slice(0, idx).trimEnd();
45
- const after = line.slice(idx).trimStart();
46
- if (before) lines.push(before);
47
- lines.push(after);
48
- } else {
49
- lines.push(line);
50
- }
51
- }
52
- const body = [];
53
- const blocks = [];
54
- let current = null;
55
- for (const line of lines) {
56
- if (VERDICT_LINE.test(line)) {
57
- if (current) blocks.push(current.join("\n"));
58
- current = [line];
59
- continue;
60
- }
61
- if (current) {
62
- if (current.length < 8) current.push(line);
63
- else {
64
- blocks.push(current.join("\n"));
65
- current = null;
66
- body.push(line);
67
- }
68
- continue;
69
- }
70
- body.push(line);
71
- }
72
- if (current) blocks.push(current.join("\n"));
73
- return { body: body.join("\n"), blocks };
74
- }
75
-
76
33
  export function truncatePrompt(text, _max = MAX_PROMPT_CHARS) {
77
34
  return String(text);
78
35
  }
@@ -90,7 +47,7 @@ export function workspaceRootOf(data) {
90
47
  export function spawnCapReason(workspaceRoot) {
91
48
  const root = workspaceRoot || process.cwd() || "";
92
49
  return (
93
- `spawn prompt exceeds ${MAX_PROMPT_CHARS} chars after scrub; write it under ${root}/.tmp/plans/ and re-spawn with that abs path`
50
+ `spawn prompt exceeds ${MAX_PROMPT_CHARS} chars after scrub; write it under ${root}/.tmp/grunt/plans/ and re-spawn with that abs path`
94
51
  );
95
52
  }
96
53
 
@@ -113,14 +70,10 @@ export function stripInlineTranscripts(text) {
113
70
 
114
71
  export function capSpawnPrompt(prompt) {
115
72
  let s = stripTranscripts(String(prompt));
116
- const extracted = extractVerdictBlocks(s);
117
- s = scrubText(extracted.body, { intent: true });
73
+ s = scrubText(s, { intent: true });
118
74
  s = stripTranscripts(s);
119
75
  s = stripInlineTranscripts(s);
120
- const again = extractVerdictBlocks(s);
121
- const blocks = extracted.blocks.concat(again.blocks);
122
- const verdictPart = blocks.length ? "\n" + blocks.join("\n") : "";
123
- return (again.body + verdictPart).trim();
76
+ return s.trim();
124
77
  }
125
78
 
126
79
  const SPAWN_TYPE_KEYS = ["subagent_type", "subagentType", "name", "type", "agent"];