@ai-dev-methodologies/rlp-desk 0.10.1 → 0.11.1

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.
@@ -64,6 +64,7 @@ function buildPaths(rootDir, slug) {
64
64
  flywheelSignalFile: path.join(deskRoot, 'memos', `${slug}-flywheel-signal.json`),
65
65
  flywheelGuardPromptFile: path.join(deskRoot, 'prompts', `${slug}.flywheel-guard.prompt.md`),
66
66
  flywheelGuardVerdictFile: path.join(deskRoot, 'memos', `${slug}-flywheel-guard-verdict.json`),
67
+ laneAuditFile: path.join(campaignLogDir, 'lane-audit.json'),
67
68
  };
68
69
  }
69
70
 
@@ -253,6 +254,10 @@ async function readCurrentState(paths, slug, options) {
253
254
  final_verifier_model: status.final_verifier_model ?? options.finalVerifierModel ?? 'opus',
254
255
  verified_us: status.verified_us ?? [],
255
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 ?? '',
256
261
  current_us: status.current_us ?? null,
257
262
  session_name: status.session_name ?? null,
258
263
  leader_pane_id: status.leader_pane_id ?? null,
@@ -334,9 +339,171 @@ async function dispatchVerifier({
334
339
  return promptFile;
335
340
  }
336
341
 
337
- async function writeSentinel(filePath, status, usId) {
338
- const content = `${status.toUpperCase()}: ${usId}\n`;
339
- 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');
340
507
  }
341
508
 
342
509
  async function runFinalSequentialVerify({
@@ -456,6 +623,9 @@ export async function run(slug, options = {}) {
456
623
  analyticsFile: paths.analyticsFile,
457
624
  statusFile: paths.statusFile,
458
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);
459
629
 
460
630
  if (await exists(paths.blockedSentinel)) {
461
631
  throw new Error(`Campaign ${slug} is blocked. Run clean first.`);
@@ -495,7 +665,49 @@ export async function run(slug, options = {}) {
495
665
 
496
666
  let fixContractPath = null;
497
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
+
498
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
+
499
711
  state.current_us = getNextUs(usList, state.verified_us, state.current_us);
500
712
  if (state.current_us === 'ALL') {
501
713
  const finalResult = await runFinalSequentialVerify({
@@ -575,6 +787,11 @@ export async function run(slug, options = {}) {
575
787
  });
576
788
 
577
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;
578
795
  await fs.unlink(paths.flywheelSignalFile).catch(() => {});
579
796
 
580
797
  // Flywheel Guard (independent validation of flywheel decision)
@@ -601,12 +818,28 @@ export async function run(slug, options = {}) {
601
818
 
602
819
  if (guardVerdict.verdict === 'inconclusive') {
603
820
  state.phase = 'blocked';
604
- 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);
605
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
+ });
606
838
  return {
607
839
  status: 'blocked',
608
840
  usId: state.current_us,
609
- reason: 'flywheel-guard-escalate-inconclusive',
841
+ reason: guardReason,
842
+ category: 'mission_abort',
610
843
  guardIssues: guardVerdict.issues,
611
844
  statusFile: paths.statusFile,
612
845
  };
@@ -615,12 +848,25 @@ export async function run(slug, options = {}) {
615
848
  if (guardVerdict.verdict === 'fail') {
616
849
  if (state.flywheel_guard_count[state.current_us] >= 3) {
617
850
  state.phase = 'blocked';
618
- 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);
619
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
+ });
620
865
  return {
621
866
  status: 'blocked',
622
867
  usId: state.current_us,
623
- reason: 'flywheel-guard-retries-exhausted',
868
+ reason: exhaustReason,
869
+ category: 'mission_abort',
624
870
  guardIssues: guardVerdict.issues,
625
871
  statusFile: paths.statusFile,
626
872
  };
@@ -679,6 +925,24 @@ export async function run(slug, options = {}) {
679
925
  }
680
926
  }
681
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
+
682
946
  const usId = signal.us_id ?? state.current_us;
683
947
  const verifierModel = deriveVerifierModel(usId, options);
684
948
  state.phase = 'verifier';
@@ -720,7 +984,9 @@ export async function run(slug, options = {}) {
720
984
 
721
985
  if (verdict.verdict === 'blocked') {
722
986
  state.phase = 'blocked';
723
- 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);
724
990
  await appendIterationAnalytics(paths, state, usId, 'blocked', options);
725
991
  await writeStatus(paths, state, options.onStatusChange, options.now);
726
992
  let svSummary;
@@ -747,10 +1013,14 @@ export async function run(slug, options = {}) {
747
1013
  analyticsFile: paths.analyticsFile,
748
1014
  now: resolveNow(options.now),
749
1015
  svSummary,
1016
+ blockedReason,
1017
+ blockedCategory: blockedClassification.reason_category,
750
1018
  });
751
1019
  return {
752
1020
  status: 'blocked',
753
1021
  usId,
1022
+ reason: blockedReason,
1023
+ category: blockedClassification.reason_category,
754
1024
  statusFile: paths.statusFile,
755
1025
  };
756
1026
  }
@@ -760,7 +1030,8 @@ export async function run(slug, options = {}) {
760
1030
  const upgradedModel = nextWorkerModel(options.workerModel ?? state.worker_model, state.consecutive_failures);
761
1031
  if (upgradedModel === 'BLOCKED') {
762
1032
  state.phase = 'blocked';
763
- 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);
764
1035
  await writeStatus(paths, state, options.onStatusChange, options.now);
765
1036
  let svSummary;
766
1037
  if (options.withSelfVerification) {
@@ -786,10 +1057,14 @@ export async function run(slug, options = {}) {
786
1057
  analyticsFile: paths.analyticsFile,
787
1058
  now: resolveNow(options.now),
788
1059
  svSummary,
1060
+ blockedReason: upgradeReason,
1061
+ blockedCategory: 'repeat_axis',
789
1062
  });
790
1063
  return {
791
1064
  status: 'blocked',
792
1065
  usId,
1066
+ reason: upgradeReason,
1067
+ category: 'repeat_axis',
793
1068
  statusFile: paths.statusFile,
794
1069
  };
795
1070
  }