@ai-dev-methodologies/rlp-desk 0.10.0 → 0.11.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.
@@ -24,7 +24,6 @@ import {
24
24
 
25
25
  const execFileAsync = promisify(execFile);
26
26
  const REQUIRED_SCAFFOLD_NAMES = ['workerPrompt', 'verifierPrompt', 'memoryFile', 'prdFile', 'testSpecFile'];
27
- const CLAUDE_MODELS = new Set(['haiku', 'sonnet', 'opus']);
28
27
  const MODEL_UPGRADES = {
29
28
  'gpt-5.5:medium': 'gpt-5.5:high',
30
29
  'gpt-5.5:high': 'gpt-5.5:xhigh',
@@ -65,6 +64,7 @@ function buildPaths(rootDir, slug) {
65
64
  flywheelSignalFile: path.join(deskRoot, 'memos', `${slug}-flywheel-signal.json`),
66
65
  flywheelGuardPromptFile: path.join(deskRoot, 'prompts', `${slug}.flywheel-guard.prompt.md`),
67
66
  flywheelGuardVerdictFile: path.join(deskRoot, 'memos', `${slug}-flywheel-guard-verdict.json`),
67
+ laneAuditFile: path.join(campaignLogDir, 'lane-audit.json'),
68
68
  };
69
69
  }
70
70
 
@@ -254,6 +254,10 @@ async function readCurrentState(paths, slug, options) {
254
254
  final_verifier_model: status.final_verifier_model ?? options.finalVerifierModel ?? 'opus',
255
255
  verified_us: status.verified_us ?? [],
256
256
  consecutive_failures: status.consecutive_failures ?? 0,
257
+ // US-021 R9 P2-I consecutive_blocks counter (governance §8). Tracks repeated
258
+ // same-canonical-reason worker blocks; verify_fail uses consecutive_failures.
259
+ consecutive_blocks: status.consecutive_blocks ?? 0,
260
+ last_block_reason: status.last_block_reason ?? '',
257
261
  current_us: status.current_us ?? null,
258
262
  session_name: status.session_name ?? null,
259
263
  leader_pane_id: status.leader_pane_id ?? null,
@@ -335,9 +339,171 @@ async function dispatchVerifier({
335
339
  return promptFile;
336
340
  }
337
341
 
338
- async function writeSentinel(filePath, status, usId) {
339
- const content = `${status.toUpperCase()}: ${usId}\n`;
340
- await fs.writeFile(filePath, content, 'utf8');
342
+ // P1-E Lane Enforcement (governance §7e). WARN-only by default; opt-in
343
+ // strict escalates lane violations to BLOCKED with downgraded action
344
+ // (recoverable=true, retry_after_fix). audit log file is initialized to
345
+ // `[]` so the file always exists, simplifying wrapper polling.
346
+ async function _initLaneAuditLog(paths) {
347
+ await fs.mkdir(path.dirname(paths.laneAuditFile), { recursive: true });
348
+ if (!(await exists(paths.laneAuditFile))) {
349
+ await fs.writeFile(paths.laneAuditFile, '[]\n', 'utf8');
350
+ }
351
+ }
352
+
353
+ // US-020 R8 P1-H Blocked exit hygiene (governance §1f, 5th channel).
354
+ // Worker must update memory.md (Blocking History) and latest.md (Known Issues)
355
+ // before signalling blocked. We compare mtimes against `now`; either file older
356
+ // than 5 minutes means the worker skipped the hygiene step. Returns true when violated.
357
+ async function _checkBlockedHygiene(paths, now = Date.now()) {
358
+ const threshold = 5 * 60 * 1000; // 5 minutes
359
+ const targets = [paths.memoryFile, paths.contextFile].filter(Boolean);
360
+ for (const file of targets) {
361
+ try {
362
+ const stat = await fs.stat(file);
363
+ if (now - stat.mtimeMs > threshold) {
364
+ return true;
365
+ }
366
+ } catch {
367
+ // Missing file counts as violated — worker had nothing to update.
368
+ return true;
369
+ }
370
+ }
371
+ return false;
372
+ }
373
+
374
+ async function _snapshotLaneMtimes(paths) {
375
+ // PRD / test-spec are read-only artifacts the worker MUST NOT modify.
376
+ // memos and context are leader-owned; worker writes them via signal
377
+ // files only, never by direct edit.
378
+ const targets = [paths.prdFile, paths.testSpecFile, paths.contextFile];
379
+ const snapshot = {};
380
+ for (const file of targets) {
381
+ try {
382
+ const stat = await fs.stat(file);
383
+ snapshot[file] = stat.mtimeMs;
384
+ } catch {
385
+ snapshot[file] = null;
386
+ }
387
+ }
388
+ return snapshot;
389
+ }
390
+
391
+ async function _checkLaneViolations(paths, snapshotBefore, snapshotAfter, state, options) {
392
+ const violations = [];
393
+ for (const [file, before] of Object.entries(snapshotBefore)) {
394
+ const after = snapshotAfter[file];
395
+ if (before !== null && after !== null && after !== before) {
396
+ violations.push({
397
+ file,
398
+ mtime_before: before,
399
+ mtime_after: after,
400
+ iter: state.iteration ?? 0,
401
+ lane_mode: options.laneStrict ? 'strict' : 'warn',
402
+ });
403
+ }
404
+ }
405
+ if (violations.length === 0) return null;
406
+ // Append to audit log (best-effort).
407
+ try {
408
+ const existing = JSON.parse(await fs.readFile(paths.laneAuditFile, 'utf8'));
409
+ await fs.writeFile(paths.laneAuditFile, `${JSON.stringify([...existing, ...violations], null, 2)}\n`, 'utf8');
410
+ } catch {
411
+ // log file corrupted or missing — re-initialize and write fresh entries.
412
+ await fs.writeFile(paths.laneAuditFile, `${JSON.stringify(violations, null, 2)}\n`, 'utf8');
413
+ }
414
+ return violations;
415
+ }
416
+
417
+ // P1-D Cross-US dependency token list (governance §1f). Keep in sync with
418
+ // the zsh helper _classify_cross_us_or_metric in lib_ralph_desk.zsh.
419
+ const CROSS_US_TOKEN_RE = /depends on US-|blocking US-|awaits US-|post-iter US-|requires US-\d+|cross-US|US-\d+ 산출물|신규 US-|post-iter/i;
420
+
421
+ // P1-D Failure Taxonomy classifier. governance §1f locks the 6 reason_category
422
+ // values + recoverable + suggested_action defaults per source. wrapper MUST
423
+ // branch on reason_category; failure_category is diagnostic only.
424
+ function _classifyBlock(source, { verdict, state, slug } = {}) {
425
+ let category;
426
+ let recoverable;
427
+ let action;
428
+ let failureCategory = null;
429
+ switch (source) {
430
+ case 'flywheel_inconclusive':
431
+ case 'flywheel_exhausted':
432
+ category = 'mission_abort';
433
+ recoverable = false;
434
+ action = 'terminal_alert';
435
+ break;
436
+ case 'model_upgrade':
437
+ category = 'repeat_axis';
438
+ recoverable = false;
439
+ action = 'next_mission_chain';
440
+ break;
441
+ case 'verifier': {
442
+ const text = `${verdict?.reason ?? ''} ${verdict?.summary ?? ''}`;
443
+ category = CROSS_US_TOKEN_RE.test(text) ? 'cross_us_dep' : 'metric_failure';
444
+ recoverable = true;
445
+ action = 'retry_after_fix';
446
+ failureCategory = verdict?.failure_category ?? null;
447
+ break;
448
+ }
449
+ default:
450
+ category = 'metric_failure';
451
+ recoverable = false;
452
+ action = 'terminal_alert';
453
+ }
454
+ return {
455
+ reason_category: category,
456
+ failure_category: failureCategory,
457
+ recoverable,
458
+ suggested_action: action,
459
+ iteration: state?.iteration ?? 0,
460
+ slug,
461
+ };
462
+ }
463
+
464
+ async function writeSentinel(filePath, status, usId, reason, classification = null, paths = null) {
465
+ // governance §1f BLOCKED Surfacing: BLOCKED is surfaced on FIVE channels —
466
+ // sentinel (markdown + JSON sidecar), status, console (stderr), report,
467
+ // and (US-020 R8 P1-H, 5th channel) memory.md/latest.md hygiene update.
468
+ // Legacy 1-line parsers still work because line 1 is unchanged.
469
+ const lines = [`${status.toUpperCase()}: ${usId}`];
470
+ if (reason) lines.push(`Reason: ${reason}`);
471
+ if (classification?.reason_category) {
472
+ lines.push(`Category: ${classification.reason_category}`);
473
+ }
474
+
475
+ // P1-D Write Order Contract:
476
+ // 1. JSON sidecar FIRST (atomic per-file rename via writeFile).
477
+ // 2. markdown sentinel SECOND.
478
+ // Invariant: markdown exists ⇒ JSON exists. Wrappers watch markdown,
479
+ // then read JSON; if JSON not yet visible (rare race), retry up to 5×50ms.
480
+ if (status === 'blocked' && classification) {
481
+ const jsonPath = filePath.replace(/\.md$/, '.json');
482
+ let hygieneViolated = false;
483
+ if (paths) {
484
+ try {
485
+ hygieneViolated = await _checkBlockedHygiene(paths);
486
+ } catch {
487
+ hygieneViolated = false;
488
+ }
489
+ }
490
+ const jsonBody = {
491
+ schema_version: '2.0',
492
+ slug: classification.slug ?? null,
493
+ us_id: usId,
494
+ blocked_at_iter: classification.iteration ?? 0,
495
+ blocked_at_utc: new Date().toISOString(),
496
+ reason_category: classification.reason_category,
497
+ reason_detail: reason ?? null,
498
+ failure_category: classification.failure_category ?? null,
499
+ recoverable: classification.recoverable ?? false,
500
+ suggested_action: classification.suggested_action ?? 'terminal_alert',
501
+ meta: { blocked_hygiene_violated: hygieneViolated },
502
+ };
503
+ await fs.writeFile(jsonPath, `${JSON.stringify(jsonBody, null, 2)}\n`, 'utf8');
504
+ }
505
+
506
+ await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8');
341
507
  }
342
508
 
343
509
  async function runFinalSequentialVerify({
@@ -457,6 +623,9 @@ export async function run(slug, options = {}) {
457
623
  analyticsFile: paths.analyticsFile,
458
624
  statusFile: paths.statusFile,
459
625
  });
626
+ // P1-E Lane Enforcement: initialize audit log to `[]` so the file always
627
+ // exists. Wrappers can then poll/tail without ENOENT special-cases.
628
+ await _initLaneAuditLog(paths);
460
629
 
461
630
  if (await exists(paths.blockedSentinel)) {
462
631
  throw new Error(`Campaign ${slug} is blocked. Run clean first.`);
@@ -496,7 +665,49 @@ export async function run(slug, options = {}) {
496
665
 
497
666
  let fixContractPath = null;
498
667
 
668
+ // P1-E Lane Enforcement: snapshot lane mtimes before each iteration,
669
+ // compare at the top of the next iteration. Drift on read-only artifacts
670
+ // (PRD, test-spec, context) emits a lane_violation_warning event + audit
671
+ // log entry. governance §7e. Strict mode escalation hook is wired below
672
+ // (sentinel BLOCKED with infra_failure + recoverable=true downgrade).
673
+ let _laneSnapshot = await _snapshotLaneMtimes(paths);
674
+
499
675
  while (state.iteration <= maxIterations) {
676
+ // Audit drift from the prior iteration before doing anything new.
677
+ const _laneSnapshotAfter = await _snapshotLaneMtimes(paths);
678
+ const _laneViolations = await _checkLaneViolations(paths, _laneSnapshot, _laneSnapshotAfter, state, options);
679
+ if (_laneViolations) {
680
+ for (const v of _laneViolations) {
681
+ await appendIterationAnalytics(paths, state, state.current_us ?? 'ALL', 'lane_violation_warning', { ...options, lane_violation: v });
682
+ }
683
+ if (options.laneStrict) {
684
+ // Strict mode: escalate to BLOCKED with downgrade
685
+ // (recoverable=true, retry_after_fix). governance §7e justifies
686
+ // the downgrade — the mtime audit is best-effort and should not
687
+ // terminally kill a campaign.
688
+ state.phase = 'blocked';
689
+ const laneReason = `lane_violation: ${_laneViolations.length} read-only artifact(s) modified during prior iteration`;
690
+ const laneClassification = {
691
+ reason_category: 'infra_failure',
692
+ failure_category: null,
693
+ recoverable: true,
694
+ suggested_action: 'retry_after_fix',
695
+ iteration: state.iteration,
696
+ slug,
697
+ };
698
+ await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us ?? 'ALL', laneReason, laneClassification, paths);
699
+ await writeStatus(paths, state, options.onStatusChange, options.now);
700
+ return {
701
+ status: 'blocked',
702
+ usId: state.current_us ?? 'ALL',
703
+ reason: laneReason,
704
+ category: 'infra_failure',
705
+ statusFile: paths.statusFile,
706
+ };
707
+ }
708
+ }
709
+ _laneSnapshot = _laneSnapshotAfter;
710
+
500
711
  state.current_us = getNextUs(usList, state.verified_us, state.current_us);
501
712
  if (state.current_us === 'ALL') {
502
713
  const finalResult = await runFinalSequentialVerify({
@@ -576,6 +787,11 @@ export async function run(slug, options = {}) {
576
787
  });
577
788
 
578
789
  state.last_flywheel_decision = flywheelSignal.decision;
790
+ // P0-A multi-mission orchestration: optionally captured from flywheel signal.
791
+ // null when the flywheel did not suggest a next mission. Consumer wrappers
792
+ // poll status.next_mission_candidate to chain missions without code edits.
793
+ // See docs/multi-mission-orchestration.md.
794
+ state.next_mission_candidate = flywheelSignal.next_mission_candidate ?? null;
579
795
  await fs.unlink(paths.flywheelSignalFile).catch(() => {});
580
796
 
581
797
  // Flywheel Guard (independent validation of flywheel decision)
@@ -602,12 +818,28 @@ export async function run(slug, options = {}) {
602
818
 
603
819
  if (guardVerdict.verdict === 'inconclusive') {
604
820
  state.phase = 'blocked';
605
- await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us);
821
+ const guardReason = 'flywheel-guard-escalate-inconclusive';
822
+ await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us, guardReason, _classifyBlock('flywheel_inconclusive', { state, slug }), paths);
606
823
  await writeStatus(paths, state, options.onStatusChange, options.now);
824
+ // governance §1f three-channel: sentinel + report + return value all
825
+ // carry the same blocked reason. SV is intentionally not generated
826
+ // here because the guard fires before the iteration runs to
827
+ // completion; the campaign report uses the default SV message.
828
+ await generateCampaignReport({
829
+ slug,
830
+ reportFile: paths.reportFile,
831
+ prdFile: paths.prdFile,
832
+ statusFile: paths.statusFile,
833
+ analyticsFile: paths.analyticsFile,
834
+ now: resolveNow(options.now),
835
+ blockedReason: guardReason,
836
+ blockedCategory: 'mission_abort',
837
+ });
607
838
  return {
608
839
  status: 'blocked',
609
840
  usId: state.current_us,
610
- reason: 'flywheel-guard-escalate-inconclusive',
841
+ reason: guardReason,
842
+ category: 'mission_abort',
611
843
  guardIssues: guardVerdict.issues,
612
844
  statusFile: paths.statusFile,
613
845
  };
@@ -616,12 +848,25 @@ export async function run(slug, options = {}) {
616
848
  if (guardVerdict.verdict === 'fail') {
617
849
  if (state.flywheel_guard_count[state.current_us] >= 3) {
618
850
  state.phase = 'blocked';
619
- await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us);
851
+ const exhaustReason = 'flywheel-guard-retries-exhausted';
852
+ await writeSentinel(paths.blockedSentinel, 'blocked', state.current_us, exhaustReason, _classifyBlock('flywheel_exhausted', { state, slug }), paths);
620
853
  await writeStatus(paths, state, options.onStatusChange, options.now);
854
+ // governance §1f three-channel: see comment above.
855
+ await generateCampaignReport({
856
+ slug,
857
+ reportFile: paths.reportFile,
858
+ prdFile: paths.prdFile,
859
+ statusFile: paths.statusFile,
860
+ analyticsFile: paths.analyticsFile,
861
+ now: resolveNow(options.now),
862
+ blockedReason: exhaustReason,
863
+ blockedCategory: 'mission_abort',
864
+ });
621
865
  return {
622
866
  status: 'blocked',
623
867
  usId: state.current_us,
624
- reason: 'flywheel-guard-retries-exhausted',
868
+ reason: exhaustReason,
869
+ category: 'mission_abort',
625
870
  guardIssues: guardVerdict.issues,
626
871
  statusFile: paths.statusFile,
627
872
  };
@@ -680,6 +925,24 @@ export async function run(slug, options = {}) {
680
925
  }
681
926
  }
682
927
 
928
+ // US-019 R7 P1-G: verify_partial malformed downgrade.
929
+ // verify_partial requires verified_acs[] to be a non-empty array. Otherwise the verifier
930
+ // has nothing to evaluate and we must treat the signal as broken contract → blocked.
931
+ if (signal && signal.status === 'verify_partial') {
932
+ const acs = Array.isArray(signal.verified_acs) ? signal.verified_acs : null;
933
+ if (!acs || acs.length === 0) {
934
+ const malformedUs = signal.us_id ?? state.current_us;
935
+ const malformedClassification = {
936
+ reason_category: 'mission_abort',
937
+ recoverable: true,
938
+ suggested_action: 'retry_after_fix',
939
+ failure_category: 'spec',
940
+ };
941
+ await writeSentinel(paths.blockedSentinel, 'blocked', malformedUs, 'verify_partial_malformed', malformedClassification, paths);
942
+ return { status: 'blocked', usId: malformedUs, reason: 'verify_partial_malformed', category: 'mission_abort' };
943
+ }
944
+ }
945
+
683
946
  const usId = signal.us_id ?? state.current_us;
684
947
  const verifierModel = deriveVerifierModel(usId, options);
685
948
  state.phase = 'verifier';
@@ -721,7 +984,9 @@ export async function run(slug, options = {}) {
721
984
 
722
985
  if (verdict.verdict === 'blocked') {
723
986
  state.phase = 'blocked';
724
- await writeSentinel(paths.blockedSentinel, 'blocked', usId);
987
+ const blockedReason = verdict.reason || verdict.summary || 'verifier-blocked';
988
+ const blockedClassification = _classifyBlock('verifier', { verdict, state, slug });
989
+ await writeSentinel(paths.blockedSentinel, 'blocked', usId, blockedReason, blockedClassification, paths);
725
990
  await appendIterationAnalytics(paths, state, usId, 'blocked', options);
726
991
  await writeStatus(paths, state, options.onStatusChange, options.now);
727
992
  let svSummary;
@@ -748,10 +1013,14 @@ export async function run(slug, options = {}) {
748
1013
  analyticsFile: paths.analyticsFile,
749
1014
  now: resolveNow(options.now),
750
1015
  svSummary,
1016
+ blockedReason,
1017
+ blockedCategory: blockedClassification.reason_category,
751
1018
  });
752
1019
  return {
753
1020
  status: 'blocked',
754
1021
  usId,
1022
+ reason: blockedReason,
1023
+ category: blockedClassification.reason_category,
755
1024
  statusFile: paths.statusFile,
756
1025
  };
757
1026
  }
@@ -761,7 +1030,8 @@ export async function run(slug, options = {}) {
761
1030
  const upgradedModel = nextWorkerModel(options.workerModel ?? state.worker_model, state.consecutive_failures);
762
1031
  if (upgradedModel === 'BLOCKED') {
763
1032
  state.phase = 'blocked';
764
- await writeSentinel(paths.blockedSentinel, 'blocked', usId);
1033
+ const upgradeReason = `model-upgrade-exhausted (worker_model=${state.worker_model}, consecutive_failures=${state.consecutive_failures})`;
1034
+ await writeSentinel(paths.blockedSentinel, 'blocked', usId, upgradeReason, _classifyBlock('model_upgrade', { state, slug }), paths);
765
1035
  await writeStatus(paths, state, options.onStatusChange, options.now);
766
1036
  let svSummary;
767
1037
  if (options.withSelfVerification) {
@@ -787,10 +1057,14 @@ export async function run(slug, options = {}) {
787
1057
  analyticsFile: paths.analyticsFile,
788
1058
  now: resolveNow(options.now),
789
1059
  svSummary,
1060
+ blockedReason: upgradeReason,
1061
+ blockedCategory: 'repeat_axis',
790
1062
  });
791
1063
  return {
792
1064
  status: 'blocked',
793
1065
  usId,
1066
+ reason: upgradeReason,
1067
+ category: 'repeat_axis',
794
1068
  statusFile: paths.statusFile,
795
1069
  };
796
1070
  }