gm-skill 2.0.1864 → 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 CHANGED
@@ -158,7 +158,7 @@ Push to any rs-* sibling -> `cascade.yml` -> rs-plugkit `release.yml` -> single
158
158
 
159
159
  ## Spool-dispatch architecture replaces hooks
160
160
 
161
- Orchestration state tracked via `.gm/` marker files, not hook events; CLI's `checkDispatchGates()` gates Write/Edit/git pre-execution. Marker set + SpoolDispatcher mechanism: the recall store (`recall: gate enforcement layer`, `recall: spool dispatch gates marker files`).
161
+ Orchestration state tracked via `.gm/` marker files, not hook events; the gate that admits Write/Edit/git pre-execution runs natively inside `plugkit.wasm` (rs-plugkit `gates.rs` + the `hook_pre_tool_use`/`hook_stop` exports), driven off the same `.gm/` markers. (The former JS `lib/spool-dispatch.js`/`checkDispatchGates()` reimplementation was removed once the gate moved into the wasm binary.) Marker set + gate mechanism: the recall store (`recall: gate enforcement layer`, `recall: spool dispatch gates marker files`).
162
162
 
163
163
  **gm tool-use sequencing**: `Skill(skill="gm")` clears needs-gm gate. One shipped skill, no subagent variant. Marker mechanics: the recall store (`recall: gm-skill tool-use sequencing mechanics`).
164
164
 
package/README.md CHANGED
@@ -61,7 +61,6 @@ gm/
61
61
  |-- bin/ <- bootstrap + plugkit launcher (gmsniff / ccsniff are separate npm packages, `bun x gmsniff`, `bun x ccsniff`)
62
62
  |-- lib/ <- runtime: spool dispatch, skill bootstrap, daemon mgmt
63
63
  |-- agents/ <- subagent prompts (gm, memorize, research-worker, textprocessing)
64
- |-- prompts/ <- bash-deny, session-start, prompt-submit, pre-compact
65
64
  |-- lang/ <- language packs (browser, ssh)
66
65
  |-- gm-plugkit/ <- separate npm package that ships the wasm-wrapper
67
66
  |-- gm.json <- version + plugkit pin
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1864",
3
+ "version": "2.0.1865",
4
4
  "description": "Bootstrap and daemon-spawn tool for gm plugkit binary. Downloads the correct platform binary, verifies SHA256, and starts the spool watcher daemon. Includes plugkit-wasm-wrapper for WASM-based spool watching.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -16,8 +16,6 @@
16
16
  "plugkit-wasm-wrapper.js",
17
17
  "plugkit.version",
18
18
  "plugkit.sha256",
19
- "SKILL.md",
20
- "SKILL-*.md",
21
19
  "instructions/"
22
20
  ],
23
21
  "keywords": [
@@ -2823,8 +2823,8 @@ function makeHostFunctions(instanceRef) {
2823
2823
  const __nl = __tail.indexOf('\n');
2824
2824
  const __jsonStr = __nl >= 0 ? __tail.slice(0, __nl) : __tail;
2825
2825
  try { __defResult = JSON.parse(__jsonStr); __defHasResult = true; } catch (_) {}
2826
- // Strip the sentinel (and the leading newline we prepended) so the
2827
- // caller's own stdout is returned clean.
2826
+ // Strip the __GM_RESULT__ sentinel line (console.log emits it on its
2827
+ // own line after the user's output) so the caller's stdout is clean.
2828
2828
  let __clean = __defStdout.slice(0, __ri) + (__nl >= 0 ? __tail.slice(__nl + 1) : '');
2829
2829
  if (__clean.endsWith('\n')) __clean = __clean.slice(0, -1);
2830
2830
  __defStdout = __clean;
@@ -4379,7 +4379,18 @@ async function runSpoolWatcher(instance, spoolDir) {
4379
4379
 
4380
4380
  setTimeout(() => {
4381
4381
  try {
4382
- _writeStatusBusy(360000);
4382
+ // The boot warmup's codesearch triggers a codeinsight reindex + full
4383
+ // in-wasm embed of the project when the stored digest is absent/stale.
4384
+ // That embed is a SYNCHRONOUS wasm call that blocks the event loop, so
4385
+ // the 5s heartbeat interval cannot fire while it runs. On a large repo a
4386
+ // cold embed can take several minutes; if it exceeds the busy_until
4387
+ // window the supervisor's stale-heartbeat check kills the watcher
4388
+ // mid-embed, the digest never persists, and every respawn re-embeds from
4389
+ // scratch -- an unbounded restart loop that spawns duplicate watchers.
4390
+ // The window must therefore comfortably exceed the worst-case cold embed;
4391
+ // it is a one-time cost (the digest persists after a single completion,
4392
+ // so subsequent boots' warmup is near-instant).
4393
+ _writeStatusBusy(1200000);
4383
4394
  const vb = new TextEncoder().encode('codesearch');
4384
4395
  const bb = new TextEncoder().encode(JSON.stringify({ query: 'index warmup', k: 1 }));
4385
4396
  const vp = writeWasmInput(instance, vb, 'boot-warmup:codesearch.verb');
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1864",
3
+ "version": "2.0.1865",
4
4
  "description": "Spool-dispatch orchestration engine with unified state machine, skills, and automated git enforcement",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
package/lib/spool.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
4
3
 
5
4
  function getSpoolBaseDir() {
6
5
  const cwd = process.cwd();
@@ -162,54 +161,10 @@ async function execSpool(body, lang, options = {}) {
162
161
  return result;
163
162
  }
164
163
 
165
- async function execCodesearch(query, options = {}) { return execSpool(query, 'codesearch', options); }
166
- async function execRecall(query, options = {}) { return execSpool(query, 'recall', options); }
167
- async function execMemorize(fact, options = {}) { return execSpool(fact, 'memorize', options); }
168
-
169
- function getAllOutputs() {
170
- const baseDir = getSpoolBaseDir();
171
- const outDir = path.join(baseDir, 'out');
172
-
173
- if (!fs.existsSync(outDir)) {
174
- return [];
175
- }
176
-
177
- const files = fs.readdirSync(outDir);
178
- const taskIds = new Set();
179
-
180
- files.forEach(file => {
181
- const match = file.match(/^(.+?)\.(out|err|json)$/);
182
- if (match) {
183
- taskIds.add(match[1]);
184
- }
185
- });
186
-
187
- return Array.from(taskIds).map(id => readSpoolOutput(id));
188
- }
189
-
190
- async function getEvents(sessionId, cwd) {
191
- try {
192
- const { getSnapshot } = require('./skill-bootstrap');
193
- return await getSnapshot(sessionId, cwd);
194
- } catch (e) {
195
- return { error: e.message };
196
- }
197
- }
198
-
199
164
  module.exports = {
200
165
  writeSpool,
201
166
  writeSpoolVerb,
202
167
  readSpoolOutput,
203
168
  waitForCompletion,
204
- execSpool,
205
- execCodesearch,
206
- execRecall,
207
- execMemorize,
208
- getAllOutputs,
209
- getSpoolBaseDir,
210
- generateTaskId,
211
- validateLang,
212
- getExtForLang,
213
- validateVerb,
214
- getEvents
169
+ execSpool
215
170
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-skill",
3
- "version": "2.0.1864",
3
+ "version": "2.0.1865",
4
4
  "description": "Canonical universal harness — AI-native software engineering via skill-driven orchestration; bootstraps plugkit for task execution and session isolation. Install in any AI coding agent host.",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -31,7 +31,6 @@
31
31
  "files": [
32
32
  "skills/",
33
33
  "agents/",
34
- "prompts/",
35
34
  "lib/",
36
35
  "lang/",
37
36
  "scripts/",
@@ -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 };