@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  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/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ prepublish_scan.py — Pre-publish gate for public repos: scans full git history + staged changes.
4
+
5
+ Usage:
6
+ prepublish_scan.py [--repo PATH] Scan full history and staged changes before publishing
7
+
8
+ Exit codes: 0=clean, 1=findings, 2=usage error
9
+ Output: CLEAR-TO-PUBLISH or STOP with offending commits/files
10
+
11
+ This gate runs TWO scans (both must pass for CLEAR-TO-PUBLISH):
12
+ 1. Full git history (--history mode): detects secrets in any prior commit
13
+ 2. Staged changes (--staged mode): detects secrets in uncommitted changes
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+
23
+ def run_secret_scan(mode, repo_path):
24
+ """Run secret_scan.py in the specified mode; return (exit_code, output, stderr)."""
25
+ scripts_dir = Path(__file__).parent
26
+ secret_scan_path = scripts_dir / "secret_scan.py"
27
+
28
+ try:
29
+ cmd = [sys.executable, str(secret_scan_path), mode, "--repo", repo_path]
30
+ result = subprocess.run(
31
+ cmd,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=120
35
+ )
36
+ return result.returncode, result.stdout, result.stderr
37
+ except subprocess.TimeoutExpired:
38
+ return 1, "", "secret_scan.py timed out"
39
+ except Exception as e:
40
+ return 1, "", f"Error running secret_scan.py: {e}"
41
+
42
+
43
+ def main():
44
+ parser = argparse.ArgumentParser(
45
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
46
+ )
47
+ parser.add_argument(
48
+ "--repo",
49
+ default=os.getcwd(),
50
+ help="Git repo path (default: current directory)",
51
+ )
52
+ args = parser.parse_args()
53
+
54
+ repo_path = args.repo
55
+
56
+ print("[1/2] Scanning full git history for secrets...")
57
+ history_exit, history_out, history_err = run_secret_scan("--history", repo_path)
58
+
59
+ print("[2/2] Scanning staged changes for secrets...")
60
+ staged_exit, staged_out, staged_err = run_secret_scan("--staged", repo_path)
61
+
62
+ # Combine outputs
63
+ print("\n=== History Scan ===")
64
+ print(history_out)
65
+ if history_err:
66
+ print(history_err, file=sys.stderr)
67
+
68
+ print("\n=== Staged Scan ===")
69
+ print(staged_out)
70
+ if staged_err:
71
+ print(staged_err, file=sys.stderr)
72
+
73
+ # Verdict
74
+ print("\n" + "=" * 50)
75
+ if history_exit == 0 and staged_exit == 0:
76
+ print("CLEAR-TO-PUBLISH: Full history and staged changes are clean")
77
+ sys.exit(0)
78
+ else:
79
+ print("STOP: Secrets found in history or staged changes")
80
+ sys.exit(1)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * proposals.mjs — Proposal lifecycle management tool
4
+ *
5
+ * Commands:
6
+ * list [--file PATH] List all pending proposals (signal key + status)
7
+ * accept <signal-key> [--file PATH] Move proposal to PROPOSALS-LOG.md as ACCEPTED
8
+ * reject <signal-key> [--file PATH] Move proposal to PROPOSALS-LOG.md as REJECTED
9
+ *
10
+ * Default file: monitor/PROPOSALS.md
11
+ * Log file (auto): same directory as PROPOSALS.md, named PROPOSALS-LOG.md
12
+ *
13
+ * Lock behavior (P0 wave-8 fix): fail-closed with exponential backoff + stale lock breaking.
14
+ * On lock timeout (default 30s), throws error instead of proceeding unlocked.
15
+ * Stale locks (>10min) are detected and broken with warning.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { acquireLock, releaseLock } from './lock.mjs';
21
+
22
+ // === Arg parsing ===
23
+ const args = process.argv.slice(2);
24
+ let command = '';
25
+ let signalKey = '';
26
+ let proposalsFile = 'monitor/PROPOSALS.md';
27
+
28
+ // Parse args: command [arg] [--file path]
29
+ if (args.length === 0) {
30
+ usage();
31
+ process.exit(1);
32
+ }
33
+
34
+ command = args[0];
35
+
36
+ // Find --file flag
37
+ let fileIdx = args.indexOf('--file');
38
+ if (fileIdx !== -1 && fileIdx + 1 < args.length) {
39
+ proposalsFile = args[fileIdx + 1];
40
+ }
41
+
42
+ // Extract signal key for accept/reject
43
+ if (command === 'accept' || command === 'reject') {
44
+ // Find first non-flag arg after command
45
+ for (let i = 1; i < args.length; i++) {
46
+ if (!args[i].startsWith('--')) {
47
+ signalKey = args[i];
48
+ break;
49
+ }
50
+ }
51
+ if (!signalKey) {
52
+ console.error(`Error: ${command} requires a signal-key argument`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ if (command !== 'list' && command !== 'accept' && command !== 'reject') {
58
+ console.error(`Error: unknown command '${command}'`);
59
+ usage();
60
+ process.exit(1);
61
+ }
62
+
63
+ // === Core functions ===
64
+
65
+ /**
66
+ * Parse PROPOSALS.md into proposal objects
67
+ * Format per monitor/collect-signals.mjs:
68
+ * ## <signal-key> — <timestamp>
69
+ * **Signal:** <signal-key>
70
+ * **Problem:** <problem>
71
+ * **Suggested change:** <change>
72
+ * ---
73
+ */
74
+ function parseProposals(content) {
75
+ const proposals = [];
76
+ // Split on line containing only "---" (handle both LF and CRLF)
77
+ const blocks = content.split(/\r?\n---\r?\n/);
78
+
79
+ for (let i = 0; i < blocks.length; i++) {
80
+ const block = blocks[i];
81
+ const trimmed = block.trim();
82
+ if (!trimmed) continue;
83
+
84
+ // Extract signal key from "**Signal:** <key>" line
85
+ const signalMatch = trimmed.match(/\*\*Signal:\*\*\s+(\S+)/);
86
+ if (!signalMatch) continue;
87
+
88
+ const key = signalMatch[1];
89
+ const firstLine = trimmed.split('\n')[0]; // e.g., "## signal-key — timestamp"
90
+
91
+ proposals.push({
92
+ key,
93
+ firstLine,
94
+ block: trimmed, // Store trimmed block (without leading/trailing whitespace)
95
+ originalBlock: block, // Store original block with original whitespace
96
+ });
97
+ }
98
+
99
+ return proposals;
100
+ }
101
+
102
+ /**
103
+ * List proposals
104
+ */
105
+ function listProposals() {
106
+ let content = '';
107
+ try {
108
+ content = fs.readFileSync(proposalsFile, 'utf8');
109
+ } catch {
110
+ console.log('No proposals file found.');
111
+ process.exit(0);
112
+ }
113
+
114
+ const proposals = parseProposals(content);
115
+ if (proposals.length === 0) {
116
+ console.log('No proposals.');
117
+ process.exit(0);
118
+ }
119
+
120
+ console.log(`Found ${proposals.length} proposal(s):\n`);
121
+ for (const p of proposals) {
122
+ console.log(` ${p.key}`);
123
+ console.log(` ${p.firstLine}`);
124
+ console.log(` Status: PENDING`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Move proposal from PROPOSALS.md to PROPOSALS-LOG.md (with atomic locking for multi-writer safety)
130
+ * P0 wave-8 fix: fail-closed lock acquisition with exponential backoff.
131
+ * On timeout, throws error (does not fall through to unlocked write).
132
+ */
133
+ function moveProposal(status) {
134
+ // Acquire lock with fail-closed behavior (throws on timeout)
135
+ let lockDir;
136
+ try {
137
+ lockDir = acquireLock(proposalsFile);
138
+ } catch (e) {
139
+ console.error(`Error: ${e.message}`);
140
+ process.exit(1);
141
+ }
142
+
143
+ try {
144
+ // ATOMIC READ: re-read to ensure we have latest content (guard against concurrent appends)
145
+ let content = '';
146
+ try {
147
+ content = fs.readFileSync(proposalsFile, 'utf8');
148
+ } catch {
149
+ console.error(`Error: Could not read ${proposalsFile}`);
150
+ process.exit(1);
151
+ }
152
+
153
+ // Check if already in log (idempotency check first)
154
+ const logFile = path.join(path.dirname(proposalsFile), 'PROPOSALS-LOG.md');
155
+ let logContent = '';
156
+ if (fs.existsSync(logFile)) {
157
+ try {
158
+ logContent = fs.readFileSync(logFile, 'utf8');
159
+ } catch {
160
+ // Log file not readable; continue
161
+ }
162
+ }
163
+
164
+ if (logContent.includes(`**Signal:** ${signalKey}`)) {
165
+ console.log(`Notice: Signal key '${signalKey}' already moved to log; no-op.`);
166
+ process.exit(0);
167
+ }
168
+
169
+ const proposals = parseProposals(content);
170
+ const proposal = proposals.find(p => p.key === signalKey);
171
+
172
+ if (!proposal) {
173
+ console.error(`Error: Signal key '${signalKey}' not found in ${proposalsFile}`);
174
+ process.exit(1);
175
+ }
176
+
177
+ // Remove proposal from source by rebuilding without this proposal
178
+ // Split on separators and filter out the matching proposal (handle both LF and CRLF)
179
+ const blocks = content.split(/\r?\n---\r?\n/);
180
+ const filteredBlocks = blocks.filter(block => {
181
+ const trimmed = block.trim();
182
+ if (!trimmed) return true; // Keep empty blocks
183
+ const signalMatch = trimmed.match(/\*\*Signal:\*\*\s+(\S+)/);
184
+ if (!signalMatch) return true; // Keep non-proposal blocks
185
+ return signalMatch[1] !== signalKey; // Filter out matching proposal
186
+ });
187
+
188
+ // Rebuild content with separators
189
+ const updatedContent = filteredBlocks.map((b, i) => {
190
+ if (i < filteredBlocks.length - 1 && b.trim()) {
191
+ return b.trim();
192
+ }
193
+ return b.trim();
194
+ }).filter(b => b).join('\n\n---\n\n');
195
+
196
+ // ATOMIC WRITE: write to temp file, then rename (atomic on all platforms)
197
+ const tmpFile = proposalsFile + '.tmp';
198
+ try {
199
+ fs.writeFileSync(tmpFile, updatedContent.trim() ? updatedContent + '\n' : '', 'utf8');
200
+ fs.renameSync(tmpFile, proposalsFile);
201
+ } catch (e) {
202
+ // Clean up temp file if it exists
203
+ try { fs.unlinkSync(tmpFile); } catch { }
204
+ console.error(`Error: Could not write ${proposalsFile}: ${e.message}`);
205
+ process.exit(1);
206
+ }
207
+
208
+ // Append to log with status heading
209
+ const timestamp = new Date().toISOString();
210
+ const logEntry = `## ${status} ${timestamp}\n\n${proposal.block}\n\n---\n`;
211
+
212
+ try {
213
+ if (!logContent) {
214
+ fs.writeFileSync(logFile, logEntry, 'utf8');
215
+ } else {
216
+ fs.appendFileSync(logFile, logEntry, 'utf8');
217
+ }
218
+ } catch (e) {
219
+ console.error(`Error: Could not write ${logFile}: ${e.message}`);
220
+ process.exit(1);
221
+ }
222
+
223
+ console.log(`✓ Moved signal '${signalKey}' to ${status} in ${path.basename(logFile)}`);
224
+ } finally {
225
+ releaseLock(lockDir);
226
+ }
227
+ }
228
+
229
+ // === Main ===
230
+ if (command === 'list') {
231
+ listProposals();
232
+ } else if (command === 'accept') {
233
+ moveProposal('ACCEPTED');
234
+ } else if (command === 'reject') {
235
+ moveProposal('REJECTED');
236
+ }
237
+
238
+ function usage() {
239
+ console.error(`
240
+ Usage:
241
+ node proposals.mjs list [--file <path>]
242
+ node proposals.mjs accept <signal-key> [--file <path>]
243
+ node proposals.mjs reject <signal-key> [--file <path>]
244
+
245
+ Default file: monitor/PROPOSALS.md
246
+ Log file: same directory as PROPOSALS.md, named PROPOSALS-LOG.md
247
+ `);
248
+ }