@ionivetech/mugiwara 0.6.2 → 0.6.4

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 (73) 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/commands/mugiwara-continue.md +42 -10
  7. package/.opencode/commands/mugiwara-execute.md +1 -1
  8. package/.opencode/commands/mugiwara-heal.md +1 -1
  9. package/.opencode/commands/mugiwara-plan.md +1 -1
  10. package/.opencode/commands/mugiwara-review.md +1 -1
  11. package/.opencode/commands/mugiwara-security.md +1 -1
  12. package/.opencode/commands/mugiwara-ship.md +1 -1
  13. package/.opencode/mugiwara-helpers.mjs +1 -1
  14. package/.opencode/plugins/mugiwara.mjs +62 -20
  15. package/AGENTS.md +16 -5
  16. package/README.md +67 -15
  17. package/content/agents/brook-healing.md +1 -1
  18. package/content/agents/chopper-checkpoint.md +1 -1
  19. package/content/agents/eval-runner.md +1 -1
  20. package/content/agents/franky-gates.md +1 -1
  21. package/content/agents/jinbe-security.md +1 -1
  22. package/content/agents/memory-keeper.md +1 -1
  23. package/content/agents/nami-planner.md +2 -2
  24. package/content/agents/resume-coordinator.md +11 -11
  25. package/content/agents/robin-reviewer.md +1 -1
  26. package/content/agents/sanji-quality.md +1 -1
  27. package/content/agents/skeptic-verifier.md +1 -1
  28. package/content/agents/usopp-brainstorm.md +2 -2
  29. package/content/agents/zoro-execution.md +3 -3
  30. package/content/skills/mugiwara-agent-security/SKILL.md +1 -18
  31. package/content/skills/mugiwara-agent-security/references/checklist.md +20 -0
  32. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -1
  33. package/content/skills/mugiwara-execution/SKILL.md +14 -13
  34. package/content/skills/mugiwara-execution/references/resume-batching.md +4 -4
  35. package/content/skills/mugiwara-healing/SKILL.md +2 -37
  36. package/content/skills/mugiwara-healing/references/workers.md +38 -0
  37. package/content/skills/mugiwara-orchestration/SKILL.md +11 -8
  38. package/content/skills/mugiwara-orchestration/references/check-ins.md +26 -7
  39. package/content/skills/mugiwara-orchestration/references/closure.md +9 -0
  40. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +9 -12
  41. package/content/skills/mugiwara-planning/SKILL.md +7 -11
  42. package/content/skills/mugiwara-planning/references/plan-template.md +4 -4
  43. package/content/skills/mugiwara-pr/SKILL.md +11 -19
  44. package/content/skills/mugiwara-pr/references/verdict-format.md +31 -0
  45. package/content/skills/mugiwara-resume/SKILL.md +37 -22
  46. package/content/skills/mugiwara-ship/SKILL.md +1 -23
  47. package/content/skills/mugiwara-ship/references/cleanup.md +25 -0
  48. package/content/skills/mugiwara-workflow/SKILL.md +7 -3
  49. package/content/skills/mugiwara-workflow/references/workspace-layout.md +11 -3
  50. package/content/skills/using-mugiwara/SKILL.md +1 -1
  51. package/dist/mugiwara.js +138 -42
  52. package/gemini-extension.json +1 -1
  53. package/hooks/session-start.ts +113 -2
  54. package/package.json +3 -3
  55. package/plugin.json +1 -1
  56. package/references/multi-actor.md +39 -14
  57. package/references/skill-versioning.md +3 -3
  58. package/references/token-budget.md +1 -1
  59. package/references/wave-banners.md +75 -0
  60. package/scripts/evidence.sh +9 -0
  61. package/scripts/gate-selftest.ts +154 -0
  62. package/scripts/lane-base.ts +114 -0
  63. package/scripts/lane.sh +5 -3
  64. package/scripts/lib/lane-base.sh +19 -0
  65. package/scripts/lib/patterns.sh +21 -0
  66. package/scripts/mission-report.sh +21 -5
  67. package/scripts/savepoint.sh +170 -56
  68. package/scripts/setup-fixtures.ts +108 -0
  69. package/scripts/validate-content.ts +61 -0
  70. package/src/cli.ts +5 -1
  71. package/src/installer.ts +70 -8
  72. package/src/mission.ts +37 -19
  73. package/src/targets/opencode.ts +34 -6
@@ -1,12 +1,34 @@
1
1
  #!/usr/bin/env bash
2
- # scripts/savepoint.sh — write .mugiwara/state.json at every wave boundary.
3
- # Computed from git + file counts; zero model judgement.
2
+ # scripts/savepoint.sh — write .mugiwara/state/<mission>/<member>.json at every
3
+ # wave boundary. Computed from git + file counts; zero model judgement.
4
+ # Identity is (mission, member), never branch. Solo = member empty → the file
5
+ # is named state.json. Usage:
6
+ # savepoint.sh <mission> [member] [wave] [mode]
4
7
  set -u
5
8
 
6
9
  die() { echo "savepoint: $*" >&2; exit 1; }
7
10
 
8
11
  MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
9
12
 
13
+ # shared path patterns — single source of truth (D3)
14
+ # shellcheck source=scripts/lib/patterns.sh
15
+ source "$(dirname "$0")/lib/patterns.sh"
16
+ # shared lane budgets — single source of truth (D5), validated by lane-base.ts
17
+ # shellcheck source=scripts/lib/lane-base.sh
18
+ source "$(dirname "$0")/lib/lane-base.sh"
19
+
20
+ # lane ordering: direct < lean < standard < full < spike (spike resizes, not a rise)
21
+ lane_rank() {
22
+ case "$1" in
23
+ direct) echo 0 ;;
24
+ lean) echo 1 ;;
25
+ standard) echo 2 ;;
26
+ full) echo 3 ;;
27
+ spike) echo 4 ;;
28
+ *) echo 0 ;;
29
+ esac
30
+ }
31
+
10
32
  # --- git identity for actor attribution ---
11
33
  # Resolve once from repo git config; used when no actor is passed explicitly.
12
34
  # GIT_AUTHOR_NAME is an env var usually unset — falling through to $USER
@@ -15,24 +37,18 @@ GIT_NAME="$(git config user.name 2>/dev/null || true)"
15
37
  GIT_EMAIL="$(git config user.email 2>/dev/null || true)"
16
38
  if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL>"; else GIT_ID="$GIT_NAME"; fi
17
39
 
18
- # --- parse mission args ---
19
- # --branch flag must parse FIRST — it shifts positionals
20
- BRANCH_MODE=0
21
- if [ "${1:-}" = "--branch" ]; then
22
- BRANCH_MODE=1
23
- shift
24
- MISSION="${1:-${STATE_MISSION:-}}"
25
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}}"
26
- BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
27
- WAVE="${4:-${STATE_WAVE:-1}}"
28
- MODE="${5:-${STATE_MODE:-guided}}"
29
- else
30
- MISSION="${1:-${STATE_MISSION:-}}"
31
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}}"
32
- BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
33
- WAVE="${4:-${STATE_WAVE:-1}}"
34
- MODE="${5:-${STATE_MODE:-guided}}"
35
- fi
40
+ # --- parse mission args: <mission> [member] [wave] [mode] ---
41
+ MISSION="${1:-${STATE_MISSION:-}}"
42
+ MEMBER="${2:-${STATE_MEMBER:-}}"
43
+ WAVE="${3:-${STATE_WAVE:-1}}"
44
+ MODE="${4:-${STATE_MODE:-guided}}"
45
+ ACTOR="${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}"
46
+ BRANCH="$(git branch --show-current 2>/dev/null || echo 'unknown')"
47
+
48
+ # treat the legacy empty-actor placeholder '""' as "no member" (solo). The old
49
+ # interface used '""' for the actor slot; the new (mission, member) interface
50
+ # uses an empty arg. Accept both so callers need not know which one.
51
+ if [ "$MEMBER" = '""' ]; then MEMBER=""; fi
36
52
 
37
53
  # mission allowlist — MISSION feeds paths + sed + node argv; traversal or
38
54
  # sed metacharacters must not reach filesystem operations
@@ -45,15 +61,32 @@ if [[ "$MISSION" =~ ^\.+$ ]]; then
45
61
  die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
46
62
  fi
47
63
 
48
- # per-branch state file when --branch used
49
- BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
50
- if [ "$BRANCH_MODE" -eq 1 ]; then
51
- STATE_FILE="$MUGIWARA_DIR/state-${BRANCH_SLUG}.json"
64
+ # member allowlist — MEMBER feeds a per-member file name; same rules as MISSION.
65
+ case "$MEMBER" in
66
+ *[!a-zA-Z0-9._-]*) die "invalid member name \"$MEMBER\" (allowlist: [a-zA-Z0-9._-])" ;;
67
+ esac
68
+ if [[ "$MEMBER" =~ ^\.+$ ]]; then
69
+ die "invalid member name \"$MEMBER\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
70
+ fi
71
+
72
+ # BRANCH_SLUG sanitized to [A-Za-z0-9._-] — it feeds the continue branch field;
73
+ # a newline/control char in BRANCH would corrupt the JSON (F5). Dropping illegal
74
+ # chars is safe: slugs are keys, not content. Dot-only slugs emptied
75
+ # (BSD/macOS-safe: no \+ BRE).
76
+ BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-' | tr -cd 'A-Za-z0-9._-' | sed 's/^\.\{1,\}$//' )
77
+
78
+ # state + continue live per (mission, member). Solo (member empty) → state.json.
79
+ STATE_DIR="$MUGIWARA_DIR/state/$MISSION"
80
+ CONTINUE_DIR="$MUGIWARA_DIR/continue/$MISSION"
81
+ if [ -n "$MEMBER" ]; then
82
+ STATE_FILE="$STATE_DIR/$MEMBER.json"
83
+ CONTINUE_FILE="$CONTINUE_DIR/$MEMBER.json"
52
84
  else
53
- STATE_FILE="$MUGIWARA_DIR/state.json"
85
+ STATE_FILE="$STATE_DIR/state.json"
86
+ CONTINUE_FILE="$CONTINUE_DIR/state.json"
54
87
  fi
55
88
 
56
- [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [actor] [branch] [wave] [mode]"
89
+ [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode]"
57
90
  [ -d .git ] || die "not a git repository"
58
91
 
59
92
  # --- computed fields ---
@@ -63,17 +96,23 @@ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
63
96
  CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || true)
64
97
  FILES_TOUCHED=$( [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | wc -l | tr -d ' ' || echo 0 )
65
98
 
99
+ LOC_INS=0
100
+ LOC_DEL=0
66
101
  LOC_DELTA=0
102
+ LOC_CHURN=0
67
103
  if [ "$BASE_SHA" != "unknown" ]; then
68
104
  STAT=$(git diff --shortstat "$BASE_SHA"..HEAD 2>/dev/null || echo "")
69
105
  INS=$(echo "$STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
70
106
  DEL=$(echo "$STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
71
- LOC_DELTA=$(( ${INS:-0} - ${DEL:-0} ))
107
+ LOC_INS=$(( ${INS:-0} + 0 ))
108
+ LOC_DEL=$(( ${DEL:-0} + 0 ))
109
+ LOC_DELTA=$(( LOC_INS - LOC_DEL ))
110
+ # churn is insertion+deletion — refactors and deletions are work too (D4)
111
+ LOC_CHURN=$(( LOC_INS + LOC_DEL ))
72
112
  fi
73
113
  [ -z "$LOC_DELTA" ] && LOC_DELTA=0
74
114
 
75
- SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env$|config/.*key|migration/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$"
76
- SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATTERNS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
115
+ SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
77
116
 
78
117
  LANE="direct"
79
118
  LANE_REASON=""
@@ -109,7 +148,6 @@ fi
109
148
  # path-weighted sizing (mirrors lane.sh): docs-only changes outside the product
110
149
  # surface never escalate to full from file count alone; sensitive-path
111
150
  # escalation above still wins.
112
- PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
113
151
  if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ]; then
114
152
  CODE_COUNT=$(echo "$CHANGED_FILES" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
115
153
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
@@ -119,6 +157,42 @@ if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ];
119
157
  fi
120
158
  fi
121
159
 
160
+ # --- lane-rise compare (F9) + monotonic clamp (D2): read previous state ---
161
+ # Clamp runs here — before LANE_BASE/BUDGET — so tokens and budget follow the
162
+ # held lane, never the raw shrunken one.
163
+ LANE_PREV=""
164
+ LANE_PEAK=""
165
+ LANE_ROSE=false
166
+ PREV_MISSION=""
167
+ if [ -f "$STATE_FILE" ]; then
168
+ PREV_JSON=$(node -e "try{const fs=require('fs');const s=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));process.stdout.write(JSON.stringify({mission:s.mission||'',lane:s.lane||'',peak:s.lane_peak||''}))}catch(e){process.stdout.write('{}')}" "$STATE_FILE" 2>/dev/null || true)
169
+ PREV_MISSION=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).mission||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
170
+ LANE_PREV=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).lane||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
171
+ LANE_PEAK=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).peak||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
172
+ fi
173
+ # fresh mission resets the clamp: a different mission in state.json is a new
174
+ # run, not a lane drop — no carry-over of peak or prev (case 10).
175
+ if [ -n "$PREV_MISSION" ] && [ "$PREV_MISSION" != "$MISSION" ]; then
176
+ LANE_PREV=""
177
+ LANE_PEAK=""
178
+ fi
179
+ # monotonic clamp: never drop below the previous peak; spike is a resize, not a rise.
180
+ if [ -n "$LANE_PEAK" ] && [ "$LANE_PEAK" != "spike" ] && [ "$(lane_rank "$LANE")" -lt "$(lane_rank "$LANE_PEAK")" ]; then
181
+ LANE="$LANE_PEAK"
182
+ LANE_REASON="$LANE_REASON (held at peak $LANE — clamp D2)"
183
+ fi
184
+ if [ -z "$LANE_PEAK" ] || [ "$LANE_PEAK" = "spike" ]; then
185
+ LANE_PEAK="$LANE"
186
+ elif [ "$(lane_rank "$LANE")" -gt "$(lane_rank "$LANE_PEAK")" ]; then
187
+ LANE_PEAK="$LANE"
188
+ fi
189
+ if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
190
+ case "$LANE_PREV:$LANE" in
191
+ direct:lean|direct:standard|direct:full|lean:standard|lean:full|standard:full) LANE_ROSE=true ;;
192
+ *) LANE_ROSE=false ;;
193
+ esac
194
+ fi
195
+
122
196
  # task counts from plan doc — plan is written date-prefixed (plans/YYYY-MM-DD-<mission>.md)
123
197
  # or bare (plans/<mission>.md); glob both, first match wins.
124
198
  PLAN_FILE=$(ls "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md 2>/dev/null | head -1 || true)
@@ -168,17 +242,18 @@ fi
168
242
 
169
243
  # tokens proxy (F7): deterministic estimate when the harness does not report
170
244
  # real usage. Monotonic beats precise — LANE_BASE stands in for the skills
171
- # loaded this lane; loc_delta and written-artifact words scale with growth.
245
+ # loaded this lane (measured from content, validated by scripts/lane-base.ts);
246
+ # loc_churn and written-artifact words scale with growth.
172
247
  # MUGIWARA_TOKENS overrides as the reported value.
173
248
  LANE_BASE=0
174
249
  case "$LANE" in
175
- lean) LANE_BASE=1500 ;;
176
- standard) LANE_BASE=4000 ;;
177
- full) LANE_BASE=9000 ;;
178
- spike) LANE_BASE=1000 ;;
250
+ lean) LANE_BASE=$LANE_BASE_lean ;;
251
+ standard) LANE_BASE=$LANE_BASE_standard ;;
252
+ full) LANE_BASE=$LANE_BASE_full ;;
253
+ spike) LANE_BASE=$LANE_BASE_spike ;;
179
254
  esac
180
255
  DOC_WORDS=$(cat "$MUGIWARA_DIR"/results/${MISSION}/*.md "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md "$MUGIWARA_DIR"/spec/${MISSION}.md "$MUGIWARA_DIR"/spec/*-${MISSION}.md "$MUGIWARA_DIR"/logs/${MISSION}.md "$MUGIWARA_DIR"/logs/*-${MISSION}.md 2>/dev/null | wc -w | tr -d ' ')
181
- LOC_TOKENS=$(( LOC_DELTA > 0 ? LOC_DELTA * 12 : 0 ))
256
+ LOC_TOKENS=$(( LOC_CHURN * 12 ))
182
257
  TOKENS_SOURCE="computed"
183
258
  TOKENS_EST=$(( LANE_BASE + DOC_WORDS * 135 / 100 + LOC_TOKENS ))
184
259
  if [ -n "${MUGIWARA_TOKENS:-}" ]; then
@@ -189,10 +264,10 @@ fi
189
264
  # budget per lane
190
265
  BUDGET=0
191
266
  case "$LANE" in
192
- lean) BUDGET=4000 ;;
193
- standard) BUDGET=10000 ;;
194
- full) BUDGET=20000 ;;
195
- spike) BUDGET=3000 ;;
267
+ lean) BUDGET=$BUDGET_lean ;;
268
+ standard) BUDGET=$BUDGET_standard ;;
269
+ full) BUDGET=$BUDGET_full ;;
270
+ spike) BUDGET=$BUDGET_spike ;;
196
271
  *) BUDGET=0 ;;
197
272
  esac
198
273
 
@@ -206,30 +281,18 @@ elif [ "$BUDGET" -gt 0 ] && [ "$TOKENS_EST" -ge "$WARN_AT" ] 2>/dev/null; then
206
281
  STATUS="warn"
207
282
  fi
208
283
 
209
- mkdir -p "$MUGIWARA_DIR"
210
-
211
- # --- lane-rise compare (F9): read previous state, flag escalation ---
212
- LANE_PREV=""
213
- LANE_ROSE=false
214
- if [ -f "$STATE_FILE" ]; then
215
- LANE_PREV=$(node -e "try{const s=require(process.argv[1]);process.stdout.write(s.lane||'')}catch(e){process.stdout.write('')}" "$STATE_FILE" 2>/dev/null || true)
216
- if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
217
- # lane order: direct < lean < standard < full < spike (spike resizes, not a rise)
218
- case "$LANE_PREV:$LANE" in
219
- direct:lean|direct:standard|direct:full|lean:standard|lean:full|standard:full) LANE_ROSE=true ;;
220
- *) LANE_ROSE=false ;;
221
- esac
222
- fi
223
- fi
284
+ mkdir -p "$MUGIWARA_DIR/state/$MISSION" "$MUGIWARA_DIR/continue/$MISSION"
224
285
 
225
286
  node -e "
226
287
  const data = {
227
288
  mission: process.argv[1],
289
+ member: process.argv[31] || null,
228
290
  actor: process.argv[2],
229
291
  branch: process.argv[3],
230
292
  lane: process.argv[4],
231
293
  lane_reason: process.argv[5],
232
294
  lane_prev: process.argv[24] || null,
295
+ lane_peak: process.argv[27] || null,
233
296
  lane_rose: process.argv[25] === 'true',
234
297
  wave: parseInt(process.argv[6], 10),
235
298
  mode: process.argv[7],
@@ -237,6 +300,9 @@ const data = {
237
300
  head_sha: process.argv[9],
238
301
  files_touched: parseInt(process.argv[10], 10),
239
302
  loc_delta: parseInt(process.argv[11], 10),
303
+ loc_ins: parseInt(process.argv[28], 10) || 0,
304
+ loc_del: parseInt(process.argv[29], 10) || 0,
305
+ loc_churn: parseInt(process.argv[30], 10) || 0,
240
306
  sensitive_paths: process.argv[12] ? process.argv[12].split(',').filter(Boolean) : [],
241
307
  tasks: { done: parseInt(process.argv[13], 10), total: parseInt(process.argv[14], 10) },
242
308
  blockers_open: parseInt(process.argv[15], 10),
@@ -257,9 +323,57 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
257
323
  "$BLOCKERS_OPEN" "$HEAL_CYCLE" "$TOKENS_EST" "$BUDGET" \
258
324
  "$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
259
325
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
260
- "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE"
326
+ "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE" "$LANE_PEAK" \
327
+ "$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER"
261
328
 
262
329
  if [ "$LANE_ROSE" = true ]; then
263
330
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
264
331
  fi
332
+
333
+ # --- continue/<mission>/<member>.json (D10): machine-written resume point ---
334
+ # Written alongside state.json so an interrupted session (step-limit truncation,
335
+ # crash, new session) can resume without human recall. Same trust as state.json:
336
+ # computed fields, never model judgement. The resume skill treats it as data to
337
+ # verify against the plan/todos, never verbatim instructions.
338
+ # Scoped by (mission, member) like state.json — solo writes state.json, team
339
+ # writes <member>.json, so parallel members never clobber each other. The
340
+ # crew-written next_session_prompt line is preserved across savepoints.
341
+ if [ -n "$MISSION" ]; then
342
+ NEXT_SESSION_PROMPT=""
343
+ if [ -f "$CONTINUE_FILE" ]; then
344
+ NEXT_SESSION_PROMPT=$(node -e "try{const s=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(s.next_session_prompt||'')}catch(e){process.stdout.write('')}" "$CONTINUE_FILE" 2>/dev/null || true)
345
+ fi
346
+ # N2: every field echoed into continue is validated first. MISSION + MEMBER
347
+ # are allowlisted upstream; WAVE numeric, MODE enum, BRANCH slug-sanitized.
348
+ CONT_WAVE=$(echo "$WAVE" | tr -cd '0-9')
349
+ case "$MODE" in
350
+ guided|semi|auto) CONT_MODE="$MODE" ;;
351
+ *) CONT_MODE="guided" ;;
352
+ esac
353
+ CONT_BRANCH="${BRANCH_SLUG:-unknown}"
354
+ node -e "
355
+ const fs = require('fs');
356
+ const data = {
357
+ mission: process.argv[1],
358
+ member: process.argv[2] || null,
359
+ actor: process.argv[13] || '',
360
+ branch: process.argv[3],
361
+ wave: parseInt(process.argv[4], 10) || 0,
362
+ mode: process.argv[5],
363
+ tasks_done: parseInt(process.argv[6], 10) || 0,
364
+ tasks_total: parseInt(process.argv[7], 10) || 0,
365
+ lane: process.argv[8],
366
+ lane_prev: process.argv[9] || null,
367
+ updated_at: process.argv[10],
368
+ next_action: 'verify this wave against the plan, then continue per plan (next wave or closure)'
369
+ };
370
+ try { data.next_session_prompt = JSON.parse(fs.readFileSync(process.argv[12],'utf8')).next_session_prompt || ''; } catch (e) { data.next_session_prompt = ''; }
371
+ fs.writeFileSync(process.argv[11], JSON.stringify(data, null, 2) + '\n');
372
+ " \
373
+ "$MISSION" "$MEMBER" "$CONT_BRANCH" "$CONT_WAVE" "$CONT_MODE" \
374
+ "$TASKS_DONE" "$TASKS_TOTAL" "$LANE" "$LANE_PREV" \
375
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
376
+ "$CONTINUE_FILE" "$CONTINUE_FILE" "$ACTOR"
377
+ fi
378
+
265
379
  echo "✓ savepoint written: $STATE_FILE (lane=$LANE, wave=$WAVE, files=$FILES_TOUCHED)"
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/setup-fixtures.ts — materialize test/fixtures/*.json into real git
3
+ // repos in a temp dir. Each fixture = a JSON manifest with base/branch file
4
+ // trees. The script writes the base tree on the trunk branch, creates the
5
+ // feature branch, applies the branch tree, and commits. Used by tests to get
6
+ // a deterministic repo per scenario. Never touches the main repo's .git.
7
+ //
8
+ // Usage:
9
+ // bun scripts/setup-fixtures.ts <fixture> <targetDir>
10
+ // bun scripts/setup-fixtures.ts --list
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const fixturesDir = join(import.meta.dirname, '..', 'test', 'fixtures');
17
+
18
+ function fixtureNames(): string[] {
19
+ return readdirSync(fixturesDir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
20
+ }
21
+
22
+ if (process.argv[2] === '--list') {
23
+ console.log(fixtureNames().join('\n'));
24
+ process.exit(0);
25
+ }
26
+
27
+ const name = process.argv[2];
28
+ const target = process.argv[3];
29
+ if (!name || !target) {
30
+ console.error('usage: bun scripts/setup-fixtures.ts <fixture> <targetDir>');
31
+ process.exit(1);
32
+ }
33
+
34
+ type Fixture = {
35
+ name: string;
36
+ baseBranch: string;
37
+ base: Record<string, string>;
38
+ branch: Record<string, string>;
39
+ binary?: string[];
40
+ gitInit?: boolean;
41
+ note?: string;
42
+ };
43
+
44
+ const file = join(fixturesDir, name + '.json');
45
+ if (!existsSync(file)) {
46
+ console.error(`fixture not found: ${name} (have: ${fixtureNames().join(', ')})`);
47
+ process.exit(1);
48
+ }
49
+ const fx = JSON.parse(readFileSync(file, 'utf8')) as Fixture;
50
+
51
+ // target must be a fresh empty dir
52
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
53
+ mkdirSync(target, { recursive: true });
54
+
55
+ if (fx.gitInit === false) {
56
+ // no-git fixture: just an empty dir with one file, no .git ever created
57
+ mkdirSync(join(target, 'src'), { recursive: true });
58
+ writeFileSync(join(target, 'src', 'main.ts'), 'export const main = () => 1;\n');
59
+ console.log(`fixture ${name}: materialized (no .git)`);
60
+ process.exit(0);
61
+ }
62
+
63
+ const writeTree = (tree: Record<string, string>) => {
64
+ for (const [path, content] of Object.entries(tree)) {
65
+ const full = join(target, path);
66
+ mkdirSync(join(full, '..'), { recursive: true });
67
+ writeFileSync(full, content);
68
+ }
69
+ };
70
+
71
+ // names feed unquoted git branch args — validate before interpolation (F3).
72
+ // Fixture files are repo-owned, but `name` is a CLI arg and `trunk` comes from
73
+ // fixture JSON; both must be safe git ref names.
74
+ const SAFE_REF = /^[A-Za-z0-9._/-]+$/;
75
+ if (!SAFE_REF.test(name) || !SAFE_REF.test(fx.baseBranch || '')) {
76
+ console.error(`fixture ${name}: unsafe branch name rejected`);
77
+ process.exit(1);
78
+ }
79
+
80
+ execSync('git init -q', { cwd: target });
81
+ execSync('git config user.email test@test.com && git config user.name Test', { cwd: target });
82
+
83
+ const trunk = fx.baseBranch || 'main';
84
+ // branch named after trunk if trunk isn't main (so savepoint base-resolve paths differ)
85
+ execSync(`git checkout -q -b ${trunk}`, { cwd: target });
86
+
87
+ // base commit on trunk
88
+ writeTree(fx.base);
89
+ for (const rel of fx.binary ?? []) {
90
+ if (fx.base[rel]) writeFileSync(join(target, rel), fx.base[rel]);
91
+ }
92
+ execSync('git add -A && git commit -q -m base', { cwd: target });
93
+
94
+ // feature branch applies the branch tree (files removed from base not in
95
+ // branch get deleted; contents updated; new files added)
96
+ execSync(`git checkout -q -b feat-${fx.name}`, { cwd: target });
97
+ for (const rel of Object.keys(fx.base)) {
98
+ if (!(rel in fx.branch)) {
99
+ execSync(`git rm -q --ignore-unmatch "${rel}"`, { cwd: target });
100
+ }
101
+ }
102
+ writeTree(fx.branch);
103
+ for (const rel of fx.binary ?? []) {
104
+ if (fx.branch[rel]) writeFileSync(join(target, rel), fx.branch[rel]);
105
+ }
106
+ execSync('git add -A && git commit -q -m wip', { cwd: target });
107
+
108
+ console.log(`fixture ${name}: materialized at ${target} (trunk=${trunk}, branch=feat-${fx.name})`);
@@ -319,5 +319,66 @@ const agentsDoc = join(docsDir, 'concepts', 'agents.md');
319
319
  if (docErrors === 0) console.log('✓ docs in sync with content/');
320
320
  }
321
321
 
322
+ // --- doc-integrity check: documented thresholds must match source constants ---
323
+ // Docs are claims; a claim that drifts from the source is a lie. LANE_BASE +
324
+ // BUDGET live in scripts/lib/lane-base.sh (validated by lane-base.ts); the
325
+ // cost docs must cite the same numbers. Evidence logs carry an exit code /
326
+ // verdict claim — a test must back it (lane-integrity cases 22-24).
327
+ const integrityArg = process.argv.indexOf('--check-doc-integrity');
328
+ if (integrityArg !== -1) {
329
+ const src = join(import.meta.dirname, '..', 'scripts', 'lib', 'lane-base.sh');
330
+ if (!existsSync(src)) {
331
+ errors.push('doc-integrity: scripts/lib/lane-base.sh not found — cannot verify thresholds');
332
+ } else {
333
+ const constants = readFileSync(src, 'utf8');
334
+ const docs = ['docs/concepts/cost.md', 'docs/concepts/lanes.md', 'README.md'];
335
+ const expected: [string, string][] = [
336
+ ['lean', '7,000'], ['standard', '13,000'], ['full', '23,000'],
337
+ ['lean', '12,000'], ['standard', '25,000'], ['full', '50,000'],
338
+ ];
339
+ for (const doc of docs) {
340
+ const p = join(import.meta.dirname, '..', doc);
341
+ if (!existsSync(p)) { errors.push(`doc-integrity: ${doc} not found`); continue; }
342
+ const text = readFileSync(p, 'utf8');
343
+ for (const [lane, num] of expected) {
344
+ // each doc must cite the budget/LANE_BASE number for that lane; accept
345
+ // both the comma form (7,000) and the k-shorthand (7k / ~7k)
346
+ const compact = num.replace(',', '').replace(/0+$/, '');
347
+ const kForm = /^(\d+)000$/.exec(num);
348
+ const variants = [num, compact, ...(kForm ? [`${kForm[1]}k`, `~${kForm[1]}k`] : [])];
349
+ if (!variants.some(v => text.includes(v))) {
350
+ errors.push(`doc-integrity: ${doc} missing ${lane} threshold ${num} (source: ${src})`);
351
+ }
352
+ }
353
+ // warn/stop are 1.5x / 3x of BUDGET (code: WARN_AT=BUDGET*3/2,
354
+ // STOP_AT=BUDGET*3) — presence is not enough, the arithmetic must be
355
+ // right. A doc claiming 1.5x/3x of LANE_BASE is the exact lie this gate
356
+ // exists to catch. Checked on the authoritative cost doc only.
357
+ if (doc === 'docs/concepts/cost.md') {
358
+ const budgets: [string, number][] = [['lean', 12000], ['standard', 25000], ['full', 50000]];
359
+ for (const [lane, budget] of budgets) {
360
+ const warn = budget * 3 / 2;
361
+ const stop = budget * 3;
362
+ const warnForms = [`${warn}`, `${Math.round(warn / 1000)}k`, `${warn.toLocaleString()}`, `${warn / 1000}k`];
363
+ const stopForms = [`${stop}`, `${stop / 1000}k`, `${stop.toLocaleString()}`];
364
+ const hasWarn = warnForms.some(f => text.includes(f));
365
+ const hasStop = stopForms.some(f => text.includes(f));
366
+ if (!hasWarn || !hasStop) {
367
+ errors.push(`doc-integrity: ${doc} ${lane} warn/stop must be 1.5x/3x of budget ${budget} (warn ${warn}, stop ${stop})`);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ if (!constants.includes('LANE_BASE_lean=7000')) errors.push('doc-integrity: source lane-base.sh lean base drifted (expected 7000)');
373
+ if (!constants.includes('BUDGET_full=50000')) errors.push('doc-integrity: source lane-base.sh full budget drifted (expected 50000)');
374
+ }
375
+ // evidence exit-code claim must be test-backed
376
+ const evTest = join(import.meta.dirname, '..', 'test', 'lane-integrity.test.ts');
377
+ const testText = existsSync(evTest) ? readFileSync(evTest, 'utf8') : '';
378
+ if (!testText.includes('# Exit:') || !testText.includes('# Verdict:')) {
379
+ errors.push('doc-integrity: audit-trail claims evidence logs carry exit code + verdict, but no test asserts them');
380
+ }
381
+ }
382
+
322
383
  if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
323
384
  console.log(`✓ content valid: ${skillDirs.length} skills, ${agentFiles.length} agents`);
package/src/cli.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs, type FlagValue, type Args } from './args.ts';
8
8
  import { createRl, choose, multiChoose, confirm } from './prompt.ts';
9
9
  import { targets, TARGET_IDS } from './targets/index.ts';
10
- import { installTo, removeInstalled, VERSION, ensureProjectGitignore } from './installer.ts';
10
+ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProjectGitignore } from './installer.ts';
11
11
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
12
12
  import { resetMission, archiveMission } from './mission.ts';
13
13
 
@@ -151,6 +151,10 @@ async function uninstall(flags: Args['flags']): Promise<void> {
151
151
  }
152
152
  const removed = removeInstalled(manifest, { dryRun: flag(flags.dryRun) });
153
153
  if (!flag(flags.dryRun)) {
154
+ if (scope === 'project') {
155
+ const gi = removeProjectGitignore(projectDir);
156
+ for (const n of gi.notes) console.log(` note: ${n}`);
157
+ }
154
158
  rmSync(file);
155
159
  const mugiDir = join(scope === 'global' ? home : projectDir, '.mugiwara');
156
160
  if (existsSync(mugiDir) && readdirSync(mugiDir).length === 0) {
package/src/installer.ts CHANGED
@@ -182,28 +182,90 @@ function assertNotSymlink(file: string): void {
182
182
  }
183
183
  }
184
184
 
185
+ const GITIGNORE_BLOCK_START = '# >>> mugiwara >>>';
186
+ const GITIGNORE_BLOCK_END = '# <<< mugiwara <<<';
187
+ // legacy marker from v0.6.2 — block was undelimited, single '# mugiwara'
188
+ // header. Kept for detection so an old install is not double-appended.
185
189
  const GITIGNORE_MARKER = '# mugiwara';
186
- const GITIGNORE_BLOCK = `# mugiwara — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
190
+ const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
187
191
  # Ignore session state and regenerated files.
188
- .mugiwara/state.json
189
- .mugiwara/state-*.json
192
+ .mugiwara/state/
193
+ .mugiwara/continue/
190
194
  .mugiwara/config
191
- .mugiwara/continue.md
192
195
  .mugiwara/refs/
196
+ # <<< mugiwara <<<
193
197
  `;
194
198
 
195
199
  export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: boolean } = {}): { appended: boolean; notes: string[] } {
196
200
  const { dryRun = false } = opts;
197
201
  const path = join(projectDir, '.gitignore');
198
202
  assertNotSymlink(path);
199
- if (existsSync(path) && readFileSync(path, 'utf8').includes(GITIGNORE_MARKER)) {
200
- return { appended: false, notes: [] };
203
+ let existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
204
+ if (existing) {
205
+ // Delimited block present with the current entries → nothing to do.
206
+ if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/state/')) {
207
+ return { appended: false, notes: [] };
208
+ }
209
+ // Legacy (v0.6.2) or outdated delimited block → upgrade in place:
210
+ // strip the old mugiwara entries (both forms), then append the new block.
211
+ // Without this, upgraded projects keep committing per-wave state/continue
212
+ // JSON to git — session state must stay ignored (Robin MAJOR, Jinbe Low).
213
+ const hadOld = existing.includes(GITIGNORE_MARKER) || existing.includes(GITIGNORE_BLOCK_START);
214
+ if (hadOld) {
215
+ const clean = removeProjectGitignore(projectDir, { dryRun });
216
+ existing = clean.removed ? (existsSync(path) ? readFileSync(path, 'utf8') : '') : existing;
217
+ }
201
218
  }
202
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
203
219
  const separator = existing.length && !existing.endsWith('\n') ? '\n' : '';
204
220
  if (!dryRun) {
205
221
  mkdirSync(dirname(path), { recursive: true });
206
222
  writeFileSync(path, existing + separator + GITIGNORE_BLOCK);
207
223
  }
208
- return { appended: true, notes: [`.gitignore ${dryRun ? 'would append' : 'appended'} mugiwara audit-trail block`] };
224
+ return { appended: true, notes: [`.gitignore ${dryRun ? 'would upgrade+append' : 'upgraded'} mugiwara audit-trail block`] };
225
+ }
226
+
227
+ // remove the delimited mugiwara block, preserving user lines. Handles both
228
+ // the current delimited block and the legacy undelimited block (v0.6.2).
229
+ export function removeProjectGitignore(projectDir: string, { dryRun = false }: { dryRun?: boolean } = {}): { removed: boolean; notes: string[] } {
230
+ const path = join(projectDir, '.gitignore');
231
+ assertNotSymlink(path);
232
+ if (!existsSync(path)) return { removed: false, notes: [] };
233
+ const current = readFileSync(path, 'utf8');
234
+
235
+ const delimited = current.includes(GITIGNORE_BLOCK_START);
236
+ let cleaned: string;
237
+ if (delimited) {
238
+ // strip everything between (and including) the delimiters. Both
239
+ // delimiters must be present — a missing END (hand-edit) with end=-1
240
+ // would slice() from char 18 and truncate the user's file.
241
+ const start = current.indexOf(GITIGNORE_BLOCK_START);
242
+ const end = current.indexOf(GITIGNORE_BLOCK_END);
243
+ if (end < start) return { removed: false, notes: ['delimiter mismatch — .gitignore left untouched'] };
244
+ cleaned = current.slice(0, start) + current.slice(end + GITIGNORE_BLOCK_END.length);
245
+ } else if (current.includes(GITIGNORE_MARKER)) {
246
+ // legacy (v0.6.2): strip the exact undelimited block — the header, the
247
+ // explanatory comment, and the five known lines. Prefix-matching would
248
+ // delete user-owned lines that merely start with a mugiwara path.
249
+ const LEGACY_LINES = new Set([
250
+ '.mugiwara/state.json',
251
+ '.mugiwara/state-*.json',
252
+ '.mugiwara/config',
253
+ '.mugiwara/continue.md',
254
+ '.mugiwara/refs/',
255
+ ]);
256
+ const lines = current.split('\n').filter(l => {
257
+ const t = l.trim();
258
+ if (t.startsWith(GITIGNORE_MARKER)) return false; // header + comment
259
+ return !LEGACY_LINES.has(t);
260
+ });
261
+ cleaned = lines.join('\n');
262
+ } else {
263
+ return { removed: false, notes: [] };
264
+ }
265
+
266
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '\n');
267
+ if (cleaned.trim() === '') cleaned = '';
268
+ if (cleaned === current) return { removed: false, notes: [] };
269
+ if (!dryRun) writeFileSync(path, cleaned);
270
+ return { removed: true, notes: [`.gitignore ${dryRun ? 'would strip' : 'stripped'} mugiwara block`] };
209
271
  }