@ai-dev-methodologies/rlp-desk 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/docs/rlp-desk/artifact-schema.md +99 -0
  2. package/docs/rlp-desk/ci-setup.md +100 -0
  3. package/docs/rlp-desk/e2e-scenarios.md +102 -0
  4. package/docs/rlp-desk/plans/rlp-desk-0.11.1-tmux-pane-disappearance.md +260 -0
  5. package/docs/rlp-desk/plans/rlp-desk-tmux-flywheel-routing.md +730 -0
  6. package/install.sh +93 -20
  7. package/package.json +8 -2
  8. package/scripts/build-node-manifest.js +52 -0
  9. package/scripts/postinstall.js +162 -8
  10. package/src/commands/rlp-desk.md +48 -25
  11. package/src/governance.md +68 -6
  12. package/src/node/MANIFEST.txt +15 -0
  13. package/src/node/cli/command-builder.mjs +25 -5
  14. package/src/node/constants.mjs +19 -0
  15. package/src/node/polling/signal-poller.mjs +119 -3
  16. package/src/node/runner/campaign-main-loop.mjs +470 -41
  17. package/src/node/runner/leader-registry.mjs +100 -0
  18. package/src/node/runner/prompt-dismisser.mjs +200 -0
  19. package/src/node/shared/fs.mjs +38 -0
  20. package/src/node/util/debug-log.mjs +56 -0
  21. package/src/node/util/shell-quote.mjs +12 -0
  22. package/docs/superpowers/plans/2026-04-24-gpt-5-5-default.md +0 -517
  23. package/docs/superpowers/specs/2026-04-24-gpt-5-5-default.md +0 -107
  24. /package/docs/{TODO-verification-next.md → rlp-desk/TODO-verification-next.md} +0 -0
  25. /package/docs/{architecture.md → rlp-desk/architecture.md} +0 -0
  26. /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-flywheel-enhancement.md +0 -0
  27. /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-pivot-step.md +0 -0
  28. /package/docs/{blueprints → rlp-desk/blueprints}/plan-flywheel-enhancement.md +0 -0
  29. /package/docs/{blueprints → rlp-desk/blueprints}/sv-architecture-rethink.md +0 -0
  30. /package/docs/{getting-started.md → rlp-desk/getting-started.md} +0 -0
  31. /package/docs/{internal → rlp-desk/internal}/verification-policy-gap-analysis.md +0 -0
  32. /package/docs/{internal → rlp-desk/internal}/verification-strategy-research.md +0 -0
  33. /package/docs/{multi-mission-orchestration.md → rlp-desk/multi-mission-orchestration.md} +0 -0
  34. /package/docs/{plans → rlp-desk/plans}/cozy-gliding-trinket.md +0 -0
  35. /package/docs/{plans → rlp-desk/plans}/frolicking-churning-honey.md +0 -0
  36. /package/docs/{plans → rlp-desk/plans}/keen-sauteeing-snowflake.md +0 -0
  37. /package/docs/{plans → rlp-desk/plans}/mutable-booping-corbato.md +0 -0
  38. /package/docs/{plans → rlp-desk/plans}/rlp-desk-0.11-handoff-7fixes.md +0 -0
  39. /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert-agent-a8cd695ffca2a3ad8.md +0 -0
  40. /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert.md +0 -0
  41. /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie-agent-a6814625642e956da.md +0 -0
  42. /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie.md +0 -0
  43. /package/docs/{plans → rlp-desk/plans}/validated-snacking-crayon.md +0 -0
  44. /package/docs/{protocol-reference.md → rlp-desk/protocol-reference.md} +0 -0
package/install.sh CHANGED
@@ -12,10 +12,24 @@ set -euo pipefail
12
12
  # Safe to run multiple times (idempotent).
13
13
  # =============================================================================
14
14
 
15
- REPO_URL="https://raw.githubusercontent.com/ai-dev-methodologies/rlp-desk/main"
15
+ # v5.7 §4.4: REPO_URL overridable for offline/local testing (test fixture).
16
+ REPO_URL="${REPO_URL:-https://raw.githubusercontent.com/ai-dev-methodologies/rlp-desk/main}"
16
17
  CLAUDE_DIR="$HOME/.claude"
17
18
  COMMANDS_DIR="$CLAUDE_DIR/commands"
18
19
  DESK_DIR="$CLAUDE_DIR/ralph-desk"
20
+ NODE_DIR="$DESK_DIR/node"
21
+
22
+ # v5.7 §4.4 / Q3: Node ≥16 preflight (matches scripts/postinstall.js policy).
23
+ if command -v node &>/dev/null; then
24
+ NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)
25
+ if [[ "$NODE_MAJOR" -lt 16 ]]; then
26
+ echo " [warn] Node.js >= 16 required for the Node leader (--mode tmux flywheel/SV)."
27
+ echo " Found Node $NODE_MAJOR. Continuing zsh install; --mode tmux features will be unavailable."
28
+ fi
29
+ else
30
+ echo " [warn] node not found in PATH. Node leader features (--flywheel, --with-self-verification in tmux) unavailable."
31
+ echo " Install Node.js >= 16: https://nodejs.org/"
32
+ fi
19
33
 
20
34
  echo ""
21
35
  echo " RLP Desk Installer"
@@ -25,30 +39,89 @@ echo ""
25
39
  # Create directories
26
40
  mkdir -p "$COMMANDS_DIR"
27
41
  mkdir -p "$DESK_DIR"
28
- mkdir -p "$DESK_DIR/docs/internal"
29
- mkdir -p "$DESK_DIR/docs/blueprints"
42
+ mkdir -p "$DESK_DIR/docs/rlp-desk/internal"
43
+ mkdir -p "$DESK_DIR/docs/rlp-desk/blueprints"
44
+ mkdir -p "$DESK_DIR/docs/rlp-desk/plans"
45
+ mkdir -p "$NODE_DIR"
46
+
47
+ # v5.7 §4.10 helpers — chmod-before-curl (unlock prior install) + chmod a-w
48
+ # (lock down). Hard-fail per Architect (no `2>/dev/null || true` swallowing).
49
+ unlock_target() {
50
+ local target="$1"
51
+ if [[ -e "$target" ]]; then
52
+ chmod u+w "$target" || {
53
+ echo " [install] FATAL: cannot unlock existing $target. Filesystem may be read-only."
54
+ exit 1
55
+ }
56
+ fi
57
+ }
58
+ lock_target() {
59
+ local target="$1"
60
+ if ! chmod a-w "$target" 2>/dev/null; then
61
+ echo " [install] WARNING: chmod a-w failed on $target. Filesystem may not honor POSIX mode bits (WSL1/NTFS); cross-session edit protection unavailable."
62
+ fi
63
+ }
64
+ fetch() {
65
+ local url="$1" target="$2"
66
+ unlock_target "$target"
67
+ curl -fsSL "$url" -o "$target" || {
68
+ echo " [install] FATAL: download failed for $url"
69
+ exit 1
70
+ }
71
+ lock_target "$target"
72
+ }
30
73
 
31
74
  # Runtime files
32
75
  echo " Downloading runtime files..."
33
- curl -sSL "$REPO_URL/src/commands/rlp-desk.md" -o "$COMMANDS_DIR/rlp-desk.md"
34
- curl -sSL "$REPO_URL/src/scripts/init_ralph_desk.zsh" -o "$DESK_DIR/init_ralph_desk.zsh"
35
- curl -sSL "$REPO_URL/src/scripts/run_ralph_desk.zsh" -o "$DESK_DIR/run_ralph_desk.zsh"
36
- curl -sSL "$REPO_URL/src/scripts/lib_ralph_desk.zsh" -o "$DESK_DIR/lib_ralph_desk.zsh"
37
- curl -sSL "$REPO_URL/src/governance.md" -o "$DESK_DIR/governance.md"
38
- curl -sSL "$REPO_URL/src/model-upgrade-table.md" -o "$DESK_DIR/model-upgrade-table.md"
39
- chmod +x "$DESK_DIR/init_ralph_desk.zsh" "$DESK_DIR/run_ralph_desk.zsh" "$DESK_DIR/lib_ralph_desk.zsh"
76
+ fetch "$REPO_URL/src/commands/rlp-desk.md" "$COMMANDS_DIR/rlp-desk.md"
77
+ fetch "$REPO_URL/src/scripts/init_ralph_desk.zsh" "$DESK_DIR/init_ralph_desk.zsh"
78
+ fetch "$REPO_URL/src/scripts/run_ralph_desk.zsh" "$DESK_DIR/run_ralph_desk.zsh"
79
+ fetch "$REPO_URL/src/scripts/lib_ralph_desk.zsh" "$DESK_DIR/lib_ralph_desk.zsh"
80
+ fetch "$REPO_URL/src/governance.md" "$DESK_DIR/governance.md"
81
+ fetch "$REPO_URL/src/model-upgrade-table.md" "$DESK_DIR/model-upgrade-table.md"
82
+
83
+ # v5.7 §4.4 — Node leader files (manifest-driven, prevents drift).
84
+ echo " Downloading Node leader runtime via MANIFEST.txt..."
85
+ MANIFEST_TMP=$(mktemp)
86
+ unlock_target "$NODE_DIR/MANIFEST.txt"
87
+ curl -fsSL "$REPO_URL/src/node/MANIFEST.txt" -o "$MANIFEST_TMP" || {
88
+ echo " [install] WARNING: src/node/MANIFEST.txt unavailable. Node leader features will be missing."
89
+ echo " Update install.sh from a 0.12.0+ source if upgrading."
90
+ MANIFEST_TMP=""
91
+ }
92
+ if [[ -n "$MANIFEST_TMP" && -s "$MANIFEST_TMP" ]]; then
93
+ cp "$MANIFEST_TMP" "$NODE_DIR/MANIFEST.txt"
94
+ while IFS= read -r relpath; do
95
+ [[ -z "$relpath" ]] && continue
96
+ target="$NODE_DIR/$relpath"
97
+ mkdir -p "$(dirname "$target")"
98
+ fetch "$REPO_URL/src/node/$relpath" "$target"
99
+ done < "$MANIFEST_TMP"
100
+ lock_target "$NODE_DIR/MANIFEST.txt"
101
+ rm -f "$MANIFEST_TMP"
102
+ fi
103
+
104
+ # Note: chmod +x is NOT needed — runtime files are invoked via `zsh script.zsh`
105
+ # or `node script.mjs`, never directly. lock_target above already chmod a-w'd
106
+ # them; an explicit chmod +x would re-add write to other.
40
107
 
41
- # Reference docs
108
+ # Reference docs (v5.7 §4.4 follow-up: same fetch() helper handles unlock+lock
109
+ # so reference-doc upgrade-over-installed-and-locked file does not silently fail).
42
110
  echo " Downloading reference docs..."
43
- curl -sSL "$REPO_URL/README.md" -o "$DESK_DIR/README.md"
44
- curl -sSL "$REPO_URL/install.sh" -o "$DESK_DIR/install.sh"
45
- curl -sSL "$REPO_URL/docs/architecture.md" -o "$DESK_DIR/docs/architecture.md"
46
- curl -sSL "$REPO_URL/docs/getting-started.md" -o "$DESK_DIR/docs/getting-started.md"
47
- curl -sSL "$REPO_URL/docs/protocol-reference.md" -o "$DESK_DIR/docs/protocol-reference.md"
48
- curl -sSL "$REPO_URL/docs/TODO-verification-next.md" -o "$DESK_DIR/docs/TODO-verification-next.md"
49
- curl -sSL "$REPO_URL/docs/internal/verification-policy-gap-analysis.md" -o "$DESK_DIR/docs/internal/verification-policy-gap-analysis.md"
50
- curl -sSL "$REPO_URL/docs/internal/verification-strategy-research.md" -o "$DESK_DIR/docs/internal/verification-strategy-research.md"
51
- curl -sSL "$REPO_URL/docs/blueprints/blueprint-v0.4-evolution.md" -o "$DESK_DIR/docs/blueprints/blueprint-v0.4-evolution.md"
111
+ fetch "$REPO_URL/README.md" "$DESK_DIR/README.md"
112
+ fetch "$REPO_URL/install.sh" "$DESK_DIR/install.sh"
113
+ fetch "$REPO_URL/docs/rlp-desk/architecture.md" "$DESK_DIR/docs/rlp-desk/architecture.md"
114
+ fetch "$REPO_URL/docs/rlp-desk/getting-started.md" "$DESK_DIR/docs/rlp-desk/getting-started.md"
115
+ fetch "$REPO_URL/docs/rlp-desk/protocol-reference.md" "$DESK_DIR/docs/rlp-desk/protocol-reference.md"
116
+ fetch "$REPO_URL/docs/rlp-desk/TODO-verification-next.md" "$DESK_DIR/docs/rlp-desk/TODO-verification-next.md"
117
+ fetch "$REPO_URL/docs/rlp-desk/multi-mission-orchestration.md" "$DESK_DIR/docs/rlp-desk/multi-mission-orchestration.md"
118
+ # Dev meta docs (v5.7 §4.15: under docs/rlp-desk/ to avoid mixing with user docs)
119
+ fetch "$REPO_URL/docs/rlp-desk/internal/verification-policy-gap-analysis.md" "$DESK_DIR/docs/rlp-desk/internal/verification-policy-gap-analysis.md"
120
+ fetch "$REPO_URL/docs/rlp-desk/internal/verification-strategy-research.md" "$DESK_DIR/docs/rlp-desk/internal/verification-strategy-research.md"
121
+ fetch "$REPO_URL/docs/rlp-desk/blueprints/blueprint-flywheel-enhancement.md" "$DESK_DIR/docs/rlp-desk/blueprints/blueprint-flywheel-enhancement.md"
122
+ fetch "$REPO_URL/docs/rlp-desk/blueprints/blueprint-pivot-step.md" "$DESK_DIR/docs/rlp-desk/blueprints/blueprint-pivot-step.md"
123
+ fetch "$REPO_URL/docs/rlp-desk/blueprints/plan-flywheel-enhancement.md" "$DESK_DIR/docs/rlp-desk/blueprints/plan-flywheel-enhancement.md"
124
+ fetch "$REPO_URL/docs/rlp-desk/blueprints/sv-architecture-rethink.md" "$DESK_DIR/docs/rlp-desk/blueprints/sv-architecture-rethink.md"
52
125
 
53
126
  # Check tmux availability
54
127
  if ! command -v tmux &>/dev/null; then
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@ai-dev-methodologies/rlp-desk",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Fresh-context iterative loops for Claude Code — autonomous task completion with independent verification",
5
5
  "scripts": {
6
6
  "postinstall": "node scripts/postinstall.js",
7
- "uninstall": "node scripts/uninstall.js"
7
+ "uninstall": "node scripts/uninstall.js",
8
+ "test:node": "node --test 'tests/node/*.mjs' 'tests/node/*.test.mjs'",
9
+ "test:zsh": "for f in tests/test_*.sh; do echo \"=== $f ===\"; zsh \"$f\" || exit 1; done",
10
+ "test:fast": "npm run test:node",
11
+ "test:full": "npm run test:fast && npm run test:zsh",
12
+ "sv-gate:fast": "zsh tests/sv-gate-fast.sh",
13
+ "sv-gate:full": "zsh tests/sv-gate-full.sh"
8
14
  },
9
15
  "files": [
10
16
  "src/commands/",
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // v5.7 §4.4 — generate src/node/MANIFEST.txt listing every Node runtime file.
5
+ // install.sh reads this manifest line-by-line and curls each file. Without
6
+ // this, the curl-pipe-shell install path has no Node leader (release-blocker).
7
+ //
8
+ // Run as `prepublishOnly` AND on every CI build to keep the manifest in sync.
9
+ // CI drift check: `node scripts/build-node-manifest.js --check` returns
10
+ // non-zero exit if the on-disk manifest does not match the regenerated form.
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const repoRoot = path.join(__dirname, "..");
16
+ const nodeDir = path.join(repoRoot, "src", "node");
17
+ const manifestPath = path.join(nodeDir, "MANIFEST.txt");
18
+
19
+ function walk(dir, base) {
20
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
21
+ const files = [];
22
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
23
+ const sourcePath = path.join(dir, entry.name);
24
+ const relPath = path.posix.join(base, entry.name);
25
+ if (entry.isDirectory()) {
26
+ files.push(...walk(sourcePath, relPath));
27
+ } else if (entry.isFile() && entry.name.endsWith(".mjs")) {
28
+ files.push(relPath);
29
+ }
30
+ }
31
+ return files;
32
+ }
33
+
34
+ const generated = walk(nodeDir, "").join("\n") + "\n";
35
+
36
+ const isCheck = process.argv.includes("--check");
37
+
38
+ if (isCheck) {
39
+ const onDisk = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath, "utf8") : "";
40
+ if (onDisk !== generated) {
41
+ console.error("MANIFEST.txt drift detected. Run: node scripts/build-node-manifest.js");
42
+ console.error("--- ON-DISK ---");
43
+ console.error(onDisk);
44
+ console.error("--- GENERATED ---");
45
+ console.error(generated);
46
+ process.exit(1);
47
+ }
48
+ console.log("MANIFEST.txt in sync (" + generated.split("\n").filter(Boolean).length + " entries).");
49
+ } else {
50
+ fs.writeFileSync(manifestPath, generated);
51
+ console.log("Wrote " + manifestPath + " (" + generated.split("\n").filter(Boolean).length + " entries).");
52
+ }
@@ -19,10 +19,12 @@ const runtimeSources = [
19
19
  ["src/model-upgrade-table.md", path.join(deskDir, "model-upgrade-table.md")],
20
20
  ["README.md", path.join(deskDir, "README.md")],
21
21
  ["install.sh", path.join(deskDir, "install.sh")],
22
- ["docs/architecture.md", path.join(docsDir, "architecture.md")],
23
- ["docs/getting-started.md", path.join(docsDir, "getting-started.md")],
24
- ["docs/protocol-reference.md", path.join(docsDir, "protocol-reference.md")],
25
- ["docs/TODO-verification-next.md", path.join(docsDir, "TODO-verification-next.md")],
22
+ // v5.7 §4.15: all rlp-desk docs (user-facing + dev meta) under docs/rlp-desk/.
23
+ ["docs/rlp-desk/architecture.md", path.join(docsDir, "rlp-desk", "architecture.md")],
24
+ ["docs/rlp-desk/getting-started.md", path.join(docsDir, "rlp-desk", "getting-started.md")],
25
+ ["docs/rlp-desk/protocol-reference.md", path.join(docsDir, "rlp-desk", "protocol-reference.md")],
26
+ ["docs/rlp-desk/TODO-verification-next.md", path.join(docsDir, "rlp-desk", "TODO-verification-next.md")],
27
+ ["docs/rlp-desk/multi-mission-orchestration.md", path.join(docsDir, "rlp-desk", "multi-mission-orchestration.md")],
26
28
  ];
27
29
  const legacyFiles = [
28
30
  path.join(deskDir, "init_ralph_desk.zsh"),
@@ -43,13 +45,117 @@ function ensureDir(dirPath) {
43
45
  fs.mkdirSync(dirPath, { recursive: true });
44
46
  }
45
47
 
48
+ function unlockTree(targetPath) {
49
+ // v5.7 §4.10: walk and chmod u+w every entry so rmSync(recursive) does not
50
+ // ENOTEMPTY on a directory full of 0o444 children. Idempotent on missing paths.
51
+ // Security review v5.7 follow-up: lstatSync first; SKIP symlinks entirely so
52
+ // a hostile symlink (e.g., ~/.claude/ralph-desk/foo -> /etc/passwd) cannot
53
+ // be chmod'd via unlockTree's chmodSync (which follows symlinks).
54
+ if (!fs.existsSync(targetPath)) return;
55
+ const stat = fs.lstatSync(targetPath);
56
+ if (stat.isSymbolicLink()) {
57
+ // Don't chmod the symlink target. lchmod is unsupported on Linux; safest
58
+ // action is to leave symlinks alone — they're not part of our install set.
59
+ return;
60
+ }
61
+ try { fs.chmodSync(targetPath, stat.isDirectory() ? 0o755 : 0o644); } catch {}
62
+ if (stat.isDirectory()) {
63
+ for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
64
+ unlockTree(path.join(targetPath, entry.name));
65
+ }
66
+ }
67
+ }
68
+
46
69
  function removePath(targetPath) {
70
+ // v5.7 §4.10: existing target tree may contain 0o444 files. Walk and unlock
71
+ // before rmSync so EACCES/ENOTEMPTY don't break the upgrade path.
72
+ unlockTree(targetPath);
47
73
  fs.rmSync(targetPath, { recursive: true, force: true });
48
74
  }
49
75
 
76
+ // v5.7 §4.10: per-extension banner format. `# DO NOT EDIT` text leaks into
77
+ // rendered Markdown, so .md uses HTML comment; .mjs/.js uses //; shell uses #.
78
+ function bannerFor(extension, sourceRelativePath) {
79
+ const msg = `DO NOT EDIT — generated from ${sourceRelativePath}. Edit source and re-sync. See ~/.claude/ralph-desk/UNLOCK.md for debug unlock.`;
80
+ switch (extension) {
81
+ case ".md":
82
+ return `<!-- ${msg} -->\n`;
83
+ case ".mjs":
84
+ case ".js":
85
+ return `// ${msg}\n`;
86
+ case ".zsh":
87
+ case ".sh":
88
+ return `# ${msg}\n`;
89
+ default:
90
+ return null; // .json and unknown types: rely on chmod alone
91
+ }
92
+ }
93
+
94
+ let _chmodWarningEmitted = false;
95
+ function tryLockFile(targetPath) {
96
+ // Best-effort write-protect. Some filesystems (WSL1/NTFS, tmpfs noexec, certain
97
+ // bind mounts) silently no-op chmod. R-V5-5: emit ONE warning per install run.
98
+ try {
99
+ fs.chmodSync(targetPath, 0o444);
100
+ const stat = fs.statSync(targetPath);
101
+ if ((stat.mode & 0o222) !== 0 && !_chmodWarningEmitted) {
102
+ console.log(" [install] WARNING: filesystem does not honor chmod a-w; cross-session edit protection unavailable.");
103
+ _chmodWarningEmitted = true;
104
+ }
105
+ } catch (err) {
106
+ if (!_chmodWarningEmitted) {
107
+ console.log(" [install] WARNING: chmod a-w failed (" + err.code + "); cross-session edit protection unavailable.");
108
+ _chmodWarningEmitted = true;
109
+ }
110
+ }
111
+ }
112
+
113
+ function injectBannerAndLock(targetPath, sourceRelativePath) {
114
+ const ext = path.extname(targetPath).toLowerCase();
115
+ const banner = bannerFor(ext, sourceRelativePath);
116
+ if (banner) {
117
+ const original = fs.readFileSync(targetPath);
118
+ // Idempotency guard (code-review v5.7 follow-up): the source file in the
119
+ // package tarball already contains an injected banner from a prior install
120
+ // ONLY if a developer ran sync from an installed copy back to the source —
121
+ // which is forbidden by CLAUDE.md. But re-running install over an existing
122
+ // installed file (e.g., npm i again) does NOT need re-injection because
123
+ // copyFileSync replaced the file with the source contents. The check below
124
+ // is defensive — only inject when the file does not already start with a
125
+ // DO NOT EDIT marker.
126
+ const head = original.subarray(0, 200).toString('utf8');
127
+ if (head.includes('DO NOT EDIT — generated from')) {
128
+ // Already banner-headed (rare: source somehow shipped with banner). Skip
129
+ // injection but still apply chmod for consistency.
130
+ tryLockFile(targetPath);
131
+ return;
132
+ }
133
+ // Shebang preservation: if first line starts with `#!`, banner goes on line 2.
134
+ if (original.length >= 2 && original[0] === 0x23 && original[1] === 0x21) {
135
+ const newlineIdx = original.indexOf(0x0a);
136
+ if (newlineIdx >= 0) {
137
+ const headBuf = original.subarray(0, newlineIdx + 1);
138
+ const tailBuf = original.subarray(newlineIdx + 1);
139
+ fs.writeFileSync(targetPath, Buffer.concat([headBuf, Buffer.from(banner), tailBuf]));
140
+ } else {
141
+ fs.writeFileSync(targetPath, Buffer.concat([original, Buffer.from("\n" + banner)]));
142
+ }
143
+ } else {
144
+ fs.writeFileSync(targetPath, Buffer.concat([Buffer.from(banner), original]));
145
+ }
146
+ }
147
+ tryLockFile(targetPath);
148
+ }
149
+
50
150
  function copyFile(sourceRelativePath, targetPath) {
51
151
  ensureDir(path.dirname(targetPath));
152
+ // v5.7 §4.10: unlock target if it exists and is write-protected from a prior
153
+ // install (R-V5-1: copyFileSync over 0o444 fails EACCES on upgrade).
154
+ if (fs.existsSync(targetPath)) {
155
+ try { fs.chmodSync(targetPath, 0o644); } catch { /* may be already writable */ }
156
+ }
52
157
  fs.copyFileSync(path.join(pkgDir, sourceRelativePath), targetPath);
158
+ injectBannerAndLock(targetPath, sourceRelativePath);
53
159
  console.log(" + " + targetPath);
54
160
  }
55
161
 
@@ -69,31 +175,73 @@ function copyMarkdownDirectory(sourceRelativeDir, targetDir) {
69
175
  }
70
176
  if (entry.isFile() && entry.name.endsWith(".md")) {
71
177
  ensureDir(path.dirname(targetPath));
178
+ // v5.7 §4.10: unlock prior-install write-protected target before copy.
179
+ if (fs.existsSync(targetPath)) {
180
+ try { fs.chmodSync(targetPath, 0o644); } catch {}
181
+ }
72
182
  fs.copyFileSync(sourcePath, targetPath);
183
+ injectBannerAndLock(targetPath, path.join(sourceRelativeDir, entry.name));
73
184
  console.log(" + " + targetPath);
74
185
  }
75
186
  }
76
187
  }
77
188
 
78
- function copyNodeRuntime(sourceDir, targetDir) {
189
+ function copyNodeRuntime(sourceDir, targetDir, sourceRelativeBase) {
190
+ // removePath already handles 0o444 unlock per v5.7 §4.10.
79
191
  removePath(targetDir);
80
192
  ensureDir(targetDir);
193
+ const baseRel = sourceRelativeBase || "src/node";
81
194
 
82
195
  for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
83
196
  const sourcePath = path.join(sourceDir, entry.name);
84
197
  const targetPath = path.join(targetDir, entry.name);
198
+ const childRel = path.join(baseRel, entry.name);
85
199
  if (entry.isDirectory()) {
86
- copyNodeRuntime(sourcePath, targetPath);
200
+ copyNodeRuntime(sourcePath, targetPath, childRel);
87
201
  continue;
88
202
  }
89
203
  if (entry.isFile()) {
90
204
  ensureDir(path.dirname(targetPath));
91
205
  fs.copyFileSync(sourcePath, targetPath);
206
+ injectBannerAndLock(targetPath, childRel);
92
207
  console.log(" + " + targetPath);
93
208
  }
94
209
  }
95
210
  }
96
211
 
212
+ // v5.7 §4.10: Documented escape hatch for debug sessions.
213
+ function writeUnlockDoc() {
214
+ const unlockPath = path.join(deskDir, "UNLOCK.md");
215
+ const content = `# UNLOCK — Debug edit escape hatch
216
+
217
+ Files in \`~/.claude/ralph-desk/\` and \`~/.claude/commands/rlp-desk.md\` are
218
+ installed read-only (\`chmod a-w\`) so cross-session AI agents cannot silently
219
+ corrupt them. Source of truth: the rlp-desk source repository.
220
+
221
+ If you need to edit an installed file for **temporary debug** (e.g., add a
222
+ \`set -x\` line, insert a \`print\` statement):
223
+
224
+ \`\`\`bash
225
+ chmod -R u+w ~/.claude/ralph-desk
226
+ chmod u+w ~/.claude/commands/rlp-desk.md
227
+ # ... edit, test, then revert ...
228
+ \`\`\`
229
+
230
+ To re-apply protection without a full reinstall, run npm install rlp-desk
231
+ from the source repo or rerun \`scripts/postinstall.js\`.
232
+
233
+ **For permanent fixes**, edit the source repo and re-publish — never edit
234
+ installed files directly. The banner at the top of every installed file
235
+ points back to its source path.
236
+ `;
237
+ if (fs.existsSync(unlockPath)) {
238
+ try { fs.chmodSync(unlockPath, 0o644); } catch {}
239
+ }
240
+ fs.writeFileSync(unlockPath, content);
241
+ console.log(" + " + unlockPath);
242
+ // UNLOCK.md is itself NOT locked — users may want to add their own notes.
243
+ }
244
+
97
245
  console.log("");
98
246
  console.log(" RLP Desk v" + pkg.version);
99
247
  console.log(" ================");
@@ -118,10 +266,16 @@ for (const [sourcePath, targetPath] of runtimeSources) {
118
266
  copyFile(sourcePath, targetPath);
119
267
  }
120
268
 
121
- copyMarkdownDirectory("docs/internal", path.join(docsDir, "internal"));
122
- copyMarkdownDirectory("docs/blueprints", path.join(docsDir, "blueprints"));
269
+ // v5.7 §4.15: dev meta docs live under docs/rlp-desk/ to avoid mixing with
270
+ // user-facing operational docs (per user feedback).
271
+ copyMarkdownDirectory("docs/rlp-desk/internal", path.join(docsDir, "rlp-desk", "internal"));
272
+ copyMarkdownDirectory("docs/rlp-desk/blueprints", path.join(docsDir, "rlp-desk", "blueprints"));
273
+ copyMarkdownDirectory("docs/rlp-desk/plans", path.join(docsDir, "rlp-desk", "plans"));
123
274
  copyNodeRuntime(path.join(pkgDir, "src", "node"), nodeDir);
124
275
 
276
+ // v5.7 §4.10: write the UNLOCK.md escape-hatch doc for debug sessions.
277
+ writeUnlockDoc();
278
+
125
279
  console.log("");
126
280
  console.log(" Done! Open Claude Code and run:");
127
281
  console.log(" /rlp-desk brainstorm \"your task description\"");
@@ -280,40 +280,51 @@ Parse the `--mode` flag. If absent or `agent`, use the Agent() path below. If `t
280
280
 
281
281
  #### Tmux Mode (`--mode tmux`)
282
282
 
283
- When `--mode tmux` is specified:
283
+ When `--mode tmux` is specified (v0.12.0+ — v5.7 §4.1 routes to Node leader for flywheel + SV support):
284
284
 
285
285
  1. **Validate scaffold** — same as Agent() mode: check `.claude/ralph-desk/prompts/<slug>.worker.prompt.md` etc.
286
286
  2. **Check sentinels** — same as Agent() mode.
287
- 3. **Check prerequisites** — verify `tmux` and `jq` are installed. If not, report what is missing and stop.
288
- 4. **Locate runner script** — find `run_ralph_desk.zsh` at `~/.claude/ralph-desk/run_ralph_desk.zsh`. If not found, tell the user to reinstall (`npm install` or `install.sh`).
289
- 5. **Launch** — shell out to the runner script with env vars derived from flags:
287
+ 3. **Check prerequisites** — verify `tmux`, `jq`, and `node` (>= 16) are installed. If not, report what is missing and stop.
288
+ 4. **Locate Node leader** — find `~/.claude/ralph-desk/node/run.mjs`. If not found, tell the user to reinstall (`npm install` or `bash install.sh`).
289
+ 5. **Launch** — shell out to the Node leader. **All dynamic args (slug + model values) MUST be passed through shell single-quote escaping** (v5.7 §4.12 G11) so bracketed model ids like `claude-opus-4-7[1m]` survive zsh parsing:
290
+
290
291
  ```bash
291
- LOOP_NAME="<slug>" \
292
- ROOT="$PWD" \
293
- MAX_ITER=<--max-iter value> \
294
- WORKER_MODEL=<--worker-model value> \
295
- LOCK_WORKER_MODEL=<1 if --lock-worker-model, else 0> \
296
- VERIFIER_MODEL=<--verifier-model value, default: sonnet> \
297
- FINAL_VERIFIER_MODEL=<--final-verifier-model value, default: opus> \
298
- VERIFY_MODE=<--verify-mode value, default: per-us> \
299
- CONSENSUS_MODE=<--consensus value, default: off> \
300
- CONSENSUS_MODEL=<--consensus-model value, default: gpt-5.5:medium> \
301
- FINAL_CONSENSUS_MODEL=<--final-consensus-model value, default: gpt-5.5:high> \
302
- CB_THRESHOLD=<--cb-threshold value, default: 6> \
303
- ITER_TIMEOUT=<--iter-timeout value, default: 600> \
304
- DEBUG=<1 if --debug, else 0> \
305
- WITH_SELF_VERIFICATION=<1 if --with-self-verification, else 0> \
306
- zsh ~/.claude/ralph-desk/run_ralph_desk.zsh
292
+ node ~/.claude/ralph-desk/node/run.mjs run '<slug>' \
293
+ --mode tmux \
294
+ --max-iter <N> \
295
+ --worker-model '<value>' \
296
+ [--lock-worker-model] \
297
+ --verifier-model '<value>' \
298
+ --final-verifier-model '<value>' \
299
+ --consensus <off|all|final-only> \
300
+ --consensus-model '<value>' \
301
+ --final-consensus-model '<value>' \
302
+ --verify-mode <per-us|batch> \
303
+ --cb-threshold <N> \
304
+ --iter-timeout <N> \
305
+ [--debug] [--autonomous] \
306
+ [--lane-strict] # was env LANE_MODE=strict \
307
+ [--test-density-strict] # was env TEST_DENSITY_MODE=strict \
308
+ [--with-self-verification] \
309
+ [--flywheel on-fail --flywheel-model '<value>'] \
310
+ [--flywheel-guard on --flywheel-guard-model '<value>']
307
311
  ```
308
- 6. **If the script exits with error (exit code 1)** — report the error to the user and STOP. Do NOT attempt to work around it. Do NOT create tmux sessions yourself. Do NOT re-launch the script in a different way. Just tell the user what went wrong and suggest using Agent mode instead.
309
- 7. **If successful** tell the user the tmux session has been started. The shell script takes over as the deterministic Leader. No Agent() calls are made in tmux mode.
312
+
313
+ **Quoting contract (v5.7 §4.1)**: every `'<value>'` placeholder above must be replaced with the user's flag value wrapped in single quotes via the equivalent of `shellQuote(value)` `"'" + value.replace(/'/g, "'\\''") + "'"` for POSIX correctness. The slug, all model values, and any future dynamic flag must follow this rule. A slug or model containing brackets / spaces / single quotes / dollar signs / backticks must NOT break the leader invocation.
314
+
315
+ **Env-var translation (v5.7 §4.1)**: the slash command historically built `LANE_MODE=strict zsh ...` and `TEST_DENSITY_MODE=strict zsh ...` from CLI flags. The Node leader uses CLI flags instead — translate `--lane-strict` and `--test-density-strict` into the corresponding flags. Direct env-var users (running zsh directly) are unaffected.
316
+
317
+ 6. **If the Node leader exits with error** — report the error to the user and STOP. Do NOT attempt to work around it. Do NOT create tmux sessions yourself. Do NOT re-launch in a different way. Tell the user what went wrong and suggest `--mode agent` as alternative.
318
+ 7. **If successful** — tell the user the tmux session has been started. The Node leader takes over as the deterministic Leader. No Agent() calls are made in tmux mode.
310
319
 
311
320
  **IMPORTANT RULES:**
312
- - Tmux mode requires the user to already be inside a tmux session. If the runner script rejects because $TMUX is not set, do NOT try to create a tmux session yourself. Tell the user: "Start tmux first, then retry."
313
- - MUST launch the runner with `run_in_background: true` so `/rlp-desk` returns control immediately while preserving live tmux visibility.
321
+ - Tmux mode requires the user to already be inside a tmux session. If the leader rejects because $TMUX is not set, do NOT try to create a tmux session yourself. Tell the user: "Start tmux first, then retry."
322
+ - MUST launch with `run_in_background: true` so `/rlp-desk` returns control immediately while preserving live tmux visibility.
314
323
  - Run-in-background is used so the shell can keep the command visible and keep the pane layout stable for status checks and completion flow.
315
324
  - Do NOT kill panes after completion. Panes stay alive for inspection. User cleans up with `/rlp-desk clean <slug> --kill-session`.
316
- - `--with-self-verification` is accepted in tmux mode. After campaign completion, `run_ralph_desk.zsh` spawns `claude CLI` to generate the SV report from campaign artifacts (done-claims, verify-verdicts, campaign-report). SV reports are written to `~/.claude/ralph-desk/analytics/<slug>/`. Requires `claude` CLI available in PATH; if not found, an error is appended to the campaign report.
325
+ - `--with-self-verification` is fully supported in tmux mode (v5.7 §4.7). The Node leader's `generateSVReport()` writes `self-verification-report.md` + `self-verification-data.json` under `<project>/.claude/ralph-desk/analytics/<slug>/` (project-local, v5.7 §4.11.b).
326
+ - `--flywheel on-fail` and `--flywheel-guard on` are fully supported in tmux mode (v5.7 §4.1). The Node leader handles pane creation, sendKeys dispatch, signal polling, and Guard retry semantics identically to agent mode.
327
+ - Legacy `zsh ~/.claude/ralph-desk/run_ralph_desk.zsh` (deprecated in 0.12.0) still runs for non-flywheel/non-SV invocations but emits a deprecation `[notice]`. Calling it with `FLYWHEEL` or `WITH_SELF_VERIFICATION` env vars exits 2 with a migration banner pointing to the Node leader.
317
328
 
318
329
  **tmux UX model (5 items):**
319
330
  - The session returns immediately after launch (`run_in_background: true`) so the command returns control to the parent CLI.
@@ -324,6 +335,18 @@ WITH_SELF_VERIFICATION=<1 if --with-self-verification, else 0> \
324
335
 
325
336
  #### Agent Mode (`--mode agent` or default)
326
337
 
338
+ **Why Agent mode is structurally immune to Bug 4/5 (mid-execution prompt hang
339
+ & A4 premature dispatch):** Worker/Verifier are dispatched as `Agent(...,
340
+ mode="bypassPermissions", ...)`. The subagent runs non-interactively under
341
+ the platform's bypass — it has no tmux pane, no TUI surface, and cannot
342
+ surface a `[y/N]` prompt to the parent Leader. The auto-dismiss /
343
+ prompt-stall / no-progress timeouts in `run_ralph_desk.zsh` (v5.7 §4.13.b /
344
+ §4.16 / §4.17) are therefore tmux-only by design. **Tradeoff**: because
345
+ `Agent()` has no timeout API, agent-mode iterations are not bounded — if
346
+ the platform's `bypassPermissions` ever fails to suppress an interactive
347
+ prompt at the SDK level, the call hangs indefinitely with no rlp-desk-side
348
+ watchdog. Use `--mode tmux` if you need bounded execution time.
349
+
327
350
  ### Preparation
328
351
  1. Validate scaffold: `.claude/ralph-desk/prompts/<slug>.worker.prompt.md` etc.
329
352
  2. **Codex CLI pre-validation**: If `--consensus` is not `off` OR `--worker-model` uses codex format (contains `:`) OR `--verifier-model` / `--final-verifier-model` / `--consensus-model` / `--final-consensus-model` uses codex format, check that `codex` CLI exists in PATH. If codex CLI not found → STOP immediately, print install instructions (`npm install -g @openai/codex`), do not start the loop.
package/src/governance.md CHANGED
@@ -297,13 +297,54 @@ BLOCKED writes a JSON sidecar (`<slug>-blocked.json`) alongside the markdown sen
297
297
  - English: `depends on US-`, `blocking US-`, `awaits US-`, `post-iter US-`, `requires US-N`, `cross-US`
298
298
  - Korean: `US-N 산출물`, `신규 US-`, `post-iter`
299
299
 
300
- **Write Order Contract (atomicity invariant)**:
301
- 1. JSON sidecar written FIRST (`fs.writeFile` / `atomic_write`).
302
- 2. markdown sentinel written SECOND.
303
- 3. Invariant: **markdown exists ⇒ JSON exists** (writer enforces order).
304
- 4. Wrappers SHOULD watch markdown sentinel, then read JSON sidecar. If JSON not yet visible (rare), retry up to 5 × 50ms before failing.
300
+ **Write Order Contract (atomicity invariant)** — v5.7 §4.24 reversed:
301
+ 1. **markdown sentinel written FIRST** via `writeSentinelExclusive` (`fs.open(path, 'wx')` O_EXCL first-writer-wins). The md acts as the race lock.
302
+ 2. **JSON sidecar written SECOND**, only by the winning writer.
303
+ 3. Invariant: **markdown exists ⇒ JSON exists** (winner writes both; losers see EEXIST and return without touching JSON, preserving the winner's content).
304
+ 4. Wrappers SHOULD watch markdown sentinel, then read JSON sidecar. If JSON not yet visible (rare ≤50ms), retry up to 5 × 50ms before failing.
305
305
 
306
- `atomic_write` provides per-file rename atomicity; cross-file ordering is enforced by the explicit two-call sequence.
306
+ `writeSentinelExclusive` (in `src/node/shared/fs.mjs`) provides per-file first-writer-wins; cross-file ordering is enforced by the explicit md-then-JSON sequence inside `writeSentinel`.
307
+
308
+ ## 1g. Sentinel Guarantee Invariant (file-guarantee contract)
309
+
310
+ **Every terminal exit of `runCampaign()` MUST leave exactly one sentinel on disk: `<slug>-blocked.md` XOR `<slug>-complete.md`.**
311
+
312
+ This invariant is the foundation of the fresh-context architecture. If a campaign exits without any sentinel, future iterations cannot determine campaign state — Worker/Verifier are dispatched into a campaign whose history they cannot reconstruct.
313
+
314
+ ### Enforcement (3-layer defense)
315
+
316
+ 1. **Per-poll-site sentinel write** (`_handlePollFailure` helper at `src/node/runner/campaign-main-loop.mjs`). Every `pollForSignal` call site (Worker, VerifierPerUS, VerifierFinal, Flywheel, Guard) is wrapped in `try { … } catch (error) { return _handlePollFailure(error, { role, … }); }`. The helper classifies via `BLOCK_TAGS` typed enum, calls `writeSentinel` (idempotent via O_EXCL), and returns `{status:'blocked', …}` so the caller exits the loop cleanly.
317
+
318
+ 2. **Run-level try/finally backstop** (`_ensureTerminalSentinel`). After the campaign body executes, a `finally` block checks `exists(blockedSentinel) XOR exists(completeSentinel)`. If neither (paused state `continue` excepted), writes a synthetic BLOCKED `infra_failure/leader_exited_without_terminal_state` so even unhandled exceptions cannot escape silently.
319
+
320
+ 3. **Schema validator at READ boundary** (`validateArtifact`). After every `pollForSignal` returns parsed JSON, validates `(slug, iteration ≥ floor, signal_type matches read context, us_id ∈ usList ∪ {ALL})`. Throws `MalformedArtifactError({field, expected, got})` → caught by same `_handlePollFailure` → BLOCKED `contract_violation/malformed_artifact` (recoverable).
321
+
322
+ ### Per-role failure-category enum
323
+
324
+ `_classifyBlock` (in `campaign-main-loop.mjs`) maps each `BLOCK_TAGS` value to one of the locked taxonomy categories:
325
+
326
+ | Tag | reason_category | recoverable | Example trigger |
327
+ |-----|----------------|-------------|-----------------|
328
+ | `WORKER_EXITED` | `infra_failure` | false | Worker pane returned to shell without writing signal |
329
+ | `VERIFIER_EXITED` | `infra_failure` | false | Per-US Verifier exited without writing verdict |
330
+ | `FINAL_VERIFIER_EXITED` | `infra_failure` | false | Final ALL-verifier exited without writing verdict |
331
+ | `FLYWHEEL_EXITED` | `infra_failure` | false | Flywheel pane crashed |
332
+ | `GUARD_EXITED` | `infra_failure` | false | Guard pane crashed |
333
+ | `PROMPT_BLOCKED` | `infra_failure` | false | Default-No prompt — auto-Enter would CANCEL |
334
+ | `<role>_TIMEOUT` | `infra_failure` | false | pollForSignal timed out without exit detected |
335
+ | `MALFORMED_ARTIFACT` | `contract_violation` | true | Worker/Verifier wrote schema-violating JSON |
336
+ | `LEADER_EXITED_WITHOUT_TERMINAL_STATE` | `infra_failure` | false | Backstop fired (uncaught exception or paths outside controlled scope) |
337
+
338
+ ### Auditing
339
+
340
+ Operators can verify the invariant for any campaign by running:
341
+
342
+ ```sh
343
+ zsh tests/sv-gate-fast.sh # 30s mechanical check (greps + units)
344
+ zsh tests/sv-gate-full.sh # 5min including REAL tmux + REAL campaign E2E
345
+ ```
346
+
347
+ The fast gate fails immediately if any pollForSignal call site lacks a `_handlePollFailure` wiring or the writeSentinelExclusive primitive is bypassed.
307
348
 
308
349
  ## 2. Roles
309
350
 
@@ -553,6 +594,14 @@ for iteration in 1..max_iter:
553
594
  • fail + retries exhausted → BLOCKED
554
595
  • inconclusive → BLOCKED (escalate to user)
555
596
  - Guard count tracked per-US in status.json
597
+ - **Mode support (v0.12.0+, v5.7 §4.3)**: flywheel runs identically in
598
+ --mode agent and --mode tmux when routed through the Node leader
599
+ (`node ~/.claude/ralph-desk/node/run.mjs run --mode tmux`). The legacy
600
+ `run_ralph_desk.zsh` runner rejects --flywheel/--flywheel-guard with
601
+ exit 2 + migration banner; users must use the Node entry. Same applies
602
+ to --with-self-verification: SV report generation is supported in
603
+ tmux mode via the Node leader's generateSVReport() (no longer
604
+ agent-mode-only).
556
605
 
557
606
  ⑦ Execute Verifier (see §7a for per-US and §7b for consensus details)
558
607
  - Build prompt (scoped to us_id if per-us mode) → log
@@ -774,6 +823,19 @@ The base signal vocabulary (`continue | verify | blocked`) is binary at the iter
774
823
 
775
824
  The downgrade is intentionally recoverable: the malformed signal is a worker-side prompt regression, not an environment failure, and the operator can fix it in-place.
776
825
 
826
+ ## 7h. Tmux Session Lifecycle Resilience (US-024/025/026 R12+R13+R14 P0)
827
+
828
+ Multi-mission queue/daemon (`RLP_BACKGROUND=1`) workflows can lose their tmux session between missions — terminal close, manual `tmux kill-session`, or tmux server restart all drop the session and every pane in it. Three independent guards now compose:
829
+
830
+ ### R12 — Pane lifecycle monitor (5s authoritative budget)
831
+ `_verify_pane_alive` and `_verify_session_alive` (lib_ralph_desk.zsh) check `#{pane_dead}` and `tmux has-session`. The runner invokes `_r12_check_lifecycle` at three sites: (1) immediately after `create_session()`, (2) at the top of every iteration, (3) right after worker dispatch and before the wait-loop. The check polls 5 attempts with 1-second sleep (5-second hard budget). On expiry it writes a BLOCKED sentinel with `reason_category=infra_failure`, `recoverable=true`, `suggested_action=restart` and exits 1 — never an infinite loop.
832
+
833
+ ### R13 — Detached session protection (RLP_BACKGROUND only)
834
+ When `tmux new-session -d` collides with an existing session and `RLP_BACKGROUND=1`, the runner appends `-bg-<epoch>-<pid>` to `SESSION_NAME` and runs a `tmux has-session` loop with random 4-digit suffixes until the name is unique. The new session also sets `destroy-unattached off` so the session survives every attached client disconnecting. **Limits**: this option is best-effort; it does NOT survive a manual `tmux kill-session` or a tmux server restart. R12 will detect those events at the next checkpoint.
835
+
836
+ ### R14 — Project-scoped runner lockfile (mkdir atomic)
837
+ `RUNNER_LOCKFILE_PATH` keys on `ROOT_HASH` (`shasum || sha1sum || cksum` of the repo root), so two different projects can run runners in parallel while the same project root is single-runner. `RUNNER_LOCKDIR` (`${RUNNER_LOCKFILE_PATH}.d`) is acquired by `mkdir` for true filesystem-level atomicity — no check-then-write race. Stale pids (no longer responding to `kill -0`) are reaped automatically; live duplicates exit 1 with a recovery hint.
838
+
777
839
  ## 8. Circuit Breaker
778
840
 
779
841
  | Condition | Verdict |