@ionivetech/mugiwara 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/mugiwara-helpers.mjs +24 -0
  7. package/.opencode/plugins/mugiwara.mjs +10 -2
  8. package/AGENTS.md +1 -1
  9. package/README.md +115 -62
  10. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -0
  11. package/content/skills/mugiwara-execution/SKILL.md +41 -41
  12. package/content/skills/mugiwara-execution/references/dispatch.md +41 -0
  13. package/content/skills/mugiwara-orchestration/SKILL.md +28 -24
  14. package/content/skills/mugiwara-orchestration/references/closure.md +34 -0
  15. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +12 -11
  16. package/content/skills/mugiwara-planning/SKILL.md +6 -0
  17. package/content/skills/mugiwara-pr/SKILL.md +7 -0
  18. package/content/skills/mugiwara-quality/SKILL.md +7 -0
  19. package/content/skills/mugiwara-ship/SKILL.md +10 -8
  20. package/content/skills/mugiwara-testcases/SKILL.md +7 -0
  21. package/content/skills/mugiwara-workflow/SKILL.md +2 -2
  22. package/content/skills/using-mugiwara/SKILL.md +7 -1
  23. package/dist/mugiwara.js +190 -10
  24. package/gemini-extension.json +1 -1
  25. package/hooks/mugiwara-mode-tracker.ts +0 -0
  26. package/hooks/session-start.ts +0 -0
  27. package/package.json +1 -1
  28. package/plugin.json +1 -1
  29. package/scripts/evidence.sh +16 -1
  30. package/scripts/gate-selftest.ts +52 -1
  31. package/scripts/initiative.ts +34 -20
  32. package/scripts/lane.sh +4 -2
  33. package/scripts/mission-report.sh +152 -29
  34. package/scripts/savepoint.sh +57 -13
  35. package/scripts/validate-content.ts +20 -0
  36. package/src/cli.ts +20 -3
  37. package/src/installer.ts +37 -1
  38. package/src/mission.ts +111 -1
  39. package/src/targets/claude.ts +27 -8
@@ -54,7 +54,7 @@ if (!existsSync(join(root, 'scripts', 'validate-content.ts'))) {
54
54
  const makeBody = (sectionLines: number) => {
55
55
  let body = '---\nname: test-skill\ndescription: Test skill for section-length gate, twenty chars\n---\n\n# Test\n\n## Skip when\n\n- Test\n\n## Long Section\n\n';
56
56
  for (let i = 0; i < sectionLines; i++) body += `Line ${i + 1} of content for section length test.\n`;
57
- body += '\n';
57
+ body += '\n## Red flags\n\n- Test flag\n';
58
58
  return body;
59
59
  };
60
60
 
@@ -262,5 +262,56 @@ if (!existsSync(brainstormSkill)) {
262
262
  }
263
263
  }
264
264
 
265
+ // --- F2: red-flags gate — strip the heading from one skill, prove red ---
266
+ console.log('\nF2 — red-flags gate');
267
+ const redSkill = join(root, 'content', 'skills', 'mugiwara-workflow', 'SKILL.md');
268
+ if (!existsSync(redSkill)) {
269
+ console.log(' ⚠ mugiwara-workflow/SKILL.md not found, skipping');
270
+ } else {
271
+ const original = readFileSync(redSkill, 'utf8');
272
+ try {
273
+ const broken = original.replace('## Red flags', '## Disabled');
274
+ writeFileSync(redSkill, broken);
275
+ assert('missing Red flags → exit 1', false, () => run('F2-redflags', 'bun scripts/validate-content.ts'));
276
+ } finally {
277
+ writeFileSync(redSkill, original);
278
+ assert('restored → exit 0', true, () => run('F2-redflags', 'bun scripts/validate-content.ts --check-manifest --check-docs'));
279
+ }
280
+ }
281
+
282
+ // --- W1: write-boundary gate — strip the section, prove red ---
283
+ console.log('\nW1 — write-boundary gate');
284
+ const wbSkill = join(root, 'content', 'skills', 'mugiwara-orchestration', 'SKILL.md');
285
+ if (!existsSync(wbSkill)) {
286
+ console.log(' ⚠ mugiwara-orchestration/SKILL.md not found, skipping');
287
+ } else {
288
+ const original = readFileSync(wbSkill, 'utf8');
289
+ try {
290
+ const broken = original.replace('## Write boundary', '## Boundary');
291
+ writeFileSync(wbSkill, broken);
292
+ assert('missing write boundary → exit 1', false, () => run('W1-wb', 'bun scripts/validate-content.ts'));
293
+ } finally {
294
+ writeFileSync(wbSkill, original);
295
+ assert('restored → exit 0', true, () => run('W1-wb', 'bun scripts/validate-content.ts --check-manifest --check-docs'));
296
+ }
297
+ }
298
+
299
+ // --- F14: agent-count gate — flip an internal agent to user-facing, prove red ---
300
+ console.log('\nF14 — agent-count gate');
301
+ const countAgent = join(root, 'content', 'agents', 'eval-runner.md');
302
+ if (!existsSync(countAgent)) {
303
+ console.log(' ⚠ eval-runner.md not found, skipping');
304
+ } else {
305
+ const original = readFileSync(countAgent, 'utf8');
306
+ try {
307
+ const broken = original.replace('internal: true', 'internal: false');
308
+ writeFileSync(countAgent, broken);
309
+ assert('count drift → exit 1', false, () => run('F14-count', 'bun scripts/validate-content.ts'));
310
+ } finally {
311
+ writeFileSync(countAgent, original);
312
+ assert('restored → exit 0', true, () => run('F14-count', 'bun scripts/validate-content.ts --check-manifest --check-docs'));
313
+ }
314
+ }
315
+
265
316
  console.log(`\n${passed} passed, ${failed} failed`);
266
317
  process.exit(failed > 0 ? 1 : 0);
@@ -46,6 +46,15 @@ function resolvePlan(planFile: string): string {
46
46
  throw new Error(err);
47
47
  }
48
48
 
49
+ // A plan is a markdown document. Garbage (no markdown structure) is not a
50
+ // "solo mission" — it is a malformed plan. Heading check keeps the
51
+ // no-sub-missions-but-valid-markdown case working as solo mission.
52
+ function assertPlanContent(content: string, planFile: string): void {
53
+ if (!/^#{1,6}\s/m.test(content)) {
54
+ throw new Error(`Not a valid plan file: "${planFile}" — expected markdown (no heading found)`);
55
+ }
56
+ }
57
+
49
58
  function parseSubMissions(content: string): SubMission[] {
50
59
  const lines = content.split('\n');
51
60
  let inTable = false;
@@ -93,6 +102,7 @@ function parseSubMissions(content: string): SubMission[] {
93
102
 
94
103
  function cmdStatus(planFile: string): void {
95
104
  const content = readFileSync(planFile, 'utf8');
105
+ assertPlanContent(content, planFile);
96
106
  const subs = parseSubMissions(content);
97
107
 
98
108
  if (subs.length === 0) {
@@ -122,6 +132,7 @@ function cmdStatus(planFile: string): void {
122
132
 
123
133
  function cmdConflictCheck(planFile: string): void {
124
134
  const content = readFileSync(planFile, 'utf8');
135
+ assertPlanContent(content, planFile);
125
136
  const subs = parseSubMissions(content);
126
137
 
127
138
  if (subs.length === 0) {
@@ -161,15 +172,13 @@ function cmdConflictCheck(planFile: string): void {
161
172
  }
162
173
 
163
174
  function cmdSetStatus(planFile: string, id: string, status: string): void {
164
- if (!STATUS_MARKERS[id]) {
165
- // `status` here is the status string from args — check it
166
- }
167
175
  if (!STATUS_MARKERS[status]) {
168
176
  console.error(`Invalid status: "${status}". Use: pending, in-progress, done, blocked`);
169
177
  process.exit(1);
170
178
  }
171
179
 
172
180
  const content = readFileSync(planFile, 'utf8');
181
+ assertPlanContent(content, planFile);
173
182
  const lines = content.split('\n');
174
183
  const newMarker = STATUS_MARKERS[status];
175
184
  let inTable = false;
@@ -225,24 +234,29 @@ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
225
234
 
226
235
  const cmd = args[0];
227
236
 
228
- if (cmd === 'status') {
229
- if (args.length < 2) { console.error('Missing plan-file argument.'); process.exit(1); }
230
- cmdStatus(resolvePlan(args[1]));
231
- } else if (cmd === 'conflict-check') {
232
- if (args.length < 2) { console.error('Missing plan-file argument.'); process.exit(1); }
233
- cmdConflictCheck(resolvePlan(args[1]));
234
- } else if (cmd === 'set-status') {
235
- const idIdx = args.indexOf('--id');
236
- const statusIdx = args.indexOf('--status');
237
- if (idIdx === -1 || statusIdx === -1 || args.length < 6) {
238
- console.error('set-status requires --id <id> --status <status>');
237
+ try {
238
+ if (cmd === 'status') {
239
+ if (args.length < 2) { console.error('Missing plan-file argument.'); process.exit(1); }
240
+ cmdStatus(resolvePlan(args[1]));
241
+ } else if (cmd === 'conflict-check') {
242
+ if (args.length < 2) { console.error('Missing plan-file argument.'); process.exit(1); }
243
+ cmdConflictCheck(resolvePlan(args[1]));
244
+ } else if (cmd === 'set-status') {
245
+ const idIdx = args.indexOf('--id');
246
+ const statusIdx = args.indexOf('--status');
247
+ if (idIdx === -1 || statusIdx === -1 || args.length < 6) {
248
+ console.error('set-status requires --id <id> --status <status>');
249
+ process.exit(1);
250
+ }
251
+ const id = args[idIdx + 1];
252
+ const statusVal = args[statusIdx + 1];
253
+ cmdSetStatus(resolvePlan(args[1]), id, statusVal);
254
+ } else {
255
+ console.error(`Unknown command: "${cmd}"`);
256
+ console.log(USAGE);
239
257
  process.exit(1);
240
258
  }
241
- const id = args[idIdx + 1];
242
- const statusVal = args[statusIdx + 1];
243
- cmdSetStatus(resolvePlan(args[1]), id, statusVal);
244
- } else {
245
- console.error(`Unknown command: "${cmd}"`);
246
- console.log(USAGE);
259
+ } catch (err) {
260
+ console.error(err instanceof Error ? err.message : String(err));
247
261
  process.exit(1);
248
262
  }
package/scripts/lane.sh CHANGED
@@ -51,7 +51,9 @@ else
51
51
  REASON="$FILE_COUNT files"
52
52
  fi
53
53
 
54
- if [ "$HAS_SENSITIVE" -eq 1 ] && [ "$LANE" != "full" ]; then
54
+ # Sensitive-path escalation is unconditional it wins over any count-based
55
+ # lane AND over the docs-only downgrade below (bug C9).
56
+ if [ "$HAS_SENSITIVE" -eq 1 ]; then
55
57
  PREV="$LANE"
56
58
  LANE="full"
57
59
  REASON="sensitive paths ($SENSITIVE) — escalated from $PREV"
@@ -63,7 +65,7 @@ fi
63
65
  # Product surface for mugiwara: content/, src/, scripts/, test/, hooks/,
64
66
  # .opencode/, .claude/, evals/ — everything else is docs/config/asset.
65
67
  PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
66
- if [ "$LANE" = "full" ] && [ -n "$CHANGED" ]; then
68
+ if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$CHANGED" ]; then
67
69
  CODE_COUNT=$(echo "$CHANGED" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
68
70
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
69
71
  PREV="$LANE"
@@ -2,6 +2,9 @@
2
2
  # scripts/mission-report.sh — generate aggregate mission report from state.json
3
3
  # + per-mission wave artifacts. Usage: mission-report.sh <mission>
4
4
  # Output: .mugiwara/reports/YYYY-MM-DD-<mission>.md — one-file summary of all waves.
5
+ # Enriched: mission header, token budget (WARN/STOP flag), tasks (state or plan
6
+ # doc fallback), gate/quality excerpt from 06-closure.md, evidence file list.
7
+ # Every section degrades to n/a when state.json or results/ artifacts are absent.
5
8
  set -u
6
9
 
7
10
  MISSION="${1:-}"
@@ -17,8 +20,6 @@ RESULTS_DIR="$MUGIWARA_DIR/results/$MISSION"
17
20
  REVIEW_DIR="$MUGIWARA_DIR/review"
18
21
  ISSUES_DIR="$MUGIWARA_DIR/issues"
19
22
 
20
- [ -f "$STATE_FILE" ] || { echo "mission-report: $STATE_FILE not found" >&2; exit 1; }
21
-
22
23
  export STATE_FILE REPORT_DIR RESULTS_DIR REVIEW_DIR ISSUES_DIR MISSION
23
24
  node << 'NODE'
24
25
  const fs = require('fs');
@@ -29,33 +30,49 @@ const reportDir = process.env.REPORT_DIR || ".mugiwara/reports";
29
30
  const resultsDir = process.env.RESULTS_DIR || ".mugiwara/results/unknown";
30
31
  const reviewDir = process.env.REVIEW_DIR || ".mugiwara/review";
31
32
  const issuesDir = process.env.ISSUES_DIR || ".mugiwara/issues";
33
+ const mugiDir = path.dirname(stateFile);
32
34
  const mission = process.env.MISSION || "unknown";
33
35
 
34
- if (!fs.existsSync(stateFile)) {
35
- console.error("mission-report: " + stateFile + " not found");
36
- process.exit(1);
36
+ fs.mkdirSync(reportDir, { recursive: true });
37
+
38
+ // state.json is optional — degrade gracefully, never hard-exit
39
+ let s = null;
40
+ if (fs.existsSync(stateFile)) {
41
+ try {
42
+ s = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
43
+ } catch (e) {
44
+ console.error("mission-report: warning: " + stateFile + " unreadable — degraded report");
45
+ }
46
+ } else {
47
+ console.error("mission-report: warning: " + stateFile + " not found — degraded report");
37
48
  }
38
49
 
39
- fs.mkdirSync(reportDir, { recursive: true });
40
- const s = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
50
+ const val = (obj, key, dflt) => {
51
+ if (!s || !obj) return dflt;
52
+ const v = obj[key];
53
+ return (v === undefined || v === null || v === "") ? dflt : v;
54
+ };
41
55
 
42
56
  const now = new Date().toISOString().slice(0, 10);
43
57
  const reportFile = path.join(reportDir, now + "-" + mission + ".md");
44
- const lane = s.lane || "unknown";
45
- const mode = s.mode || "guided";
46
- const actor = s.actor || "unknown";
47
- const branch = s.branch || "unknown";
48
- const wave = s.wave || 0;
49
- const filesTouched = s.files_touched || 0;
50
- const locDelta = s.loc_delta || 0;
51
- const sensitive = s.sensitive_paths || [];
52
- const tasks = s.tasks || {};
53
- const blockers = s.blockers_open || 0;
54
- const healCycle = s.heal_cycle || 1;
55
- const tokens = s.tokens_est || 0;
56
- const budget = s.budget || 0;
57
- const evidence = s.evidence || [];
58
- const updated = s.updated_at || new Date().toISOString();
58
+ const lane = val(s, 'lane', 'n/a');
59
+ const laneReason = val(s, 'lane_reason', 'n/a');
60
+ const mode = val(s, 'mode', 'n/a');
61
+ const actor = val(s, 'actor', 'n/a');
62
+ const branch = val(s, 'branch', 'n/a');
63
+ const wave = val(s, 'wave', 'n/a');
64
+ const filesTouched = val(s, 'files_touched', 0);
65
+ const locDelta = val(s, 'loc_delta', 0);
66
+ const sensitive = s ? (s.sensitive_paths || []) : [];
67
+ const tasks = s ? (s.tasks || {}) : {};
68
+ const blockers = val(s, 'blockers_open', 0);
69
+ const healCycle = val(s, 'heal_cycle', 1);
70
+ const tokens = Number(val(s, 'tokens_est', 0)) || 0;
71
+ const tokensSource = val(s, 'tokens_source', 'n/a');
72
+ const budget = Number(val(s, 'budget', 0)) || 0;
73
+ const budgetStatus = val(s, 'budget_status', 'n/a');
74
+ const evidence = s ? (s.evidence || []) : [];
75
+ const updated = val(s, 'updated_at', now);
59
76
 
60
77
  // --- wave artifact scan: results/<mission>/NN-*.md with verdict sniffing ---
61
78
  const WAVE_LABEL = {
@@ -117,26 +134,133 @@ if (fs.existsSync(issuesDir)) {
117
134
  }
118
135
  }
119
136
 
137
+ // --- tasks: state.json counts, plan-doc fallback (savepoint-style counting) ---
138
+ function findPlan(mDir, m) {
139
+ const plansDir = path.join(mDir, 'plans');
140
+ if (!fs.existsSync(plansDir)) return null;
141
+ const direct = path.join(plansDir, m + '.md');
142
+ if (fs.existsSync(direct)) return direct;
143
+ const matches = fs.readdirSync(plansDir).filter(f => f.endsWith('-' + m + '.md'));
144
+ return matches.length ? path.join(plansDir, matches.sort()[0]) : null;
145
+ }
146
+
147
+ let tasksDone = tasks.done || 0;
148
+ let tasksTotal = tasks.total || 0;
149
+ let tasksSource = "state.json";
150
+ if (tasksTotal === 0) {
151
+ const plan = findPlan(mugiDir, mission);
152
+ if (plan) {
153
+ const lines = fs.readFileSync(plan, 'utf8').split(/\r?\n/);
154
+ const done = lines.filter(l => l.includes('[x]')).length;
155
+ const total = done + lines.filter(l => l.includes('[ ]')).length;
156
+ if (total > 0) {
157
+ tasksDone = done;
158
+ tasksTotal = total;
159
+ tasksSource = "plan doc";
160
+ }
161
+ }
162
+ }
163
+ if (tasksDone === 0 && tasksTotal === 0) { tasksDone = 'n/a'; tasksTotal = 'n/a'; }
164
+
165
+ // --- token budget flag: WARN/STOP line when status is not ok ---
166
+ let budgetFlag = "";
167
+ if (budgetStatus === "warn" || budgetStatus === "stop") {
168
+ const warnAt = Math.floor(budget * 3 / 2);
169
+ const stopAt = budget * 3;
170
+ if (budgetStatus === "stop") {
171
+ budgetFlag = "🛑 STOP: tokens " + tokens.toLocaleString() + " ≥ 3× budget " + budget.toLocaleString() + " (stop at " + stopAt.toLocaleString() + ") — halt; escalate to Luffy";
172
+ } else {
173
+ budgetFlag = "⚠ WARN: tokens " + tokens.toLocaleString() + " ≥ 1.5× budget " + budget.toLocaleString() + " (warn at " + warnAt.toLocaleString() + ") — checkpoint before continuing";
174
+ }
175
+ }
176
+
177
+ // --- gate/quality excerpt: headings + body from 06-closure.md, as-is ---
178
+ function sectionExcerpt(file, heading) {
179
+ if (!fs.existsSync(file)) return null;
180
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
181
+ let collecting = false;
182
+ let content = 0;
183
+ const out = [];
184
+ for (const l of lines) {
185
+ if (/^##\s/.test(l)) {
186
+ if (collecting) break;
187
+ if (l.trim() === heading) { collecting = true; out.push(l); continue; }
188
+ } else if (collecting) {
189
+ if (l.trim()) {
190
+ out.push(l);
191
+ content++;
192
+ if (content >= 20) break; // cap excerpt
193
+ }
194
+ }
195
+ }
196
+ return collecting ? out : null;
197
+ }
198
+
199
+ const closureFile = path.join(resultsDir, "06-closure.md");
200
+ const gateSec = sectionExcerpt(closureFile, "## Gate verdicts");
201
+ const testsSec = sectionExcerpt(closureFile, "## Tests");
202
+
203
+ // --- evidence file list: all .md artifacts in results/<mission>/ ---
204
+ let evidenceFiles = [];
205
+ if (fs.existsSync(resultsDir)) {
206
+ evidenceFiles = fs.readdirSync(resultsDir).filter(f => f.endsWith('.md')).sort();
207
+ }
208
+
209
+ // --- assemble report ---
120
210
  let report = "# Mission: " + mission + " . " + now + "\n\n";
121
- report += "**Lane** " + lane + " . **Mode** " + mode + " . **Actor** " + actor + " . **Branch** " + branch + "\n\n";
211
+
212
+ report += "## Mission header\n\n| Field | Value |\n|-------|-------|\n";
213
+ report += "| Mission | " + mission + " |\n";
214
+ report += "| Branch | " + branch + " |\n";
215
+ report += "| Lane | " + lane + " |\n";
216
+ report += "| Lane reason | " + laneReason + " |\n";
217
+ report += "| Mode | " + mode + " |\n";
218
+ report += "| Wave | " + wave + " |\n";
219
+ report += "| Actor | " + actor + " |\n\n";
220
+
221
+ report += "## Token budget\n\n| Field | Value |\n|-------|-------|\n";
222
+ report += "| Tokens (est) | " + tokens.toLocaleString() + " |\n";
223
+ report += "| Tokens source | " + tokensSource + " |\n";
224
+ report += "| Budget | " + budget.toLocaleString() + " |\n";
225
+ report += "| Budget status | " + budgetStatus + " |\n";
226
+ if (budgetFlag) report += "\n" + budgetFlag;
227
+ report += "\n\n";
228
+
229
+ report += "## Tasks\n\n| Field | Value |\n|-------|-------|\n";
230
+ report += "| Done | " + tasksDone + " |\n";
231
+ report += "| Total | " + tasksTotal + " |\n";
232
+ report += "| Source | " + tasksSource + " |\n\n";
122
233
 
123
234
  report += "## What changed\n\n";
124
235
  report += filesTouched + " files, +" + locDelta + " LOC";
125
236
  if (sensitive.length) report += "\nSensitive paths: " + sensitive.join(", ");
237
+ report += "\n\n";
126
238
 
127
- report += "\n\n## Waves\n\n| Wave | Artifact | Verdict |\n|------|----------|---------|";
239
+ report += "## Waves\n\n| Wave | Artifact | Verdict |\n|------|----------|---------|";
128
240
  if (waveRows.length) {
129
241
  for (const r of waveRows) report += "\n| " + r.label + " | `" + r.file + "` | " + r.verdict + " |";
130
242
  } else {
131
243
  report += "\n| (no wave artifacts found in `" + resultsDir + "`) | | |";
132
244
  }
245
+ report += "\n\n";
246
+
247
+ report += "## Gate & quality\n\n";
248
+ if (gateSec) report += gateSec.join("\n") + "\n";
249
+ if (testsSec) report += (gateSec ? "\n" : "") + testsSec.join("\n") + "\n";
250
+ if (!gateSec && !testsSec) report += "n/a\n";
251
+ report += "\n";
133
252
 
134
- report += "\n\n## Review & blockers\n\n";
253
+ report += "## Evidence files\n\n";
254
+ report += evidenceFiles.length ? evidenceFiles.map(f => "- " + f).join("\n") : "n/a";
255
+ report += "\n\n";
256
+
257
+ report += "## Review & blockers\n\n";
135
258
  report += "Review + security files: " + (reviewFiles.length ? reviewFiles.join(", ") : "none") + "\n";
136
259
  report += "Findings: " + reviewFindings + "\n";
137
260
  report += "Blocker ledger rows: " + issueRows.length + (issueRows.length ? "\n" + issueRows.map(r => "- " + r).join("\n") : "");
261
+ report += "\n\n";
138
262
 
139
- report += "\n\n## State\n\n| Field | Value |\n|-------|-------|\n";
263
+ report += "## State\n\n| Field | Value |\n|-------|-------|\n";
140
264
  report += "| Wave | " + wave + " |\n";
141
265
  report += "| Tasks | " + (tasks.done || 0) + "/" + (tasks.total || 0) + " done |\n";
142
266
  report += "| Blockers open | " + blockers + " |\n";
@@ -144,9 +268,8 @@ report += "| Heal cycles | " + healCycle + " |\n";
144
268
  report += "| Tokens used | " + tokens.toLocaleString() + " / " + budget.toLocaleString() + " |\n\n";
145
269
 
146
270
  report += "## Evidence\n\n";
147
- for (const e of evidence) report += "- " + e + "\n";
148
-
149
- report += "\n## Updated\n\n" + updated + "\n";
271
+ report += evidence.length ? evidence.map(e => "- " + e).join("\n") : "n/a";
272
+ report += "\n\n## Updated\n\n" + updated + "\n";
150
273
 
151
274
  fs.writeFileSync(reportFile, report);
152
275
  console.log("\u2713 mission report: " + reportFile);
@@ -31,6 +31,11 @@ fi
31
31
  case "$MISSION" in
32
32
  ""|*[!a-zA-Z0-9._-]*) die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-])" ;;
33
33
  esac
34
+ # dot-only names (".", "..", "...") pass the char allowlist but resolve upward
35
+ # through join(...,"..") — reject them before any path is built from MISSION.
36
+ if [[ "$MISSION" =~ ^\.+$ ]]; then
37
+ die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
38
+ fi
34
39
 
35
40
  # per-branch state file when --branch used
36
41
  BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
@@ -59,20 +64,35 @@ if [ "$BASE_SHA" != "unknown" ]; then
59
64
  fi
60
65
  [ -z "$LOC_DELTA" ] && LOC_DELTA=0
61
66
 
62
- SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env|config/|migration/|\.sql$|schema\.|\.prisma$"
67
+ SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env$|config/.*key|migration/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$"
63
68
  SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATTERNS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
64
69
 
65
70
  LANE="direct"
66
71
  LANE_REASON=""
67
- if [ "$FILES_TOUCHED" -ge 9 ] 2>/dev/null || [ -n "$SENSITIVE_PATHS" ]; then
72
+ # Sensitive-path escalation is unconditional it wins over any count-based
73
+ # lane AND over the docs-only downgrade below (bug C9).
74
+ if [ -n "$SENSITIVE_PATHS" ]; then
75
+ LANE="full"
76
+ LANE_REASON="sensitive paths: $SENSITIVE_PATHS"
77
+ elif [ "$FILES_TOUCHED" -ge 9 ] 2>/dev/null; then
68
78
  LANE="full"
69
- LANE_REASON="$( [ -n "$SENSITIVE_PATHS" ] && echo "sensitive paths: $SENSITIVE_PATHS" || echo "$FILES_TOUCHED files")"
79
+ LANE_REASON="$FILES_TOUCHED files"
70
80
  elif [ "$FILES_TOUCHED" -ge 3 ] 2>/dev/null; then
71
81
  LANE="standard"
72
82
  LANE_REASON="$FILES_TOUCHED files"
73
83
  elif [ "$FILES_TOUCHED" -ge 2 ] 2>/dev/null; then
74
84
  LANE="lean"
75
85
  LANE_REASON="$FILES_TOUCHED files"
86
+ elif [ "$FILES_TOUCHED" -eq 1 ] 2>/dev/null; then
87
+ # 1-file rule mirrors lane.sh: >=20 added LOC -> lean, else direct
88
+ ADDED=$(git diff --numstat "$BASE_SHA"..HEAD 2>/dev/null | awk '{s+=$1} END {print s+0}')
89
+ if [ "${ADDED:-0}" -ge 20 ] 2>/dev/null; then
90
+ LANE="lean"
91
+ LANE_REASON="1 file, $ADDED LOC"
92
+ else
93
+ LANE="direct"
94
+ LANE_REASON="1 file, <20 LOC"
95
+ fi
76
96
  else
77
97
  LANE="direct"
78
98
  LANE_REASON="$FILES_TOUCHED file(s) under 20 LOC"
@@ -82,7 +102,7 @@ fi
82
102
  # surface never escalate to full from file count alone; sensitive-path
83
103
  # escalation above still wins.
84
104
  PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
85
- if [ "$LANE" = "full" ] && [ -n "$CHANGED_FILES" ]; then
105
+ if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ]; then
86
106
  CODE_COUNT=$(echo "$CHANGED_FILES" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
87
107
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
88
108
  PREV="$LANE"
@@ -91,20 +111,26 @@ if [ "$LANE" = "full" ] && [ -n "$CHANGED_FILES" ]; then
91
111
  fi
92
112
  fi
93
113
 
94
- # task counts from plan doc
95
- PLAN_FILE=$(ls "$MUGIWARA_DIR/plans/${MISSION}.md" 2>/dev/null || true)
114
+ # task counts from plan doc — plan is written date-prefixed (plans/YYYY-MM-DD-<mission>.md)
115
+ # or bare (plans/<mission>.md); glob both, first match wins.
116
+ PLAN_FILE=$(ls "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md 2>/dev/null | head -1 || true)
96
117
  TASKS_DONE=0
97
118
  TASKS_TOTAL=0
98
119
  if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
99
- TASKS_TOTAL=$(grep -c '\[ \]' "$PLAN_FILE" 2>/dev/null || echo 0)
100
- TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || echo 0)
120
+ # total counts ALL task lines (checked + unchecked); done counts checked only.
121
+ # A fully-completed plan must read total=N done=N, never total=0 (the old
122
+ # unchecked-only grep degenerated a done plan to tasks.total=0).
123
+ TASKS_TOTAL=$(grep -cE '^\s*-\s*\[[ xX]\]' "$PLAN_FILE" 2>/dev/null || true)
124
+ TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || true)
101
125
  fi
102
126
 
103
127
  # blocker count
104
128
  BLOCKERS_FILE=$(ls "$MUGIWARA_DIR/issues/${MISSION}-blockers.md" 2>/dev/null || true)
105
129
  BLOCKERS_OPEN=0
106
130
  if [ -n "$BLOCKERS_FILE" ] && [ -f "$BLOCKERS_FILE" ]; then
107
- BLOCKERS_OPEN=$(grep -c '|' "$BLOCKERS_FILE" 2>/dev/null || echo 0)
131
+ # data rows start with a wave number; header "| wave |" and separator
132
+ # "|---|" are excluded by the ^\| ?[0-9]+ \| pattern
133
+ BLOCKERS_OPEN=$(grep -cE '^\| ?[0-9]+ ?\|' "$BLOCKERS_FILE" 2>/dev/null || true)
108
134
  fi
109
135
 
110
136
  # heal cycle — count WAVE-8 banner occurrences in the trace, not the word
@@ -113,7 +139,7 @@ fi
113
139
  HEAL_CYCLE=1
114
140
  TRACE_FILE=$(ls "$MUGIWARA_DIR/results/${MISSION}/"*trace*.md 2>/dev/null | head -1 || true)
115
141
  if [ -n "$TRACE_FILE" ] && [ -f "$TRACE_FILE" ]; then
116
- HEAL_COUNT=$(grep -ci '^.*Wave 8.*\|wave 8' "$TRACE_FILE" 2>/dev/null || echo 0)
142
+ HEAL_COUNT=$(grep -ci '^.*Wave 8.*\|wave 8' "$TRACE_FILE" 2>/dev/null || true)
117
143
  HEAL_CYCLE=$((HEAL_COUNT + 1))
118
144
  fi
119
145
 
@@ -132,8 +158,25 @@ if [ -f package.json ]; then
132
158
  [ -f "$PKG_JSON" ] && SKILL_VERSION=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version.split('.')[0])}catch(e){console.log('1')}" "$PKG_JSON" 2>/dev/null || echo "1")
133
159
  fi
134
160
 
135
- # tokens from env var (harness exports estimated tokens consumed)
136
- TOKENS_EST=${MUGIWARA_TOKENS:-0}
161
+ # tokens proxy (F7): deterministic estimate when the harness does not report
162
+ # real usage. Monotonic beats precise — LANE_BASE stands in for the skills
163
+ # loaded this lane; loc_delta and written-artifact words scale with growth.
164
+ # MUGIWARA_TOKENS overrides as the reported value.
165
+ LANE_BASE=0
166
+ case "$LANE" in
167
+ lean) LANE_BASE=1500 ;;
168
+ standard) LANE_BASE=4000 ;;
169
+ full) LANE_BASE=9000 ;;
170
+ spike) LANE_BASE=1000 ;;
171
+ esac
172
+ 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 ' ')
173
+ LOC_TOKENS=$(( LOC_DELTA > 0 ? LOC_DELTA * 12 : 0 ))
174
+ TOKENS_SOURCE="computed"
175
+ TOKENS_EST=$(( LANE_BASE + DOC_WORDS * 135 / 100 + LOC_TOKENS ))
176
+ if [ -n "${MUGIWARA_TOKENS:-}" ]; then
177
+ TOKENS_EST="${MUGIWARA_TOKENS}"
178
+ TOKENS_SOURCE="reported"
179
+ fi
137
180
 
138
181
  # budget per lane
139
182
  BUDGET=0
@@ -191,6 +234,7 @@ const data = {
191
234
  blockers_open: parseInt(process.argv[15], 10),
192
235
  heal_cycle: parseInt(process.argv[16], 10),
193
236
  tokens_est: parseInt(process.argv[17], 10) || 0,
237
+ tokens_source: process.argv[26] || 'computed',
194
238
  budget: parseInt(process.argv[18], 10) || 0,
195
239
  budget_status: process.argv[19],
196
240
  skill_version: process.argv[20],
@@ -205,7 +249,7 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
205
249
  "$BLOCKERS_OPEN" "$HEAL_CYCLE" "$TOKENS_EST" "$BUDGET" \
206
250
  "$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
207
251
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
208
- "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE"
252
+ "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE"
209
253
 
210
254
  if [ "$LANE_ROSE" = true ]; then
211
255
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
@@ -27,6 +27,7 @@ function checkFile(file: string, wantName: string, kind: 'skill' | 'agent'): Rec
27
27
  if (bullets.length === 0) errors.push(`skill ${file}: "## Skip when" needs ≥1 bullet`);
28
28
  if (bullets.length > 4) errors.push(`skill ${file}: "## Skip when" block exceeds 4 bullets`);
29
29
  }
30
+ if (kind === 'skill' && !body.includes('## Red flags')) errors.push(`skill ${file}: missing required "## Red flags" block`);
30
31
  if (kind === 'skill') {
31
32
  const lines = body.split(/\r?\n/);
32
33
  const headingRe = /^## /;
@@ -145,6 +146,25 @@ for (const f of agentFiles) {
145
146
  if (scope === 'source' && !SOURCE_SCOPED.has(name)) errors.push(`agent ${f}: only zoro-execution and brook-healing may declare write-scope: source`);
146
147
  }
147
148
 
149
+ // --- write-boundary gate (W1): the hub skill must carry the refusal rule ---
150
+ const hubFile = join(root, 'skills', 'mugiwara-orchestration', 'SKILL.md');
151
+ if (existsSync(hubFile) && !readFileSync(hubFile, 'utf8').includes('## Write boundary')) {
152
+ errors.push('skill mugiwara-orchestration: missing "## Write boundary" section (non-executors refuse source writes)');
153
+ }
154
+
155
+ // --- agent-count gate (F14): user-facing/internal split derivable from content/agents/ ---
156
+ const internalAgents = agentFiles.filter(f => {
157
+ const parsed = parseFrontmatter(readFileSync(join(agentDir, f), 'utf8'));
158
+ return parsed.data.internal === 'true';
159
+ });
160
+ const canonicalCount = `${agentFiles.length - internalAgents.length} agents (+${internalAgents.length} internal)`;
161
+ for (const doc of ['README.md', 'docs/index.md', 'docs/concepts/agents.md']) {
162
+ const p = join(import.meta.dirname, '..', doc);
163
+ if (existsSync(p) && !readFileSync(p, 'utf8').includes(canonicalCount)) {
164
+ errors.push(`${doc}: missing canonical agent count "${canonicalCount}"`);
165
+ }
166
+ }
167
+
148
168
  // --- hub-rule gate (F3): every non-Luffy agent carries both hub sections ---
149
169
  for (const f of agentFiles) {
150
170
  const name = f.replace(/\.md$/, '');