@ionivetech/mugiwara 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,18 @@ set -u
8
8
 
9
9
  die() { echo "savepoint: $*" >&2; exit 1; }
10
10
 
11
+ # count_boxes <file> <char-class> — count markdown checkboxes.
12
+ # Anchored so prose mentioning "- [x]" is not counted; skips fenced code blocks
13
+ # so documentation examples are not counted; matches [x] and [X] alike. (B3)
14
+ count_boxes() {
15
+ [ -f "$1" ] || { echo 0; return; }
16
+ awk -v pat="$2" '
17
+ /^[[:space:]]*```/ { inblock = !inblock; next }
18
+ !inblock && $0 ~ ("^[[:space:]]*-[[:space:]]*\\[" pat "\\]") { n++ }
19
+ END { print n+0 }
20
+ ' "$1"
21
+ }
22
+
11
23
  MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
12
24
 
13
25
  # optional provider-reported tokens file (T4): --tokens-file <path> JSON {input_tokens, output_tokens}
@@ -51,9 +63,27 @@ if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL
51
63
 
52
64
  # --- parse mission args: <mission> [member] [wave] [mode] ---
53
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)
54
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
55
73
  WAVE="${3:-${STATE_WAVE:-1}}"
56
- 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
57
87
  # Triage lane (M7): the lane Luffy assigned at Flow 0. Without it savepoint
58
88
  # recomputed the lane from file counts alone and silently discarded the
59
89
  # triage decision — a Lane 3 mission recorded itself as "direct". Explicit
@@ -79,7 +109,7 @@ esac
79
109
  # verbosity from config (project .mugiwara/config), default normal; env override
80
110
  VERBOSITY="${STATE_VERBOSITY:-normal}"
81
111
  if [ -f "$MUGIWARA_DIR/config" ]; then
82
- 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:]')
83
113
  [ -n "$CFG_VERBOSITY" ] && VERBOSITY="$CFG_VERBOSITY"
84
114
  fi
85
115
  case "$VERBOSITY" in
@@ -90,7 +120,7 @@ esac
90
120
  # heal_max_cycles from config (project .mugiwara/config), default 3; env override
91
121
  HEAL_MAX_CYCLES="${STATE_HEAL_MAX_CYCLES:-3}"
92
122
  if [ -f "$MUGIWARA_DIR/config" ]; then
93
- 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:]')
94
124
  [ -n "$CFG_HEAL_MAX" ] && HEAL_MAX_CYCLES="$CFG_HEAL_MAX"
95
125
  fi
96
126
  case "$HEAL_MAX_CYCLES" in
@@ -101,7 +131,7 @@ esac
101
131
  # delegate_threshold from config (project .mugiwara/config), default 60; env override
102
132
  DELEGATE_THRESHOLD="${STATE_DELEGATE_THRESHOLD:-60}"
103
133
  if [ -f "$MUGIWARA_DIR/config" ]; then
104
- 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:]')
105
135
  [ -n "$CFG_DELEGATE" ] && DELEGATE_THRESHOLD="$CFG_DELEGATE"
106
136
  fi
107
137
  case "$DELEGATE_THRESHOLD" in
@@ -154,6 +184,11 @@ esac
154
184
  # (BSD/macOS-safe: no \+ BRE).
155
185
  BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-' | tr -cd 'A-Za-z0-9._-' | sed 's/^\.\{1,\}$//' )
156
186
 
187
+ # Resolve the repo root: handles subdirectories and git worktrees, where .git
188
+ # is a file rather than a directory. (B4)
189
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not a git repository"
190
+ cd "$REPO_ROOT" || die "cannot enter repo root"
191
+
157
192
  # state + continue live in the mission dir. Solo (member empty) → state.json
158
193
  # + continue.json; team writes <member>.json + continue-<member>.json so
159
194
  # parallel members never clobber each other.
@@ -166,8 +201,18 @@ else
166
201
  CONTINUE_FILE="$MISSION_DIR/continue.json"
167
202
  fi
168
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
+
169
215
  [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode] [lane]"
170
- [ -d .git ] || die "not a git repository"
171
216
 
172
217
  # --- computed fields ---
173
218
  BASE_SHA=$(git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git merge-base HEAD "$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')" 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "unknown")
@@ -176,7 +221,7 @@ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
176
221
  # lane_scope_glob (T5): monorepo scoping — count only files matching the glob
177
222
  LANE_SCOPE_GLOB=""
178
223
  if [ -f "$MUGIWARA_DIR/config" ]; then
179
- _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 "'")
180
225
  [ -n "$_cfg_scope" ] && LANE_SCOPE_GLOB="$_cfg_scope"
181
226
  fi
182
227
  # union of committed + staged + unstaged + untracked (F) — see patterns.sh
@@ -328,16 +373,23 @@ if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
328
373
  # total counts ALL task lines (checked + unchecked); done counts checked only.
329
374
  # A fully-completed plan must read total=N done=N, never total=0 (the old
330
375
  # unchecked-only grep degenerated a done plan to tasks.total=0).
331
- TASKS_TOTAL=$(grep -cE '^\s*-\s*\[[ xX]\]' "$PLAN_FILE" 2>/dev/null || true)
332
- TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || true)
376
+ TASKS_TOTAL=$(count_boxes "$PLAN_FILE" '[ xX]')
377
+ TASKS_DONE=$(count_boxes "$PLAN_FILE" '[xX]')
333
378
  fi
334
379
  # Fallback for large campaigns (>3 phases, >1500 lines) where master plan.md is an index
335
380
  # and tasks live in sub-plan/*.md — only when plan.md has zero checkbox tasks to
336
381
  # keep simple missions unchanged.
337
382
  if [ "${TASKS_TOTAL:-0}" -eq 0 ] 2>/dev/null && [ -d "$MISSION_DIR/sub-plan" ]; then
338
- TASKS_TOTAL=$(grep -rcE '^\s*-\s*\[[ xX]\]' "$MISSION_DIR/sub-plan" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}' || true)
339
- TASKS_DONE=$(grep -rc '\[x\]' "$MISSION_DIR/sub-plan" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}' || true)
383
+ TASKS_TOTAL=0; TASKS_DONE=0
384
+ for _sp in "$MISSION_DIR"/sub-plan/*.md; do
385
+ [ -f "$_sp" ] || continue
386
+ TASKS_TOTAL=$(( TASKS_TOTAL + $(count_boxes "$_sp" '[ xX]') ))
387
+ TASKS_DONE=$(( TASKS_DONE + $(count_boxes "$_sp" '[xX]') ))
388
+ done
340
389
  fi
390
+ # done ≤ total is an invariant of the audit trail — never let a report show
391
+ # progress above 100%, whatever the plan file contains. (B3)
392
+ [ "${TASKS_DONE:-0}" -gt "${TASKS_TOTAL:-0}" ] 2>/dev/null && TASKS_DONE="$TASKS_TOTAL"
341
393
 
342
394
  # blocker count
343
395
  BLOCKERS_FILE="$MISSION_DIR/blockers.md"
@@ -372,6 +424,16 @@ if [ "$HEAL_CYCLE" -ge "$HEAL_MAX_CYCLES" ] 2>/dev/null; then
372
424
  HEAL_HALT=true
373
425
  fi
374
426
 
427
+ # W10: register plan.md read so repeated_reads is not structurally zero (evidence.registerRead)
428
+ if [ -f "$MISSION_DIR/plan.md" ] && [ ! -s "$MISSION_DIR/context-registry.jsonl" ]; then
429
+ mkdir -p "$MISSION_DIR"
430
+ _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)
431
+ if [ -n "$_plan_fp" ]; then
432
+ _plan_chars=$(wc -c < "$MISSION_DIR/plan.md" 2>/dev/null | tr -d ' ' || echo 0)
433
+ 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
434
+ fi
435
+ fi
436
+
375
437
  # slop — context (repeated reads) per cost-governor §§21-24,31-32 — T5 wire all crews Luffy/Nami/Zoro/Brook
376
438
  REPEATED_READS=0
377
439
  REPEATED_THRESHOLD=3
@@ -387,11 +449,11 @@ fi
387
449
  # gates flow stage can read, not prose.
388
450
  DEPTH_REVIEW="full"; DEPTH_QUALITY="full"; DEPTH_VERIFY="off"
389
451
  if [ -f "$MUGIWARA_DIR/config" ]; then
390
- _cfg_r=$(grep -E '^review_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
452
+ _cfg_r=$(grep -E '^review_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
391
453
  [ -n "$_cfg_r" ] && DEPTH_REVIEW="$_cfg_r"
392
- _cfg_q=$(grep -E '^quality_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
454
+ _cfg_q=$(grep -E '^quality_depth=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
393
455
  [ -n "$_cfg_q" ] && DEPTH_QUALITY="$_cfg_q"
394
- _cfg_v=$(grep -E '^verify_merged=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
456
+ _cfg_v=$(grep -E '^verify_merged=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
395
457
  [ -n "$_cfg_v" ] && DEPTH_VERIFY="$_cfg_v"
396
458
  fi
397
459
  case "$DEPTH_REVIEW" in full|standard|lean) ;; *) DEPTH_REVIEW="full" ;; esac
@@ -499,6 +561,75 @@ if [ "$BUDGET" -gt 0 ] 2>/dev/null; then
499
561
  fi
500
562
  fi
501
563
 
564
+ # team_members for posture (W5) — config key team_members, default 1
565
+ TEAM_MEMBERS=1
566
+ if [ -f "$MUGIWARA_DIR/config" ]; then
567
+ _tm=$(grep -E '^team_members=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
568
+ [ -n "$_tm" ] && TEAM_MEMBERS="$_tm"
569
+ fi
570
+ case "$TEAM_MEMBERS" in ''|*[!0-9]*) TEAM_MEMBERS=1 ;; esac
571
+ # plan metrics for posture decision (phase-isolated / parallel)
572
+ PLAN_LINES=0
573
+ PHASES=1
574
+ INDEPENDENT_TASKS=0
575
+ if [ -f "$MISSION_DIR/plan.md" ]; then
576
+ PLAN_LINES=$(wc -l < "$MISSION_DIR/plan.md" 2>/dev/null | tr -d ' ' || echo 0)
577
+ PH=$(grep -c "^## Wave" "$MISSION_DIR/plan.md" 2>/dev/null || true)
578
+ [ "$PH" -gt 0 ] 2>/dev/null && PHASES="$PH"
579
+ INDEPENDENT_TASKS=$(grep -c "\[PARALLEL\]" "$MISSION_DIR/plan.md" 2>/dev/null || true)
580
+ fi
581
+ # governor for posture
582
+ GOVERNOR="normal"
583
+ case "$STATUS" in
584
+ stop) GOVERNOR="stop" ;;
585
+ warn) GOVERNOR="avoid" ;;
586
+ *) GOVERNOR="normal" ;;
587
+ esac
588
+ CONTEXT_PRESSURE=false
589
+ if [ "$BUDGET" -gt 0 ] 2>/dev/null && [ "$TOKENS_EST" -gt $(( BUDGET * 6 / 10 )) ] 2>/dev/null; then
590
+ CONTEXT_PRESSURE=true
591
+ fi
592
+ POSTURE="inline-sequential"
593
+ POSTURE_REASON="no parallel/phase/team/relief trigger — default inline in plan order"
594
+ POSTURE_PAUSE=false
595
+ if [ "$GOVERNOR" = "stop" ]; then
596
+ POSTURE="inline-sequential"
597
+ POSTURE_REASON="governor stop — pause safely, keep inline; state + continue emitted"
598
+ POSTURE_PAUSE=true
599
+ elif [ "$TEAM_MEMBERS" -gt 1 ] 2>/dev/null; then
600
+ POSTURE="team-scoped"
601
+ POSTURE_REASON="$TEAM_MEMBERS team members with non-overlapping scope"
602
+ elif [ "$PHASES" -gt 3 ] 2>/dev/null || [ "$PLAN_LINES" -gt 1500 ] 2>/dev/null; then
603
+ POSTURE="phase-isolated"
604
+ POSTURE_REASON="large campaign — $PHASES phases / $PLAN_LINES lines"
605
+ elif [ "$CONTEXT_PRESSURE" = true ]; then
606
+ POSTURE="context-relief"
607
+ POSTURE_REASON="context pressure with ordered dependent tasks — one worker at a time, order preserved"
608
+ elif [ "$INDEPENDENT_TASKS" -ge 2 ] 2>/dev/null; then
609
+ POSTURE="parallel-workers"
610
+ POSTURE_REASON="$INDEPENDENT_TASKS independent tasks, no shared files/interfaces"
611
+ fi
612
+ # investigation config (W8) — three keys, defaults 2/5/2
613
+ INV_MAX_PASSES=2
614
+ INV_MAX_UNRELATED=5
615
+ INV_REPEATED_THRESH=2
616
+ if [ -f "$MUGIWARA_DIR/config" ]; then
617
+ _v=$(grep -E '^investigation_max_passes=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
618
+ [ -n "$_v" ] && INV_MAX_PASSES="$_v"
619
+ _v=$(grep -E '^investigation_max_unrelated_files=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
620
+ [ -n "$_v" ] && INV_MAX_UNRELATED="$_v"
621
+ _v=$(grep -E '^investigation_repeated_read_threshold=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | cut -d'#' -f1 | tr -d '[:space:]')
622
+ [ -n "$_v" ] && INV_REPEATED_THRESH="$_v"
623
+ fi
624
+ case "$INV_MAX_PASSES" in ''|*[!0-9]*) INV_MAX_PASSES=2 ;; esac
625
+ case "$INV_MAX_UNRELATED" in ''|*[!0-9]*) INV_MAX_UNRELATED=5 ;; esac
626
+ case "$INV_REPEATED_THRESH" in ''|*[!0-9]*) INV_REPEATED_THRESH=2 ;; esac
627
+ # investigation_status: simple threshold check on repeated_reads (W8/W10)
628
+ INVESTIGATION_STATUS="continue"
629
+ if [ "$REPEATED_READS" -ge "$INV_REPEATED_THRESH" ] 2>/dev/null; then
630
+ INVESTIGATION_STATUS="stop"
631
+ fi
632
+
502
633
  mkdir -p "$MISSION_DIR"
503
634
 
504
635
  node -e "
@@ -542,7 +673,12 @@ const data = {
542
673
  evidence: process.argv[21] ? process.argv[21].split(',').filter(Boolean) : [],
543
674
  updated_at: process.argv[22],
544
675
  schema_version: 2,
545
- repeated_reads: parseInt(process.argv[41], 10) || 0
676
+ repeated_reads: parseInt(process.argv[41], 10) || 0,
677
+ team_members: parseInt(process.argv[42], 10) || 1,
678
+ posture: process.argv[43] || 'inline-sequential',
679
+ posture_reason: process.argv[44] || '',
680
+ posture_pause: process.argv[45] === 'true',
681
+ investigation_status: process.argv[46] || 'continue'
546
682
  };
547
683
  require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\n');
548
684
  " \
@@ -556,7 +692,7 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
556
692
  "$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER" "$VERBOSITY" \
557
693
  "$HEAL_MAX_CYCLES" "$HEAL_HALT" "$DELEGATE_THRESHOLD" "$DELEGATE_DUE" \
558
694
  "$MODEL" "$DEPTH_REVIEW" "$DEPTH_QUALITY" "$DEPTH_VERIFY" \
559
- "$REPEATED_READS"
695
+ "$REPEATED_READS" "$TEAM_MEMBERS" "$POSTURE" "$POSTURE_REASON" "$POSTURE_PAUSE" "$INVESTIGATION_STATUS"
560
696
 
561
697
  if [ "$LANE_ROSE" = true ]; then
562
698
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
@@ -414,7 +414,175 @@ if (integrityArg !== -1) {
414
414
  }
415
415
  if (!constants.includes('LANE_BASE_lean=8421')) errors.push('doc-integrity: source lane-base.sh lean base drifted (expected 8421)');
416
416
  if (!constants.includes('BUDGET_full=50000')) errors.push('doc-integrity: source lane-base.sh full budget drifted (expected 50000)');
417
+ // W12 stale path check: obsolete layout must not appear
418
+ const staleChecks: [string, string[]][] = [
419
+ ['state/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
420
+ ['continue/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
421
+ ['plans/<mission>', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
422
+ ['logs/lessons', ['docs/concepts/comparison.md', 'docs/concepts/features.md', 'references/multi-actor.md', 'README.md']],
423
+ ];
424
+ for (const [pat, docs] of staleChecks) {
425
+ for (const doc of docs) {
426
+ const p = join(import.meta.dirname, '..', doc);
427
+ if (existsSync(p) && readFileSync(p, 'utf8').includes(pat)) {
428
+ errors.push(`doc-integrity: ${doc} contains obsolete path "${pat}" — use missions/<mission>/ layout`);
429
+ }
430
+ }
431
+ }
432
+ // W17 metrics must come from .metrics/latest.json — check for hardcoded stale numbers not in metrics
433
+ const metricsPath2 = join(import.meta.dirname, '..', '.metrics/latest.json');
434
+ if (existsSync(metricsPath2)) {
435
+ const m2 = JSON.parse(readFileSync(metricsPath2, 'utf8'));
436
+ const readme2 = readFileSync(join(import.meta.dirname, '..', 'README.md'), 'utf8');
437
+ // ensure README rank-1 and pointers match metrics (also checked in --check-readme-metrics, but this is integrity)
438
+ const rankMatch2 = readme2.match(/Retrieval routing rank-1[^\n]*?(\d+\.\d+)%/);
439
+ if (rankMatch2 && parseFloat(rankMatch2[1]) !== Number(m2.retrieval_rank1)) {
440
+ errors.push(`doc-integrity: README rank-1 ${rankMatch2[1]}% != metrics ${m2.retrieval_rank1}%`);
441
+ }
442
+ }
443
+ // stale CLI commands: any `mugiwara <word>` where word is not a valid CLI case, appearing as code, is stale
444
+ const validCmds = new Set(['install','update','uninstall','list','reset','archive','clean','continue','status','cost','run','savepoint','blame','handoff','sign','migrate','lesson','help','version','mode','off']);
445
+ const docsToScan = ['docs/concepts/workflow.md','docs/concepts/config.md','README.md','references/multi-actor.md'];
446
+ for (const doc of docsToScan) {
447
+ const p = join(import.meta.dirname, '..', doc);
448
+ if (!existsSync(p)) continue;
449
+ const txt = readFileSync(p, 'utf8');
450
+ for (const m of txt.matchAll(/`mugiwara ([a-z-]+)/g)) {
451
+ const cmd = m[1];
452
+ if (!validCmds.has(cmd) && cmd !== '--help' && cmd !== '--version') {
453
+ errors.push(`doc-integrity: ${doc} contains stale command "mugiwara ${cmd}" not in src/cli.ts`);
454
+ }
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ // --- config drift gate (W11): every key code reads must appear in DEFAULT_CONFIG and docs, and vice versa ---
461
+ if (process.argv.includes('--check-config')) {
462
+ const cfgSrc = readFileSync(join(import.meta.dirname, '..', 'src/config.ts'), 'utf8');
463
+ const m = cfgSrc.match(/DEFAULT_CONFIG\s*=\s*\[([\s\S]*?)\]\.join/);
464
+ let defaultKeys: string[] = [];
465
+ if (m) {
466
+ const block = m[1];
467
+ for (const line of block.split(/\r?\n/)) {
468
+ const t = line.trim();
469
+ if (!t) continue;
470
+ // extract string content between quotes
471
+ const q = t.match(/['"`]([^'"`]*?)['"`]/);
472
+ if (!q) continue;
473
+ let s = q[1].trim();
474
+ if (!s) continue;
475
+ if (s.startsWith('#')) s = s.slice(1).trim();
476
+ if (!s) continue;
477
+ const eq = s.indexOf('=');
478
+ if (eq === -1) continue;
479
+ const key = s.slice(0, eq).trim();
480
+ if (key) defaultKeys.push(key);
481
+ }
482
+ }
483
+ // docs keys from config.md table (only the ## Keys section, not template examples)
484
+ const docPath = join(import.meta.dirname, '..', 'docs/concepts/config.md');
485
+ let docKeys: string[] = [];
486
+ if (existsSync(docPath)) {
487
+ const docText = readFileSync(docPath, 'utf8');
488
+ const keysSectionMatch = docText.match(/## Keys([\s\S]*?)(?:\n## |\n#|$)/);
489
+ const keysSection = keysSectionMatch ? keysSectionMatch[1] : docText;
490
+ for (const line of keysSection.split(/\r?\n/)) {
491
+ const cm = line.match(/\|\s*`([^`]+)`\s*\|/);
492
+ if (cm) {
493
+ const k = cm[1].trim();
494
+ if (k && !docKeys.includes(k)) docKeys.push(k);
495
+ }
496
+ }
497
+ } else {
498
+ errors.push('config-drift: docs/concepts/config.md not found');
499
+ }
500
+ // code keys: scan src/*.ts, scripts/*.sh, hooks/*.ts for key patterns
501
+ const codeRoots = [
502
+ join(import.meta.dirname, '..', 'src'),
503
+ join(import.meta.dirname, '..', 'scripts'),
504
+ join(import.meta.dirname, '..', 'hooks'),
505
+ ];
506
+ const codeTextAll = codeRoots.map(r => {
507
+ if (!existsSync(r)) return '';
508
+ 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');
509
+ // also need subdirectories
510
+ let sub = '';
511
+ try {
512
+ for (const e of readdirSync(r, { withFileTypes: true })) {
513
+ if (e.isDirectory()) {
514
+ const subdir = join(r, e.name);
515
+ for (const f of readdirSync(subdir, { withFileTypes: true }).filter(x => x.isFile() && (x.name.endsWith('.ts') || x.name.endsWith('.sh')))) {
516
+ sub += readFileSync(join(subdir, f.name), 'utf8') + '\n';
517
+ }
518
+ }
519
+ }
520
+ } catch {}
521
+ return files + sub;
522
+ }).join('\n');
523
+ // check each default key appears in code
524
+ for (const k of defaultKeys) {
525
+ if (!codeTextAll.includes(k)) {
526
+ errors.push(`config-drift: DEFAULT_CONFIG key "${k}" not found in code (src/*.ts, scripts/*.sh, hooks/*.ts)`);
527
+ }
528
+ if (!docKeys.includes(k)) {
529
+ errors.push(`config-drift: DEFAULT_CONFIG key "${k}" missing from docs/concepts/config.md`);
530
+ }
531
+ }
532
+ for (const k of docKeys) {
533
+ if (!defaultKeys.includes(k)) {
534
+ errors.push(`config-drift: docs/concepts/config.md key "${k}" not in DEFAULT_CONFIG`);
535
+ }
536
+ if (!codeTextAll.includes(k)) {
537
+ errors.push(`config-drift: docs key "${k}" not found in code`);
538
+ }
539
+ }
540
+ if (!errors.some(e => e.startsWith('config-drift'))) {
541
+ console.log(`✓ config in sync: ${defaultKeys.length} keys (${defaultKeys.join(', ')})`);
542
+ }
417
543
  }
544
+
545
+ // --- wiring gate (W7): every src module must be imported somewhere ---
546
+ if (process.argv.includes('--check-wiring')) {
547
+ const srcDir = join(import.meta.dirname, '..', 'src');
548
+ const ENTRY = new Set(['cli.ts', 'index.ts', 'installer.ts']);
549
+ const srcFiles = readdirSync(srcDir).filter(n => n.endsWith('.ts'));
550
+ const searchDirs = [srcDir, join(import.meta.dirname, '..', 'hooks'), join(import.meta.dirname, '..', 'scripts')];
551
+ const allFiles: string[] = [];
552
+ for (const d of searchDirs) {
553
+ if (!existsSync(d)) continue;
554
+ const walk = (dir: string) => {
555
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
556
+ const full = join(dir, e.name);
557
+ if (e.isDirectory()) walk(full);
558
+ else if (e.isFile() && (e.name.endsWith('.ts') || e.name.endsWith('.js') || e.name.endsWith('.sh') || e.name.endsWith('.mjs'))) {
559
+ allFiles.push(full);
560
+ }
561
+ }
562
+ };
563
+ walk(d);
564
+ }
565
+ for (const f of srcFiles) {
566
+ if (ENTRY.has(f)) continue;
567
+ const stem = f.slice(0, -3);
568
+ let found = false;
569
+ for (const full of allFiles) {
570
+ if (full.endsWith(`/src/${f}`) || full === join(srcDir, f)) continue;
571
+ try {
572
+ const txt = readFileSync(full, 'utf8');
573
+ if (txt.includes(`'./${stem}`) || txt.includes(`"./${stem}`) || txt.includes(`'./${stem}.ts'`) || txt.includes(`"./${stem}.ts"`)) {
574
+ found = true;
575
+ break;
576
+ }
577
+ } catch {}
578
+ }
579
+ if (!found) {
580
+ errors.push(`src/${f}: imported by nothing — wire it, or delete it with its tests and its features.md claim`);
581
+ }
582
+ }
583
+ if (!errors.some(e => e.includes('imported by nothing'))) {
584
+ console.log(`✓ wiring: all src modules imported`);
585
+ }
418
586
  }
419
587
 
420
588
  // --- README metrics gate (D3): README table must match .metrics/latest.json ---
package/src/args.ts CHANGED
@@ -6,7 +6,7 @@ export type Args = {
6
6
  flags: Record<string, FlagValue>;
7
7
  };
8
8
 
9
- const VALUE_FLAGS: Record<string, string> = { '--project': 'project', '--target': 'target', '--before': 'before', '--backend': 'backend', '--mission': 'mission' };
9
+ const VALUE_FLAGS: Record<string, string> = { '--project': 'project', '--target': 'target', '--before': 'before', '--backend': 'backend', '--mission': 'mission', '--to-team': 'toTeam', '--to-solo': 'toSolo' };
10
10
  const BOOL_FLAGS: Record<string, string> = {
11
11
  '--global': 'global', '--yes': 'yes', '-y': 'yes', '--force': 'force',
12
12
  '--dry-run': 'dryRun', '--keep-logs': 'keepLogs', '--check': 'check', '--all': 'all', '--verify': 'verify',