@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ r"""Drain UI inbox submissions, tracking which have been processed.
3
+
4
+ Usage:
5
+ python inbox_drain.py pending # List unprocessed inbox items
6
+ python inbox_drain.py mark <ISO-ts> # Mark one timestamp as processed
7
+ python inbox_drain.py mark-all # Mark all pending items as processed
8
+
9
+ Purpose:
10
+ Makes inbox submissions (written by the aesop dashboard) survive sessions.
11
+ Maintains a processed-marker file that tracks which items have been actioned.
12
+ Enables /power and daemons to pick up queued work that arrived while no
13
+ session was running.
14
+
15
+ Configuration:
16
+ - Reads aesop.config.json for inbox_path and inbox_seen_path (optional).
17
+ - Env vars override config: AESOP_INBOX_PATH, AESOP_INBOX_SEEN_PATH.
18
+ - Falls back to defaults: state/ui-inbox.md, state/.ui-inbox-seen.
19
+
20
+ Format (ui-inbox.md):
21
+ - [ISO-TS] item text
22
+ - [ISO-TS] item text
23
+
24
+ Subcommands:
25
+ pending: Read inbox, compare against processed-marker, print unprocessed.
26
+ Exit 0. If none exist, print "NO PENDING" and exit 0.
27
+ mark <ts>: Append ISO timestamp to seen-file. If already marked, noop.
28
+ mark-all: Append ALL pending timestamps to seen-file in one write.
29
+
30
+ Robustness:
31
+ - Missing inbox or seen files treated as empty (no crash).
32
+ - Timestamps are exact match (whitespace-preserved).
33
+ - Exit 0 always (never fails on missing files).
34
+
35
+ Output:
36
+ pending: Each unprocessed item as "[TS] text" (one per line).
37
+ mark/mark-all: Prints "[i/total] marked" summary to stderr.
38
+ """
39
+
40
+ import argparse
41
+ import json
42
+ import os
43
+ import sys
44
+ from pathlib import Path
45
+ from datetime import datetime
46
+
47
+
48
+ def load_config():
49
+ """Load aesop.config.json if present; return dict."""
50
+ try:
51
+ config_path = Path.cwd() / 'aesop.config.json'
52
+ if config_path.exists():
53
+ with open(config_path) as f:
54
+ return json.load(f)
55
+ except Exception:
56
+ pass
57
+ return {}
58
+
59
+
60
+ def resolve_paths(config):
61
+ """Resolve paths with precedence: env var > config > default."""
62
+ aesop_root = Path.cwd()
63
+ state_root = Path(
64
+ os.environ.get(
65
+ 'AESOP_STATE_ROOT',
66
+ config.get('state_root', str(aesop_root / 'state'))
67
+ )
68
+ ).expanduser()
69
+
70
+ # inbox_path: env AESOP_INBOX_PATH > config inbox_path > default state/ui-inbox.md
71
+ inbox_path = Path(
72
+ os.environ.get(
73
+ 'AESOP_INBOX_PATH',
74
+ config.get('inbox_path', str(state_root / 'ui-inbox.md'))
75
+ )
76
+ ).expanduser()
77
+
78
+ # inbox_seen_path: env AESOP_INBOX_SEEN_PATH > config inbox_seen_path > default state/.ui-inbox-seen
79
+ inbox_seen_path = Path(
80
+ os.environ.get(
81
+ 'AESOP_INBOX_SEEN_PATH',
82
+ config.get('inbox_seen_path', str(state_root / '.ui-inbox-seen'))
83
+ )
84
+ ).expanduser()
85
+
86
+ return inbox_path, inbox_seen_path
87
+
88
+
89
+ config = load_config()
90
+ INBOX_PATH, SEEN_PATH = resolve_paths(config)
91
+
92
+
93
+ def read_inbox():
94
+ """Read inbox.md and return list of (timestamp, text) tuples.
95
+
96
+ Returns:
97
+ List of (ts, text) tuples; empty list if file missing/empty.
98
+ """
99
+ if not INBOX_PATH.exists():
100
+ return []
101
+
102
+ items = []
103
+ try:
104
+ # Try UTF-8 first, fall back to latin-1 for extended-ASCII files
105
+ try:
106
+ with open(INBOX_PATH, "r", encoding="utf-8") as f:
107
+ content = f.read()
108
+ except UnicodeDecodeError:
109
+ with open(INBOX_PATH, "r", encoding="latin-1") as f:
110
+ content = f.read()
111
+
112
+ for line in content.splitlines():
113
+ line = line.rstrip("\r\n")
114
+ if line.startswith("- [") and "]" in line:
115
+ # Parse "- [ISO-TS] text"
116
+ end_bracket = line.index("]")
117
+ ts = line[3:end_bracket] # Extract ISO-TS from - [ISO-TS]
118
+ text = line[end_bracket + 2 :] if end_bracket + 2 < len(line) else ""
119
+ items.append((ts, text))
120
+ except Exception:
121
+ pass
122
+
123
+ return items
124
+
125
+
126
+ def read_seen():
127
+ """Read seen-file and return set of marked timestamps.
128
+
129
+ Returns:
130
+ Set of already-processed ISO timestamps; empty set if file missing.
131
+ """
132
+ if not SEEN_PATH.exists():
133
+ return set()
134
+
135
+ seen = set()
136
+ try:
137
+ try:
138
+ with open(SEEN_PATH, "r", encoding="utf-8") as f:
139
+ content = f.read()
140
+ except UnicodeDecodeError:
141
+ with open(SEEN_PATH, "r", encoding="latin-1") as f:
142
+ content = f.read()
143
+
144
+ for line in content.splitlines():
145
+ ts = line.rstrip("\r\n")
146
+ if ts:
147
+ seen.add(ts)
148
+ except Exception:
149
+ pass
150
+
151
+ return seen
152
+
153
+
154
+ def get_pending():
155
+ """Get list of unprocessed items.
156
+
157
+ Returns:
158
+ List of (ts, text) tuples that haven't been seen yet.
159
+ """
160
+ inbox = read_inbox()
161
+ seen = read_seen()
162
+ return [(ts, text) for ts, text in inbox if ts not in seen]
163
+
164
+
165
+ def cmd_pending():
166
+ """List unprocessed inbox items. Exit 0."""
167
+ pending = get_pending()
168
+ if not pending:
169
+ print("NO PENDING")
170
+ return
171
+
172
+ for ts, text in pending:
173
+ print(f"[{ts}] {text}")
174
+
175
+
176
+ def cmd_mark(timestamp):
177
+ """Mark one timestamp as processed.
178
+
179
+ Args:
180
+ timestamp: ISO timestamp string.
181
+ """
182
+ seen = read_seen()
183
+ if timestamp in seen:
184
+ print(f"[1/1] already marked", file=sys.stderr)
185
+ return
186
+
187
+ SEEN_PATH.parent.mkdir(parents=True, exist_ok=True)
188
+ with open(SEEN_PATH, "a", encoding="utf-8") as f:
189
+ f.write(timestamp + "\n")
190
+
191
+ print(f"[1/1] marked", file=sys.stderr)
192
+
193
+
194
+ def cmd_mark_all():
195
+ """Mark all pending items as processed in one write.
196
+
197
+ Prints [i/total] summary to stderr.
198
+ """
199
+ pending = get_pending()
200
+ if not pending:
201
+ print(f"[0/0] no pending items", file=sys.stderr)
202
+ return
203
+
204
+ seen = read_seen()
205
+ to_mark = [ts for ts, _ in pending if ts not in seen]
206
+ if not to_mark:
207
+ print(f"[0/{len(pending)}] no new items", file=sys.stderr)
208
+ return
209
+
210
+ SEEN_PATH.parent.mkdir(parents=True, exist_ok=True)
211
+ with open(SEEN_PATH, "a", encoding="utf-8") as f:
212
+ for ts in to_mark:
213
+ f.write(ts + "\n")
214
+
215
+ print(f"[{len(to_mark)}/{len(pending)}] marked all", file=sys.stderr)
216
+
217
+
218
+ def main():
219
+ """Main entry point."""
220
+ parser = argparse.ArgumentParser(
221
+ description="Drain UI inbox submissions, tracking processed items.",
222
+ formatter_class=argparse.RawDescriptionHelpFormatter,
223
+ )
224
+ subparsers = parser.add_subparsers(dest="command", help="Subcommand")
225
+
226
+ # pending subcommand
227
+ subparsers.add_parser("pending", help="List unprocessed inbox items")
228
+
229
+ # mark subcommand
230
+ mark_parser = subparsers.add_parser("mark", help="Mark one item as processed")
231
+ mark_parser.add_argument("timestamp", help="ISO timestamp to mark")
232
+
233
+ # mark-all subcommand
234
+ subparsers.add_parser("mark-all", help="Mark all pending items as processed")
235
+
236
+ args = parser.parse_args()
237
+
238
+ if args.command == "pending":
239
+ cmd_pending()
240
+ elif args.command == "mark":
241
+ cmd_mark(args.timestamp)
242
+ elif args.command == "mark-all":
243
+ cmd_mark_all()
244
+ else:
245
+ parser.print_help()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
@@ -22,6 +22,55 @@ import time
22
22
  from pathlib import Path
23
23
 
24
24
 
25
+ def resolve_git_bash_path():
26
+ r"""
27
+ Resolve git-bash.exe path portably in priority order:
28
+ 1. env var AESOP_GIT_BASH (if set and exists)
29
+ 2. shutil.which('git-bash') (on PATH)
30
+ 3. Derived from shutil.which('git') -> ../git-bash.exe on Windows
31
+ 4. Hardcoded default: C:\Program Files\Git\git-bash.exe
32
+
33
+ Raises FileNotFoundError with clear message if none found.
34
+ """
35
+ attempted = []
36
+
37
+ # 1. Check env var
38
+ env_path = os.environ.get("AESOP_GIT_BASH")
39
+ if env_path:
40
+ attempted.append(f"AESOP_GIT_BASH={env_path}")
41
+ if os.path.exists(env_path):
42
+ return env_path
43
+
44
+ # 2. Check which('git-bash')
45
+ which_bash = shutil.which("git-bash")
46
+ if which_bash:
47
+ attempted.append(f"which('git-bash')={which_bash}")
48
+ if os.path.exists(which_bash):
49
+ return which_bash
50
+
51
+ # 3. Derive from which('git')
52
+ which_git = shutil.which("git")
53
+ if which_git:
54
+ # git is typically at C:\Program Files\Git\cmd\git.exe
55
+ # We want C:\Program Files\Git\git-bash.exe (two levels up, then git-bash.exe)
56
+ git_path = Path(which_git)
57
+ derived_bash = git_path.parent.parent / "git-bash.exe"
58
+ attempted.append(f"derived from git={derived_bash}")
59
+ if derived_bash.exists():
60
+ return str(derived_bash)
61
+
62
+ # 4. Hardcoded default
63
+ default_path = r"C:\Program Files\Git\git-bash.exe"
64
+ attempted.append(f"default={default_path}")
65
+ if os.path.exists(default_path):
66
+ return default_path
67
+
68
+ # None found
69
+ raise FileNotFoundError(
70
+ "git-bash.exe not found. Tried (in order): " + ", ".join(attempted)
71
+ )
72
+
73
+
25
74
  def find_terminal():
26
75
  """
27
76
  Locate a terminal executable.
@@ -30,7 +79,7 @@ def find_terminal():
30
79
  command_builder_fn(script_path, title) returns command list to spawn.
31
80
  is_wt_bool: True if using wt.exe (needs bash process tracking); False if git-bash (stable pid).
32
81
  """
33
- git_bash = r"C:\Program Files\Git\git-bash.exe"
82
+ git_bash = resolve_git_bash_path()
34
83
  wt_exe = shutil.which("wt.exe")
35
84
  bash_exe = r"C:\Program Files\Git\bin\bash.exe"
36
85
 
@@ -56,8 +105,7 @@ def find_terminal():
56
105
  return wt_exe, wt_cmd, True
57
106
 
58
107
  raise FileNotFoundError(
59
- "Terminal not found. Tried: Git Bash at "
60
- f"{git_bash}, Windows Terminal (wt.exe) with bash at {bash_exe}"
108
+ "Terminal not found. Tried: Git Bash, Windows Terminal (wt.exe) with bash at {bash_exe}"
61
109
  )
62
110
 
63
111
 
package/tools/lock.mjs ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * lock.mjs — Fail-closed atomic lock acquisition for PROPOSALS.md
3
+ *
4
+ * Provides atomic lock operations (mkdir-based) with:
5
+ * - Exponential backoff (configurable base delay, default 10ms)
6
+ * - Configurable timeout (env var AESOP_LOCK_TIMEOUT_MS or config, default 30s)
7
+ * - Stale lock detection and breaking (locks older than 10min are stale)
8
+ * - Fail-closed: throws on timeout instead of proceeding unlocked
9
+ *
10
+ * Usage:
11
+ * import { acquireLock, releaseLock } from './lock.mjs';
12
+ * const lock = acquireLock(proposalsFile);
13
+ * try { ... } finally { releaseLock(lock); }
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ // Configuration: precedence env > default
20
+ const LOCK_BASE_DELAY_MS = parseInt(process.env.AESOP_LOCK_BASE_DELAY_MS || '10', 10);
21
+ const LOCK_TIMEOUT_MS = parseInt(process.env.AESOP_LOCK_TIMEOUT_MS || '30000', 10); // 30s default
22
+ const STALE_LOCK_THRESHOLD = 10 * 60 * 1000; // 10 minutes
23
+
24
+ /**
25
+ * Check whether a pid is a live process, cross-platform (Windows + POSIX).
26
+ *
27
+ * A stale lock marker's timestamp alone is not trustworthy: a process with
28
+ * write access to state/ could forge an old timestamp to force-break a
29
+ * LEGITIMATE live lock (data loss on PROPOSALS.md). Signal-0 delivery via
30
+ * process.kill() lets us verify the recorded owner is actually gone before
31
+ * we ever remove its lock directory.
32
+ *
33
+ * @param {number} pid
34
+ * @returns {boolean} true if the pid is alive (or owned by another user —
35
+ * EPERM means a process with that pid exists but we can't signal it, which
36
+ * still counts as alive for staleness purposes), false if no such process.
37
+ */
38
+ function isPidAlive(pid) {
39
+ if (!Number.isInteger(pid) || pid <= 0) {
40
+ return false;
41
+ }
42
+ try {
43
+ // Signal 0 does not actually send a signal; it only checks existence/permission.
44
+ process.kill(pid, 0);
45
+ return true;
46
+ } catch (e) {
47
+ if (e.code === 'ESRCH') {
48
+ return false; // No such process — definitely dead.
49
+ }
50
+ if (e.code === 'EPERM') {
51
+ return true; // Process exists but we lack permission to signal it — alive.
52
+ }
53
+ // Unknown error (e.g. platform quirk): fail closed and assume alive so we
54
+ // never break a lock we can't actually prove is dead.
55
+ return true;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Acquire an atomic lock directory for a file.
61
+ * Implements exponential backoff + stale lock breaking.
62
+ *
63
+ * @param {string} filePath - Path to the file to lock (lock dir is {filePath}.lock)
64
+ * @param {Object} opts - Optional: { timeoutMs: number }
65
+ * @returns {string} Lock directory path
66
+ * @throws {Error} On timeout (fail-closed; never returns null/undefined on success)
67
+ */
68
+ export function acquireLock(filePath, opts = {}) {
69
+ const lockDir = filePath + '.lock';
70
+ const lockMarkerFile = path.join(lockDir, 'pid-timestamp.txt');
71
+ const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : LOCK_TIMEOUT_MS;
72
+ const startTime = Date.now();
73
+
74
+ let attempt = 0;
75
+
76
+ while (true) {
77
+ const elapsedMs = Date.now() - startTime;
78
+ if (elapsedMs >= timeoutMs) {
79
+ // Timeout exceeded; throw with diagnostics
80
+ let diagnostic = '';
81
+ try {
82
+ if (fs.existsSync(lockDir)) {
83
+ const markerPath = path.join(lockDir, 'pid-timestamp.txt');
84
+ const content = fs.readFileSync(markerPath, 'utf8').trim();
85
+ const lines = content.split('\n');
86
+ if (lines.length >= 2) {
87
+ const holderPid = lines[0];
88
+ const holderAge = Date.now() - parseInt(lines[1], 10) * 1000;
89
+ diagnostic = ` (holder pid: ${holderPid}, lock age: ${Math.round(holderAge / 1000)}s)`;
90
+ }
91
+ }
92
+ } catch {
93
+ // Ignore diagnostics errors
94
+ }
95
+ throw new Error(
96
+ `Failed to acquire ${path.basename(filePath)}.lock after ${timeoutMs}ms${diagnostic}`
97
+ );
98
+ }
99
+
100
+ try {
101
+ fs.mkdirSync(lockDir, { exclusive: true });
102
+ // Lock acquired; write pid+timestamp for staleness detection
103
+ const lockMarker = `${process.pid}\n${Math.floor(Date.now() / 1000)}\n`;
104
+ try {
105
+ fs.writeFileSync(lockMarkerFile, lockMarker, 'utf8');
106
+ } catch {
107
+ // Marker write failed, but lock is held; continue
108
+ }
109
+ return lockDir;
110
+ } catch (e) {
111
+ if (e.code === 'EEXIST') {
112
+ // Lock exists; check if it's stale
113
+ try {
114
+ const markerPath = path.join(lockDir, 'pid-timestamp.txt');
115
+ const markerContent = fs.readFileSync(markerPath, 'utf8').trim();
116
+ const lines = markerContent.split('\n');
117
+ if (lines.length >= 2) {
118
+ const lockEpoch = parseInt(lines[1], 10);
119
+ const lockAge = Date.now() - lockEpoch * 1000;
120
+ if (lockAge > STALE_LOCK_THRESHOLD) {
121
+ // Timestamp alone is not trustworthy: a process with write
122
+ // access to state/ could forge an old timestamp to break a
123
+ // LEGITIMATE live lock (data loss on PROPOSALS.md). Verify the
124
+ // recorded owner pid is actually dead before reclaiming.
125
+ const pidField = lines[0];
126
+ const holderPid = parseInt(pidField, 10);
127
+ const pidValid = Number.isInteger(holderPid) && holderPid > 0 && String(holderPid) === pidField.trim();
128
+
129
+ let ownerDead;
130
+ if (!pidValid) {
131
+ console.error(
132
+ `Warning: Stale lock marker for ${path.basename(filePath)} has missing/unparseable pid ("${pidField}"); treating as stale-eligible`
133
+ );
134
+ ownerDead = true;
135
+ } else {
136
+ ownerDead = !isPidAlive(holderPid);
137
+ }
138
+
139
+ if (ownerDead) {
140
+ // Stale lock detected; warn and reclaim it
141
+ console.error(
142
+ `Warning: Stale lock detected for ${path.basename(filePath)} (age: ${Math.round(lockAge / 1000)}s, pid: ${pidValid ? holderPid : 'unknown'}); breaking lock`
143
+ );
144
+ try {
145
+ fs.rmSync(lockDir, { recursive: true, force: true });
146
+ } catch {
147
+ // Cleanup failed; will retry or timeout
148
+ }
149
+ // Retry immediately after cleanup
150
+ attempt++;
151
+ continue;
152
+ }
153
+ // Timestamp is stale but the recorded owner process is still
154
+ // alive — do not break a live holder's lock. Fall through to
155
+ // the backoff/retry below; fail-closed timeout still applies
156
+ // if the holder never releases.
157
+ }
158
+ }
159
+ } catch {
160
+ // Could not read marker; assume lock is active
161
+ }
162
+
163
+ // Lock is held; wait and retry with exponential backoff
164
+ attempt++;
165
+ const delayMs = LOCK_BASE_DELAY_MS * Math.pow(2, Math.min(attempt - 1, 5)); // Cap exponential growth at 2^5 = 32x
166
+ const start = Date.now();
167
+ while (Date.now() - start < delayMs && Date.now() - startTime < timeoutMs) {
168
+ // Busy-wait for calculated backoff delay
169
+ }
170
+ } else {
171
+ throw e;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Release an atomic lock directory (must be owned by this process).
179
+ * Verifies pid ownership before deletion (fail-safe: won't delete locks from other processes).
180
+ *
181
+ * @param {string|null} lockDir - Lock directory path (safe to pass null)
182
+ */
183
+ export function releaseLock(lockDir) {
184
+ if (!lockDir) {
185
+ return;
186
+ }
187
+
188
+ try {
189
+ const markerFile = path.join(lockDir, 'pid-timestamp.txt');
190
+ let shouldDelete = false;
191
+
192
+ try {
193
+ const markerContent = fs.readFileSync(markerFile, 'utf8').trim();
194
+ const lines = markerContent.split('\n');
195
+ if (lines.length >= 1) {
196
+ const lockPid = lines[0];
197
+ if (lockPid === String(process.pid)) {
198
+ shouldDelete = true;
199
+ }
200
+ }
201
+ } catch {
202
+ // If we can't read the marker, don't delete (fail-safe)
203
+ }
204
+
205
+ if (shouldDelete) {
206
+ fs.rmSync(lockDir, { recursive: true, force: true });
207
+ }
208
+ } catch {
209
+ // Ignore cleanup errors
210
+ }
211
+ }