gm-thebird 2.0.1069 → 2.0.1071

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/bin/bootstrap.js CHANGED
@@ -4,20 +4,13 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const https = require('https');
8
7
  const crypto = require('crypto');
9
- const { URL } = require('url');
10
-
11
- const RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
12
- const ATTEMPT_TIMEOUT_MS = 5 * 60 * 1000;
13
- const STALL_TIMEOUT_MS = 15 * 1000;
14
- const MAX_ATTEMPTS = 5;
15
- const BACKOFF_MS = [2000, 5000, 15000, 30000];
16
- // Worst case: a slow link downloading 140MB at 1MB/s = ~140s. Allow 30 minutes
17
- // before another bootstrap process treats this lock as abandoned. Below this,
18
- // concurrent bootstrap calls would wipe an in-progress download mid-stream
19
- // (see the v0.1.294 incident where a race between two wrappers blew away the
20
- // .partial during a 10-minute fetch).
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const NPM_PACKAGE = '@anentrypoint/plugkit-wasm';
11
+ const ATTEMPT_TIMEOUT_MS = 10 * 60 * 1000;
12
+ const MAX_ATTEMPTS = 3;
13
+ const BACKOFF_MS = [5000, 15000];
21
14
  const LOCK_STALE_MS = 30 * 60 * 1000;
22
15
 
23
16
  function log(msg) {
@@ -73,18 +66,6 @@ function obsEvent(subsystem, event, fields) {
73
66
  } catch (_) {}
74
67
  }
75
68
 
76
- function platformKey() {
77
- const p = os.platform();
78
- const a = os.arch();
79
- if (p === 'win32') return a === 'arm64' ? 'win32-arm64' : 'win32-x64';
80
- if (p === 'darwin') return a === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
81
- return (a === 'arm64' || a === 'aarch64') ? 'linux-arm64' : 'linux-x64';
82
- }
83
-
84
- function binaryName() {
85
- const key = platformKey();
86
- return key.startsWith('win32') ? `plugkit-${key}.exe` : `plugkit-${key}`;
87
- }
88
69
 
89
70
  function rtkBinaryName() {
90
71
  const key = platformKey();
@@ -117,98 +98,22 @@ function gmToolsDir() {
117
98
  // through node. Self-update inside the Rust binary keeps gm-tools fresh from
118
99
  // here on. Skipped silently on any error — the next session-start hook will
119
100
  // retry via ensure_tools_current.
120
- function killHoldersOfPath(targetPath) {
121
- if (process.platform !== 'win32') return 0;
122
- try {
123
- const { spawnSync } = require('child_process');
124
- const norm = path.resolve(targetPath).replace(/\//g, '\\');
125
- const r = spawnSync('wmic', ['process', 'where', `ExecutablePath='${norm.replace(/\\/g, '\\\\')}'`, 'get', 'ProcessId', '/format:value'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
126
- if (r.status !== 0 || !r.stdout) return 0;
127
- const pids = [];
128
- for (const line of r.stdout.split(/\r?\n/)) {
129
- const m = line.match(/ProcessId=(\d+)/);
130
- if (m) {
131
- const pid = parseInt(m[1], 10);
132
- if (Number.isFinite(pid) && pid !== process.pid) pids.push(pid);
133
- }
134
- }
135
- for (const pid of pids) {
136
- try { spawnSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true, timeout: 3000 }); } catch (_) {}
137
- }
138
- return pids.length;
139
- } catch (_) { return 0; }
140
- }
141
-
142
- function cleanOrphanNewFiles(dst, exeName) {
143
- try {
144
- for (const name of fs.readdirSync(dst)) {
145
- if (name === exeName + '.new') continue;
146
- if (/^plugkit(\.\d+\.\d+\.\d+)?\.new$/i.test(name)) {
147
- try { fs.unlinkSync(path.join(dst, name)); } catch (_) {}
148
- }
149
- }
150
- } catch (_) {}
151
- }
152
-
153
- function renameWithRetry(src, dst, attempts) {
154
- for (let i = 0; i < attempts; i++) {
155
- try { fs.renameSync(src, dst); return true; }
156
- catch (err) {
157
- if (err.code !== 'EEXIST' && err.code !== 'EPERM' && err.code !== 'EBUSY' && err.code !== 'EACCES') throw err;
158
- if (i === Math.floor(attempts / 2)) killHoldersOfPath(dst);
159
- try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 200)'], { timeout: 400, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
160
- }
161
- }
162
- return false;
163
- }
164
101
 
165
- function copyToGmTools(finalPath, wrapperDir, version) {
102
+ function copyWasmToGmTools(wasmPath, wrapperDir, version) {
166
103
  const dst = gmToolsDir();
167
104
  fs.mkdirSync(dst, { recursive: true });
168
- const exeName = process.platform === 'win32' ? 'plugkit.exe' : 'plugkit';
169
- const target = path.join(dst, exeName);
170
- const targetTmp = target + '.new';
171
- cleanOrphanNewFiles(dst, exeName);
105
+ const target = path.join(dst, 'plugkit.wasm');
172
106
  if (fs.existsSync(target)) {
173
- let needsRefresh = true;
174
107
  try {
175
108
  const cur = sha256OfFileSync(target);
176
- const src = sha256OfFileSync(finalPath);
177
- if (cur === src) needsRefresh = false;
178
- } catch (_) {}
179
- if (!needsRefresh) {
180
- try { fs.writeFileSync(path.join(dst, 'plugkit.version'), version); } catch (_) {}
181
- return;
182
- }
183
- try { killHoldersOfPath(target); } catch (_) {}
184
- }
185
- fs.copyFileSync(finalPath, targetTmp);
186
- if (!renameWithRetry(targetTmp, target, 8)) {
187
- try { killHoldersOfPath(target); } catch (_) {}
188
- try { fs.unlinkSync(target); } catch (_) {}
189
- try { fs.renameSync(targetTmp, target); }
190
- catch (_) {
191
- try { fs.unlinkSync(targetTmp); } catch (_) {}
192
- obsEvent('bootstrap', 'gmtools.rename.failed', { target });
193
- writeBootstrapError({
194
- expected_version: version,
195
- cached_version: null,
196
- error_phase: 'gmtools-rename',
197
- error_message: `cannot replace ${target} after kill+unlink retry; orphan removed`,
198
- });
199
- throw new Error(`gm-tools update blocked: cannot replace ${target} (held open by running plugkit and kill-retry exhausted)`);
200
- }
201
- }
202
- try {
203
- for (const name of fs.readdirSync(dst)) {
204
- if (/^plugkit(\.\d+\.\d+\.\d+)?\.new$/i.test(name)) {
205
- try { fs.unlinkSync(path.join(dst, name)); } catch (_) {}
109
+ const src = sha256OfFileSync(wasmPath);
110
+ if (cur === src) {
111
+ try { fs.writeFileSync(path.join(dst, 'plugkit.version'), version); } catch (_) {}
112
+ return;
206
113
  }
207
- }
208
- } catch (_) {}
209
- if (process.platform !== 'win32') {
210
- try { fs.chmodSync(target, 0o755); } catch (_) {}
114
+ } catch (_) {}
211
115
  }
116
+ fs.copyFileSync(wasmPath, target);
212
117
  fs.writeFileSync(path.join(dst, 'plugkit.version'), version);
213
118
  try {
214
119
  const srcSha = path.join(wrapperDir, 'plugkit.sha256');
@@ -319,88 +224,54 @@ function sha256OfFile(filePath) {
319
224
  });
320
225
  }
321
226
 
322
- function fetchToFile(url, destPath, expectedTotal) {
323
- return new Promise((resolve, reject) => {
324
- let existing = 0;
325
- try { existing = fs.statSync(destPath).size; } catch (_) {}
326
- const headers = { 'User-Agent': 'plugkit-bootstrap', 'Accept': '*/*' };
327
- if (existing > 0) headers['Range'] = `bytes=${existing}-`;
328
-
329
- const u = new URL(url);
330
- const req = https.request({
331
- method: 'GET',
332
- hostname: u.hostname,
333
- path: u.pathname + u.search,
334
- headers,
335
- timeout: ATTEMPT_TIMEOUT_MS,
336
- }, (res) => {
337
- if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
338
- res.resume();
339
- return resolve(fetchToFile(res.headers.location, destPath, expectedTotal));
340
- }
341
- if (res.statusCode === 416) {
342
- res.resume();
343
- try { fs.unlinkSync(destPath); } catch (_) {}
344
- return reject(new Error('range-not-satisfiable: cleared partial, retry'));
345
- }
346
- if (!(res.statusCode === 200 || res.statusCode === 206)) {
347
- res.resume();
348
- return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
227
+ async function extractNpmPackageWasm(destPath) {
228
+ const tempDir = path.join(path.dirname(destPath), '.npm-extract-' + Date.now());
229
+ try {
230
+ ensureDir(tempDir);
231
+ const startMs = Date.now();
232
+ log(`extracting npm package ${NPM_PACKAGE}@latest to ${tempDir}`);
233
+ obsEvent('bootstrap', 'npm.extract.start', { package: NPM_PACKAGE, version: 'latest' });
234
+
235
+ const result = spawnSync(
236
+ process.platform === 'win32' ? 'bunx.cmd' : 'bunx',
237
+ ['--bun', NPM_PACKAGE + '@latest', '--save-exact', '--prefix', tempDir],
238
+ {
239
+ stdio: ['ignore', 'pipe', 'pipe'],
240
+ timeout: ATTEMPT_TIMEOUT_MS,
241
+ encoding: 'utf8',
242
+ windowsHide: true,
349
243
  }
350
- const append = res.statusCode === 206 && existing > 0;
351
- // Ensure parent dir exists — a concurrent prune may have removed it
352
- // between lock-acquire and now. Recreating is cheap and avoids a
353
- // confusing ENOENT later.
354
- try { ensureDir(path.dirname(destPath)); } catch (_) {}
355
- const out = fs.createWriteStream(destPath, { flags: append ? 'a' : 'w' });
356
- let bytes = append ? existing : 0;
357
- let lastStderr = Date.now();
358
- let lastByte = Date.now();
359
- const fetchStart = Date.now();
360
- const safeUrl = (() => { try { const p = new URL(url); return p.hostname + p.pathname; } catch(_) { return url.split('?')[0]; } })();
361
- obsEvent('bootstrap', 'fetch.start', { url: safeUrl, resume_from: existing, status: res.statusCode });
362
- const stallTimer = setInterval(() => {
363
- if (Date.now() - lastByte > STALL_TIMEOUT_MS) {
364
- clearInterval(stallTimer);
365
- req.destroy(new Error(`stalled: no bytes for ${STALL_TIMEOUT_MS}ms`));
366
- }
367
- }, 2000);
368
- res.on('data', c => {
369
- bytes += c.length;
370
- lastByte = Date.now();
371
- if (Date.now() - lastStderr > 5000) {
372
- const pct = expectedTotal ? ` ${Math.floor(bytes / expectedTotal * 100)}%` : '';
373
- try { process.stderr.write(`[plugkit-bootstrap] downloading: ${(bytes / 1048576).toFixed(1)} MiB${pct}\n`); } catch (_) {}
374
- lastStderr = Date.now();
375
- }
376
- });
377
- res.pipe(out);
378
- out.on('finish', () => {
379
- clearInterval(stallTimer);
380
- obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: true });
381
- out.close(() => resolve(bytes));
382
- });
383
- out.on('error', err => { clearInterval(stallTimer); obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: false, err: String(err.message || err) }); reject(err); });
384
- res.on('error', err => { clearInterval(stallTimer); reject(err); });
385
- res.on('end', () => clearInterval(stallTimer));
386
- });
387
- req.on('timeout', () => { req.destroy(new Error(`timeout after ${ATTEMPT_TIMEOUT_MS}ms`)); });
388
- req.on('error', reject);
389
- req.end();
390
- });
244
+ );
245
+
246
+ if (result.error) throw result.error;
247
+ if (result.status !== 0) {
248
+ throw new Error(`bunx extraction failed: ${result.stderr || result.stdout || 'unknown error'}`);
249
+ }
250
+
251
+ const nodeModulesPath = path.join(tempDir, 'node_modules', NPM_PACKAGE, 'plugkit.wasm');
252
+ if (!fs.existsSync(nodeModulesPath)) {
253
+ throw new Error(`plugkit.wasm not found in extracted npm package at ${nodeModulesPath}`);
254
+ }
255
+
256
+ fs.copyFileSync(nodeModulesPath, destPath);
257
+ log(`extracted ${nodeModulesPath} ${destPath}`);
258
+ obsEvent('bootstrap', 'npm.extract.end', { dur_ms: Date.now() - startMs, ok: true });
259
+ } finally {
260
+ try { fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 }); } catch (_) {}
261
+ }
391
262
  }
392
263
 
393
- async function downloadWithRetry(url, destPath) {
264
+ async function extractNpmPackageWithRetry(destPath) {
394
265
  let lastErr;
395
266
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
396
267
  try {
397
- log(`fetch attempt ${attempt}/${MAX_ATTEMPTS}: ${url}`);
398
- await fetchToFile(url, destPath);
268
+ log(`npm extract attempt ${attempt}/${MAX_ATTEMPTS}: ${NPM_PACKAGE}@latest`);
269
+ await extractNpmPackageWasm(destPath);
399
270
  return;
400
271
  } catch (err) {
401
272
  lastErr = err;
402
273
  log(`attempt ${attempt} failed: ${err.message}`);
403
- obsEvent('bootstrap', 'fetch.attempt_failed', { url, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
274
+ obsEvent('bootstrap', 'npm.extract.attempt_failed', { package: NPM_PACKAGE, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
404
275
  if (attempt < MAX_ATTEMPTS) {
405
276
  const wait = BACKOFF_MS[attempt - 1] || 120000;
406
277
  log(`backing off ${wait}ms`);
@@ -447,8 +318,8 @@ async function bootstrap(opts) {
447
318
  const wrapperDir = opts.wrapperDir || __dirname;
448
319
  const version = opts.version || readVersionFile(wrapperDir);
449
320
  const shaManifest = readShaManifest(wrapperDir);
450
- const binName = binaryName();
451
- const expectedSha = shaManifest ? shaManifest[binName] : null;
321
+ const wasmName = 'plugkit.wasm';
322
+ const wasmExpectedSha = shaManifest ? shaManifest[wasmName] : null;
452
323
 
453
324
  let root = cacheRoot();
454
325
  try { ensureDir(root); }
@@ -457,121 +328,116 @@ async function bootstrap(opts) {
457
328
  const verDir = path.join(root, `v${version}`);
458
329
  ensureDir(verDir);
459
330
 
460
- const finalPath = path.join(verDir, binName);
461
- const okSentinel = path.join(verDir, '.ok');
462
- const partialPath = `${finalPath}.partial`;
331
+ const wasmFinalPath = path.join(verDir, wasmName);
332
+ const wasmOkSentinel = path.join(verDir, '.wasm-ok');
333
+ const wasmPartialPath = `${wasmFinalPath}.partial`;
463
334
 
464
- if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
465
- if (expectedSha) {
466
- const actualSha = sha256OfFileSync(finalPath);
467
- if (actualSha === expectedSha) {
468
- obsEvent('bootstrap', 'decision.hit', { reason: 'sha-match', version, path: finalPath });
469
- copyToGmTools(finalPath, wrapperDir, version);
335
+ if (fs.existsSync(wasmFinalPath) && fs.existsSync(wasmOkSentinel)) {
336
+ if (wasmExpectedSha) {
337
+ const actualSha = sha256OfFileSync(wasmFinalPath);
338
+ if (actualSha === wasmExpectedSha) {
339
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sha-match', version, path: wasmFinalPath });
340
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
470
341
  clearBootstrapError();
471
- return finalPath;
342
+ return wasmFinalPath;
472
343
  }
473
- log(`decision: fetch reason: cache-hit-sha-mismatch (dir=v${version} expected ${expectedSha.slice(0,12)}… got ${(actualSha||'').slice(0,12)}…)`);
344
+ log(`decision: fetch reason: cache-hit-sha-mismatch (dir=v${version} expected ${wasmExpectedSha.slice(0,12)}… got ${(actualSha||'').slice(0,12)}…)`);
474
345
  writeBootstrapError({
475
346
  expected_version: version,
476
347
  cached_version: null,
477
348
  error_phase: 'cache-hit-sha-mismatch',
478
- error_message: `cached binary at ${finalPath} sha=${actualSha} but manifest expects ${expectedSha}`,
349
+ error_message: `cached wasm at ${wasmFinalPath} sha=${actualSha} but manifest expects ${wasmExpectedSha}`,
479
350
  });
480
- try { fs.unlinkSync(finalPath); } catch (_) {}
481
- try { fs.unlinkSync(okSentinel); } catch (_) {}
351
+ try { fs.unlinkSync(wasmFinalPath); } catch (_) {}
352
+ try { fs.unlinkSync(wasmOkSentinel); } catch (_) {}
482
353
  } else {
483
- obsEvent('bootstrap', 'decision.hit', { reason: 'sentinel+no-sha-manifest', path: finalPath });
484
- copyToGmTools(finalPath, wrapperDir, version);
354
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sentinel+no-sha-manifest', path: wasmFinalPath });
355
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
485
356
  clearBootstrapError();
486
- return finalPath;
357
+ return wasmFinalPath;
487
358
  }
488
359
  }
489
360
 
490
- if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
491
- obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match', path: finalPath });
361
+ if (healIfShaMatches(wasmFinalPath, wasmExpectedSha, wasmOkSentinel, wasmPartialPath, 'wasm')) {
362
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match', path: wasmFinalPath });
492
363
  spawnDetachedRtkFetch(wrapperDir);
493
- copyToGmTools(finalPath, wrapperDir, version);
364
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
494
365
  clearBootstrapError();
495
- return finalPath;
366
+ return wasmFinalPath;
496
367
  }
497
368
 
498
369
  const lockPath = path.join(verDir, '.lock');
499
370
  acquireLock(lockPath);
500
371
  try {
501
- if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
502
- obsEvent('bootstrap', 'decision.hit', { reason: 'lock-race-resolved', path: finalPath });
503
- copyToGmTools(finalPath, wrapperDir, version);
372
+ if (fs.existsSync(wasmFinalPath) && fs.existsSync(wasmOkSentinel)) {
373
+ obsEvent('bootstrap', 'decision.hit', { reason: 'lock-race-resolved', path: wasmFinalPath });
374
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
504
375
  clearBootstrapError();
505
- return finalPath;
376
+ return wasmFinalPath;
506
377
  }
507
- if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
508
- obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match-under-lock', path: finalPath });
378
+ if (healIfShaMatches(wasmFinalPath, wasmExpectedSha, wasmOkSentinel, wasmPartialPath, 'wasm')) {
379
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match-under-lock', path: wasmFinalPath });
509
380
  spawnDetachedRtkFetch(wrapperDir);
510
- copyToGmTools(finalPath, wrapperDir, version);
381
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
511
382
  clearBootstrapError();
512
- return finalPath;
383
+ return wasmFinalPath;
513
384
  }
514
385
 
515
- if (fs.existsSync(partialPath)) {
386
+ if (fs.existsSync(wasmPartialPath)) {
516
387
  try {
517
- const st = fs.statSync(partialPath);
388
+ const st = fs.statSync(wasmPartialPath);
518
389
  if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
519
- fs.unlinkSync(partialPath);
520
- log(`cleared stale partial: ${partialPath}`);
390
+ fs.unlinkSync(wasmPartialPath);
391
+ log(`cleared stale partial: ${wasmPartialPath}`);
521
392
  }
522
393
  } catch (_) {}
523
394
  }
524
- const url = `https://github.com/${RELEASE_REPO}/releases/download/v${version}/${binName}`;
525
395
  try {
526
- await downloadWithRetry(url, partialPath);
527
- } catch (fetchErr) {
396
+ await extractNpmPackageWithRetry(wasmPartialPath);
397
+ } catch (extractErr) {
528
398
  writeBootstrapError({
529
399
  expected_version: version,
530
400
  cached_version: null,
531
- error_phase: 'download',
532
- error_message: fetchErr && fetchErr.message ? fetchErr.message : String(fetchErr),
401
+ error_phase: 'npm-extract',
402
+ error_message: extractErr && extractErr.message ? extractErr.message : String(extractErr),
533
403
  });
534
- throw fetchErr;
404
+ throw extractErr;
535
405
  }
536
406
 
537
- if (expectedSha) {
538
- const got = await sha256OfFile(partialPath);
539
- if (got !== expectedSha) {
540
- try { fs.unlinkSync(partialPath); } catch (_) {}
407
+ if (wasmExpectedSha) {
408
+ const got = await sha256OfFile(wasmPartialPath);
409
+ if (got !== wasmExpectedSha) {
410
+ try { fs.unlinkSync(wasmPartialPath); } catch (_) {}
541
411
  writeBootstrapError({
542
412
  expected_version: version,
543
413
  cached_version: null,
544
414
  error_phase: 'sha256-mismatch',
545
- error_message: `sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`,
415
+ error_message: `sha256 mismatch for ${wasmName}: expected ${wasmExpectedSha}, got ${got}`,
546
416
  });
547
- throw new Error(`sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`);
417
+ throw new Error(`sha256 mismatch for ${wasmName}: expected ${wasmExpectedSha}, got ${got}`);
548
418
  }
549
419
  log('sha256 verified');
550
420
  } else {
551
421
  log('no sha256 manifest — skipping verify');
552
422
  }
553
423
 
554
- try { fs.renameSync(partialPath, finalPath); }
424
+ try { fs.renameSync(wasmPartialPath, wasmFinalPath); }
555
425
  catch (err) {
556
426
  if (err.code === 'EEXIST' || err.code === 'EPERM') {
557
- try { fs.unlinkSync(finalPath); } catch (_) {}
558
- fs.renameSync(partialPath, finalPath);
427
+ try { fs.unlinkSync(wasmFinalPath); } catch (_) {}
428
+ fs.renameSync(wasmPartialPath, wasmFinalPath);
559
429
  } else throw err;
560
430
  }
561
431
 
562
- if (os.platform() !== 'win32') {
563
- try { fs.chmodSync(finalPath, 0o755); } catch (_) {}
564
- }
565
-
566
- fs.writeFileSync(okSentinel, new Date().toISOString());
567
- log(`decision: fetch reason: install-complete (${finalPath})`);
568
- obsEvent('bootstrap', 'install.done', { path: finalPath, version, kind: 'plugkit' });
569
- proactiveKillForNewInstall(version, finalPath);
432
+ fs.writeFileSync(wasmOkSentinel, new Date().toISOString());
433
+ log(`decision: fetch reason: install-complete (${wasmFinalPath})`);
434
+ obsEvent('bootstrap', 'install.done', { path: wasmFinalPath, version, kind: 'wasm' });
570
435
  pruneOldVersions(root, version, readRtkVersion(wrapperDir));
571
436
  spawnDetachedRtkFetch(wrapperDir);
572
- copyToGmTools(finalPath, wrapperDir, version);
437
+ copyWasmToGmTools(wasmFinalPath, wrapperDir, version);
438
+
573
439
  clearBootstrapError();
574
- return finalPath;
440
+ return wasmFinalPath;
575
441
  } finally {
576
442
  releaseLock(lockPath);
577
443
  }
@@ -616,8 +482,33 @@ async function bootstrapRtk(plugkitVerDir, plugkitVersion, wrapperDir, silent, r
616
482
  if (!silent) log(`rtk cache heal (sha match): ${rtkPath}`);
617
483
  return rtkPath;
618
484
  }
619
- const url = `https://github.com/${RELEASE_REPO}/releases/download/v${plugkitVersion}/${rtkName}`;
620
- await downloadWithRetry(url, tmp);
485
+ const RTKS_RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
486
+ const url = `https://github.com/${RTKS_RELEASE_REPO}/releases/download/v${plugkitVersion}/${rtkName}`;
487
+ const startMs = Date.now();
488
+ let lastErr;
489
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
490
+ try {
491
+ log(`rtk download attempt ${attempt}/${MAX_ATTEMPTS}: ${url}`);
492
+ const result = spawnSync(
493
+ 'curl',
494
+ ['-fSL', '--max-time', String(Math.floor(ATTEMPT_TIMEOUT_MS / 1000)), '-o', tmp, url],
495
+ { stdio: 'pipe', timeout: ATTEMPT_TIMEOUT_MS + 5000, windowsHide: true }
496
+ );
497
+ if (result.error) throw result.error;
498
+ if (result.status !== 0) throw new Error(`curl failed with status ${result.status}`);
499
+ break;
500
+ } catch (err) {
501
+ lastErr = err;
502
+ log(`rtk attempt ${attempt} failed: ${err.message}`);
503
+ obsEvent('bootstrap', 'rtk.download.attempt_failed', { attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
504
+ if (attempt < MAX_ATTEMPTS) {
505
+ const wait = BACKOFF_MS[attempt - 1] || 120000;
506
+ log(`backing off ${wait}ms`);
507
+ await new Promise(r => setTimeout(r, wait));
508
+ }
509
+ }
510
+ }
511
+ if (lastErr) throw lastErr;
621
512
  if (expected) {
622
513
  const got = await sha256OfFile(tmp);
623
514
  if (got !== expected) {
@@ -635,7 +526,7 @@ async function bootstrapRtk(plugkitVerDir, plugkitVersion, wrapperDir, silent, r
635
526
  if (os.platform() !== 'win32') { try { fs.chmodSync(rtkPath, 0o755); } catch (_) {} }
636
527
  fs.writeFileSync(rtkOk, new Date().toISOString());
637
528
  log(`installed ${rtkPath}`);
638
- obsEvent('bootstrap', 'install.done', { path: rtkPath, plugkit_version: plugkitVersion, rtk_version: readRtkVersion(wrapperDir) || plugkitVersion, kind: 'rtk' });
529
+ obsEvent('bootstrap', 'install.done', { path: rtkPath, plugkit_version: plugkitVersion, rtk_version: readRtkVersion(wrapperDir) || plugkitVersion, kind: 'rtk', dur_ms: Date.now() - startMs });
639
530
  return rtkPath;
640
531
  }
641
532
 
@@ -655,7 +546,7 @@ function resolveCachedRtk(opts) {
655
546
  return null;
656
547
  }
657
548
 
658
- function resolveCachedBinary(opts) {
549
+ function getWasmPath(opts) {
659
550
  opts = opts || {};
660
551
  const wrapperDir = opts.wrapperDir || __dirname;
661
552
  const version = opts.version || readVersionFile(wrapperDir);
@@ -664,9 +555,9 @@ function resolveCachedBinary(opts) {
664
555
  catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
665
556
  })();
666
557
  const verDir = path.join(root, `v${version}`);
667
- const finalPath = path.join(verDir, binaryName());
668
- const okSentinel = path.join(verDir, '.ok');
669
- if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) return finalPath;
558
+ const wasmPath = path.join(verDir, 'plugkit.wasm');
559
+ const okSentinel = path.join(verDir, '.wasm-ok');
560
+ if (fs.existsSync(wasmPath) && fs.existsSync(okSentinel)) return wasmPath;
670
561
  return null;
671
562
  }
672
563
 
@@ -729,72 +620,6 @@ function killRunningDaemons(reason) {
729
620
  return killedPids;
730
621
  }
731
622
 
732
- function listRunningPlugkitImagePaths() {
733
- const out = [];
734
- try {
735
- const { spawnSync } = require('child_process');
736
- if (os.platform() === 'win32') {
737
- let parsed = null;
738
- try {
739
- const p = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', "Get-Process plugkit* -ErrorAction SilentlyContinue | Select-Object Id,Path | ConvertTo-Json -Compress"], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
740
- const text = ((p && p.stdout) || '').trim();
741
- if (text) {
742
- const j = JSON.parse(text);
743
- parsed = Array.isArray(j) ? j : [j];
744
- }
745
- } catch (_) {}
746
- if (parsed) {
747
- for (const item of parsed) {
748
- if (!item) continue;
749
- const pid = parseInt(item.Id, 10);
750
- if (!Number.isFinite(pid)) continue;
751
- out.push({ pid, path: (item.Path || '').trim() });
752
- }
753
- } else {
754
- const r = spawnSync('tasklist', ['/FI', 'IMAGENAME eq plugkit*', '/FO', 'CSV', '/NH'], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
755
- const text = (r && r.stdout) || '';
756
- const seen = new Set();
757
- for (const line of text.split(/\r?\n/)) {
758
- const m = line.match(/^"([^"]+)","(\d+)"/);
759
- if (!m) continue;
760
- const pid = parseInt(m[2], 10);
761
- if (!Number.isFinite(pid) || seen.has(pid)) continue;
762
- seen.add(pid);
763
- out.push({ pid, path: '' });
764
- }
765
- }
766
- } else if (os.platform() === 'linux') {
767
- let entries = [];
768
- try { entries = fs.readdirSync('/proc'); } catch (_) {}
769
- for (const e of entries) {
770
- if (!/^\d+$/.test(e)) continue;
771
- const pid = parseInt(e, 10);
772
- let comm = '';
773
- try { comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); } catch (_) { continue; }
774
- if (!/^plugkit/i.test(comm)) continue;
775
- let imagePath = '';
776
- try { imagePath = fs.readlinkSync(`/proc/${pid}/exe`); } catch (_) {}
777
- out.push({ pid, path: imagePath });
778
- }
779
- } else {
780
- const r = spawnSync('ps', ['-axo', 'pid=,comm='], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
781
- const text = (r && r.stdout) || '';
782
- for (const line of text.split(/\r?\n/)) {
783
- const m = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
784
- if (!m) continue;
785
- if (!/plugkit/i.test(m[2])) continue;
786
- const pid = parseInt(m[1], 10);
787
- let imagePath = '';
788
- try {
789
- const p = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
790
- imagePath = ((p && p.stdout) || '').trim().split(/\s+/)[0] || '';
791
- } catch (_) {}
792
- out.push({ pid, path: imagePath });
793
- }
794
- }
795
- } catch (_) {}
796
- return out;
797
- }
798
623
 
799
624
  function killSpoolWatcherInCwd(reason) {
800
625
  try {
@@ -811,39 +636,21 @@ function killSpoolWatcherInCwd(reason) {
811
636
  return null;
812
637
  }
813
638
 
814
- function proactiveKillForNewInstall(installedVersion, finalPath) {
639
+ function proactiveKillForNewInstall(installedVersion) {
815
640
  try {
816
641
  const reason = `install:v${installedVersion}`;
817
- const target = finalPath ? path.resolve(finalPath).toLowerCase() : null;
818
- const cacheRootNorm = (() => {
819
- try { return path.resolve(cacheRoot()).toLowerCase(); } catch (_) { return null; }
820
- })();
821
- const procs = listRunningPlugkitImagePaths();
822
- for (const { pid, path: imagePath } of procs) {
823
- if (!Number.isFinite(pid) || pid === process.pid) continue;
824
- if (!imagePath) continue;
825
- const norm = path.resolve(imagePath).toLowerCase();
826
- if (target && norm === target) continue;
827
- if (!cacheRootNorm || !norm.startsWith(cacheRootNorm + path.sep.toLowerCase())) continue;
828
- if (killPid(pid)) {
829
- try { process.stderr.write(`[bootstrap] killed stale daemon pid=${pid} path=${imagePath} (current install: v${installedVersion})\n`); } catch (_) {}
830
- obsEvent('bootstrap', 'daemon.killed', { pid, oldPath: imagePath, installedVersion, mechanism: 'process-path' });
831
- }
832
- }
833
642
  killRunningDaemons(reason);
834
643
  killSpoolWatcherInCwd(reason);
835
644
  writeDaemonVersion(installedVersion);
836
645
  } catch (_) {}
837
646
  }
838
647
 
839
- // Compare wrapper-pinned version against last-recorded daemon version. If
840
- // they differ, kill the daemon so it respawns under the new binary.
841
648
  function killStaleDaemonIfVersionChanged(wrapperDir) {
842
649
  let currentVersion;
843
650
  try { currentVersion = readVersionFile(wrapperDir); } catch (_) { return; }
844
- const cached = resolveCachedBinary({ wrapperDir, version: currentVersion });
651
+ const cached = getWasmPath({ wrapperDir, version: currentVersion });
845
652
  if (cached) {
846
- proactiveKillForNewInstall(currentVersion, cached);
653
+ proactiveKillForNewInstall(currentVersion);
847
654
  return;
848
655
  }
849
656
  const recorded = readDaemonVersion();
@@ -852,7 +659,7 @@ function killStaleDaemonIfVersionChanged(wrapperDir) {
852
659
  writeDaemonVersion(currentVersion);
853
660
  }
854
661
 
855
- module.exports = { bootstrap, resolveCachedBinary, resolveCachedRtk, platformKey, binaryName, rtkBinaryName, cacheRoot, obsEvent, killRunningDaemons, killStaleDaemonIfVersionChanged, killSpoolWatcherInCwd, proactiveKillForNewInstall };
662
+ module.exports = { bootstrap, getWasmPath, resolveCachedRtk, rtkBinaryName, cacheRoot, obsEvent, killRunningDaemons, killStaleDaemonIfVersionChanged, killSpoolWatcherInCwd, proactiveKillForNewInstall };
856
663
 
857
664
  if (require.main === module) {
858
665
  const argv = process.argv.slice(2);
package/bin/plugkit.js CHANGED
@@ -81,13 +81,40 @@ function ensureReady(silent) {
81
81
  return r.status === 0 && isReady();
82
82
  }
83
83
 
84
+ function wasmPath() {
85
+ return path.join(wrapperDir, 'plugkit.wasm');
86
+ }
87
+
88
+ function shouldUseWasm() {
89
+ if (process.env.GM_USE_WASM === '1') return true;
90
+ if (fs.existsSync(wasmPath()) && !fs.existsSync(toolsBin())) return true;
91
+ return false;
92
+ }
93
+
94
+ async function runWasm(args) {
95
+ try {
96
+ const WasmHost = require('../lib/wasm-host');
97
+ const host = new WasmHost(wasmPath());
98
+ const verb = args[0] || 'health';
99
+ const body = args.slice(1).join(' ') || '{}';
100
+ const result = await host.dispatch(verb, body);
101
+ console.log(JSON.stringify(result, null, 2));
102
+ process.exit(result.ok ? 0 : 1);
103
+ } catch (err) {
104
+ console.error('[plugkit wasm]', err.message);
105
+ process.exit(1);
106
+ }
107
+ }
108
+
84
109
  function main() {
85
110
  const args = process.argv.slice(2);
86
111
  const isHook = args[0] === 'hook';
87
112
  const hookSubcmd = isHook ? (args[1] || '') : '';
88
113
 
89
- // Synchronous readiness check on these hooks. Hot path: isReady() is sha-match
90
- // against pinned manifest, returns true in <50ms with no network.
114
+ if (shouldUseWasm()) {
115
+ return runWasm(args);
116
+ }
117
+
91
118
  const blocksUntilReady = hookSubcmd === 'session-start' || hookSubcmd === 'prompt-submit';
92
119
 
93
120
  if (blocksUntilReady) {
@@ -96,8 +123,6 @@ function main() {
96
123
  process.exit(1);
97
124
  }
98
125
  } else if (!fs.existsSync(toolsBin())) {
99
- // For non-blocking hooks (pre-tool-use, post-tool-use, stop, etc.): if the
100
- // binary doesn't exist yet, exit cleanly — session-start will populate it.
101
126
  if (isHook) process.exit(0);
102
127
  process.exit(1);
103
128
  }
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1069",
3
+ "version": "2.0.1071",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-thebird",
3
- "version": "2.0.1069",
3
+ "version": "2.0.1071",
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/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1069",
3
+ "version": "2.0.1071",
4
4
  "description": "Spool-dispatch orchestration engine with unified state machine, skills, and automated git enforcement",
5
5
  "author": {
6
6
  "name": "AnEntrypoint",
@@ -5,7 +5,7 @@ description: Mandatory codebase search workflow. Use whenever you need to find a
5
5
 
6
6
  # Codebase search
7
7
 
8
- `exec:codesearch` is the only codebase search tool. Grep, Glob, Find, Explore, raw `grep`/`rg`/`find` inside `exec:bash` are all hook-blocked. No fallback.
8
+ `exec:codesearch` is the only codebase search tool. Never use Grep, Glob, Find, Explore, raw `grep`/`rg`/`find` inside `exec:bash`. No fallback.
9
9
 
10
10
  A `@<discipline>` first-token after the verb scopes the search to that discipline's index; absent the sigil, results fan across default plus enabled disciplines, prefixed by source.
11
11
 
@@ -26,7 +26,7 @@ Failure triage: broken output to EMIT, wrong logic to EXECUTE, new unknown to PL
26
26
  - `git_clean` — `git status --porcelain` returns empty
27
27
  - `git_pushed` — `git log origin/main..HEAD --oneline` returns empty
28
28
  - `ci_passed` — every GitHub Actions run reaches `conclusion: success`
29
- - `mutables_resolved` — `.gm/mutables.yml` deleted OR every entry `status: witnessed`. Stop hook hard-blocks turn-stop while any entry is `status: unknown`.
29
+ - `mutables_resolved` — `.gm/mutables.yml` deleted OR every entry `status: witnessed`. Never stop the turn while any entry is `status: unknown` — this gate is self-enforced.
30
30
  - `prd_empty` — `.gm/prd.yml` deleted AFTER residual scan: enumerate every in-spirit reachable residual surfaced this session; any hit re-enters `planning`, appends PRD items, executes. Empty PRD is necessary, not sufficient — done = empty PRD AND zero reachable in-spirit residuals. Out-of-spirit-or-unreachable residuals are named in the response and skipped; everything else is this turn's work.
31
31
  - `stress_suite_clear` — change walked through M1–D1 (governance), none flunked
32
32
  - `hidden_decision_posture` — open → down_weighted → closed only when CI is green AND stress suite is clear
@@ -81,7 +81,7 @@ Both must return empty. Local commit without push is not complete.
81
81
 
82
82
  ## CI is automated
83
83
 
84
- The Stop hook watches Actions for the pushed HEAD. Do not call `gh run list` manually. All-green Stop approves with CI summary in next-turn context. FailureStop blocks with run names + IDs; investigate via `gh run view <id> --log-failed`, fix, push, hook re-watches. Deadline 180s (override `GM_CI_WATCH_SECS`); slow jobs get a "still in progress" approve.
84
+ After `git push`, poll `gh run list --branch main --limit 5 --json status,name,databaseId` until all runs reach a terminal state. Greencontinue; failure investigate via `gh run view <id> --log-failed`, fix, push again. Deadline 180s (override `GM_CI_WATCH_SECS`). Poll every 10s via a nodejs spool file with a `setInterval` loop writing results to stdout.
85
85
 
86
86
  ## Hygiene sweep
87
87
 
@@ -49,7 +49,7 @@ Re-import from disk — in-memory state is stale and inadmissible. Run identical
49
49
 
50
50
  ## Mutables gate
51
51
 
52
- Before pre-emit run, read `.gm/mutables.yml`. Any entry with `status: unknown` → regress to `gm-execute`. The pre-tool-use hook hard-blocks Write/Edit/NotebookEdit while unresolved entries exist; trying to emit anyway returns deny. Zero unresolved is the precondition for every legitimacy question below.
52
+ Before pre-emit run, read `.gm/mutables.yml`. Any entry with `status: unknown` → regress to `gm-execute`. Never use Write/Edit/NotebookEdit while unresolved entries exist this gate is self-enforced. Zero unresolved is the precondition for every legitimacy question below.
53
53
 
54
54
  ## Gate (all true at once)
55
55
 
@@ -30,7 +30,7 @@ Resolves to KNOWN only when all four pass:
30
30
 
31
31
  Unresolved after 2 passes regresses to `planning`. Never narrate past an unresolved mutable.
32
32
 
33
- Every witness that resolves a mutable writes back to `.gm/mutables.yml` the same step: set `status: witnessed` and fill `witness_evidence` with concrete proof (file:line, codesearch hit, exec output snippet). No write-back = the mutable stays unknown and the EMIT-gate stays closed. The hook reads this file; the agent's memory of "I resolved it" does not unblock anything.
33
+ Every witness that resolves a mutable writes back to `.gm/mutables.yml` the same step: set `status: witnessed` and fill `witness_evidence` with concrete proof (file:line, codesearch hit, exec output snippet). No write-back = the mutable stays unknown and the EMIT-gate stays closed. The file is the record; the agent's memory of "I resolved it" does not count.
34
34
 
35
35
  Route candidates from PLAN are `weak_prior` only. Plausibility is the right to test, not the right to believe. A claim with no witness in the current session is a hypothesis — say so when stating it, and say what would settle it. The next reader (you, next turn) needs to know which lines were earned and which were carried forward.
36
36
 
@@ -44,13 +44,13 @@ Code AND utility verbs both run through the file-spool. Write a file to `.gm/exe
44
44
 
45
45
  Pack runs: `Promise.allSettled`, each idea own try/catch, under 12s per call. Runner: write `in/runner/<N>.txt` with body `start` | `stop` | `status`.
46
46
 
47
- Every exec daemonizes. The hook tails the task logfile up to 30s wall-clock and returns whatever is there — short tasks complete inside the window and look synchronous; long tasks return a task_id with partial output. Continue with `exec:tail` (drain, bounded), `exec:watch` (resume blocking until match or timeout), or `exec:close` (terminate). Never re-spawn a long task to check on it — that orphans the first one. `exec:wait` is a pure timer; `exec:sleep` blocks on a specific task's output; `exec:watch` is the match-or-timeout primitive. Every execution-platform RPC returns the live list of running tasks for this session — close stragglers via `exec:close\n<id>` so the list stays scannable. Session-end (clear/logout/prompt_input_exit) kills the session's tasks; compaction/handoff preserves them.
47
+ Every exec daemonizes. The spool watcher tails the task logfile up to 30s wall-clock and returns whatever is there — short tasks complete inside the window and look synchronous; long tasks return a task_id with partial output. Continue with `exec:tail` (drain, bounded), `exec:watch` (resume blocking until match or timeout), or `exec:close` (terminate). Never re-spawn a long task to check on it — that orphans the first one. `exec:wait` is a pure timer; `exec:sleep` blocks on a specific task's output; `exec:watch` is the match-or-timeout primitive. Every execution-platform RPC returns the live list of running tasks for this session — close stragglers via `exec:close\n<id>` so the list stays scannable. Session-end (clear/logout/prompt_input_exit) kills the session's tasks; compaction/handoff preserves them.
48
48
 
49
- Every utility verb dispatches via `in/<verb>/<N>.txt`; the body of the file is the verb's argument. There is no inline form and no Bash-prefix form — both are denied by the hook.
49
+ Every utility verb dispatches via `in/<verb>/<N>.txt`; the body of the file is the verb's argument. There is no inline form and no Bash-prefix form — use only the spool path.
50
50
 
51
51
  ## Codebase search
52
52
 
53
- Codesearch only. Grep, Glob, Find, Explore, raw grep/rg/find inside Bash are all hook-blocked. Write query to `.gm/exec-spool/in/codesearch/<N>.txt`. Read result from `.gm/exec-spool/out/<N>.out`.
53
+ Codesearch only. Never use Grep, Glob, Find, Explore, raw grep/rg/find inside Bash. Write query to `.gm/exec-spool/in/codesearch/<N>.txt`. Read result from `.gm/exec-spool/out/<N>.out`.
54
54
 
55
55
  Start two words, change/add one per pass, minimum four attempts before concluding absent. Known absolute path → `Read`. Known directory → nodejs spool file + `fs.readdirSync`.
56
56
 
@@ -80,4 +80,4 @@ Up to 3 `gm:gm` subagents for independent items in one message. Browser escalati
80
80
 
81
81
  ## CI is automated
82
82
 
83
- `git push` triggers the Stop hook to watch Actions for the pushed HEAD on the same repo (downstream cascades are not auto-watched). Green → Stop approves with summary; failure → run names + IDs surfaced, investigate via `gh run view <id> --log-failed`. Deadline 180s (override `GM_CI_WATCH_SECS`).
83
+ After `git push`, poll `gh run list --branch main --limit 5 --json status,name,databaseId` until all runs reach a terminal state. Green → continue; failure → investigate via `gh run view <id> --log-failed`, fix, push again. Deadline 180s (override `GM_CI_WATCH_SECS`). Poll every 10s via a nodejs spool file with a `setInterval` loop writing results to stdout.
@@ -77,7 +77,7 @@ Plan exits when zero new unknowns surfaced last pass AND every item has acceptan
77
77
 
78
78
  ## .gm/mutables.yml — co-equal with .gm/prd.yml
79
79
 
80
- Every unknown surfaced during PLAN lands as an entry in `.gm/mutables.yml` the same pass. Live during work, deleted when empty. Hook-gated: Write/Edit/NotebookEdit and `git commit`/`git push` are hard-blocked while any entry has `status: unknown`; turn-stop is hard-blocked the same way.
80
+ Every unknown surfaced during PLAN lands as an entry in `.gm/mutables.yml` the same pass. Live during work, deleted when empty. Self-enforced: never use Write/Edit/NotebookEdit, never run `git commit`/`git push`, never stop the turn while any entry has `status: unknown`. This discipline is owned by the agent — not by external infrastructure.
81
81
 
82
82
  ```yaml
83
83
  - id: kebab-id
@@ -143,7 +143,7 @@ The 200 lines are a *budget* for maximum surface coverage, not a target. Subsyst
143
143
 
144
144
  Code execution AND utility verbs both write to `.gm/exec-spool/in/<lang-or-verb>/<N>.<ext>`. Languages live under `in/<lang>/` (nodejs, python, bash, typescript, go, rust, c, cpp, java, deno); verbs live under `in/<verb>/` (codesearch, recall, memorize, wait, sleep, status, close, browser, runner, type, kill-port, forget, feedback, learn-status, learn-debug, learn-build, discipline, pause, health). The spool watcher runs the file and streams to `out/<N>.out` (stdout) + `out/<N>.err` (stderr) line-by-line, then writes `out/<N>.json` metadata (exitCode, durationMs, timedOut, startedAt, endedAt) at completion. Both streams return as systemMessage with `--- stdout ---` / `--- stderr ---` separators. `in/` and `out/` are wiped at session start and at real-exit session end. Only `git` (and `gh`) run directly via Bash; never `Bash(node/npm/npx/bun)`, never `Bash(exec:<anything>)`. Spool paths in nodejs files are platform-literal — use `os.tmpdir()` and `path.join`. The spool enforces per-task timeouts; on timeout, partial output is preserved and the watcher emits `[exec timed out after Nms; partial output above]`.
145
145
 
146
- Codesearch only — Grep/Glob/Find/Explore are hook-blocked. Write to `.gm/exec-spool/in/codesearch/<N>.txt`. Start two words, change/add one per pass, minimum four attempts before concluding absent.
146
+ Codesearch only — never use Grep/Glob/Find/Explore. Write to `.gm/exec-spool/in/codesearch/<N>.txt`. Start two words, change/add one per pass, minimum four attempts before concluding absent.
147
147
 
148
148
  Pack runs use `Promise.allSettled`, each idea its own try/catch, under 12s per call.
149
149