@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.opencode/mugiwara-helpers.mjs +1 -1
- package/.opencode/plugins/mugiwara.mjs +173 -1
- package/README.md +63 -58
- package/content/agents/luffy-orchestrator.md +16 -1
- package/content/agents/zoro-execution.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
- package/content/skills/mugiwara-execution/SKILL.md +5 -5
- package/content/skills/mugiwara-gates/SKILL.md +1 -0
- package/content/skills/mugiwara-healing/SKILL.md +1 -0
- package/content/skills/mugiwara-lessons/SKILL.md +2 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +15 -20
- package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
- package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
- package/content/skills/mugiwara-orchestration/references/solo-team.md +18 -0
- package/content/skills/mugiwara-planning/SKILL.md +7 -15
- package/content/skills/mugiwara-planning/references/sub-missions.md +14 -0
- package/content/skills/mugiwara-quality/SKILL.md +1 -0
- package/content/skills/mugiwara-review/SKILL.md +2 -0
- package/content/skills/mugiwara-security/SKILL.md +2 -2
- package/content/skills/mugiwara-ship/SKILL.md +12 -0
- package/content/skills/mugiwara-workflow/SKILL.md +3 -3
- package/dist/mugiwara.js +934 -158
- package/gemini-extension.json +1 -1
- package/hooks/engagement-marker.js +9 -1
- package/hooks/engagement-marker.ts +9 -1
- package/hooks/hooks.json +12 -0
- package/hooks/pipeline-guard.js +137 -3
- package/hooks/pipeline-guard.ts +161 -3
- package/hooks/pretool-guard.js +84 -0
- package/hooks/pretool-guard.ts +60 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/references/multi-actor.md +17 -14
- package/references/wave-banners.md +22 -27
- package/scripts/build-hooks.ts +1 -1
- package/scripts/gate-selftest.ts +480 -0
- package/scripts/savepoint.sh +139 -14
- package/scripts/validate-content.ts +345 -2
- package/scripts/write-metrics.ts +25 -1
- package/src/args.ts +1 -1
- package/src/cli.ts +158 -3
- package/src/config.ts +33 -11
- package/src/guards.ts +40 -0
- package/src/initiative.ts +174 -0
- package/src/mission.ts +137 -52
- package/src/targets/claude.ts +1 -0
package/scripts/savepoint.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 [ -
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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).
|