@ai-dev-methodologies/rlp-desk 0.11.1 → 0.13.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 (50) hide show
  1. package/docs/plans/spicy-booping-galaxy.md +322 -0
  2. package/docs/rlp-desk/artifact-schema.md +99 -0
  3. package/docs/rlp-desk/ci-setup.md +100 -0
  4. package/docs/rlp-desk/e2e-scenarios.md +102 -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 +9 -3
  8. package/scripts/build-node-manifest.js +52 -0
  9. package/scripts/postinstall.js +162 -8
  10. package/src/commands/rlp-desk.md +73 -50
  11. package/src/governance.md +56 -7
  12. package/src/node/MANIFEST.txt +15 -0
  13. package/src/node/cli/command-builder.mjs +43 -5
  14. package/src/node/constants.mjs +19 -0
  15. package/src/node/init/campaign-initializer.mjs +100 -10
  16. package/src/node/polling/signal-poller.mjs +139 -3
  17. package/src/node/reporting/campaign-reporting.mjs +5 -1
  18. package/src/node/run.mjs +31 -2
  19. package/src/node/runner/campaign-main-loop.mjs +521 -44
  20. package/src/node/runner/leader-registry.mjs +100 -0
  21. package/src/node/runner/prompt-detector.mjs +41 -0
  22. package/src/node/runner/prompt-dismisser.mjs +200 -0
  23. package/src/node/shared/fs.mjs +38 -0
  24. package/src/node/util/debug-log.mjs +56 -0
  25. package/src/node/util/desk-root.mjs +24 -0
  26. package/src/node/util/shell-quote.mjs +12 -0
  27. package/docs/superpowers/plans/2026-04-24-gpt-5-5-default.md +0 -517
  28. package/docs/superpowers/specs/2026-04-24-gpt-5-5-default.md +0 -107
  29. /package/docs/{TODO-verification-next.md → rlp-desk/TODO-verification-next.md} +0 -0
  30. /package/docs/{architecture.md → rlp-desk/architecture.md} +0 -0
  31. /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-flywheel-enhancement.md +0 -0
  32. /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-pivot-step.md +0 -0
  33. /package/docs/{blueprints → rlp-desk/blueprints}/plan-flywheel-enhancement.md +0 -0
  34. /package/docs/{blueprints → rlp-desk/blueprints}/sv-architecture-rethink.md +0 -0
  35. /package/docs/{getting-started.md → rlp-desk/getting-started.md} +0 -0
  36. /package/docs/{internal → rlp-desk/internal}/verification-policy-gap-analysis.md +0 -0
  37. /package/docs/{internal → rlp-desk/internal}/verification-strategy-research.md +0 -0
  38. /package/docs/{multi-mission-orchestration.md → rlp-desk/multi-mission-orchestration.md} +0 -0
  39. /package/docs/{plans → rlp-desk/plans}/cozy-gliding-trinket.md +0 -0
  40. /package/docs/{plans → rlp-desk/plans}/frolicking-churning-honey.md +0 -0
  41. /package/docs/{plans → rlp-desk/plans}/keen-sauteeing-snowflake.md +0 -0
  42. /package/docs/{plans → rlp-desk/plans}/mutable-booping-corbato.md +0 -0
  43. /package/docs/{plans → rlp-desk/plans}/rlp-desk-0.11-handoff-7fixes.md +0 -0
  44. /package/docs/{plans → rlp-desk/plans}/rlp-desk-0.11.1-tmux-pane-disappearance.md +0 -0
  45. /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert-agent-a8cd695ffca2a3ad8.md +0 -0
  46. /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert.md +0 -0
  47. /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie-agent-a6814625642e956da.md +0 -0
  48. /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie.md +0 -0
  49. /package/docs/{plans → rlp-desk/plans}/validated-snacking-crayon.md +0 -0
  50. /package/docs/{protocol-reference.md → rlp-desk/protocol-reference.md} +0 -0
@@ -0,0 +1,100 @@
1
+ // v5.7 §4.11.c — Leader-only cross-project campaign registry.
2
+ //
3
+ // Worker/Verifier/Flywheel/Guard prompts MUST NEVER reference this file. Only
4
+ // the Leader (slash-command-tier process) appends one line per campaign-state-change.
5
+ // /rlp-desk status (slug-less) reads this file and dereferences each project_root
6
+ // to read that project's local analytics. Append-only — no in-place edits, no
7
+ // compaction. Stale entries (project_root no longer exists) are tolerated by
8
+ // the status reader.
9
+ //
10
+ // Path: ~/.claude/ralph-desk/registry.jsonl
11
+ //
12
+ // CI lint (v5.7 §4.11.c guardrail): run
13
+ // grep -rn 'registry\.jsonl' src/commands src/scripts src/node | grep -v Leader
14
+ // must return empty (only Leader-tier code references this path).
15
+
16
+ import fs from 'node:fs/promises';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ const REGISTRY_PATH = path.join(os.homedir(), '.claude', 'ralph-desk', 'registry.jsonl');
21
+
22
+ export function getRegistryPath() {
23
+ return REGISTRY_PATH;
24
+ }
25
+
26
+ /**
27
+ * Append a JSONL entry for a campaign state change. Idempotent on errors:
28
+ * silently swallow filesystem failures (registry is best-effort observability,
29
+ * not load-bearing). The Leader's `--add-dir "$HOME/.claude/ralph-desk"` permits
30
+ * the write without TUI prompts.
31
+ *
32
+ * @param {Object} entry — fields documented inline.
33
+ * @param {string} entry.slug
34
+ * @param {string} entry.projectRoot
35
+ * @param {'running'|'complete'|'blocked'|'aborted'} entry.status
36
+ * @param {string} [entry.workerModel]
37
+ * @param {string} [entry.verifierModel]
38
+ * @param {string} [entry.note]
39
+ */
40
+ export async function appendRegistryEntry(entry) {
41
+ const line = JSON.stringify({
42
+ ts: new Date().toISOString(),
43
+ slug: entry.slug,
44
+ project_root: entry.projectRoot,
45
+ status: entry.status,
46
+ ...(entry.workerModel ? { worker_model: entry.workerModel } : {}),
47
+ ...(entry.verifierModel ? { verifier_model: entry.verifierModel } : {}),
48
+ ...(entry.note ? { note: entry.note } : {}),
49
+ }) + '\n';
50
+
51
+ try {
52
+ await fs.mkdir(path.dirname(REGISTRY_PATH), { recursive: true });
53
+ await fs.appendFile(REGISTRY_PATH, line, 'utf8');
54
+ } catch {
55
+ // Registry is best-effort. A failure here must NOT abort the campaign.
56
+ // The campaign's project-local analytics remain authoritative.
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read all registry entries. Each line is a JSON object; malformed lines are
62
+ * skipped. Returns most recent state per slug (last-write-wins on slug+project).
63
+ */
64
+ export async function readRegistry() {
65
+ let raw;
66
+ try {
67
+ raw = await fs.readFile(REGISTRY_PATH, 'utf8');
68
+ } catch {
69
+ return [];
70
+ }
71
+ const entries = [];
72
+ for (const line of raw.split('\n')) {
73
+ if (!line.trim()) continue;
74
+ try {
75
+ entries.push(JSON.parse(line));
76
+ } catch {
77
+ // Skip malformed lines; do not abort.
78
+ }
79
+ }
80
+ return entries;
81
+ }
82
+
83
+ /**
84
+ * Dereference each entry's project_root and check whether it still exists.
85
+ * Used by /rlp-desk status to mark stale entries (worktree removed, etc.).
86
+ */
87
+ export async function annotateStaleness(entries) {
88
+ const annotated = [];
89
+ for (const entry of entries) {
90
+ let stale = false;
91
+ try {
92
+ const stat = await fs.stat(entry.project_root);
93
+ if (!stat.isDirectory()) stale = true;
94
+ } catch {
95
+ stale = true;
96
+ }
97
+ annotated.push({ ...entry, stale });
98
+ }
99
+ return annotated;
100
+ }
@@ -0,0 +1,41 @@
1
+ // v0.13.0: early-detect Claude Code permission prompts in worker stdout.
2
+ // Pre-v0.13.0 the leader only noticed via 30-min pollForSignal timeout, which
3
+ // hid the failure category. Now we surface BLOCKED with category=permission_prompt
4
+ // within seconds so wrappers can react.
5
+
6
+ const SIGNATURES = [
7
+ /Do you want to /,
8
+ /\u276F\s*1\.\s*Yes/,
9
+ /allow Claude to edit its own settings/,
10
+ /1\.\s*Yes(?:,?\s*and allow Claude)/,
11
+ ];
12
+
13
+ export function detectPermissionPrompt(chunk) {
14
+ if (typeof chunk !== 'string' || chunk.length === 0) {
15
+ return false;
16
+ }
17
+
18
+ for (const pattern of SIGNATURES) {
19
+ if (pattern.test(chunk)) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
25
+
26
+ export const PERMISSION_PROMPT_CATEGORY = 'permission_prompt';
27
+
28
+ export function buildPermissionPromptBlocked(slug, iteration, snippet) {
29
+ const trimmedSnippet = typeof snippet === 'string'
30
+ ? snippet.split(/\r?\n/).slice(0, 5).join('\n').slice(0, 600)
31
+ : '';
32
+ return {
33
+ slug,
34
+ iteration: iteration ?? 0,
35
+ reason_category: 'infra_failure',
36
+ failure_category: PERMISSION_PROMPT_CATEGORY,
37
+ recoverable: false,
38
+ suggested_action: 'switch_worker_to_codex_or_use_agent_mode',
39
+ evidence_snippet: trimmedSnippet,
40
+ };
41
+ }
@@ -0,0 +1,200 @@
1
+ // v5.7 §4.13.b — Mid-execution permission-prompt auto-dismiss (Bug 4 fix).
2
+ //
3
+ // claude CLI surfaces TUI-layer prompts ("Do you want to create...", "Do you trust...")
4
+ // even with --dangerously-skip-permissions on certain Write paths. Without this
5
+ // helper, Workers/Verifiers in tmux mode hang until idle-nudge timeout.
6
+ //
7
+ // Window-bounded match (codex Critic v5.7): require both a prompt phrase AND a
8
+ // TUI affordance marker on the SAME, PREVIOUS, or NEXT line. Whole-capture dual
9
+ // matching would let unrelated text trigger Enter (R-V5-9 false-positive).
10
+ // Per-pane 3-second debounce prevents rapid double-Enter.
11
+
12
+ // PROMPT_RE / AFFORDANCE_RE mirror src/scripts/run_ralph_desk.zsh `_PROMPT_RE`
13
+ // and `_AFFORDANCE_RE` so the Node leader catches the same TUI prompts the zsh
14
+ // runner does — including codex CLI variants (`Proceed?`, `Approve this`,
15
+ // `Press y to`, `Choose an option`, `Select [`) AND claude v2.x's new trust
16
+ // prompt format (Quick safety check / trust this folder / `❯1.Yes`).
17
+ // If you change one regex, change the other and the corresponding tests.
18
+ const PROMPT_RE =
19
+ /Do you (want to|trust)|Confirm execution|Are you sure|Continue\?|Proceed\?|Allow this|Approve this|Press y to|Choose an option|Select \[|Quick safety check|trust this (folder|directory)|Is this a project you/;
20
+ // AFFORDANCE_RE: `(yes/no` (open form) covers prose default-No prompts;
21
+ // `❯\s*\d+\.` covers numbered pickers with optional space after cursor (claude
22
+ // v2.x narrow-pane wrap renders `❯1.` without space); `Enter to confirm`
23
+ // matches the new trust-prompt footer; `1) Yes` / `Y)` / `press y to` cover
24
+ // codex CLI selection menus.
25
+ const AFFORDANCE_RE =
26
+ /\(y\/n\)|\[Y\/n\]|\[y\/N\]|\(yes\/no|❯\s*\d+\.|(?:^|\s)1\) (Yes|No)|(?:^|\s)[YyNn]\)|press (y|enter) to|Enter to confirm/;
27
+ // v5.7 §4.17 (Node parity): default-No prompts must NOT auto-Enter — that
28
+ // CANCELS the operation. Mirror zsh `_DEFAULT_NO_RE`. Bracket form is
29
+ // case-sensitive (`[y/N]` only — `[Y/n]` is default-Yes); prose form is
30
+ // case-insensitive via explicit char classes so we don't fall back to the `i`
31
+ // regex flag (which would also match `[Y/n]`).
32
+ const DEFAULT_NO_RE = /\[y\/N\]|\(yes\/no,\s*default\s+no\)|[Dd]efault[: ]+[Nn]o|^\s*N\)/;
33
+ // v5.7 §4.18: "active task" markers (omc-team parity, tmux-session.ts:659).
34
+ // Used to suppress unknown-prompt fast-fail when the Worker is busy producing
35
+ // output that may legitimately contain "(y/n)"-shaped substrings.
36
+ const ACTIVE_TASK_RE =
37
+ /esc to interrupt|background terminal running|^\s*[·✻]\s+[A-Za-z]+(\.{3}|…)/m;
38
+ const DEBOUNCE_MS = 3000;
39
+
40
+ const lastApprovalAt = new Map();
41
+
42
+ export function _resetForTesting() {
43
+ lastApprovalAt.clear();
44
+ }
45
+
46
+ export async function autoDismissPrompts(paneId, deps) {
47
+ const {
48
+ sendKeys,
49
+ capturePane,
50
+ log = () => {},
51
+ now = Date.now,
52
+ onDefaultNoBlock,
53
+ } = deps;
54
+
55
+ const t = now();
56
+ const prev = lastApprovalAt.get(paneId);
57
+ if (prev !== undefined && t - prev < DEBOUNCE_MS) {
58
+ return false;
59
+ }
60
+
61
+ let capture;
62
+ try {
63
+ capture = await capturePane(paneId);
64
+ } catch {
65
+ return false;
66
+ }
67
+ if (!capture) {
68
+ return false;
69
+ }
70
+
71
+ // v5.7 §4.21 (E2E real-claude-CLI finding): claude v2.x trust prompt is
72
+ // multi-line and wraps narrowly, so line-adjacency PROMPT_RE+AFFORDANCE_RE
73
+ // misses it. Special-case the signature ("Quick safety check ... Enter to
74
+ // confirm" with `❯N.` numbered picker selecting Yes by default).
75
+ // This is a default-Yes prompt — pressing Enter approves trust.
76
+ // §4.21.b: tmux narrow-pane wrap breaks `Quick safety check` across
77
+ // lines (`Quick safety\n check`). Normalize whitespace before matching.
78
+ const normalizedCapture = capture.replace(/\s+/g, ' ');
79
+ if (
80
+ (/Quick safety check/.test(normalizedCapture) ||
81
+ /trust this (folder|directory)/.test(normalizedCapture)) &&
82
+ /Enter to confirm/.test(normalizedCapture) &&
83
+ /❯ ?\d+\. ?Yes/.test(normalizedCapture)
84
+ ) {
85
+ log({
86
+ category: 'FLOW',
87
+ event: 'claude_trust_prompt_auto_approved',
88
+ pane_id: paneId,
89
+ });
90
+ await sendKeys(paneId, 'Enter');
91
+ lastApprovalAt.set(paneId, t);
92
+ return true;
93
+ }
94
+ // Older claude trust prompt format ("Do you trust the contents of this
95
+ // directory?" + "Yes, continue / No, quit" — omc-team parity).
96
+ if (
97
+ /Do you trust the contents of this directory/.test(capture) &&
98
+ /Yes,\s*continue|Press enter to continue/.test(capture)
99
+ ) {
100
+ log({
101
+ category: 'FLOW',
102
+ event: 'claude_trust_prompt_auto_approved',
103
+ pane_id: paneId,
104
+ });
105
+ await sendKeys(paneId, 'Enter');
106
+ lastApprovalAt.set(paneId, t);
107
+ return true;
108
+ }
109
+
110
+ // v5.7 §4.23 (E2E real-claude-CLI finding): tmux narrow-pane wrap breaks
111
+ // multi-line prompts ("Do you want to\nmake this edit to\nprd-sum-fn.md?\n
112
+ // ❯ 1. Yes") so line-adjacency PROMPT+AFFORDANCE±1 misses them. Fix:
113
+ // examine the LAST 15 normalized lines (where the active prompt lives)
114
+ // as a single joined+whitespace-collapsed string. PROMPT_RE + AFFORDANCE
115
+ // both present → auto-Enter unless DEFAULT_NO_RE also present (BLOCK).
116
+ // §4.17.b is preserved: scan-all default-No protects against scrollback
117
+ // contamination (older [Y/n] alongside active [y/N]).
118
+ const lines = capture.split('\n');
119
+ const tailLines = lines.slice(-15);
120
+ const tailNormalized = tailLines.join(' ').replace(/\s+/g, ' ');
121
+
122
+ const promptVisible = PROMPT_RE.test(tailNormalized) && AFFORDANCE_RE.test(tailNormalized);
123
+ // Default-No: scan FULL capture (not just tail) so an older default-Yes
124
+ // bracket in scrollback can't override an active default-No. §4.17.b.
125
+ const defaultNoSeen = DEFAULT_NO_RE.test(capture);
126
+ const samplePattern = tailNormalized.slice(0, 120);
127
+
128
+ if (defaultNoSeen) {
129
+ log({
130
+ category: 'FLOW',
131
+ event: 'permission_prompt_default_no_blocked',
132
+ pane_id: paneId,
133
+ });
134
+ if (typeof onDefaultNoBlock === 'function') {
135
+ try {
136
+ onDefaultNoBlock({
137
+ paneId,
138
+ reason: `default-No prompt requires explicit human decision (sample: ${samplePattern})`,
139
+ category: 'infra_failure',
140
+ });
141
+ } catch {
142
+ // Caller errors must not propagate into the poll loop.
143
+ }
144
+ }
145
+ lastApprovalAt.set(paneId, t);
146
+ return false;
147
+ }
148
+
149
+ if (promptVisible) {
150
+ log({ category: 'FLOW', event: 'permission_prompt_auto_approved', pane_id: paneId });
151
+ await sendKeys(paneId, 'Enter');
152
+ lastApprovalAt.set(paneId, t);
153
+ return true;
154
+ }
155
+
156
+ // v5.7 §4.18: unknown-prompt fast-fail (E2E + omc benchmarking).
157
+ // If pane has an affordance bracket but no recognized PROMPT_RE phrasing,
158
+ // refuse to guess auto-Enter (could be wrong default) and BLOCK so the
159
+ // operator can extend PROMPT_RE — instead of waiting 10 min for freeze
160
+ // timeout. Skip if active-task markers are present (Worker is producing
161
+ // output and the affordance text is likely just transcript).
162
+ const captureHasActiveTask = ACTIVE_TASK_RE.test(capture);
163
+ if (captureHasActiveTask) {
164
+ return false;
165
+ }
166
+ // Only inspect the last 5 non-empty lines (where an idle prompt would sit).
167
+ const tail5Lines = lines.filter((l) => l.length > 0).slice(-5);
168
+ let suspectLine = '';
169
+ for (const line of tail5Lines) {
170
+ if (AFFORDANCE_RE.test(line)) {
171
+ suspectLine = line;
172
+ break;
173
+ }
174
+ }
175
+ if (suspectLine) {
176
+ const tailHasDefaultNo = tail5Lines.some((l) => DEFAULT_NO_RE.test(l));
177
+ log({
178
+ category: 'GOV',
179
+ event: 'unknown_prompt_detected',
180
+ pane_id: paneId,
181
+ default_no: tailHasDefaultNo,
182
+ });
183
+ if (typeof onDefaultNoBlock === 'function') {
184
+ try {
185
+ onDefaultNoBlock({
186
+ paneId,
187
+ reason: tailHasDefaultNo
188
+ ? `Pane shows a default-No affordance but the surrounding prompt phrasing is not in PROMPT_RE. Sample: ${suspectLine.slice(0, 120)}`
189
+ : `Pane shows a y/n affordance marker without a recognized prompt phrasing. Refusing to guess auto-Enter. Sample: ${suspectLine.slice(0, 120)}`,
190
+ category: 'infra_failure',
191
+ });
192
+ } catch {
193
+ // Caller errors must not propagate.
194
+ }
195
+ }
196
+ lastApprovalAt.set(paneId, t);
197
+ return false;
198
+ }
199
+ return false;
200
+ }
@@ -21,3 +21,41 @@ export async function writeFileAtomic(targetPath, content) {
21
21
  throw error;
22
22
  }
23
23
  }
24
+
25
+ // v5.7 §4.24 — first-writer-wins sentinel write (BLOCKED/COMPLETE).
26
+ // Distinct from `writeFileAtomic` (last-writer-wins via rename): sentinels
27
+ // must NOT be overwritten once any path has classified the campaign outcome.
28
+ // Multiple race-prone error paths in `runCampaign()` (worker exit, verifier
29
+ // timeout, malformed signal, leader crash backstop) can fire concurrently;
30
+ // O_EXCL guarantees exactly one writes.
31
+ //
32
+ // IMPORTANT: this primitive intentionally does NOT call `ensureProjectPath`.
33
+ // Sentinels are written under the CAMPAIGN root (e.g. `/tmp/user-project/.
34
+ // claude/ralph-desk/memos/`), which is independent of the rlp-desk source
35
+ // repo. Path validation is the caller's responsibility (run() resolves
36
+ // rootDir from options.rootDir or process.cwd()).
37
+ //
38
+ // Returns:
39
+ // { wrote: true } — this caller wrote the sentinel
40
+ // { wrote: false, reason: 'already_exists' } — another path already wrote
41
+ // throws on filesystem errors other than EEXIST
42
+ export async function writeSentinelExclusive(targetPath, content) {
43
+ const resolvedPath = path.resolve(targetPath);
44
+ const targetDirectory = path.dirname(resolvedPath);
45
+ await fs.mkdir(targetDirectory, { recursive: true });
46
+ let handle;
47
+ try {
48
+ handle = await fs.open(resolvedPath, 'wx');
49
+ } catch (error) {
50
+ if (error && error.code === 'EEXIST') {
51
+ return { wrote: false, reason: 'already_exists' };
52
+ }
53
+ throw error;
54
+ }
55
+ try {
56
+ await handle.writeFile(content);
57
+ } finally {
58
+ await handle.close();
59
+ }
60
+ return { wrote: true };
61
+ }
@@ -0,0 +1,56 @@
1
+ // v5.7 §4.6 — 4-category structured debug log (telemetry parity with zsh).
2
+ //
3
+ // zsh runner emits 67 lines tagged [GOV] / [DECIDE] / [OPTION] / [FLOW] to
4
+ // debug.log; Node leader had zero. This helper provides the structured
5
+ // emission API. Call sites are ported incrementally — every new code path
6
+ // SHOULD use debugLog() instead of console/manual writes.
7
+ //
8
+ // Categories (governance §1f traceability):
9
+ // - GOV : governance enforcement (IL, CB triggers, scope locks, verdicts)
10
+ // - DECIDE: leader decisions (model selection, fix contracts, escalation)
11
+ // - OPTION: configuration snapshot at loop start
12
+ // - FLOW : execution progress (worker/verifier dispatch, signal reads, transitions)
13
+
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ const VALID_CATEGORIES = new Set(['GOV', 'DECIDE', 'OPTION', 'FLOW']);
18
+
19
+ /**
20
+ * Append a structured log line to debug.log. Format mirrors zsh log_debug:
21
+ * [YYYY-MM-DD HH:MM:SS] [CATEGORY] key=value key=value ...
22
+ *
23
+ * @param {Object} args
24
+ * @param {string} args.debugLogPath — absolute path to debug.log
25
+ * @param {'GOV'|'DECIDE'|'OPTION'|'FLOW'} args.category
26
+ * @param {Object<string,string|number|boolean>} args.fields — flat key/value
27
+ * pairs, serialized as `key=value`. Avoid nested objects; pre-stringify.
28
+ * @returns {Promise<void>} — resolves even on filesystem errors (best-effort).
29
+ */
30
+ export async function debugLog({ debugLogPath, category, fields }) {
31
+ if (!debugLogPath || !VALID_CATEGORIES.has(category)) return;
32
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
33
+ const flat = Object.entries(fields ?? {})
34
+ .map(([k, v]) => `${k}=${formatValue(v)}`)
35
+ .join(' ');
36
+ const line = `[${ts}] [${category}] ${flat}\n`;
37
+ try {
38
+ await fs.mkdir(path.dirname(debugLogPath), { recursive: true });
39
+ await fs.appendFile(debugLogPath, line, 'utf8');
40
+ } catch {
41
+ // Best-effort: never abort the campaign for a debug log write failure.
42
+ }
43
+ }
44
+
45
+ function formatValue(v) {
46
+ if (v === null || v === undefined) return 'null';
47
+ if (typeof v === 'string' && /[\s=]/.test(v)) return JSON.stringify(v);
48
+ return String(v);
49
+ }
50
+
51
+ /**
52
+ * Convenience: bind debugLogPath so callers get a per-campaign logger.
53
+ */
54
+ export function makeDebugLogger(debugLogPath) {
55
+ return (category, fields) => debugLog({ debugLogPath, category, fields });
56
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+
3
+ export const DEFAULT_DESK_REL = '.rlp-desk';
4
+ export const LEGACY_DESK_REL = path.join('.claude', 'ralph-desk');
5
+
6
+ export function resolveDeskRoot(rootDir, env = process.env) {
7
+ const override = (env && typeof env.RLP_DESK_RUNTIME_DIR === 'string') ? env.RLP_DESK_RUNTIME_DIR : '';
8
+ const trimmed = override.trim();
9
+
10
+ if (!trimmed) {
11
+ return path.join(rootDir, DEFAULT_DESK_REL);
12
+ }
13
+
14
+ if (path.isAbsolute(trimmed)) {
15
+ throw new Error('RLP_DESK_RUNTIME_DIR must be relative to project root, not absolute');
16
+ }
17
+
18
+ const segments = trimmed.split(/[\\/]/);
19
+ if (segments.includes('..')) {
20
+ throw new Error('RLP_DESK_RUNTIME_DIR must not contain parent traversal (..)');
21
+ }
22
+
23
+ return path.join(rootDir, trimmed);
24
+ }
@@ -0,0 +1,12 @@
1
+ // POSIX-safe single-quote escape for shell argument values.
2
+ // Use when emitting commands as strings (claude/codex CLI invocations,
3
+ // tmux send-keys payloads). Defends against brackets, spaces, single
4
+ // quotes, and other shell metacharacters in model ids and slugs.
5
+ //
6
+ // Contract: shellQuote("opus") -> "'opus'"
7
+ // shellQuote("claude-opus-4-7[1m]") -> "'claude-opus-4-7[1m]'"
8
+ // shellQuote("model'with'quote") -> "'model'\\''with'\\''quote'"
9
+
10
+ export function shellQuote(value) {
11
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
12
+ }