@ionivetech/mugiwara 0.8.2 → 0.9.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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/mugiwara-helpers.mjs +1 -1
  7. package/.opencode/plugins/mugiwara.mjs +173 -1
  8. package/README.md +63 -58
  9. package/content/agents/luffy-orchestrator.md +16 -1
  10. package/content/agents/zoro-execution.md +1 -1
  11. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
  12. package/content/skills/mugiwara-execution/SKILL.md +5 -5
  13. package/content/skills/mugiwara-gates/SKILL.md +1 -0
  14. package/content/skills/mugiwara-healing/SKILL.md +1 -0
  15. package/content/skills/mugiwara-lessons/SKILL.md +2 -0
  16. package/content/skills/mugiwara-orchestration/SKILL.md +15 -20
  17. package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
  18. package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
  19. package/content/skills/mugiwara-orchestration/references/solo-team.md +18 -0
  20. package/content/skills/mugiwara-planning/SKILL.md +7 -15
  21. package/content/skills/mugiwara-planning/references/sub-missions.md +14 -0
  22. package/content/skills/mugiwara-quality/SKILL.md +1 -0
  23. package/content/skills/mugiwara-review/SKILL.md +2 -0
  24. package/content/skills/mugiwara-security/SKILL.md +2 -2
  25. package/content/skills/mugiwara-ship/SKILL.md +12 -0
  26. package/content/skills/mugiwara-workflow/SKILL.md +3 -3
  27. package/dist/mugiwara.js +934 -158
  28. package/gemini-extension.json +1 -1
  29. package/hooks/engagement-marker.js +9 -1
  30. package/hooks/engagement-marker.ts +9 -1
  31. package/hooks/hooks.json +12 -0
  32. package/hooks/pipeline-guard.js +137 -3
  33. package/hooks/pipeline-guard.ts +161 -3
  34. package/hooks/pretool-guard.js +84 -0
  35. package/hooks/pretool-guard.ts +60 -0
  36. package/package.json +1 -1
  37. package/plugin.json +1 -1
  38. package/references/multi-actor.md +17 -14
  39. package/references/wave-banners.md +22 -27
  40. package/scripts/build-hooks.ts +1 -1
  41. package/scripts/gate-selftest.ts +480 -0
  42. package/scripts/savepoint.sh +139 -14
  43. package/scripts/validate-content.ts +345 -2
  44. package/scripts/write-metrics.ts +25 -1
  45. package/src/args.ts +1 -1
  46. package/src/cli.ts +158 -3
  47. package/src/config.ts +33 -11
  48. package/src/guards.ts +40 -0
  49. package/src/initiative.ts +174 -0
  50. package/src/mission.ts +137 -52
  51. package/src/targets/claude.ts +1 -0
@@ -63,9 +63,27 @@ if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL
63
63
 
64
64
  # --- parse mission args: <mission> [member] [wave] [mode] ---
65
65
  MISSION="${1:-${STATE_MISSION:-}}"
66
+ # member: positional > env > config team_member > empty (solo). Never derive
67
+ # silently from git identity — solo vs team is a recorded Flow 0 decision, not
68
+ # an inference. (W3)
66
69
  MEMBER="${2:-${STATE_MEMBER:-}}"
70
+ if [ -z "$MEMBER" ] && [ -f "$MUGIWARA_DIR/config" ]; then
71
+ MEMBER=$(grep -E '^team_member=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
72
+ fi
67
73
  WAVE="${3:-${STATE_WAVE:-1}}"
68
- MODE="${4:-${STATE_MODE:-guided}}"
74
+ # mode: positional > env > project config > global config > guided. The hook
75
+ # passes it positionally; direct script and CLI calls must fall back to config
76
+ # or 11 of 12 harnesses record the wrong mode. (W1)
77
+ MODE="${4:-${STATE_MODE:-}}"
78
+ if [ -z "$MODE" ]; then
79
+ for _cfg in "$MUGIWARA_DIR/config" "$HOME/.mugiwara/config"; do
80
+ [ -f "$_cfg" ] || continue
81
+ _m=$(grep -E '^mode=' "$_cfg" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
82
+ [ -n "$_m" ] && { MODE="$_m"; break; }
83
+ done
84
+ fi
85
+ MODE="${MODE:-guided}"
86
+ case "$MODE" in guided|semi|auto) ;; *) MODE="guided" ;; esac
69
87
  # Triage lane (M7): the lane Luffy assigned at Flow 0. Without it savepoint
70
88
  # recomputed the lane from file counts alone and silently discarded the
71
89
  # triage decision — a Lane 3 mission recorded itself as "direct". Explicit
@@ -91,7 +109,7 @@ esac
91
109
  # verbosity from config (project .mugiwara/config), default normal; env override
92
110
  VERBOSITY="${STATE_VERBOSITY:-normal}"
93
111
  if [ -f "$MUGIWARA_DIR/config" ]; then
94
- CFG_VERBOSITY=$(grep -E '^verbosity=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
112
+ CFG_VERBOSITY=$(grep -E '^verbosity=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
95
113
  [ -n "$CFG_VERBOSITY" ] && VERBOSITY="$CFG_VERBOSITY"
96
114
  fi
97
115
  case "$VERBOSITY" in
@@ -102,7 +120,7 @@ esac
102
120
  # heal_max_cycles from config (project .mugiwara/config), default 3; env override
103
121
  HEAL_MAX_CYCLES="${STATE_HEAL_MAX_CYCLES:-3}"
104
122
  if [ -f "$MUGIWARA_DIR/config" ]; then
105
- CFG_HEAL_MAX=$(grep -E '^heal_max_cycles=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
123
+ CFG_HEAL_MAX=$(grep -E '^heal_max_cycles=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
106
124
  [ -n "$CFG_HEAL_MAX" ] && HEAL_MAX_CYCLES="$CFG_HEAL_MAX"
107
125
  fi
108
126
  case "$HEAL_MAX_CYCLES" in
@@ -113,7 +131,7 @@ esac
113
131
  # delegate_threshold from config (project .mugiwara/config), default 60; env override
114
132
  DELEGATE_THRESHOLD="${STATE_DELEGATE_THRESHOLD:-60}"
115
133
  if [ -f "$MUGIWARA_DIR/config" ]; then
116
- CFG_DELEGATE=$(grep -E '^delegate_threshold=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
134
+ CFG_DELEGATE=$(grep -E '^delegate_threshold=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
117
135
  [ -n "$CFG_DELEGATE" ] && DELEGATE_THRESHOLD="$CFG_DELEGATE"
118
136
  fi
119
137
  case "$DELEGATE_THRESHOLD" in
@@ -183,6 +201,17 @@ else
183
201
  CONTINUE_FILE="$MISSION_DIR/continue.json"
184
202
  fi
185
203
 
204
+ # A mission is solo or team, never both. Two layouts side by side orphan one of
205
+ # them: hidden from `status`, still read by the integrity gate. (W4)
206
+ if [ -n "$MEMBER" ] && [ -f "$MISSION_DIR/state.json" ] && [ "${MUGIWARA_ALLOW_LAYOUT_SWITCH:-0}" != "1" ]; then
207
+ die "mission '$MISSION' is solo (state.json exists) — refusing to add member '$MEMBER'. Run: mugiwara migrate --to-team $MEMBER"
208
+ fi
209
+ if [ -z "$MEMBER" ] && ls "$MISSION_DIR"/*.json >/dev/null 2>&1; then
210
+ if ls "$MISSION_DIR"/*.json | grep -qv -e 'state.json' -e 'continue'; then
211
+ die "mission '$MISSION' is team — pass a member: savepoint.sh $MISSION <member> ..."
212
+ fi
213
+ fi
214
+
186
215
  [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode] [lane]"
187
216
 
188
217
  # --- computed fields ---
@@ -192,7 +221,7 @@ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
192
221
  # lane_scope_glob (T5): monorepo scoping — count only files matching the glob
193
222
  LANE_SCOPE_GLOB=""
194
223
  if [ -f "$MUGIWARA_DIR/config" ]; then
195
- _cfg_scope=$(grep -E '^lane_scope_glob=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"' | tr -d "'")
224
+ _cfg_scope=$(grep -E '^lane_scope_glob=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"' | tr -d "'")
196
225
  [ -n "$_cfg_scope" ] && LANE_SCOPE_GLOB="$_cfg_scope"
197
226
  fi
198
227
  # union of committed + staged + unstaged + untracked (F) — see patterns.sh
@@ -336,11 +365,19 @@ if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
336
365
  esac
337
366
  fi
338
367
 
339
- # task counts from plan doc bare name, one file per mission.
368
+ # task counts the execution mirror wins over the plan doc.
369
+ # Zoro tracks one box per task in flows/todos.md as work lands, while plan.md
370
+ # may carry no boxes at all (Steps: inline) or a different-granularity
371
+ # acceptance list. Counting plan-only left finished missions reading 0/N.
372
+ # Mirror wins when it has boxes; plan.md otherwise; sub-plan/ as before.
340
373
  PLAN_FILE="$MISSION_DIR/plan.md"
374
+ TODOS_FILE="$MISSION_DIR/flows/todos.md"
341
375
  TASKS_DONE=0
342
376
  TASKS_TOTAL=0
343
- if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
377
+ if [ -f "$TODOS_FILE" ] && [ "$(count_boxes "$TODOS_FILE" '[ xX]')" -gt 0 ] 2>/dev/null; then
378
+ TASKS_TOTAL=$(count_boxes "$TODOS_FILE" '[ xX]')
379
+ TASKS_DONE=$(count_boxes "$TODOS_FILE" '[xX]')
380
+ elif [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
344
381
  # total counts ALL task lines (checked + unchecked); done counts checked only.
345
382
  # A fully-completed plan must read total=N done=N, never total=0 (the old
346
383
  # unchecked-only grep degenerated a done plan to tasks.total=0).
@@ -395,6 +432,16 @@ if [ "$HEAL_CYCLE" -ge "$HEAL_MAX_CYCLES" ] 2>/dev/null; then
395
432
  HEAL_HALT=true
396
433
  fi
397
434
 
435
+ # W10: register plan.md read so repeated_reads is not structurally zero (evidence.registerRead)
436
+ if [ -f "$MISSION_DIR/plan.md" ] && [ ! -s "$MISSION_DIR/context-registry.jsonl" ]; then
437
+ mkdir -p "$MISSION_DIR"
438
+ _plan_fp=$(node -e "const crypto=require('crypto');const fs=require('fs');try{const d=fs.readFileSync(process.argv[1],'utf8');process.stdout.write(crypto.createHash('sha256').update(d).digest('hex'))}catch(e){process.stdout.write('')}" "$MISSION_DIR/plan.md" 2>/dev/null || true)
439
+ if [ -n "$_plan_fp" ]; then
440
+ _plan_chars=$(wc -c < "$MISSION_DIR/plan.md" 2>/dev/null | tr -d ' ' || echo 0)
441
+ printf '{"fingerprint":"%s","kind":"file","file":"plan.md","id":"E001","reads":1,"chars":%s,"ref":"E001 plan.md"}\n' "$_plan_fp" "$_plan_chars" >> "$MISSION_DIR/context-registry.jsonl" 2>/dev/null || true
442
+ fi
443
+ fi
444
+
398
445
  # slop — context (repeated reads) per cost-governor §§21-24,31-32 — T5 wire all crews Luffy/Nami/Zoro/Brook
399
446
  REPEATED_READS=0
400
447
  REPEATED_THRESHOLD=3
@@ -410,15 +457,19 @@ fi
410
457
  # gates flow stage can read, not prose.
411
458
  DEPTH_REVIEW="full"; DEPTH_QUALITY="full"; DEPTH_VERIFY="off"
412
459
  if [ -f "$MUGIWARA_DIR/config" ]; then
413
- _cfg_r=$(grep -E '^review_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
460
+ _cfg_r=$(grep -E '^review_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
414
461
  [ -n "$_cfg_r" ] && DEPTH_REVIEW="$_cfg_r"
415
- _cfg_q=$(grep -E '^quality_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
462
+ _cfg_q=$(grep -E '^quality_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
416
463
  [ -n "$_cfg_q" ] && DEPTH_QUALITY="$_cfg_q"
417
- _cfg_v=$(grep -E '^verify_merged=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
464
+ _cfg_v=$(grep -E '^verify_merged=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
418
465
  [ -n "$_cfg_v" ] && DEPTH_VERIFY="$_cfg_v"
419
466
  fi
420
- case "$DEPTH_REVIEW" in full|standard|lean) ;; *) DEPTH_REVIEW="full" ;; esac
421
- case "$DEPTH_QUALITY" in full|standard|lean) ;; *) DEPTH_QUALITY="full" ;; esac
467
+ # `quick` is the documented third level; `lean` was an undocumented synonym.
468
+ # Accept both, normalise to the documented name. (N3)
469
+ [ "$DEPTH_REVIEW" = "lean" ] && DEPTH_REVIEW="quick"
470
+ [ "$DEPTH_QUALITY" = "lean" ] && DEPTH_QUALITY="quick"
471
+ case "$DEPTH_REVIEW" in full|standard|quick) ;; *) DEPTH_REVIEW="full" ;; esac
472
+ case "$DEPTH_QUALITY" in full|standard|quick) ;; *) DEPTH_QUALITY="full" ;; esac
422
473
  case "$DEPTH_VERIFY" in on|off) ;; *) DEPTH_VERIFY="off" ;; esac
423
474
 
424
475
  # evidence paths — the mission's flow folder (quoted printf, no sed — mission
@@ -522,6 +573,75 @@ if [ "$BUDGET" -gt 0 ] 2>/dev/null; then
522
573
  fi
523
574
  fi
524
575
 
576
+ # team_members for posture (W5) — config key team_members, default 1
577
+ TEAM_MEMBERS=1
578
+ if [ -f "$MUGIWARA_DIR/config" ]; then
579
+ _tm=$(grep -E '^team_members=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
580
+ [ -n "$_tm" ] && TEAM_MEMBERS="$_tm"
581
+ fi
582
+ case "$TEAM_MEMBERS" in ''|*[!0-9]*) TEAM_MEMBERS=1 ;; esac
583
+ # plan metrics for posture decision (phase-isolated / parallel)
584
+ PLAN_LINES=0
585
+ PHASES=1
586
+ INDEPENDENT_TASKS=0
587
+ if [ -f "$MISSION_DIR/plan.md" ]; then
588
+ PLAN_LINES=$(wc -l < "$MISSION_DIR/plan.md" 2>/dev/null | tr -d ' ' || echo 0)
589
+ PH=$(grep -c "^## Wave" "$MISSION_DIR/plan.md" 2>/dev/null || true)
590
+ [ "$PH" -gt 0 ] 2>/dev/null && PHASES="$PH"
591
+ INDEPENDENT_TASKS=$(grep -c "\[PARALLEL\]" "$MISSION_DIR/plan.md" 2>/dev/null || true)
592
+ fi
593
+ # governor for posture
594
+ GOVERNOR="normal"
595
+ case "$STATUS" in
596
+ stop) GOVERNOR="stop" ;;
597
+ warn) GOVERNOR="avoid" ;;
598
+ *) GOVERNOR="normal" ;;
599
+ esac
600
+ CONTEXT_PRESSURE=false
601
+ if [ "$BUDGET" -gt 0 ] 2>/dev/null && [ "$TOKENS_EST" -gt $(( BUDGET * 6 / 10 )) ] 2>/dev/null; then
602
+ CONTEXT_PRESSURE=true
603
+ fi
604
+ POSTURE="inline-sequential"
605
+ POSTURE_REASON="no parallel/phase/team/relief trigger — default inline in plan order"
606
+ POSTURE_PAUSE=false
607
+ if [ "$GOVERNOR" = "stop" ]; then
608
+ POSTURE="inline-sequential"
609
+ POSTURE_REASON="governor stop — pause safely, keep inline; state + continue emitted"
610
+ POSTURE_PAUSE=true
611
+ elif [ "$TEAM_MEMBERS" -gt 1 ] 2>/dev/null; then
612
+ POSTURE="team-scoped"
613
+ POSTURE_REASON="$TEAM_MEMBERS team members with non-overlapping scope"
614
+ elif [ "$PHASES" -gt 3 ] 2>/dev/null || [ "$PLAN_LINES" -gt 1500 ] 2>/dev/null; then
615
+ POSTURE="phase-isolated"
616
+ POSTURE_REASON="large campaign — $PHASES phases / $PLAN_LINES lines"
617
+ elif [ "$CONTEXT_PRESSURE" = true ]; then
618
+ POSTURE="context-relief"
619
+ POSTURE_REASON="context pressure with ordered dependent tasks — one worker at a time, order preserved"
620
+ elif [ "$INDEPENDENT_TASKS" -ge 2 ] 2>/dev/null; then
621
+ POSTURE="parallel-workers"
622
+ POSTURE_REASON="$INDEPENDENT_TASKS independent tasks, no shared files/interfaces"
623
+ fi
624
+ # investigation config (W8) — three keys, defaults 2/5/2
625
+ INV_MAX_PASSES=2
626
+ INV_MAX_UNRELATED=5
627
+ INV_REPEATED_THRESH=2
628
+ if [ -f "$MUGIWARA_DIR/config" ]; then
629
+ _v=$(grep -E '^investigation_max_passes=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
630
+ [ -n "$_v" ] && INV_MAX_PASSES="$_v"
631
+ _v=$(grep -E '^investigation_max_unrelated_files=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
632
+ [ -n "$_v" ] && INV_MAX_UNRELATED="$_v"
633
+ _v=$(grep -E '^investigation_repeated_read_threshold=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
634
+ [ -n "$_v" ] && INV_REPEATED_THRESH="$_v"
635
+ fi
636
+ case "$INV_MAX_PASSES" in ''|*[!0-9]*) INV_MAX_PASSES=2 ;; esac
637
+ case "$INV_MAX_UNRELATED" in ''|*[!0-9]*) INV_MAX_UNRELATED=5 ;; esac
638
+ case "$INV_REPEATED_THRESH" in ''|*[!0-9]*) INV_REPEATED_THRESH=2 ;; esac
639
+ # investigation_status: simple threshold check on repeated_reads (W8/W10)
640
+ INVESTIGATION_STATUS="continue"
641
+ if [ "$REPEATED_READS" -ge "$INV_REPEATED_THRESH" ] 2>/dev/null; then
642
+ INVESTIGATION_STATUS="stop"
643
+ fi
644
+
525
645
  mkdir -p "$MISSION_DIR"
526
646
 
527
647
  node -e "
@@ -565,7 +685,12 @@ const data = {
565
685
  evidence: process.argv[21] ? process.argv[21].split(',').filter(Boolean) : [],
566
686
  updated_at: process.argv[22],
567
687
  schema_version: 2,
568
- repeated_reads: parseInt(process.argv[41], 10) || 0
688
+ repeated_reads: parseInt(process.argv[41], 10) || 0,
689
+ team_members: parseInt(process.argv[42], 10) || 1,
690
+ posture: process.argv[43] || 'inline-sequential',
691
+ posture_reason: process.argv[44] || '',
692
+ posture_pause: process.argv[45] === 'true',
693
+ investigation_status: process.argv[46] || 'continue'
569
694
  };
570
695
  require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\n');
571
696
  " \
@@ -579,7 +704,7 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
579
704
  "$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER" "$VERBOSITY" \
580
705
  "$HEAL_MAX_CYCLES" "$HEAL_HALT" "$DELEGATE_THRESHOLD" "$DELEGATE_DUE" \
581
706
  "$MODEL" "$DEPTH_REVIEW" "$DEPTH_QUALITY" "$DEPTH_VERIFY" \
582
- "$REPEATED_READS"
707
+ "$REPEATED_READS" "$TEAM_MEMBERS" "$POSTURE" "$POSTURE_REASON" "$POSTURE_PAUSE" "$INVESTIGATION_STATUS"
583
708
 
584
709
  if [ "$LANE_ROSE" = true ]; then
585
710
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
@@ -187,10 +187,18 @@ for (const doc of ['README.md', 'docs/index.md', 'docs/concepts/agents.md']) {
187
187
  // --- hub-rule gate (F3): every non-Luffy agent carries both hub sections ---
188
188
  for (const f of agentFiles) {
189
189
  const name = f.replace(/\.md$/, '');
190
- if (name === 'luffy-orchestrator') continue;
191
190
  const text = readFileSync(join(agentDir, f), 'utf8');
191
+ // Entry protocol: EVERY agent, Luffy included. Exempting him is what let a
192
+ // captain with no pre-flight checklist ship. (E2)
192
193
  if (!text.includes('## Before you start')) errors.push(`agent ${f}: missing "## Before you start" entry protocol`);
193
- if (!text.includes('## Return to Luffy')) errors.push(`agent ${f}: missing "## Return to Luffy" hub rule`);
194
+ // Return-to-Luffy: every agent EXCEPT Luffy he cannot return to himself.
195
+ if (name !== 'luffy-orchestrator' && !text.includes('## Return to Luffy')) {
196
+ errors.push(`agent ${f}: missing "## Return to Luffy" hub rule`);
197
+ }
198
+ // Luffy carries the routing counterpart instead.
199
+ if (name === 'luffy-orchestrator' && !text.includes('Brainstorm is Usopp')) {
200
+ errors.push('agent luffy-orchestrator: missing the "never do another crew member\'s work" routing rule');
201
+ }
194
202
  }
195
203
 
196
204
  // --- hub-skill gate (F3): every agent lists mugiwara-orchestration (the hub rule's home) ---
@@ -414,7 +422,270 @@ if (integrityArg !== -1) {
414
422
  }
415
423
  if (!constants.includes('LANE_BASE_lean=8421')) errors.push('doc-integrity: source lane-base.sh lean base drifted (expected 8421)');
416
424
  if (!constants.includes('BUDGET_full=50000')) errors.push('doc-integrity: source lane-base.sh full budget drifted (expected 50000)');
425
+ // W12 stale path check: obsolete layout must not appear
426
+ const staleChecks: [string, string[]][] = [
427
+ ['state/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
428
+ ['continue/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
429
+ ['plans/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
430
+ ['logs/lessons', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
431
+ ];
432
+ for (const [pat, docs] of staleChecks) {
433
+ for (const doc of docs) {
434
+ const p = join(import.meta.dirname, '..', doc);
435
+ if (existsSync(p) && readFileSync(p, 'utf8').includes(pat)) {
436
+ errors.push(`doc-integrity: ${doc} contains obsolete path "${pat}" — use missions/<mission>/ layout`);
437
+ }
438
+ }
439
+ }
440
+ // W17 metrics must come from .metrics/latest.json — check for hardcoded stale numbers not in metrics
441
+ const metricsPath2 = join(import.meta.dirname, '..', '.metrics/latest.json');
442
+ if (existsSync(metricsPath2)) {
443
+ const m2 = JSON.parse(readFileSync(metricsPath2, 'utf8'));
444
+ const readme2 = readFileSync(join(import.meta.dirname, '..', 'README.md'), 'utf8');
445
+ // ensure README rank-1 and pointers match metrics (also checked in --check-readme-metrics, but this is integrity)
446
+ const rankMatch2 = readme2.match(/Retrieval routing rank-1[^\n]*?(\d+\.\d+)%/);
447
+ if (rankMatch2 && parseFloat(rankMatch2[1]) !== Number(m2.retrieval_rank1)) {
448
+ errors.push(`doc-integrity: README rank-1 ${rankMatch2[1]}% != metrics ${m2.retrieval_rank1}%`);
449
+ }
450
+ }
451
+ // N4: a skill that instructs `mugiwara <cmd>` when the CLI has no such case is an
452
+ // instruction the agent cannot follow. This is how `initiative` shipped as a
453
+ // dangling reference. Cases are read from the CLI source, not hardcoded.
454
+ const cliSrc = readFileSync(join(import.meta.dirname, '..', 'src', 'cli.ts'), 'utf8');
455
+ // In-session phrases, not CLI verbs — see mugiwara-workflow.
456
+ const IN_SESSION = new Set(['mode', 'off']);
457
+ const referenced = new Set<string>();
458
+ const walkMarkdown = (dir: string): string[] =>
459
+ listFiles(dir).filter((f) => f.endsWith('.md')).map((f) => join(dir, f));
460
+ for (const dir of ['content', 'docs', 'references']) {
461
+ for (const file of walkMarkdown(join(import.meta.dirname, '..', dir))) {
462
+ const text = readFileSync(file, 'utf8');
463
+ for (const m of text.matchAll(/`mugiwara ([a-z-]+)/g)) referenced.add(m[1]);
464
+ }
465
+ }
466
+ // Also scan repo-root markdown (README, AGENTS) — same defect class.
467
+ for (const file of ['README.md', 'AGENTS.md']) {
468
+ const p = join(import.meta.dirname, '..', file);
469
+ if (!existsSync(p)) continue;
470
+ for (const m of readFileSync(p, 'utf8').matchAll(/`mugiwara ([a-z-]+)/g)) referenced.add(m[1]);
471
+ }
472
+ for (const cmd of referenced) {
473
+ if (IN_SESSION.has(cmd)) continue;
474
+ if (cmd.startsWith('--')) continue;
475
+ if (!cliSrc.includes(`case '${cmd}'`)) {
476
+ errors.push(`doc-integrity: docs instruct "mugiwara ${cmd}" but src/cli.ts has no case '${cmd}'`);
477
+ }
478
+ }
479
+ // N2 banner-format: no raw ANSI escapes in model-facing instructions. The
480
+ // colour table in wave-banners.md is data for the plugin, not an
481
+ // instruction — it holds hex, never escapes, so no exemption is needed.
482
+ // N8 in-session phrases must never read as slash commands.
483
+ const proseFiles: string[] = [];
484
+ for (const dir of ['content', 'docs', 'references']) {
485
+ proseFiles.push(...walkMarkdown(join(import.meta.dirname, '..', dir)));
486
+ }
487
+ for (const file of ['README.md', 'AGENTS.md']) {
488
+ const p = join(import.meta.dirname, '..', file);
489
+ if (existsSync(p)) proseFiles.push(p);
490
+ }
491
+ for (const file of proseFiles) {
492
+ const text = readFileSync(file, 'utf8');
493
+ if (/\\x1b\[|38;2;|38;5;/.test(text)) {
494
+ errors.push(`doc-integrity: ${file} instructs raw ANSI escapes the model cannot emit — banners are plain headings`);
495
+ }
496
+ // `/mugiwara continue` is a real CLI verb and out of scope — only the mode
497
+ // switch is an in-session phrase, so only its slash forms are flagged.
498
+ if (/`\/(mugiwara mode|mugiwara (guided|semi|auto))/.test(text)) {
499
+ errors.push(`doc-integrity: ${file} writes the in-session mode phrase as a slash command — say "mugiwara mode <level>" in session, no slash, no CLI flag`);
500
+ }
501
+ }
502
+ // N5: the flow-summary contract must exist — it is what keeps normal
503
+ // verbosity to one line per stage.
504
+ const orchSkill = readFileSync(join(import.meta.dirname, '..', 'content', 'skills', 'mugiwara-orchestration', 'SKILL.md'), 'utf8');
505
+ if (!orchSkill.includes('## Flow summary line')) {
506
+ errors.push('doc-integrity: mugiwara-orchestration SKILL.md lost its "## Flow summary line" contract');
507
+ }
508
+ // N9: the platform count must stay qualified — 9 installable + 3 marketplace.
509
+ const readme = readFileSync(join(import.meta.dirname, '..', 'README.md'), 'utf8');
510
+ if (readme.includes('12 platforms') && !readme.includes('via marketplace manifest')) {
511
+ errors.push('doc-integrity: README "12 platforms" is unqualified — split 9 via install + 3 via marketplace manifest');
512
+ }
513
+ }
514
+ }
515
+
516
+ // --- config drift gate (W11): every key code reads must appear in DEFAULT_CONFIG and docs, and vice versa ---
517
+ if (process.argv.includes('--check-config')) {
518
+ const cfgSrc = readFileSync(join(import.meta.dirname, '..', 'src/config.ts'), 'utf8');
519
+ const m = cfgSrc.match(/DEFAULT_CONFIG\s*=\s*\[([\s\S]*?)\]\.join/);
520
+ let defaultKeys: string[] = [];
521
+ if (m) {
522
+ const block = m[1];
523
+ for (const line of block.split(/\r?\n/)) {
524
+ const t = line.trim();
525
+ if (!t) continue;
526
+ // extract string content between quotes
527
+ const q = t.match(/['"`]([^'"`]*?)['"`]/);
528
+ if (!q) continue;
529
+ let s = q[1].trim();
530
+ if (!s) continue;
531
+ if (s.startsWith('#')) s = s.slice(1).trim();
532
+ if (!s) continue;
533
+ const eq = s.indexOf('=');
534
+ if (eq === -1) continue;
535
+ const key = s.slice(0, eq).trim();
536
+ if (key) defaultKeys.push(key);
537
+ }
538
+ }
539
+ // docs keys from config.md table (only the ## Keys section, not template examples)
540
+ const docPath = join(import.meta.dirname, '..', 'docs/concepts/config.md');
541
+ let docKeys: string[] = [];
542
+ if (existsSync(docPath)) {
543
+ const docText = readFileSync(docPath, 'utf8');
544
+ const keysSectionMatch = docText.match(/## Keys([\s\S]*?)(?:\n## |\n#|$)/);
545
+ const keysSection = keysSectionMatch ? keysSectionMatch[1] : docText;
546
+ for (const line of keysSection.split(/\r?\n/)) {
547
+ const cm = line.match(/\|\s*`([^`]+)`\s*\|/);
548
+ if (cm) {
549
+ const k = cm[1].trim();
550
+ if (k && !docKeys.includes(k)) docKeys.push(k);
551
+ }
552
+ }
553
+ } else {
554
+ errors.push('config-drift: docs/concepts/config.md not found');
555
+ }
556
+ // code keys: scan src/*.ts, scripts/*.sh, hooks/*.ts for key patterns
557
+ const codeRoots = [
558
+ join(import.meta.dirname, '..', 'src'),
559
+ join(import.meta.dirname, '..', 'scripts'),
560
+ join(import.meta.dirname, '..', 'hooks'),
561
+ ];
562
+ const codeTextAll = codeRoots.map(r => {
563
+ if (!existsSync(r)) return '';
564
+ const files = readdirSync(r, { withFileTypes: true }).filter(e => e.isFile() && (e.name.endsWith('.ts') || e.name.endsWith('.sh'))).map(e => readFileSync(join(r, e.name), 'utf8')).join('\n');
565
+ // also need subdirectories
566
+ let sub = '';
567
+ try {
568
+ for (const e of readdirSync(r, { withFileTypes: true })) {
569
+ if (e.isDirectory()) {
570
+ const subdir = join(r, e.name);
571
+ for (const f of readdirSync(subdir, { withFileTypes: true }).filter(x => x.isFile() && (x.name.endsWith('.ts') || x.name.endsWith('.sh')))) {
572
+ sub += readFileSync(join(subdir, f.name), 'utf8') + '\n';
573
+ }
574
+ }
575
+ }
576
+ } catch {}
577
+ return files + sub;
578
+ }).join('\n');
579
+ // check each default key appears in code
580
+ for (const k of defaultKeys) {
581
+ if (!codeTextAll.includes(k)) {
582
+ errors.push(`config-drift: DEFAULT_CONFIG key "${k}" not found in code (src/*.ts, scripts/*.sh, hooks/*.ts)`);
583
+ }
584
+ if (!docKeys.includes(k)) {
585
+ errors.push(`config-drift: DEFAULT_CONFIG key "${k}" missing from docs/concepts/config.md`);
586
+ }
587
+ }
588
+ for (const k of docKeys) {
589
+ if (!defaultKeys.includes(k)) {
590
+ errors.push(`config-drift: docs/concepts/config.md key "${k}" not in DEFAULT_CONFIG`);
591
+ }
592
+ if (!codeTextAll.includes(k)) {
593
+ errors.push(`config-drift: docs key "${k}" not found in code`);
594
+ }
595
+ }
596
+ // N6: key parity is not value parity. A documented enum value the code rejects
597
+ // falls back silently — the user gets the default and no error. Compare both
598
+ // directions. (auto_commit is advisory-only by design — no code allowlist
599
+ // exists, so there is nothing to compare.)
600
+ const configMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : '';
601
+ const savepointSh = readFileSync(join(import.meta.dirname, '..', 'scripts', 'savepoint.sh'), 'utf8');
602
+ const parseDocumentedValues = (key: string): string[] => {
603
+ const m = configMd.match(new RegExp(`^\\|\\s*\`${key}\`\\s*\\|\\s*([^|]+)\\|`, 'm'));
604
+ if (!m) return [];
605
+ return m[1].split('/').map((v) => v.trim()).filter(Boolean);
606
+ };
607
+ const parseShellAllowlist = (varName: string): string[] => {
608
+ const m = savepointSh.match(new RegExp(`case "\\$${varName}" in\\s*([^)]+)\\)`));
609
+ if (!m) return [];
610
+ return m[1].split(/[|\s]+/).map((v) => v.trim()).filter(Boolean);
611
+ };
612
+ const parseTsUnion = (file: string, typeName: string, extra: string[] = [], exclude: RegExp | null = null): string[] => {
613
+ const p = join(import.meta.dirname, '..', file);
614
+ if (!existsSync(p)) return [];
615
+ const src = readFileSync(p, 'utf8');
616
+ const m = src.match(new RegExp(`type ${typeName} = ([^;]+);`));
617
+ if (!m) return [];
618
+ const vals = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
619
+ return [...new Set([...vals, ...extra])].filter((v) => !(exclude && exclude.test(v)));
620
+ };
621
+ const ENUM_CHECKS: Array<{ key: string; accepted: string[] }> = [
622
+ { key: 'mode', accepted: parseShellAllowlist('MODE') },
623
+ { key: 'verbosity', accepted: parseShellAllowlist('VERBOSITY') },
624
+ { key: 'review_depth', accepted: parseShellAllowlist('DEPTH_REVIEW') },
625
+ { key: 'quality_depth', accepted: parseShellAllowlist('DEPTH_QUALITY') },
626
+ { key: 'verify_merged', accepted: parseShellAllowlist('DEPTH_VERIFY') },
627
+ // sign allowlist lives in TypeScript: read the exported union, not grep.
628
+ // 'minisign-fail' is internal (never a valid config value); 'auto' is an
629
+ // explicit resolveBackend case, so it counts as accepted.
630
+ { key: 'sign', accepted: parseTsUnion('src/sign.ts', 'BackendChoice', ['auto'], /-fail$/) },
631
+ { key: 'enforce', accepted: parseTsUnion('hooks/pipeline-guard.ts', 'Enforce') },
632
+ ];
633
+ for (const { key, accepted } of ENUM_CHECKS) {
634
+ const documented = parseDocumentedValues(key);
635
+ if (!documented.length || !accepted.length) continue;
636
+ const missing = documented.filter((v) => !accepted.includes(v));
637
+ const undocumented = accepted.filter((v) => !documented.includes(v));
638
+ if (missing.length) errors.push(`config ${key}: documented but rejected by code: ${missing.join(', ')}`);
639
+ if (undocumented.length) errors.push(`config ${key}: accepted by code but undocumented: ${undocumented.join(', ')}`);
640
+ }
641
+ if (!errors.some(e => e.startsWith('config-drift') || e.startsWith('config '))) {
642
+ console.log(`✓ config in sync: ${defaultKeys.length} keys (${defaultKeys.join(', ')})`);
643
+ }
417
644
  }
645
+
646
+ // --- wiring gate (W7): every src module must be imported somewhere ---
647
+ if (process.argv.includes('--check-wiring')) {
648
+ const srcDir = join(import.meta.dirname, '..', 'src');
649
+ const ENTRY = new Set(['cli.ts', 'index.ts', 'installer.ts']);
650
+ const srcFiles = readdirSync(srcDir).filter(n => n.endsWith('.ts'));
651
+ const searchDirs = [srcDir, join(import.meta.dirname, '..', 'hooks'), join(import.meta.dirname, '..', 'scripts')];
652
+ const allFiles: string[] = [];
653
+ for (const d of searchDirs) {
654
+ if (!existsSync(d)) continue;
655
+ const walk = (dir: string) => {
656
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
657
+ const full = join(dir, e.name);
658
+ if (e.isDirectory()) walk(full);
659
+ else if (e.isFile() && (e.name.endsWith('.ts') || e.name.endsWith('.js') || e.name.endsWith('.sh') || e.name.endsWith('.mjs'))) {
660
+ allFiles.push(full);
661
+ }
662
+ }
663
+ };
664
+ walk(d);
665
+ }
666
+ for (const f of srcFiles) {
667
+ if (ENTRY.has(f)) continue;
668
+ const stem = f.slice(0, -3);
669
+ let found = false;
670
+ for (const full of allFiles) {
671
+ if (full.endsWith(`/src/${f}`) || full === join(srcDir, f)) continue;
672
+ try {
673
+ const txt = readFileSync(full, 'utf8');
674
+ // hooks import shared code as '../src/<stem>' (bundled by build-hooks)
675
+ // — that is a first-class wire, not a dangling module.
676
+ if (txt.includes(`'./${stem}`) || txt.includes(`"./${stem}`) || txt.includes(`'./${stem}.ts'`) || txt.includes(`"./${stem}.ts"`) || txt.includes(`'../src/${stem}.ts'`) || txt.includes(`"../src/${stem}.ts"`)) {
677
+ found = true;
678
+ break;
679
+ }
680
+ } catch {}
681
+ }
682
+ if (!found) {
683
+ errors.push(`src/${f}: imported by nothing — wire it, or delete it with its tests and its features.md claim`);
684
+ }
685
+ }
686
+ if (!errors.some(e => e.includes('imported by nothing'))) {
687
+ console.log(`✓ wiring: all src modules imported`);
688
+ }
418
689
  }
419
690
 
420
691
  // --- README metrics gate (D3): README table must match .metrics/latest.json ---
@@ -477,6 +748,78 @@ if (process.argv.includes('--check-readme-metrics')) {
477
748
  }
478
749
  }
479
750
 
751
+ // --- invariant-mechanism gate (E-gaps 5.2): every never/always/MUST has a mechanism ---
752
+ // A rule with no machine behind it and no prose-only entry is a gap, not a
753
+ // rule. Concepts live in docs/concepts/enforcement.md; each concept below
754
+ // must have its anchor there, and every never/always/MUST line in content/
755
+ // must match at least one concept. Adding a rule = table row + concept here
756
+ // + mutation in gate-selftest.ts.
757
+ if (process.argv.includes('--check-invariants')) {
758
+ const enfPath = join(import.meta.dirname, '..', 'docs', 'concepts', 'enforcement.md');
759
+ const enf = existsSync(enfPath) ? readFileSync(enfPath, 'utf8') : '';
760
+ if (!enf) errors.push('invariant gate: docs/concepts/enforcement.md is missing');
761
+ const CONCEPTS: Array<{ id: string; re: RegExp; anchor: string }> = [
762
+ { id: 'INV-triage', re: /triage|savepoint|flow 0/i, anchor: 'hooks/pipeline-guard.js' },
763
+ { id: 'INV-write-scope', re: /write-scope|delegat.*zoro|another crew member|crew member's (work|job)|dispatch another crew|one role at a time|embodies one|never.*mutat|mode: all|subagent|never forward|never dispatch|never.*route|never execute source/i, anchor: 'INV-write-scope' },
764
+ { id: 'INV-luffy-hub', re: /return to luffy|luffy routes?|routes? .*luffy|orchestrator|route reasons|check-in|decision log|next_action|flow stage|omitted|in-flight|exits 2/i, anchor: 'INV-hub' },
765
+ { id: 'INV-plan-nami', re: /only nami|nami's|without a GO|executor without/i, anchor: 'INV-plan-nami' },
766
+ { id: 'INV-banner', re: /banner/i, anchor: 'INV-banner' },
767
+ { id: 'INV-no-deploy', re: /deploys?|merging?|creates a PR|gh pr|push.*branch|terminal step|reaches a user|ship.*user/i, anchor: 'hooks/pretool-guard.js' },
768
+ { id: 'INV-heal-cap', re: /heal|4-phase|reproduce.*localize/i, anchor: 'INV-heal-cap' },
769
+ { id: 'INV-lane', re: /\blane\b|mission split|sub-mission|parallel.*safe|never.*parallel|\[PARALLEL\]|shortcut|full pipeline/i, anchor: 'INV-lane' },
770
+ { id: 'INV-evidence', re: /evidence|re-run|re run|claim|never validate|doubt|fresh (context|agent)|adversarial|no pass/i, anchor: 'INV-evidence' },
771
+ { id: 'INV-mode', re: /auto.?commit|auto mode|guided|semi|mode.*flip|steps cap|verbosity|auto never|never.*auto|never ask/i, anchor: 'INV-mode' },
772
+ { id: 'INV-quality', re: /weaken|threshold|coverage|lint|fake pass|silent.*pass|duplicat|complexity|dead code|strict|sonar|maintainability|assert green|failing suite|default-on|trigger|may raise|fixed numbers|invent tooling/i, anchor: 'INV-quality' },
773
+ { id: 'INV-tests', re: /\btdd\b|failing first|immutable|oracle|user.?test|flaky|intermittent|never.*test\b|gherkin|feature file|banned|translate-or-command|long unreachable|can never prove|never saw|small steps|never create inte|integration tests/i, anchor: 'INV-tests' },
774
+ { id: 'INV-resume', re: /restart|resume|continue\.json|continue from|never restarts?|never scan|state proves/i, anchor: 'INV-resume' },
775
+ { id: 'INV-english', re: /always english|one language only|conversational language/i, anchor: 'INV-english' },
776
+ { id: 'INV-plan-discipline', re: /zero-question|unverified path|\btbd\b|plan above or below|40-file|task index|stranger must|plan.*hole|regression|correctness.*break|never.*plan|never appended|2000\+ lines/i, anchor: 'INV-plan-discipline' },
777
+ { id: 'INV-security-contract', re: /secret|sanitiz|authz|\.safeParse|trust|inject|data, never|finding, not|dangerouslySetInnerHTML|owasp|stride|exploit|permission|pii|never trust|minor by default|severity|gets the matrix|expiry|revoke/i, anchor: 'INV-security-contract' },
778
+ { id: 'INV-git-hygiene', re: /commit|git add|revert|broken tree|staging|micro-commit|never.*tree|force-add|gitignore/i, anchor: 'INV-git-hygiene' },
779
+ { id: 'INV-conduct', re: /ego|yes-man|interrogat|trade-off|assume silently|batched question|sparring|recommendation|reconsider|pushes back|silently defer|verdict only/i, anchor: 'INV-conduct' },
780
+ { id: 'INV-mirror', re: /todo.*mirror|same response|transcript.*sufficient|task N\/M|echoing raw|compact.*table|report table|evidence link|never changes|always visible|audit surface|audit trail|never narrows|one-liner|mid-argument|overwrite|detailed summary|lags|never seeded|list never|must always see|never depends/i, anchor: 'INV-mirror' },
781
+ { id: 'INV-trust', re: /redefine.*rule|artifact trust|untrusted|HIGH trust|LOW-trust|instruction.*data|verbatim instructions|lesson/i, anchor: 'INV-trust' },
782
+ { id: 'INV-role', re: /never implements|never fixes|outside your role|luffy's, always|who never|never does what|11th member|finding yourself|coordinator|auditor/i, anchor: 'INV-role' },
783
+ { id: 'INV-role-conduct', re: /never refuse|never file|not verdicts|input, not|plain |generic assistant|embodies roles|fix the SKILL, never/i, anchor: 'INV-role-conduct' },
784
+ { id: 'INV-execution-misc', re: /inline.*main thread|worker|sequential|main thread IS the crew|frame persists|never drop the roles|inspection.*only|no network|no shell|read-only|pre-flow|never dispatch|wave|dispatch.*flow|never create config|never print|control-command|mid-task|posture/i, anchor: 'INV-execution-model' },
785
+ { id: 'INV-debug', re: /repro|root cause|symptom|minimal change|one theory|no debugging/i, anchor: 'INV-debug' },
786
+ { id: 'INV-a11y', re: /alt=|outline|aria|reduced-motion|contrast|gray-100|role\/label|focus|color-only/i, anchor: 'INV-a11y' },
787
+ { id: 'INV-code-facts', re: /operator|operand|almost always a bug|≠/i, anchor: 'INV-code-facts' },
788
+ { id: 'INV-contract', re: /contract|additive|versions|bump|deprecated/i, anchor: 'INV-contract' },
789
+ { id: 'INV-backend', re: /migration|ad-hoc|atomic|pagination|unbounded|N\+1|eager-load|invalidation|buffer whole|timeouts|cancellation|hang/i, anchor: 'INV-backend' },
790
+ ];
791
+ for (const c of CONCEPTS) {
792
+ if (enf && !enf.includes(c.anchor)) errors.push(`invariant gate: concept ${c.id} has no mechanism row in enforcement.md (anchor "${c.anchor}")`);
793
+ }
794
+ // The matrix documents the per-tier side of the hook concepts — a removed
795
+ // guard row must fail this gate, not slip through as prose.
796
+ for (const row of ['Irreversible-command guard', 'Turn-end enforcement']) {
797
+ const matrix = readFileSync(join(import.meta.dirname, '..', 'docs', 'reference', 'harness-matrix.md'), 'utf8');
798
+ if (!matrix.includes(row)) errors.push(`invariant gate: harness-matrix.md lost its "${row}" row`);
799
+ }
800
+ const lineRe = /\bnever\b|\balways\b|MUST/;
801
+ const scanRoots = [join(root, 'skills'), join(root, 'agents')];
802
+ const unreg: string[] = [];
803
+ const seen = new Set<string>();
804
+ const walkInv = (dir: string) => {
805
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
806
+ const full = join(dir, e.name);
807
+ if (e.isDirectory()) { walkInv(full); continue; }
808
+ if (!e.name.endsWith('.md')) continue;
809
+ for (const line of readFileSync(full, 'utf8').split(/\r?\n/)) {
810
+ if (!lineRe.test(line)) continue;
811
+ const key = line.trim();
812
+ if (seen.has(key)) continue;
813
+ seen.add(key);
814
+ if (!CONCEPTS.some((c) => c.re.test(line))) unreg.push(`${full.replace(root + '/', '')}: ${key.slice(0, 100)}`);
815
+ }
816
+ }
817
+ };
818
+ for (const d of scanRoots) walkInv(d);
819
+ for (const u of unreg) errors.push(`invariant without mechanism: ${u} — add a concept row in enforcement.md + a bucket above`);
820
+ if (!unreg.length && enf) console.log(`✓ invariants: every never/always/MUST maps to a mechanism row`);
821
+ }
822
+
480
823
  // Conditional-assertion guard: an expect() reachable only inside a truthiness
481
824
  // check silently passes when the value is absent. This class produced 9 defects.
482
825
  // Allowed: checks keyed on a declared invariant (tier, fixture keys).