@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.
- package/docs/rlp-desk/artifact-schema.md +99 -0
- package/docs/rlp-desk/ci-setup.md +100 -0
- package/docs/rlp-desk/e2e-scenarios.md +102 -0
- package/docs/rlp-desk/plans/rlp-desk-0.11.1-tmux-pane-disappearance.md +260 -0
- package/docs/rlp-desk/plans/rlp-desk-tmux-flywheel-routing.md +730 -0
- package/install.sh +93 -20
- package/package.json +8 -2
- package/scripts/build-node-manifest.js +52 -0
- package/scripts/postinstall.js +162 -8
- package/src/commands/rlp-desk.md +48 -25
- package/src/governance.md +68 -6
- package/src/node/MANIFEST.txt +15 -0
- package/src/node/cli/command-builder.mjs +25 -5
- package/src/node/constants.mjs +19 -0
- package/src/node/polling/signal-poller.mjs +119 -3
- package/src/node/runner/campaign-main-loop.mjs +470 -41
- package/src/node/runner/leader-registry.mjs +100 -0
- package/src/node/runner/prompt-dismisser.mjs +200 -0
- package/src/node/shared/fs.mjs +38 -0
- package/src/node/util/debug-log.mjs +56 -0
- package/src/node/util/shell-quote.mjs +12 -0
- package/docs/superpowers/plans/2026-04-24-gpt-5-5-default.md +0 -517
- package/docs/superpowers/specs/2026-04-24-gpt-5-5-default.md +0 -107
- /package/docs/{TODO-verification-next.md → rlp-desk/TODO-verification-next.md} +0 -0
- /package/docs/{architecture.md → rlp-desk/architecture.md} +0 -0
- /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-flywheel-enhancement.md +0 -0
- /package/docs/{blueprints → rlp-desk/blueprints}/blueprint-pivot-step.md +0 -0
- /package/docs/{blueprints → rlp-desk/blueprints}/plan-flywheel-enhancement.md +0 -0
- /package/docs/{blueprints → rlp-desk/blueprints}/sv-architecture-rethink.md +0 -0
- /package/docs/{getting-started.md → rlp-desk/getting-started.md} +0 -0
- /package/docs/{internal → rlp-desk/internal}/verification-policy-gap-analysis.md +0 -0
- /package/docs/{internal → rlp-desk/internal}/verification-strategy-research.md +0 -0
- /package/docs/{multi-mission-orchestration.md → rlp-desk/multi-mission-orchestration.md} +0 -0
- /package/docs/{plans → rlp-desk/plans}/cozy-gliding-trinket.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/frolicking-churning-honey.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/keen-sauteeing-snowflake.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/mutable-booping-corbato.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/rlp-desk-0.11-handoff-7fixes.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert-agent-a8cd695ffca2a3ad8.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/rlp-desk-elegant-papert.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie-agent-a6814625642e956da.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/toasty-whistling-diffie.md +0 -0
- /package/docs/{plans → rlp-desk/plans}/validated-snacking-crayon.md +0 -0
- /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,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
|
+
}
|
package/src/node/shared/fs.mjs
CHANGED
|
@@ -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,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
|
+
}
|