gm-skill 2.0.1863 → 2.0.1865
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.md +2 -2
- package/README.md +0 -1
- package/gm-plugkit/instructions/residual/browser-open.md +1 -1
- package/gm-plugkit/instructions/residual/dirty-tree.md +1 -1
- package/gm-plugkit/instructions/residual/imperative.md +1 -1
- package/gm-plugkit/instructions/residual/tasks-running.md +1 -1
- package/gm-plugkit/package.json +1 -3
- package/gm-plugkit/plugkit-wasm-wrapper.js +72 -9
- package/gm-plugkit/supervisor.js +20 -13
- package/gm.json +1 -1
- package/lib/spool.js +1 -46
- package/package.json +1 -2
- package/skills/fifth-dimension-engine/SKILL.md +5 -5
- package/skills/fifth-dimension-engine/references/research-kernel-extraction.md +3 -3
- package/skills/fifth-dimension-engine/references/route-inspection-guide.md +1 -1
- package/skills/fifth-dimension-engine/references/route-structure.md +1 -1
- package/skills/gm/SKILL.md +2 -2
- package/skills/polaris-goal-compiler/SKILL.md +5 -5
- package/skills/polaris-goal-compiler/references/claim-ceiling-examples.md +2 -2
- package/skills/polaris-goal-compiler/references/task-atomization.md +11 -11
- package/skills/polaris-goal-compiler/references/verification-gates.md +6 -6
- package/skills/polaris-protocol/SKILL.md +16 -16
- package/skills/wfgy-method/SKILL.md +28 -28
- package/skills/wfgy-method/references/failure-modes.md +5 -5
- package/skills/wfgy-method/references/honesty-and-provenance.md +10 -10
- package/skills/wfgy-method/references/lessons-template.md +4 -4
- package/skills/wfgy-method/references/wfgy-core-mechanism.md +14 -14
- package/lib/spool-dispatch.js +0 -484
package/lib/spool-dispatch.js
DELETED
|
@@ -1,484 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const { spawnSync } = require('child_process');
|
|
5
|
-
|
|
6
|
-
const GM_LOG_ROOT = process.env.GM_LOG_DIR || path.join(os.homedir(), '.claude', 'gm-log');
|
|
7
|
-
|
|
8
|
-
function logDeviation(event, fields) {
|
|
9
|
-
if (process.env.GM_LOG_DISABLE) return;
|
|
10
|
-
try {
|
|
11
|
-
const day = new Date().toISOString().slice(0, 10);
|
|
12
|
-
const dir = path.join(GM_LOG_ROOT, day);
|
|
13
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
-
const f = fields || {};
|
|
15
|
-
const sessOverride = (f.sess !== undefined) ? f.sess : null;
|
|
16
|
-
const rest = { ...f };
|
|
17
|
-
delete rest.sess;
|
|
18
|
-
const sess = (sessOverride && String(sessOverride).length > 0)
|
|
19
|
-
? String(sessOverride)
|
|
20
|
-
: (process.env.CLAUDE_SESSION_ID || process.env.GM_SESSION_ID || '');
|
|
21
|
-
const line = JSON.stringify({
|
|
22
|
-
ts: new Date().toISOString(),
|
|
23
|
-
sub: 'hook',
|
|
24
|
-
event,
|
|
25
|
-
pid: process.pid,
|
|
26
|
-
sess,
|
|
27
|
-
cwd: process.cwd(),
|
|
28
|
-
...rest,
|
|
29
|
-
});
|
|
30
|
-
fs.appendFileSync(path.join(dir, 'hook.jsonl'), line + '\n');
|
|
31
|
-
} catch (_) {}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isWorktreeDirty(cwd) {
|
|
35
|
-
try {
|
|
36
|
-
const r = spawnSync('git', ['status', '--porcelain'], {
|
|
37
|
-
cwd: cwd || process.cwd(), encoding: 'utf8', timeout: 1500, windowsHide: true
|
|
38
|
-
});
|
|
39
|
-
if (r.status !== 0) return { dirty: false, files: [], available: false };
|
|
40
|
-
const lines = r.stdout.split('\n').filter(l => l.length > 0);
|
|
41
|
-
return { dirty: lines.length > 0, files: lines, available: true };
|
|
42
|
-
} catch (_) {
|
|
43
|
-
return { dirty: false, files: [], available: false };
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function hasUnpushedCommits(cwd) {
|
|
48
|
-
try {
|
|
49
|
-
const r = spawnSync('git', ['log', '@{u}..HEAD', '--oneline'], {
|
|
50
|
-
cwd: cwd || process.cwd(), encoding: 'utf8', timeout: 1500, windowsHide: true
|
|
51
|
-
});
|
|
52
|
-
if (r.status !== 0) return { unpushed: false, count: 0, available: false };
|
|
53
|
-
const lines = r.stdout.split('\n').filter(l => l.length > 0);
|
|
54
|
-
return { unpushed: lines.length > 0, count: lines.length, available: true };
|
|
55
|
-
} catch (_) {
|
|
56
|
-
return { unpushed: false, count: 0, available: false };
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const TOPLEVEL_DOC_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md', 'README.md', 'SKILLS.md', 'CHANGELOG.md', 'LICENSE', 'LICENSE.md']);
|
|
61
|
-
|
|
62
|
-
const BROWSER_FILE_ALWAYS_RE = /\.(html?|tsx|jsx|vue|svelte)$/i;
|
|
63
|
-
const BROWSER_FILE_DIRGATED_RE = /\.(mjs|cjs|js|ts|css|scss|sass)$/i;
|
|
64
|
-
const BROWSER_FILE_DIR_RE = /^(src|public|site|app|pages|components|client|web)[\\/]/i;
|
|
65
|
-
|
|
66
|
-
function isBrowserRunningFile(rel) {
|
|
67
|
-
if (!rel) return false;
|
|
68
|
-
const norm = String(rel).replace(/\\/g, '/');
|
|
69
|
-
if (BROWSER_FILE_ALWAYS_RE.test(norm)) return true;
|
|
70
|
-
if (BROWSER_FILE_DIRGATED_RE.test(norm) && BROWSER_FILE_DIR_RE.test(norm)) return true;
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function browserEditsFile(cwd) {
|
|
75
|
-
return path.join(cwd || process.cwd(), '.gm', 'exec-spool', '.turn-browser-edits.json');
|
|
76
|
-
}
|
|
77
|
-
function browserWitnessFile(cwd) {
|
|
78
|
-
return path.join(cwd || process.cwd(), '.gm', 'exec-spool', '.turn-browser-witnessed');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function hashFileShort(root, rel) {
|
|
82
|
-
try {
|
|
83
|
-
const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
84
|
-
const buf = fs.readFileSync(abs);
|
|
85
|
-
return require('crypto').createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
|
86
|
-
} catch (_) { return ''; }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function recordBrowserEdit(cwd, filePath) {
|
|
90
|
-
try {
|
|
91
|
-
const root = cwd || process.cwd();
|
|
92
|
-
let rel = filePath;
|
|
93
|
-
try { rel = path.relative(root, filePath); } catch (_) {}
|
|
94
|
-
if (!isBrowserRunningFile(rel)) return false;
|
|
95
|
-
const f = browserEditsFile(root);
|
|
96
|
-
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
97
|
-
let list = [];
|
|
98
|
-
try { list = JSON.parse(fs.readFileSync(f, 'utf8')); if (!Array.isArray(list)) list = []; } catch (_) {}
|
|
99
|
-
const relPath = rel.replace(/\\/g, '/');
|
|
100
|
-
const hash = hashFileShort(root, relPath);
|
|
101
|
-
const idx = list.findIndex(e => e && e.file === relPath);
|
|
102
|
-
const entry = { file: relPath, ts: Date.now(), hash };
|
|
103
|
-
if (idx === -1) list.push(entry); else list[idx] = entry;
|
|
104
|
-
fs.writeFileSync(f, JSON.stringify(list));
|
|
105
|
-
return true;
|
|
106
|
-
} catch (_) { return false; }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function clearBrowserTurnMarkers(cwd) {
|
|
110
|
-
const root = cwd || process.cwd();
|
|
111
|
-
for (const p of [browserEditsFile(root), browserWitnessFile(root)]) {
|
|
112
|
-
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (_) {}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function markBrowserWitnessed(cwd, meta) {
|
|
117
|
-
try {
|
|
118
|
-
const root = cwd || process.cwd();
|
|
119
|
-
const f = browserWitnessFile(root);
|
|
120
|
-
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
121
|
-
const edits = readBrowserEdits(root);
|
|
122
|
-
const witnessed_hashes = {};
|
|
123
|
-
for (const e of edits) {
|
|
124
|
-
if (!e || !e.file) continue;
|
|
125
|
-
witnessed_hashes[e.file] = hashFileShort(root, e.file);
|
|
126
|
-
}
|
|
127
|
-
fs.writeFileSync(f, JSON.stringify({ ts: Date.now(), witnessed_hashes, ...(meta || {}) }));
|
|
128
|
-
} catch (_) {}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function readBrowserWitness(cwd) {
|
|
132
|
-
try {
|
|
133
|
-
const f = browserWitnessFile(cwd);
|
|
134
|
-
if (!fs.existsSync(f)) return null;
|
|
135
|
-
return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
136
|
-
} catch (_) { return null; }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function readBrowserEdits(cwd) {
|
|
140
|
-
try {
|
|
141
|
-
const f = browserEditsFile(cwd);
|
|
142
|
-
if (!fs.existsSync(f)) return [];
|
|
143
|
-
const list = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
144
|
-
return Array.isArray(list) ? list : [];
|
|
145
|
-
} catch (_) { return []; }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isBrowserWitnessed(cwd) {
|
|
149
|
-
try { return fs.existsSync(browserWitnessFile(cwd)); } catch (_) { return false; }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function unsolicitedDocs(cwd) {
|
|
153
|
-
try {
|
|
154
|
-
const r = spawnSync('git', ['status', '--porcelain'], {
|
|
155
|
-
cwd: cwd || process.cwd(), encoding: 'utf8', timeout: 1500, windowsHide: true
|
|
156
|
-
});
|
|
157
|
-
if (r.status !== 0) return { count: 0, files: [], available: false };
|
|
158
|
-
const flagged = [];
|
|
159
|
-
for (const line of r.stdout.split('\n')) {
|
|
160
|
-
if (!line || line.length < 4) continue;
|
|
161
|
-
const code = line.slice(0, 2);
|
|
162
|
-
let rel;
|
|
163
|
-
if (code === '??') {
|
|
164
|
-
rel = line.slice(3).trim();
|
|
165
|
-
} else if (/^[MADRCU ][MADRCU ]$/.test(code)) {
|
|
166
|
-
const rest = line.slice(3).trim();
|
|
167
|
-
const arrowIdx = rest.indexOf(' -> ');
|
|
168
|
-
rel = arrowIdx >= 0 ? rest.slice(arrowIdx + 4).trim() : rest;
|
|
169
|
-
} else {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (!rel) continue;
|
|
173
|
-
if (!/\.(md|txt)$/i.test(rel)) continue;
|
|
174
|
-
const base = rel.includes('/') ? rel.slice(rel.lastIndexOf('/') + 1) : rel;
|
|
175
|
-
if (TOPLEVEL_DOC_ALLOWLIST.has(base)) continue;
|
|
176
|
-
if (rel.startsWith('node_modules/') || rel.startsWith('target/') || rel.startsWith('.gm/') || rel.startsWith('dist/') || rel.startsWith('build/')) continue;
|
|
177
|
-
flagged.push(rel);
|
|
178
|
-
}
|
|
179
|
-
return { count: flagged.length, files: flagged, available: true };
|
|
180
|
-
} catch (_) {
|
|
181
|
-
return { count: 0, files: [], available: false };
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function yamlStatusValues(content) {
|
|
186
|
-
const values = [];
|
|
187
|
-
for (const raw of String(content).split(/\r?\n/)) {
|
|
188
|
-
const line = raw.replace(/#.*$/, '');
|
|
189
|
-
const m = line.match(/^\s*(?:-\s*)?status\s*:\s*([A-Za-z0-9_-]+)\s*$/);
|
|
190
|
-
if (m) values.push(m[1]);
|
|
191
|
-
}
|
|
192
|
-
return values;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function sessionMarkerPath(sessionId, kind) {
|
|
196
|
-
const cwd = process.cwd();
|
|
197
|
-
return path.join(cwd, '.gm', 'exec-spool', `.session-${kind}-${sessionId || 'anon'}`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function hasDispatchedInstruction(sessionId) {
|
|
201
|
-
return fs.existsSync(sessionMarkerPath(sessionId, 'instruction-seen'));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function markInstructionSeen(sessionId) {
|
|
205
|
-
try {
|
|
206
|
-
fs.mkdirSync(path.dirname(sessionMarkerPath(sessionId, 'instruction-seen')), { recursive: true });
|
|
207
|
-
fs.writeFileSync(sessionMarkerPath(sessionId, 'instruction-seen'), String(Date.now()));
|
|
208
|
-
} catch (_) {}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const SPOOL_POLL_PATTERNS = [
|
|
212
|
-
/\bsleep\s+\d+(?:\.\d+)?\s*[;&]+\s*(?:cat|ls|tail|head|find|test|grep)\b[^|]*\.gm[\\/](?:exec-spool|spool)/i,
|
|
213
|
-
/\bStart-Sleep\b[^;|]*?[;|]\s*(?:Get-Content|Test-Path|Get-ChildItem|cat|ls|gci|gc|tp)\b[^|]*\.gm[\\/](?:exec-spool|spool)/i,
|
|
214
|
-
/\b(?:cat|ls|tail|head|Get-Content|Test-Path|Get-ChildItem)\b[^|]*\.gm[\\/](?:exec-spool|spool)[^|]*?[;&|]+\s*(?:sleep|Start-Sleep)\b/i,
|
|
215
|
-
/\bwhile\b[^;]*?(?:!|-not)\s*(?:-(?:f|e)\s+|Test-Path\s+)[^;]*?\.gm[\\/](?:exec-spool|spool)/i,
|
|
216
|
-
/\buntil\b[^;]*?(?:-f|-e|Test-Path)\s+[^;]*?\.gm[\\/](?:exec-spool|spool)/i,
|
|
217
|
-
/\bfor\s+i\s+in\b[^;]*?;\s*do\b[^;]*?(?:sleep|Start-Sleep)[^;]*?\.gm[\\/](?:exec-spool|spool)/i,
|
|
218
|
-
/\b(?:ls|dir|Get-ChildItem|gci)\s+(?:-[A-Za-z]+\s+)*['"]?[^'"|;&]*\.gm[\\/](?:exec-spool|spool)(?:[\\/](?:in|out)?)?[\\/]?['"]?\s*(?:$|[|;&])/i,
|
|
219
|
-
/\b(?:test|Test-Path|tp)\s+(?:-[A-Za-z]+\s+)?['"]?[^'"|;&]*\.gm[\\/](?:exec-spool|spool)[\\/](?:out|in)[\\/]/i,
|
|
220
|
-
/\bfind\b[^|]*['"]?[^'"|;&]*\.gm[\\/](?:exec-spool|spool)\b/i,
|
|
221
|
-
/\b(?:xargs|parallel|fzf)\b[^|]*\.gm[\\/](?:exec-spool|spool)/i,
|
|
222
|
-
];
|
|
223
|
-
|
|
224
|
-
const SPOOL_POLL_REASON = 'spool POLLING (sleep+cat, while !test, ls/find on the spool dirs) is forbidden -- plugkit is synchronous from your view, so the response file is there the moment the watcher finishes the verb. Specific replacements:\n\n- Instead of `ls .gm/exec-spool/out/` -> check the specific response file you wrote, e.g. `Read .gm/exec-spool/out/<verb>-<N>.json`\n- Instead of `sleep N; cat .gm/exec-spool/<...>` -> just Read the response file directly; if it doesn\'t exist yet, the watcher is dead (the SKILL.md boot probe `cat .gm/exec-spool/.status.json; date +%s%3N` is the way to check liveness) or the verb is slow (Read .gm/exec-spool/.watcher.log for the dispatch trace)\n- Instead of `while [ ! -f ... ]; do sleep ...; done` -> write the request, Read the response in the same message, accept the file-not-found and re-Read in the next message\n\nThe SKILL.md-prescribed boot probe (`cat .gm/exec-spool/.status.json; date +%s%3N`) is NOT a violation -- it is the canonical liveness check because it pipes with `date` for ts comparison. The Read tool can\'t do that in one call. What this gate denies is the *polling* pattern around the spool dirs, not the boot-probe cat. You are the state machine. Plugkit serves the response the moment you write the request file.';
|
|
225
|
-
|
|
226
|
-
function stripHeredocsAndStringLiterals(command) {
|
|
227
|
-
let s = String(command);
|
|
228
|
-
s = s.replace(/<<-?\s*'([A-Z_]+)'[\s\S]*?\n\1/g, '');
|
|
229
|
-
s = s.replace(/<<-?\s*"?([A-Z_]+)"?[\s\S]*?\n\1/g, '');
|
|
230
|
-
s = s.replace(/\$\(cat\s+<<-?\s*'?([A-Z_]+)'?[\s\S]*?\n\1\s*\)/g, '');
|
|
231
|
-
s = s.replace(/-m\s+(['"])(?:\\.|(?!\1)[^\\])*\1/g, '-m STR');
|
|
232
|
-
s = s.replace(/--message[= ]+(['"])(?:\\.|(?!\1)[^\\])*\1/g, '--message STR');
|
|
233
|
-
return s;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function isSpoolPollCommand(command) {
|
|
237
|
-
if (!command) return null;
|
|
238
|
-
const stripped = stripHeredocsAndStringLiterals(command);
|
|
239
|
-
for (const re of SPOOL_POLL_PATTERNS) {
|
|
240
|
-
if (re.test(stripped)) return re.source;
|
|
241
|
-
}
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const NATIVE_SEARCH_PATTERNS = [
|
|
246
|
-
/\bgrep\s+-(?!-)[A-Za-z]*[rR][A-Za-z]*(?:[=\s]|$)/,
|
|
247
|
-
/\bgrep\s+--recursive\b/,
|
|
248
|
-
/(?:^|[\n;&|]|&&)\s*rg\s+(?!-{1,2}(?:version|help)\b)/,
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
const NATIVE_SEARCH_REASON = 'native code/file/symbol search (grep -r / rg over the tree) is forbidden -- route the lookup through the codesearch verb so it hits the committed code-search index and stays in the spool ledger. Write .gm/exec-spool/in/codesearch/<N>.txt with {"query":"..."} and Read the response; use the recall verb for prior knowledge. A pipe-filter on command output (cmd | grep X) and grep on a single named file are fine; what this gate denies is searching the tree natively (grep -r/-R/-rl/-rn, ripgrep), which bypasses the index and is non-portable across harnesses.';
|
|
252
|
-
|
|
253
|
-
function isNativeSearchCommand(command) {
|
|
254
|
-
if (!command) return null;
|
|
255
|
-
const stripped = stripHeredocsAndStringLiterals(command);
|
|
256
|
-
for (const re of NATIVE_SEARCH_PATTERNS) {
|
|
257
|
-
if (re.test(stripped)) return re.source;
|
|
258
|
-
}
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const DEFER_MARKERS = [
|
|
263
|
-
'next pass', 'next session', 'next turn',
|
|
264
|
-
'defer to later', 'deferred to later', 'deferred for later',
|
|
265
|
-
'future pass', 'future session', 'future turn',
|
|
266
|
-
'address it next', 'address this next', 'leave for next',
|
|
267
|
-
'documented for next', 'documented for future',
|
|
268
|
-
'below criticality', 'skip for now', 'punt for now',
|
|
269
|
-
'do later', 'fix later', 'later pass',
|
|
270
|
-
];
|
|
271
|
-
|
|
272
|
-
function deferMarkerIn(text) {
|
|
273
|
-
if (!text) return null;
|
|
274
|
-
const lower = String(text).toLowerCase();
|
|
275
|
-
for (const m of DEFER_MARKERS) {
|
|
276
|
-
if (lower.includes(m)) return m;
|
|
277
|
-
}
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function readPendingStep(cwd) {
|
|
282
|
-
try {
|
|
283
|
-
const f = path.join(cwd, '.gm', 'turn-state.json');
|
|
284
|
-
if (!fs.existsSync(f)) return null;
|
|
285
|
-
const st = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
286
|
-
if (!st || !st.pending_step_id) return null;
|
|
287
|
-
const deadline = Number(st.pending_step_deadline_ms || 0);
|
|
288
|
-
if (deadline && Date.now() > deadline) return null;
|
|
289
|
-
return { step_id: st.pending_step_id, deadline_ms: deadline };
|
|
290
|
-
} catch (_) { return null; }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const AWAIT_RESULT_ALLOWED_VERBS = new Set(['memorize-continue', 'instruction', 'phase-status', 'health']);
|
|
294
|
-
|
|
295
|
-
function checkDispatchGates(sessionId, operation, extra) {
|
|
296
|
-
const cwd = process.cwd();
|
|
297
|
-
const gm = path.join(cwd, '.gm');
|
|
298
|
-
const prdPath = path.join(gm, 'prd.yml');
|
|
299
|
-
const mutsPath = path.join(gm, 'mutables.yml');
|
|
300
|
-
const needsGmPath = path.join(gm, 'needs-gm');
|
|
301
|
-
const gmFiredPath = path.join(gm, `gm-fired-${sessionId}`);
|
|
302
|
-
|
|
303
|
-
if (operation === 'verb' && extra && extra.verb) {
|
|
304
|
-
const pending = readPendingStep(cwd);
|
|
305
|
-
if (pending && !AWAIT_RESULT_ALLOWED_VERBS.has(extra.verb)) {
|
|
306
|
-
logDeviation('deviation.await-result-violation', { verb: extra.verb, step_id: pending.step_id });
|
|
307
|
-
return {
|
|
308
|
-
allowed: false,
|
|
309
|
-
reason: `pipeline suspended at step_id=${pending.step_id}; only memorize-continue advances state. Read the AWAIT-RESULT instruction (dispatch \`instruction\`), compute the step inline using its prompt_template, then dispatch memorize-continue with the result. No other verb is valid until this completes.`,
|
|
310
|
-
await_result: true,
|
|
311
|
-
pending_step_id: pending.step_id,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (['bash', 'sh', 'shell', 'zsh', 'powershell', 'ps1'].includes(extra.verb)) {
|
|
316
|
-
const cmd = String((extra.body && (extra.body.command || extra.body.code || extra.body.script)) || extra.command || '').trim();
|
|
317
|
-
const isGitToken = (tok) => tok === 'git' || /[\\/]git(\.exe)?$/i.test(tok);
|
|
318
|
-
const segments = cmd
|
|
319
|
-
.split(/&&|\|\||[;&|]|(?<=["'])\s*&\s*(?=["'])/)
|
|
320
|
-
.map((s) => s.trim())
|
|
321
|
-
.filter(Boolean);
|
|
322
|
-
const GIT_SUBCOMMAND_RE = /(^|[\s"'(;&|])git(\.exe)?\s+(add|am|apply|bisect|branch|checkout|cherry-pick|clean|clone|commit|config|diff|fetch|finalize|gc|grep|init|log|merge|mv|notes|pull|push|rebase|reflog|remote|reset|restore|revert|rm|show|stash|status|submodule|switch|tag|worktree)\b/i;
|
|
323
|
-
const gitDominant = segments.some((seg) => {
|
|
324
|
-
const stripped = seg.replace(/^["']|["']$/g, '');
|
|
325
|
-
const tokens = stripped.split(/\s+/).filter(Boolean);
|
|
326
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
327
|
-
const tok = tokens[i].replace(/^["']|["']$/g, '');
|
|
328
|
-
if (isGitToken(tok)) return true;
|
|
329
|
-
if (tok === 'env' || tok === 'exec' || tok === 'cd') continue;
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
332
|
-
return GIT_SUBCOMMAND_RE.test(stripped);
|
|
333
|
-
});
|
|
334
|
-
if (gitDominant) {
|
|
335
|
-
logDeviation('deviation.bash-git-bypass', { verb: extra.verb, cmd: cmd.slice(0, 80) });
|
|
336
|
-
return {
|
|
337
|
-
allowed: false,
|
|
338
|
-
reason: `bash-git-bypass: a \`${extra.verb}\` verb invoking \`git\` is denied -- git is a first-class spool surface, not a shell command. Use the git verb: git_status/git_log/git_diff/git_show/git_branch (inspect); git_add/git_commit/git_finalize/git_push (stage/commit/push); git_checkout/git_fetch/git_rm/git_revert/git_reset (mutate). git_finalize {message} bundles add->commit->porcelain-gate->push in ONE dispatch.`,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (['stop', 'complete'].includes(operation)) {
|
|
345
|
-
const residuals = [];
|
|
346
|
-
if (fs.existsSync(prdPath)) {
|
|
347
|
-
try {
|
|
348
|
-
const raw = fs.readFileSync(prdPath, 'utf8');
|
|
349
|
-
if (raw.trim().length === 0) {
|
|
350
|
-
residuals.push('prd.yml is empty/truncated -- cannot confirm PRD is actually done; restore from git or re-scope before declaring done');
|
|
351
|
-
} else {
|
|
352
|
-
const statuses = yamlStatusValues(raw);
|
|
353
|
-
if (statuses.includes('pending') || statuses.includes('in_progress')) {
|
|
354
|
-
residuals.push('PRD has open items -- resolve or name-and-stop before declaring done');
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
} catch (e) {
|
|
358
|
-
residuals.push(`prd.yml unreadable (${e.message}) -- cannot verify PRD state`);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
if (fs.existsSync(mutsPath)) {
|
|
362
|
-
try {
|
|
363
|
-
const raw = fs.readFileSync(mutsPath, 'utf8');
|
|
364
|
-
if (raw.trim().length === 0) {
|
|
365
|
-
residuals.push('mutables.yml is empty/truncated -- cannot confirm mutables are actually resolved; restore from git or re-scope before declaring done');
|
|
366
|
-
} else {
|
|
367
|
-
const statuses = yamlStatusValues(raw);
|
|
368
|
-
if (statuses.includes('unknown')) {
|
|
369
|
-
residuals.push('unresolved mutables present -- resolve with witness_evidence before declaring done');
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
} catch (e) {
|
|
373
|
-
residuals.push(`mutables.yml unreadable (${e.message}) -- cannot verify mutable state`);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
const dirty = isWorktreeDirty(cwd);
|
|
377
|
-
if (!dirty.available) {
|
|
378
|
-
residuals.push('worktree git state UNKNOWN (git status failed or timed out) -- cannot confirm a clean tree; re-run git status and commit/push any residual before declaring done');
|
|
379
|
-
} else if (dirty.dirty) {
|
|
380
|
-
residuals.push(`worktree dirty (${dirty.files.length} file${dirty.files.length === 1 ? '' : 's'}) -- commit and push before declaring done`);
|
|
381
|
-
}
|
|
382
|
-
const unpushed = hasUnpushedCommits(cwd);
|
|
383
|
-
if (!unpushed.available) {
|
|
384
|
-
residuals.push('unpushed-commit state UNKNOWN (git log @{u}..HEAD failed or timed out) -- cannot confirm origin reflects HEAD; verify and push before declaring done');
|
|
385
|
-
} else if (unpushed.unpushed) {
|
|
386
|
-
residuals.push(`${unpushed.count} unpushed commit${unpushed.count === 1 ? '' : 's'} -- push to remote before declaring done`);
|
|
387
|
-
}
|
|
388
|
-
const docs = unsolicitedDocs(cwd);
|
|
389
|
-
if (docs.available && docs.count > 0) {
|
|
390
|
-
residuals.push(`${docs.count} unsolicited doc${docs.count === 1 ? '' : 's'} (${docs.files.slice(0, 3).join(', ')}${docs.files.length > 3 ? ', ...' : ''}) -- delete or fold into commit/PRD/memorize, do not ship`);
|
|
391
|
-
for (const f of docs.files) {
|
|
392
|
-
logDeviation('deviation.unsolicited-doc-created', { file: f, operation });
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
const browserEdits = readBrowserEdits(cwd);
|
|
396
|
-
if (browserEdits.length > 0 && !isBrowserWitnessed(cwd)) {
|
|
397
|
-
const files = browserEdits.map(e => e.file);
|
|
398
|
-
const shown = files.slice(0, 5).join(', ') + (files.length > 5 ? `, +${files.length - 5} more` : '');
|
|
399
|
-
residuals.push(`Browser Witness required: you edited ${shown} without dispatching the browser verb to witness the change in a live page. This is non-negotiable. Either dispatch browser to verify the edit works in-browser, or revert the changes.`);
|
|
400
|
-
logDeviation('deviation.browser-witness-missing', { files, operation });
|
|
401
|
-
} else if (browserEdits.length > 0 && isBrowserWitnessed(cwd)) {
|
|
402
|
-
const witness = readBrowserWitness(cwd) || {};
|
|
403
|
-
const wh = witness.witnessed_hashes || {};
|
|
404
|
-
const mismatches = [];
|
|
405
|
-
for (const e of browserEdits) {
|
|
406
|
-
if (!e || !e.file) continue;
|
|
407
|
-
const witnessed = wh[e.file];
|
|
408
|
-
if (!witnessed) {
|
|
409
|
-
mismatches.push({ file: e.file, reason: 'no witnessed hash recorded (file edited after witness, or witness predates edit)' });
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
const current = hashFileShort(cwd || process.cwd(), e.file);
|
|
413
|
-
if (current !== witnessed) {
|
|
414
|
-
mismatches.push({ file: e.file, witnessed_hash: witnessed, current_hash: current || '(unreadable)' });
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
if (mismatches.length > 0) {
|
|
418
|
-
const summary = mismatches.slice(0, 3).map(m =>
|
|
419
|
-
`${m.file} (witnessed=${m.witnessed_hash || 'none'}, current=${m.current_hash || '(none)'}${m.reason ? '; ' + m.reason : ''})`
|
|
420
|
-
).join('; ');
|
|
421
|
-
residuals.push(`Browser Witness hash mismatch: you witnessed file(s) at one state, but their current content differs. Either the witness was on a different state or the file was reverted/re-edited without re-witnessing. Re-run the browser verb against the current state. Mismatches: ${summary}${mismatches.length > 3 ? `, +${mismatches.length - 3} more` : ''}`);
|
|
422
|
-
logDeviation('deviation.browser-witness-hash-mismatch', { mismatches, operation });
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
if (residuals.length > 0) {
|
|
426
|
-
logDeviation('deviation.gate-deny', { operation, reason: 'stop-gate residuals', residuals });
|
|
427
|
-
return { allowed: false, reason: `stop-gate residuals: ${residuals.join('; ')}`, residuals };
|
|
428
|
-
}
|
|
429
|
-
return { allowed: true };
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (['write', 'edit'].includes(operation) && !hasDispatchedInstruction(sessionId)) {
|
|
433
|
-
logDeviation('deviation.write-before-instruction', { operation, sessionId });
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (operation === 'bash' && extra && extra.command) {
|
|
437
|
-
const pattern = isSpoolPollCommand(extra.command);
|
|
438
|
-
if (pattern) {
|
|
439
|
-
logDeviation('deviation.spool-poll', { operation, pattern, command_excerpt: String(extra.command).slice(0, 200) });
|
|
440
|
-
return { allowed: false, reason: SPOOL_POLL_REASON };
|
|
441
|
-
}
|
|
442
|
-
const searchPattern = isNativeSearchCommand(extra.command);
|
|
443
|
-
if (searchPattern) {
|
|
444
|
-
logDeviation('deviation.native-search-bash', { operation, pattern: searchPattern, command_excerpt: String(extra.command).slice(0, 200) });
|
|
445
|
-
return { allowed: false, reason: NATIVE_SEARCH_REASON };
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (operation === 'mutable-resolve' && extra && (!extra.witness_evidence || String(extra.witness_evidence).trim() === '')) {
|
|
450
|
-
logDeviation('deviation.mutable-without-evidence', { mutable_id: extra.id || null });
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (operation === 'git' && extra && extra.commit_message) {
|
|
454
|
-
const marker = deferMarkerIn(extra.commit_message);
|
|
455
|
-
if (marker) {
|
|
456
|
-
logDeviation('deviation.commit-message-defer', { marker, operation });
|
|
457
|
-
return {
|
|
458
|
-
allowed: false,
|
|
459
|
-
reason: `commit message rejected: deferral phrase '${marker}' detected. Defer markers force closure: either inline-fix and re-witness, or split the deferred work as a separate PRD item with blockedBy: [external] before committing. Rewrite the commit message and retry.`,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (!['write', 'edit', 'git'].includes(operation)) return { allowed: true };
|
|
465
|
-
|
|
466
|
-
if (fs.existsSync(prdPath) && fs.existsSync(needsGmPath) && !fs.existsSync(gmFiredPath)) {
|
|
467
|
-
logDeviation('deviation.gate-deny', { operation, reason: 'gm orchestration in progress' });
|
|
468
|
-
return { allowed: false, reason: 'gm orchestration in progress; skills must complete work before tools execute' };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (fs.existsSync(mutsPath)) {
|
|
472
|
-
try {
|
|
473
|
-
const content = fs.readFileSync(mutsPath, 'utf8');
|
|
474
|
-
if (yamlStatusValues(content).includes('unknown')) {
|
|
475
|
-
logDeviation('deviation.gate-deny', { operation, reason: 'unresolved mutables' });
|
|
476
|
-
return { allowed: false, reason: 'unresolved mutables block tool execution; resolve all mutables before proceeding' };
|
|
477
|
-
}
|
|
478
|
-
} catch (_) {}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return { allowed: true };
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
module.exports = { checkDispatchGates, isWorktreeDirty, hasUnpushedCommits, unsolicitedDocs, logDeviation, markInstructionSeen, hasDispatchedInstruction, isSpoolPollCommand, isNativeSearchCommand, SPOOL_POLL_REASON, recordBrowserEdit, markBrowserWitnessed, clearBrowserTurnMarkers, isBrowserRunningFile, readBrowserEdits, isBrowserWitnessed };
|