@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.
- package/.agents/agents/grunt.md +2 -23
- package/.agents/agents/orchestrator.md +5 -45
- package/.agents/skills/ask/SKILL.md +11 -0
- package/.agents/skills/auto/SKILL.md +13 -0
- package/.agents/skills/browser/SKILL.md +4 -7
- package/.agents/skills/clasp/SKILL.md +11 -0
- package/.agents/skills/commit/SKILL.md +3 -1
- package/.agents/skills/commit-push/SKILL.md +2 -0
- package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
- package/.agents/skills/commit-push-release/SKILL.md +2 -0
- package/.agents/skills/explain/SKILL.md +7 -13
- package/.agents/skills/google-workspace/SKILL.md +13 -0
- package/.agents/skills/handoff/SKILL.md +7 -11
- package/.agents/skills/implement-plan/SKILL.md +5 -108
- package/.agents/skills/listen/SKILL.md +15 -0
- package/.agents/skills/pickup/SKILL.md +6 -12
- package/.agents/skills/speak/SKILL.md +13 -0
- package/.agents/skills/tmp/SKILL.md +54 -0
- package/.agents/skills/write-plan/SKILL.md +5 -74
- package/.claude/agents/grunt.md +2 -25
- package/.claude/agents/orchestrator.md +12 -45
- package/.claude/skills/ask/SKILL.md +11 -0
- package/.claude/skills/auto/SKILL.md +13 -0
- package/.claude/skills/browser/SKILL.md +4 -7
- package/.claude/skills/clasp/SKILL.md +11 -0
- package/.claude/skills/commit/SKILL.md +3 -1
- package/.claude/skills/commit-push/SKILL.md +2 -0
- package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
- package/.claude/skills/commit-push-release/SKILL.md +2 -0
- package/.claude/skills/explain/SKILL.md +7 -13
- package/.claude/skills/google-workspace/SKILL.md +13 -0
- package/.claude/skills/handoff/SKILL.md +7 -11
- package/.claude/skills/implement-plan/SKILL.md +5 -108
- package/.claude/skills/listen/SKILL.md +15 -0
- package/.claude/skills/pickup/SKILL.md +6 -12
- package/.claude/skills/speak/SKILL.md +13 -0
- package/.claude/skills/tmp/SKILL.md +54 -0
- package/.claude/skills/write-plan/SKILL.md +5 -74
- package/.codex/agents/grunt.toml +2 -25
- package/.codex/agents/orchestrator.toml +4 -45
- package/.grok/agents/grunt.md +2 -23
- package/.grok/agents/orchestrator.md +4 -43
- package/.grok/global-settings.toml +1 -3
- package/.grok/hooks/orchestrate-parent.js +278 -228
- package/.grok/skills/ask/SKILL.md +11 -0
- package/.grok/skills/auto/SKILL.md +13 -0
- package/.grok/skills/browser/SKILL.md +4 -7
- package/.grok/skills/clasp/SKILL.md +11 -0
- package/.grok/skills/commit/SKILL.md +3 -1
- package/.grok/skills/commit-push/SKILL.md +2 -0
- package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
- package/.grok/skills/commit-push-release/SKILL.md +2 -0
- package/.grok/skills/explain/SKILL.md +7 -13
- package/.grok/skills/google-workspace/SKILL.md +13 -0
- package/.grok/skills/handoff/SKILL.md +7 -11
- package/.grok/skills/implement-plan/SKILL.md +5 -108
- package/.grok/skills/listen/SKILL.md +15 -0
- package/.grok/skills/pickup/SKILL.md +6 -12
- package/.grok/skills/speak/SKILL.md +13 -0
- package/.grok/skills/tmp/SKILL.md +54 -0
- package/.grok/skills/write-plan/SKILL.md +5 -74
- package/.rulesync/hooks.jsonc +1 -1
- package/.rulesync/mcp-policy.jsonc +0 -2
- package/.rulesync/reference/INDEX.md +30 -17
- package/.rulesync/reference/browser.md +13 -6
- package/.rulesync/reference/cascade.md +6 -84
- package/.rulesync/reference/clasp.md +21 -0
- package/.rulesync/reference/google-workspace.md +79 -0
- package/.rulesync/reference/hooks.md +7 -9
- package/.rulesync/reference/implementation-format.md +113 -0
- package/.rulesync/reference/law.md +5 -3
- package/.rulesync/reference/listen.md +80 -0
- package/.rulesync/reference/map.md +39 -31
- package/.rulesync/reference/output.md +3 -41
- package/.rulesync/reference/plan-format.md +14 -12
- package/.rulesync/reference/refs-map.md +12 -5
- package/.rulesync/reference/rtk.md +3 -2
- package/.rulesync/reference/scope.md +37 -0
- package/.rulesync/reference/setup.md +27 -0
- package/.rulesync/reference/skills-map.md +13 -9
- package/.rulesync/reference/speak.md +59 -0
- package/.rulesync/rules/CLAUDE.md +3 -44
- package/.rulesync/rules/overview.md +9 -42
- package/.rulesync/skills/ask/SKILL.md +11 -0
- package/.rulesync/skills/auto/SKILL.md +11 -0
- package/.rulesync/skills/browser/SKILL.md +4 -8
- package/.rulesync/skills/clasp/SKILL.md +9 -0
- package/.rulesync/skills/commit/SKILL.md +3 -1
- package/.rulesync/skills/commit-push/SKILL.md +2 -0
- package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
- package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
- package/.rulesync/skills/explain/SKILL.md +7 -14
- package/.rulesync/skills/google-workspace/SKILL.md +11 -0
- package/.rulesync/skills/handoff/SKILL.md +7 -11
- package/.rulesync/skills/implement-plan/SKILL.md +5 -116
- package/.rulesync/skills/listen/SKILL.md +13 -0
- package/.rulesync/skills/pickup/SKILL.md +6 -12
- package/.rulesync/skills/speak/SKILL.md +13 -0
- package/.rulesync/skills/tmp/SKILL.md +54 -0
- package/.rulesync/skills/write-plan/SKILL.md +5 -80
- package/.rulesync/subagents/grunt.md +2 -23
- package/.rulesync/subagents/orchestrator.md +14 -54
- package/AGENTS.md +9 -42
- package/CHANGELOG.md +89 -0
- package/CLAUDE.md +1 -44
- package/README.md +97 -88
- package/cli/grunt.mjs +29 -13
- package/cli/grunt.test.ts +62 -20
- package/cli/init.mjs +218 -31
- package/cli/init.test.ts +669 -46
- package/cli/prompt.mjs +8 -0
- package/cli/prompt.test.ts +14 -0
- package/package.json +13 -1
- package/scripts/browser.mjs +98 -5
- package/scripts/doctor.mjs +82 -1
- package/scripts/emit-gemini.mjs +1 -1
- package/scripts/emit-mcp-policy.mjs +1 -1
- package/scripts/gate-fat-tools.mjs +223 -173
- package/scripts/google-workspace.mjs +713 -0
- package/scripts/grunt-job.mjs +453 -69
- package/scripts/listen.mjs +829 -0
- package/scripts/parse-need.mjs +21 -2
- package/scripts/persist-implementation.mjs +278 -0
- package/scripts/persist-plan.mjs +17 -2
- package/scripts/persist-tmp.mjs +123 -0
- package/scripts/scrub-spawn-prompt.mjs +4 -51
- package/scripts/setup.mjs +746 -0
- package/scripts/speak.mjs +291 -0
- package/.agents/agents/implementer.md +0 -32
- package/.agents/agents/thinker.md +0 -28
- package/.agents/skills/cascade/SKILL.md +0 -26
- package/.agents/skills/parent/SKILL.md +0 -6
- package/.agents/skills/solo/SKILL.md +0 -23
- package/.claude/agents/implementer.md +0 -37
- package/.claude/agents/thinker.md +0 -35
- package/.claude/skills/cascade/SKILL.md +0 -26
- package/.claude/skills/parent/SKILL.md +0 -6
- package/.claude/skills/solo/SKILL.md +0 -23
- package/.codex/agents/implementer.toml +0 -26
- package/.codex/agents/thinker.toml +0 -25
- package/.grok/agents/implementer.md +0 -27
- package/.grok/agents/thinker.md +0 -26
- package/.grok/parent.md +0 -1
- package/.grok/roles/implementer.toml +0 -5
- package/.grok/roles/thinker.toml +0 -5
- package/.grok/skills/cascade/SKILL.md +0 -26
- package/.grok/skills/parent/SKILL.md +0 -6
- package/.grok/skills/solo/SKILL.md +0 -23
- package/.rulesync/skills/cascade/SKILL.md +0 -26
- package/.rulesync/skills/parent/SKILL.md +0 -6
- package/.rulesync/skills/solo/SKILL.md +0 -23
- package/.rulesync/subagents/implementer.md +0 -49
- package/.rulesync/subagents/thinker.md +0 -48
package/scripts/parse-need.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/scripts/persist-plan.mjs
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
117
|
-
s = scrubText(extracted.body, { intent: true });
|
|
73
|
+
s = scrubText(s, { intent: true });
|
|
118
74
|
s = stripTranscripts(s);
|
|
119
75
|
s = stripInlineTranscripts(s);
|
|
120
|
-
|
|
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"];
|