promptwheel 0.8.2 → 0.8.4

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.
@@ -16,7 +16,7 @@ import { execSync, execFileSync } from 'node:child_process';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, realpathSync, readdirSync, lstatSync, copyFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
- import { join, dirname, basename, isAbsolute, relative, sep } from 'node:path';
19
+ import { join, dirname, basename, isAbsolute, relative, sep, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -36,6 +36,22 @@ const spread = (xs) => { const a = xs.filter((x) => x != null); return a.length
36
36
  // repo is still measuring in (a long gate elsewhere must not lose its worktree mid-run)
37
37
  function tmpOwnerTag(repo) { let r = repo; try { r = realpathSync(repo); } catch { /* keep as given */ } return createHash('sha1').update(String(r)).digest('hex').slice(0, 8); }
38
38
  const tmpPrefix = (repo, kind) => `${kind}-${tmpOwnerTag(repo)}-`;
39
+ // where the engine makes its clean copies: the system temp folder, or, where that is read-only (Claude Code's sandbox,
40
+ // a locked-down CI runner), a folder inside the repo's own .git, which git status never shows
41
+ const tmpBases = new Map();
42
+ function tmpBase(repo) {
43
+ if (tmpBases.has(repo)) return tmpBases.get(repo);
44
+ let base = tmpdir();
45
+ try { rmSync(mkdtempSync(join(base, 'pw-probe-')), { recursive: true, force: true }); }
46
+ catch {
47
+ let gd = ''; try { gd = resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-common-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { /* */ }
48
+ let ok = false;
49
+ if (gd) { const g = join(gd, 'promptwheel-tmp'); try { mkdirSync(g, { recursive: true }); rmSync(mkdtempSync(join(g, 'pw-probe-')), { recursive: true, force: true }); base = g; ok = true; } catch { /* */ } }
50
+ // a sandboxed shell (Claude Code's sandbox makes the temp folder and .git read-only): say so plainly, not with a stack
51
+ if (!ok) throw new Error('this shell cannot write a temp folder or .git (a sandboxed agent shell). Run the check outside the sandbox; under attach, the Stop hook checks each turn outside it.');
52
+ }
53
+ tmpBases.set(repo, base); return base;
54
+ }
39
55
  // exactly the names mkdtemp makes from tmpPrefix: the kind, the 8-hex owner tag, then mkdtemp's 6 random
40
56
  // characters. Self-heal removes nothing else, so a folder that only starts with "promptwheel-" is never ours.
41
57
  const ENGINE_TMP = /^(promptwheel-src|promptwheel|pw-idx)-([0-9a-f]{8})-[A-Za-z0-9]{6}$/;
@@ -273,7 +289,7 @@ const setupFails = new WeakMap(); // a measureAt result -> how its setup faile
273
289
 
274
290
  // measure every metric `repeat` times at a ref, in a throwaway worktree (never touches your tree)
275
291
  function measureAt(repo, ref, cfg, repeat) {
276
- const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel')));
292
+ const wt = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'promptwheel')));
277
293
  git(['worktree', 'add', '--quiet', '--detach', wt, ref], repo);
278
294
  try {
279
295
  const env = bridgeEnv(repo, wt, cfg);
@@ -489,7 +505,7 @@ function measureSourceOnlyAll(repo, base, head, defs, cfg, repeat) {
489
505
  if (!defs.length) return out;
490
506
  const { source } = changedSourcePaths(repo, base, head, evaluatorMatcher(cfg));
491
507
  if (!source.length) { for (const m of defs) out[m.name] = { samples: [], hadSourceChange: false }; return out; } // a "win" with zero source edits = goalposts moved
492
- const wt = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'promptwheel-src')));
508
+ const wt = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'promptwheel-src')));
493
509
  git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
494
510
  try {
495
511
  const env = bridgeEnv(repo, wt, cfg);
@@ -550,7 +566,7 @@ function judgeGaming(m, srcResult, threshold = 0.5) {
550
566
  // touching the real index or files, so a gate sees a file an agent just added.
551
567
  // Returns 'HEAD' when nothing changed.
552
568
  function workingSnapshot(repo) {
553
- const idxDir = mkdtempSync(join(tmpdir(), tmpPrefix(repo, 'pw-idx')));
569
+ const idxDir = mkdtempSync(join(tmpBase(repo), tmpPrefix(repo, 'pw-idx')));
554
570
  const env = {
555
571
  ...process.env, GIT_INDEX_FILE: join(idxDir, 'index'),
556
572
  GIT_AUTHOR_NAME: 'promptwheel', GIT_AUTHOR_EMAIL: 'promptwheel@local',
@@ -589,7 +605,7 @@ function selfHeal(repo) {
589
605
  git(['worktree', 'list', '--porcelain'], repo)
590
606
  .split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9).trim()),
591
607
  );
592
- const tmp = tmpdir(), now = Date.now(), mine = tmpOwnerTag(repo);
608
+ const tmp = tmpBase(repo), now = Date.now(), mine = tmpOwnerTag(repo);
593
609
  for (const name of readdirSync(tmp)) {
594
610
  const made = ENGINE_TMP.exec(name);
595
611
  if (!made) continue; // not a name this engine generates (worktrees + temp-index dirs): never removed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "A deterministic, zero-LLM check for your coding loop. A win must survive a re-run from the agent's source edits alone, so a test that went green by editing the test reads GAMED. Runs in CI or a Claude Code plugin.",
5
5
  "type": "module",
6
6
  "bin": {