@ai-dev-methodologies/rlp-desk 0.15.2 → 0.15.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.
@@ -6,15 +6,19 @@
6
6
  // SHOULD use debugLog() instead of console/manual writes.
7
7
  //
8
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)
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
+ // - LIFECYCLE : v0.15.4 PR-B4 — tmux/process lifecycle metrics gated on
14
+ // RLP_LIFECYCLE_METRICS=1. Emission rules: see plan v3 §B4
15
+ // Table (5 metrics). Helper is no-op when flag unset (verified
16
+ // by tests/node/test-campaign-jsonl-shape.mjs).
13
17
 
14
18
  import fs from 'node:fs/promises';
15
19
  import path from 'node:path';
16
20
 
17
- const VALID_CATEGORIES = new Set(['GOV', 'DECIDE', 'OPTION', 'FLOW']);
21
+ const VALID_CATEGORIES = new Set(['GOV', 'DECIDE', 'OPTION', 'FLOW', 'LIFECYCLE']);
18
22
 
19
23
  /**
20
24
  * Append a structured log line to debug.log. Format mirrors zsh log_debug:
@@ -22,7 +26,7 @@ const VALID_CATEGORIES = new Set(['GOV', 'DECIDE', 'OPTION', 'FLOW']);
22
26
  *
23
27
  * @param {Object} args
24
28
  * @param {string} args.debugLogPath — absolute path to debug.log
25
- * @param {'GOV'|'DECIDE'|'OPTION'|'FLOW'} args.category
29
+ * @param {'GOV'|'DECIDE'|'OPTION'|'FLOW'|'LIFECYCLE'} args.category
26
30
  * @param {Object<string,string|number|boolean>} args.fields — flat key/value
27
31
  * pairs, serialized as `key=value`. Avoid nested objects; pre-stringify.
28
32
  * @returns {Promise<void>} — resolves even on filesystem errors (best-effort).
@@ -0,0 +1,102 @@
1
+ // v0.15.4 PR-B4 — Lifecycle observability helper.
2
+ //
3
+ // Plan: docs/plans/v0.15-phase-b-plan-v3.md §B4.
4
+ // Audit: docs/plans/v0.15-phase-b-lifecycle-audit.md §3 Table 2.
5
+ //
6
+ // Five metrics tracked, all gated on RLP_LIFECYCLE_METRICS=1 env flag:
7
+ // - iter_signal_write_to_read_ms leader-poll-resolves vs worker-FS-write
8
+ // - verdict_write_to_read_ms leader-poll-resolves vs verifier-FS-write
9
+ // - pane_eof_to_cleanup_ms pane process exit vs killPaneProcess return
10
+ // - pane_reap_latency_ms done-claim observed vs C-c×2 + waitForExit
11
+ // - sentinel_lock_to_unlock_ms per type, _lock vs _unlock (object)
12
+ //
13
+ // Emission discipline:
14
+ // - debug.log: tagged [LIFECYCLE] per record (when flag set)
15
+ // - campaign.jsonl: ONE batched lifecycle_metrics object per iteration
16
+ // (the collector accumulates, the iter-end flush emits)
17
+ // When flag is unset:
18
+ // - record() is a no-op (early return) — zero overhead beyond a Map check
19
+ // - flush() returns null so analytics writer can branch on the field
20
+
21
+ const ENV_FLAG_NAME = 'RLP_LIFECYCLE_METRICS';
22
+
23
+ export function lifecycleMetricsEnabled(env = process.env) {
24
+ return env[ENV_FLAG_NAME] === '1';
25
+ }
26
+
27
+ export class LifecycleMetricsCollector {
28
+ constructor({ env = process.env, debugLog = null } = {}) {
29
+ this._enabled = lifecycleMetricsEnabled(env);
30
+ this._debugLog = debugLog;
31
+ this._records = [];
32
+ this._sentinelLockTimes = new Map();
33
+ }
34
+
35
+ get enabled() {
36
+ return this._enabled;
37
+ }
38
+
39
+ // Record a single timing metric. value is in milliseconds. ctx is a flat
40
+ // object of audit fields (iter, us_id, pane_id, sentinel_type, etc).
41
+ record(name, valueMs, ctx = {}) {
42
+ if (!this._enabled) return;
43
+ const entry = {
44
+ metric: name,
45
+ value_ms: Math.max(0, Math.round(valueMs)),
46
+ ts: new Date().toISOString(),
47
+ ...ctx,
48
+ };
49
+ this._records.push(entry);
50
+ if (this._debugLog) {
51
+ // Best-effort fire-and-forget. The debug-log helper is itself best-
52
+ // effort (appendFile error swallowed), so we don't await it.
53
+ this._debugLog('LIFECYCLE', { metric: name, value_ms: entry.value_ms, ...ctx });
54
+ }
55
+ }
56
+
57
+ // Convenience: pair-bookkeeping for sentinel_lock_to_unlock_ms (object-
58
+ // valued metric keyed by sentinel type). Call markLockStart at chmod 0o444
59
+ // time, markUnlock at chmod 0o644 time (or end-of-iter for never-unlocked).
60
+ //
61
+ // v0.15.4 audit H2: done-claim is intentionally NOT instrumented with this
62
+ // pair. In production happy path done-claim is locked-but-never-unlocked
63
+ // (campaign-main-loop unlocks only signalFile + verdictFile at iter start);
64
+ // markUnlock for done-claim never fires, so the metric would silently never
65
+ // emit. Future work: emit at lib_ralph_desk.zsh:602 archival site if needed.
66
+ //
67
+ // v0.15.4 audit H3: callers must invoke markLockStart BEFORE the chmod
68
+ // operation, not after, so the metric covers full lock duration including
69
+ // chmod execution time. Sub-ms skew, but semantically correct.
70
+ markLockStart(sentinelType, t = Date.now()) {
71
+ if (!this._enabled) return;
72
+ this._sentinelLockTimes.set(sentinelType, t);
73
+ }
74
+
75
+ markUnlock(sentinelType, ctx = {}, t = Date.now()) {
76
+ if (!this._enabled) return;
77
+ const start = this._sentinelLockTimes.get(sentinelType);
78
+ if (start === undefined) return;
79
+ this.record('sentinel_lock_to_unlock_ms', t - start, {
80
+ ...ctx,
81
+ sentinel_type: sentinelType,
82
+ });
83
+ this._sentinelLockTimes.delete(sentinelType);
84
+ }
85
+
86
+ // Snapshot + reset for end-of-iteration flush. Returns null when disabled
87
+ // so the analytics writer can omit the field cleanly.
88
+ flush() {
89
+ if (!this._enabled) return null;
90
+ const records = this._records;
91
+ this._records = [];
92
+ // Group by metric name for compact campaign.jsonl shape:
93
+ // { iter_signal_write_to_read_ms: [{value_ms,ts,...}, ...], ... }
94
+ const grouped = {};
95
+ for (const r of records) {
96
+ const { metric, ...rest } = r;
97
+ if (!grouped[metric]) grouped[metric] = [];
98
+ grouped[metric].push(rest);
99
+ }
100
+ return grouped;
101
+ }
102
+ }
@@ -261,6 +261,19 @@ _kill_pane_process() {
261
261
  if typeset -f log_debug >/dev/null 2>&1; then
262
262
  log_debug "[bug7] kill_pane_process pane=$pane_id role=$role"
263
263
  fi
264
+ # v0.15.4 PR-B4: pane_eof_to_cleanup_ms instrumentation (flag-gated).
265
+ # Records the wallclock from kill-start to wait_for_pane_ready return so
266
+ # B3 can value-assert the substrate fix actually closes the race window.
267
+ # Uses zsh native $EPOCHREALTIME (microsec) — portable to macOS BSD where
268
+ # `date +%N` is not supported.
269
+ local _b4_t0_ms=0
270
+ if [[ "${RLP_LIFECYCLE_METRICS:-0}" == "1" ]]; then
271
+ zmodload -e zsh/datetime || zmodload zsh/datetime 2>/dev/null
272
+ if [[ -n "${EPOCHREALTIME:-}" ]]; then
273
+ local _b4_t0_str="${EPOCHREALTIME//./}"
274
+ _b4_t0_ms=${_b4_t0_str:0:13}
275
+ fi
276
+ fi
264
277
  tmux send-keys -t "$pane_id" C-c 2>/dev/null
265
278
  sleep 0.5
266
279
  tmux send-keys -t "$pane_id" C-c 2>/dev/null
@@ -268,6 +281,12 @@ _kill_pane_process() {
268
281
  if typeset -f wait_for_pane_ready >/dev/null 2>&1; then
269
282
  wait_for_pane_ready "$pane_id" 5 2>/dev/null || true
270
283
  fi
284
+ if (( _b4_t0_ms > 0 )); then
285
+ local _b4_t1_str="${EPOCHREALTIME//./}"
286
+ local _b4_t1_ms=${_b4_t1_str:0:13}
287
+ log_lifecycle_metric "pane_eof_to_cleanup_ms" $((_b4_t1_ms - _b4_t0_ms)) \
288
+ "pane=$pane_id role=$role"
289
+ fi
271
290
  return 0
272
291
  }
273
292
 
@@ -285,6 +304,53 @@ _unlock_sentinel() {
285
304
  return 0
286
305
  }
287
306
 
307
+ # =============================================================================
308
+ # v0.15.4 PR-B4: Lifecycle observability — log_lifecycle_metric
309
+ # =============================================================================
310
+ # Plan: docs/plans/v0.15-phase-b-plan-v3.md §B4 (P2.1 critic-round-2 fix).
311
+ # Helper is GATED on $RLP_LIFECYCLE_METRICS=1 (no-op when unset). Emits to
312
+ # debug.log via log_debug, in a backgrounded subshell so the caller does not
313
+ # block on the FS write. The Node-side mirror is src/node/util/lifecycle-
314
+ # metrics.mjs LifecycleMetricsCollector.
315
+ #
316
+ # v0.15.4 audit M2: concurrent-appender semantics — `( ... ) &!` spawns a
317
+ # disowned subshell per metric. Multiple metrics can fire in rapid succession
318
+ # (e.g., during iter teardown) and race on debug.log. POSIX guarantees atomic
319
+ # append for writes <= PIPE_BUF (4096 bytes). A single LIFECYCLE line is
320
+ # ~150 bytes, well under the limit, so on local filesystems (APFS, ext4, xfs)
321
+ # concurrent appends produce intact non-interleaved lines. On NFS / FUSE /
322
+ # some Docker overlay setups PIPE_BUF guarantees may not hold; in those
323
+ # environments, expect possible interleaving. This is best-effort logging
324
+ # by design — the metric values land in campaign.jsonl via the Node leader's
325
+ # batched flush as the canonical authoritative record. debug.log is an
326
+ # audit aid, not the source of truth.
327
+ #
328
+ # Args:
329
+ # $1 metric_name e.g. iter_signal_write_to_read_ms
330
+ # $2 value_ms integer milliseconds (will be coerced via printf %d)
331
+ # $3 context (optional, free-form key=val pairs joined with spaces)
332
+ #
333
+ # Side effects:
334
+ # - When flag unset: returns 0 immediately (no fork, no FS call).
335
+ # - When flag set: forks `( log_debug "..." ) &!` to debug.log.
336
+ #
337
+ # Examples:
338
+ # log_lifecycle_metric "iter_signal_write_to_read_ms" "$delta" \
339
+ # "iter=$ITERATION us=$us_id pane=$WORKER_PANE"
340
+ # log_lifecycle_metric "pane_reap_latency_ms" "$delta" \
341
+ # "iter=$ITERATION sentinel=done-claim"
342
+ log_lifecycle_metric() {
343
+ [[ "${RLP_LIFECYCLE_METRICS:-0}" == "1" ]] || return 0
344
+ local metric="$1"
345
+ local value_ms="$2"
346
+ local ctx="${3:-}"
347
+ [[ -n "$metric" && -n "$value_ms" ]] || return 0
348
+ if typeset -f log_debug >/dev/null 2>&1; then
349
+ ( log_debug "[LIFECYCLE] metric=$metric value_ms=$value_ms $ctx" ) &!
350
+ fi
351
+ return 0
352
+ }
353
+
288
354
  # PR-A (Bug #10) — validate operator-written manual recovery artifacts.
289
355
  # Returns 0 when all 5 checks pass; 1 otherwise. Sets RECOVERY_FAIL_REASON
290
356
  # (global) on failure for caller logging. Mirrors the Node-side helper
@@ -369,6 +435,81 @@ _validate_operator_recovery_artifacts() {
369
435
  return 0
370
436
  }
371
437
 
438
+ # PR-E (Phase C1, stabilization) — operator-cleared BLOCKED recovery validator.
439
+ # Pair to PR-A (_validate_operator_recovery_artifacts above). Together they
440
+ # close two recovery surfaces: phase=verify (PR-A) and phase=blocked
441
+ # sentinel-cleared (PR-E this helper).
442
+ #
443
+ # Returns 0 when all 4 checks pass; 1 otherwise. Sets BLOCKED_RECOVERY_FAIL_REASON
444
+ # (global) on failure for caller logging. Mirrors Node `_validateBlockedRecovery`
445
+ # in src/node/runner/campaign-main-loop.mjs.
446
+ #
447
+ # Args:
448
+ # $1 blocked sentinel path (.md)
449
+ # $2 blocked sidecar path (.json)
450
+ # $3 status.json path
451
+ _validate_blocked_recovery() {
452
+ local sentinel_md="$1" sidecar_json="$2" status_file="$3"
453
+ BLOCKED_RECOVERY_FAIL_REASON=""
454
+
455
+ # Check 1: precondition — caller verified phase=blocked already
456
+ # (passed in via status read; no need to re-read here)
457
+
458
+ # Check 2: sentinel cleared by operator
459
+ if [[ -f "$sentinel_md" ]]; then
460
+ BLOCKED_RECOVERY_FAIL_REASON="blocked sentinel still present (operator did not clear)"
461
+ return 1
462
+ fi
463
+
464
+ # Check 3: status.json must exist + counters non-zero
465
+ if [[ ! -f "$status_file" ]]; then
466
+ BLOCKED_RECOVERY_FAIL_REASON="status.json missing"
467
+ return 1
468
+ fi
469
+ if ! command -v jq >/dev/null 2>&1; then
470
+ BLOCKED_RECOVERY_FAIL_REASON="jq unavailable; cannot validate"
471
+ return 1
472
+ fi
473
+ local fails blocks
474
+ fails=$(jq -r '.consecutive_failures // 0' "$status_file" 2>/dev/null)
475
+ blocks=$(jq -r '.consecutive_blocks // 0' "$status_file" 2>/dev/null)
476
+ if [[ "$fails" == "0" && "$blocks" == "0" ]]; then
477
+ BLOCKED_RECOVERY_FAIL_REASON="counters already zero, nothing to recover"
478
+ return 1
479
+ fi
480
+
481
+ # Check 4: sidecar safety — if sidecar exists and recoverable=false, fall through
482
+ if [[ -f "$sidecar_json" ]]; then
483
+ if ! jq -e . "$sidecar_json" >/dev/null 2>&1; then
484
+ BLOCKED_RECOVERY_FAIL_REASON="blocked.json sidecar parse error"
485
+ return 1
486
+ fi
487
+ local recoverable category
488
+ recoverable=$(jq -r '.recoverable' "$sidecar_json" 2>/dev/null)
489
+ category=$(jq -r '.reason_category // "unknown"' "$sidecar_json" 2>/dev/null)
490
+ if [[ "$recoverable" == "false" ]]; then
491
+ BLOCKED_RECOVERY_FAIL_REASON="non-recoverable category $category from sidecar (use clean to reset)"
492
+ return 1
493
+ fi
494
+ fi
495
+
496
+ return 0
497
+ }
498
+
499
+ # PR-E helper: rename the recovered sidecar so operator can audit what was
500
+ # recovered from. Best-effort — failure is non-fatal.
501
+ #
502
+ # Args:
503
+ # $1 blocked sidecar path (.json)
504
+ _archive_recovered_sidecar() {
505
+ local sidecar_json="$1"
506
+ [[ -f "$sidecar_json" ]] || return 0
507
+ local iso
508
+ iso=$(date -u +%Y-%m-%dT%H-%M-%SZ)
509
+ mv "$sidecar_json" "${sidecar_json}.recovered-${iso}" 2>/dev/null || true
510
+ return 0
511
+ }
512
+
372
513
  # PR-0b-narrow (Plan v6) — stamp leader handshake ack onto the sentinel.
373
514
  # Mirror of src/node/shared/fs.mjs::stampAckField. Best-effort, audit-only:
374
515
  # any failure is silently swallowed. Sequence:
@@ -710,6 +710,10 @@ handle_worker_exit_codex() {
710
710
  dc_us_id=$(jq -r '.us_id // "unknown"' "$DONE_CLAIM_FILE" 2>/dev/null)
711
711
  log " Codex worker completed with done-claim (us_id=$dc_us_id) and clean tree. Auto-generating signal."
712
712
  echo '{"iteration":'"$iter"',"status":"verify","us_id":"'"$dc_us_id"'","summary":"auto-generated after codex exit (clean tree)","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$signal_file"
713
+ # v0.15.4 PR-B2-FIX: codex worker pane already exited — reaper would no-op,
714
+ # but lock done-claim as defense-in-depth so any orphaned subprocess cannot
715
+ # rewrite the file before lib_ralph_desk.zsh:602 archives it.
716
+ _lock_sentinel "$DONE_CLAIM_FILE"
713
717
  _emit_a4_fallback_audit "$dc_us_id" "$iter" "codex_exit_with_done_claim_clean"
714
718
  return 0
715
719
  }
@@ -2292,6 +2296,15 @@ poll_for_signal() {
2292
2296
  if _bug8_check_synth_allowed "$ITERATION" "$dc_us_id" "inline_polling_a4_clean"; then
2293
2297
  log " WARNING: done-claim exists for $dc_us_id but no iter-signal. Tree clean — auto-generating signal (A4 fallback)."
2294
2298
  log_debug "[GOV] iter=$ITERATION done_claim_without_signal=true us_id=$dc_us_id action=auto_generate_signal"
2299
+ # v0.15.4 PR-B2-FIX: Worker pane is alive and idling post-done-claim
2300
+ # (the canonical Bug #5/7 race window). Reap before synthesizing the
2301
+ # signal so the worker cannot revise done-claim or emit a late
2302
+ # iter-signal that races the leader's synthesized one. Mirror of
2303
+ # Bug #7 Fix-Q parity at run_ralph_desk.zsh:3181 — kill before lock,
2304
+ # lock before synth-write so the next leader read sees a frozen
2305
+ # done-claim and a fresh signal_file in that order.
2306
+ _kill_pane_process "$pane_id" "worker-a4"
2307
+ _lock_sentinel "$DONE_CLAIM_FILE"
2295
2308
  echo '{"iteration":'"$ITERATION"',"status":"verify","us_id":"'"$dc_us_id"'","summary":"auto-generated by A4 fallback (done-claim + clean tree)","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$signal_file"
2296
2309
  _emit_a4_fallback_audit "$dc_us_id" "$ITERATION" "inline_polling_a4_clean"
2297
2310
  return 0
@@ -3069,6 +3082,32 @@ main() {
3069
3082
  fi
3070
3083
  fi
3071
3084
 
3085
+ # PR-E (Phase C1, stabilization): operator-cleared BLOCKED recovery.
3086
+ # Pair to PR-A above. Runs AFTER PR-A (so phase=verify wins) and skipped
3087
+ # when SKIP_NEXT_WORKER=1 (PR-A already honored). Resets stale counters
3088
+ # in status.json when operator manually deleted the BLOCKED sentinel.
3089
+ # Mirrors Node `_validateBlockedRecovery` + branch in campaign-main-loop.mjs.
3090
+ if [[ "$LAST_PHASE" == "blocked" && "$SKIP_NEXT_WORKER" -eq 0 ]]; then
3091
+ local _blocked_sidecar="$MEMOS_DIR/${SLUG}-blocked.json"
3092
+ if _validate_blocked_recovery \
3093
+ "$BLOCKED_SENTINEL" "$_blocked_sidecar" "$STATUS_FILE"; then
3094
+ local _prev_reason
3095
+ _prev_reason=$(jq -r '.last_block_reason // ""' "$STATUS_FILE" 2>/dev/null)
3096
+ log "[recovery] Operator-cleared BLOCKED detected (was: ${_prev_reason:-unrecorded}). Resetting counters and resuming as worker. iter=$ITERATION"
3097
+ log_debug "[recovery] iter=$ITERATION blocked_recovery=applied reason=\"${BLOCKED_RECOVERY_FAIL_REASON:-sidecar absent or recoverable=true}\""
3098
+ # Reset counters in-process. update_status writes fresh status when
3099
+ # next phase transition fires. Operator's intent was a clean restart.
3100
+ CONSECUTIVE_FAILURES=0
3101
+ CONSECUTIVE_BLOCKS=0
3102
+ LAST_BLOCK_REASON=""
3103
+ # Archive sidecar (rename, not delete) for audit trail.
3104
+ _archive_recovered_sidecar "$_blocked_sidecar"
3105
+ else
3106
+ log "[recovery] phase=blocked ignored: ${BLOCKED_RECOVERY_FAIL_REASON}"
3107
+ log_debug "[recovery] iter=$ITERATION blocked_recovery=skipped reason=\"${BLOCKED_RECOVERY_FAIL_REASON}\""
3108
+ fi
3109
+ fi
3110
+
3072
3111
  if (( ! SKIP_NEXT_WORKER )); then
3073
3112
  # --- governance.md s7 step 8 (cleanup): Clean previous iteration signals ---
3074
3113
  # Bug #7 Fix-R cleanup: unlock 0o444 sentinels written by the previous
@@ -3154,6 +3193,11 @@ main() {
3154
3193
  # self-review and rewrite iter-signal.json (1m43s drift observed).
3155
3194
  _kill_pane_process "$WORKER_PANE" "worker"
3156
3195
  _lock_sentinel "$SIGNAL_FILE"
3196
+ # v0.15.4 PR-B2-FIX: same worker pass also produced done-claim. Freeze
3197
+ # it alongside iter-signal so Bug #8 gates and the iter-NNN-done-claim
3198
+ # archive (lib_ralph_desk.zsh:602) read a snapshot the worker can no
3199
+ # longer revise. Symmetric with iter-signal/verdict lock contract.
3200
+ _lock_sentinel "$DONE_CLAIM_FILE"
3157
3201
  # PR-0b-narrow: stamp leader handshake ack on the iter-signal (audit-only).
3158
3202
  _stamp_ack_field "$SIGNAL_FILE"
3159
3203
  else
@@ -1,49 +0,0 @@
1
- # Bug Report Overhaul — P2/P3 Backlog
2
-
3
- > Companion to `bug-report-overhaul-v1.md` (PR-A/B/C plan).
4
- > User stop-rule: ralplan iterates only until P0+P1 = 0; P2 and below are captured here, NOT blockers.
5
- > Re-prioritize from this file in a future ralplan when the operator-minutes-saved metric from PR-A/B/C lands.
6
-
7
- ---
8
-
9
- ## P2 — should fix in a follow-up PR after PR-A/B/C land
10
-
11
- ### From v0 plan (Option C/D, deferred features)
12
-
13
- - **Heartbeat-warning sidecar (Option B from v0)** — emit `<slug>-warning.{md,json}` when heartbeat anomaly crosses 50% of `iter-timeout`. Lets operator pre-empt a BLOCKED before the 30-min wall hits. Decoupled from this PR set because (a) report-quality is the dominant pain (D1), and (b) warning sidecar adds a second sentinel surface that risks false-positive fatigue. Revisit after PR-A/B land and we measure how many BLOCKEDs would have been pre-empted.
14
- - **GitHub Issues integration (Option D from v0)** — POST blocked context to a configured GitHub repo issue. Requires per-repo authn story (token storage, network retry, rate-limits) — violates principle 3 in the current PR set. Re-evaluate after a credible authn proposal exists.
15
- - **Pattern-learning loop** — mine `~/.claude/ralph-desk/analytics/*/bug-reports/` for emerging clusters. Auto-extends `docs/bug-patterns.json` with new candidate signatures for human review.
16
- - **Cross-campaign bug-report dashboard in `/rlp-desk analytics`** — surface patterns across projects.
17
- - **Auto-suggest "this looks like Bug #N — try fix-X" inline in CLI output** — operationalize PR-C's `pattern_match` data with an inline suggestion. Held back so the deterministic Jaccard implementation can be calibrated against real campaign data first.
18
- - **Operator-CLI `/rlp-desk recover <slug> --to verify`** — write the manual recovery artifacts (`iter-signal.json`, `done-claim.json`, `status.json` patch) deterministically. Currently a hand-rolled `jq` pipeline per Bug #10 §7 workaround.
19
-
20
- ### From Codex Critic Round 2 (BACKLOG)
21
-
22
- - **[P2-1]** PR-A `_validateOperatorRecoveryArtifacts` return shape — current pseudo-code mixes `if (valid)` (boolean coercion) with `valid.reason` (object access). Resolve at implementation time to either `{ ok: bool, reason: string }` (object) or pure boolean + separate side-channel for the warning text. Affects the audit log line shape.
23
- - **[P2-2]** PR-A test summary in §5 says "5 ACs (R1–R5)" but §8 added AC-R6 (`_skipNextWorkerDispatch` cleared after one use). Update §5 to "6 ACs (R1–R6)" for consistency before PR-A merges.
24
-
25
- ### From Codex Critic Round 3 (BACKLOG)
26
-
27
- - **[P2-3]** §9 step 5 banner-aware diff command only covers `run_ralph_desk.zsh`. PR-A and PR-B both also touch `lib_ralph_desk.zsh`. Add a matching `diff <(cat src/scripts/lib_ralph_desk.zsh) <(tail -n +N ~/.claude/ralph-desk/scripts/lib_ralph_desk.zsh)` step in the implementation runbook (verify the right `tail -n +N` offset at impl time — `lib_*.zsh` is sourced and may have no shebang). Extend to `init_ralph_desk.zsh` if PR-B touches it.
28
-
29
- ## P3 — nice-to-have polish
30
-
31
- ### From Codex Critic Round 2
32
-
33
- - **[P3-1]** Option C/D/E rejection rationale in v1 §4 says "Same as v0" — acceptable because v0 is co-located, but inline one-sentence rationale would make the v1 plan self-contained for future readers who do not have the v0 file.
34
-
35
- ### From Architect Round 1 (residual notes)
36
-
37
- - Validate the `bug-patterns.json` Jaccard threshold (0.7) against actual past blocks once we have ≥20 historical reports — current threshold is hand-picked. Likely needs a small calibration script in `scripts/`.
38
- - Consider whether `bug-reports/` should ship in the npm tarball default `.gitignore` of newly initialized projects — currently the schema doc only recommends operators add it themselves.
39
-
40
- ---
41
-
42
- ## Promotion criteria (when to re-ralplan one of these)
43
-
44
- A backlog item moves back into a planner draft when **any** of these is true:
45
-
46
- 1. PR-A/B/C lands and we measure ≥3 BLOCKEDs where the deferred item would have moved D1 by ≥10 minutes (e.g. heartbeat warning would have pre-empted a 30-min wait).
47
- 2. Operator hand-files ≥2 bug reports about the same backlog gap (signal that the deferral was wrong).
48
- 3. The `bug-patterns.json` seed becomes too large for human authoring (≥30 entries) — triggers the pattern-learning loop item.
49
- 4. A user explicitly asks for one (e.g. operator-CLI `/rlp-desk recover` once they fatigue of jq pipelines).