@ionivetech/mugiwara 0.6.5 → 0.7.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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +3 -9
  3. package/.codex-plugin/plugin.json +2 -8
  4. package/.cursor-plugin/plugin.json +2 -8
  5. package/.kimi-plugin/plugin.json +2 -8
  6. package/.opencode/commands/mugiwara-continue.md +44 -31
  7. package/.opencode/commands/mugiwara-review.md +1 -1
  8. package/.opencode/commands/mugiwara-security.md +1 -1
  9. package/.opencode/commands/mugiwara.md +2 -2
  10. package/.opencode/plugins/mugiwara.mjs +3 -19
  11. package/AGENTS.md +6 -6
  12. package/GEMINI.md +3 -3
  13. package/README.md +82 -74
  14. package/content/agents/brook-healing.md +12 -11
  15. package/content/agents/chopper-checkpoint.md +14 -13
  16. package/content/agents/eval-runner.md +6 -9
  17. package/content/agents/franky-gates.md +7 -10
  18. package/content/agents/jinbe-security.md +10 -14
  19. package/content/agents/luffy-orchestrator.md +20 -22
  20. package/content/agents/memory-keeper.md +9 -12
  21. package/content/agents/nami-planner.md +7 -10
  22. package/content/agents/resume-coordinator.md +12 -15
  23. package/content/agents/robin-reviewer.md +13 -12
  24. package/content/agents/sanji-quality.md +9 -12
  25. package/content/agents/skeptic-verifier.md +7 -10
  26. package/content/agents/usopp-brainstorm.md +6 -9
  27. package/content/agents/zoro-execution.md +9 -12
  28. package/content/skills/mugiwara-backend/SKILL.md +4 -0
  29. package/content/skills/mugiwara-backend/references/database.md +61 -0
  30. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  31. package/content/skills/mugiwara-checkpoint/SKILL.md +17 -14
  32. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +5 -5
  33. package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
  34. package/content/skills/mugiwara-execution/SKILL.md +24 -24
  35. package/content/skills/mugiwara-execution/references/dispatch.md +3 -3
  36. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  37. package/content/skills/mugiwara-frontend/SKILL.md +5 -6
  38. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  39. package/content/skills/mugiwara-gates/SKILL.md +14 -10
  40. package/content/skills/mugiwara-git/SKILL.md +7 -2
  41. package/content/skills/mugiwara-healing/SKILL.md +7 -5
  42. package/content/skills/mugiwara-healing/references/workers.md +2 -2
  43. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  44. package/content/skills/mugiwara-orchestration/SKILL.md +28 -26
  45. package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
  46. package/content/skills/mugiwara-orchestration/references/closure.md +18 -13
  47. package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
  48. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +32 -22
  49. package/content/skills/mugiwara-planning/SKILL.md +5 -18
  50. package/content/skills/mugiwara-planning/references/plan-template.md +3 -3
  51. package/content/skills/mugiwara-quality/SKILL.md +10 -7
  52. package/content/skills/mugiwara-resume/SKILL.md +20 -22
  53. package/content/skills/mugiwara-review/SKILL.md +7 -2
  54. package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
  55. package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
  56. package/content/skills/mugiwara-security/SKILL.md +6 -2
  57. package/content/skills/mugiwara-ship/SKILL.md +2 -2
  58. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  59. package/content/skills/mugiwara-testcases/SKILL.md +5 -3
  60. package/content/skills/mugiwara-workflow/SKILL.md +32 -35
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +41 -40
  62. package/dist/mugiwara.js +1370 -153
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +114 -0
  65. package/hooks/auto-savepoint.ts +102 -0
  66. package/hooks/engagement-marker.js +59 -0
  67. package/hooks/engagement-marker.ts +97 -0
  68. package/hooks/hooks.json +41 -2
  69. package/hooks/mugiwara-mode-tracker.js +79 -0
  70. package/hooks/pipeline-guard.js +186 -0
  71. package/hooks/pipeline-guard.ts +270 -0
  72. package/hooks/session-start.js +109 -0
  73. package/hooks/session-start.ts +62 -45
  74. package/package.json +7 -3
  75. package/plugin.json +1 -1
  76. package/references/agent-protocol.md +15 -0
  77. package/references/complexity.md +77 -0
  78. package/references/definition-of-done.md +1 -1
  79. package/references/multi-actor.md +4 -7
  80. package/references/prose-style.md +54 -0
  81. package/references/skill-versioning.md +1 -1
  82. package/references/wave-banners.md +16 -16
  83. package/scripts/build-hooks.ts +56 -0
  84. package/scripts/check-doc-links.ts +40 -0
  85. package/scripts/conformance.ts +23 -16
  86. package/scripts/coverage-gate.ts +193 -0
  87. package/scripts/gate-selftest.ts +118 -7
  88. package/scripts/install.ps1 +5 -4
  89. package/scripts/install.sh +4 -4
  90. package/scripts/lane.sh +56 -6
  91. package/scripts/lib/patterns.sh +89 -0
  92. package/scripts/policy-force.ts +22 -0
  93. package/scripts/retrieval-eval.ts +1 -1
  94. package/scripts/savepoint.sh +249 -67
  95. package/scripts/validate-content.ts +22 -9
  96. package/scripts/verify-install.ts +156 -44
  97. package/src/args.ts +2 -2
  98. package/src/budget.ts +56 -0
  99. package/src/cli.ts +272 -4
  100. package/src/continue.ts +262 -0
  101. package/src/installer.ts +59 -15
  102. package/src/integrity.ts +158 -0
  103. package/src/mission.ts +228 -95
  104. package/src/policy.ts +156 -0
  105. package/src/provenance.ts +116 -0
  106. package/src/rollback.ts +95 -0
  107. package/src/routing.ts +69 -0
  108. package/src/run.ts +82 -0
  109. package/src/sign.ts +61 -0
  110. package/src/targets/claude.ts +140 -2
  111. package/src/targets/copilot.ts +20 -1
  112. package/src/targets/generic.ts +9 -4
  113. package/src/targets/opencode.ts +0 -1
  114. package/.opencode/commands/mugiwara-execute.md +0 -13
  115. package/.opencode/commands/mugiwara-heal.md +0 -13
  116. package/.opencode/commands/mugiwara-onboard.md +0 -15
  117. package/.opencode/commands/mugiwara-plan.md +0 -17
  118. package/.opencode/commands/mugiwara-ship.md +0 -13
  119. package/.opencode/commands/using-mugiwara.md +0 -20
  120. package/content/agents/onboarding-guide.md +0 -124
  121. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  122. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  123. package/content/skills/mugiwara-context-budget/SKILL.md +0 -64
  124. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  125. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  126. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  127. package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
  128. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  129. package/content/skills/using-mugiwara/SKILL.md +0 -51
  130. package/references/token-budget.md +0 -34
  131. package/scripts/evidence.sh +0 -81
  132. package/scripts/initiative.ts +0 -296
  133. package/scripts/mission-report.sh +0 -292
  134. package/scripts/onboard.ts +0 -266
  135. package/scripts/probe.ts +0 -40
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/coverage-gate.ts — enforce the `coverage_new` / `coverage_modified`
3
+ // thresholds declared in `.mugiwara/config` against the files THIS diff adds
4
+ // and changes, not against a global project number. Two keys exist precisely
5
+ // because the two populations are judged differently (mugiwara-gates SKILL.md
6
+ // §Coverage gate); a global percentage would collapse them into one and let an
7
+ // untested new file hide behind an old well-tested one.
8
+ //
9
+ // Run: bun scripts/coverage-gate.ts enforce (gate step)
10
+ // bun scripts/coverage-gate.ts --base X compare against ref X
11
+ // bun scripts/coverage-gate.ts --show print per-file numbers, never fail
12
+ //
13
+ // Exit 0 = pass or recorded SKIP. Exit 1 = a threshold was missed.
14
+ // A repo with no coverage tooling or no test suite records a SKIP with its
15
+ // reason — never a fake pass (decision log row 50).
16
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { execFileSync, spawnSync } from 'node:child_process';
18
+ import { homedir } from 'node:os';
19
+ import { join, relative, sep } from 'node:path';
20
+
21
+ const root = join(import.meta.dirname, '..');
22
+ const SHOW = process.argv.includes('--show');
23
+
24
+ /** `.mugiwara/config` → project, then `~`. Returns undefined if the key is set nowhere. */
25
+ function config(key: string): string | undefined {
26
+ for (const base of [root, homedir()]) {
27
+ const file = join(base, '.mugiwara', 'config');
28
+ if (!existsSync(file)) continue;
29
+ // \r?\n: a config written on Windows is still a config
30
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
31
+ const t = line.trim();
32
+ if (!t || t.startsWith('#')) continue;
33
+ const eq = t.indexOf('=');
34
+ if (eq === -1) continue;
35
+ if (t.slice(0, eq).trim() !== key) continue;
36
+ return t.slice(eq + 1).trim();
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /** Documented defaults: new >= 90, modified >= 80. A key set to 0 = no threshold. */
43
+ function threshold(key: string, fallback: number): number {
44
+ const raw = config(key);
45
+ if (raw === undefined || raw === '') return fallback;
46
+ const n = Number(raw);
47
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
48
+ console.log(`coverage-gate: ignoring unparseable ${key}="${raw}", using ${fallback}`);
49
+ return fallback;
50
+ }
51
+ return n;
52
+ }
53
+
54
+ function skip(reason: string): never {
55
+ console.log(`coverage-gate: SKIP — ${reason}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ function git(args: string[]): string {
60
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
61
+ }
62
+
63
+ // ---- 1. is there anything to measure at all? -------------------------------
64
+
65
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
66
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
67
+ if (!deps['@vitest/coverage-v8'] && !deps['@vitest/coverage-istanbul'])
68
+ skip('no coverage tooling installed (no @vitest/coverage-* in package.json)');
69
+ if (!existsSync(join(root, 'test')))
70
+ skip('no test suite found (no test/ directory)');
71
+
72
+ // ---- 2. which files does this diff add and change? ------------------------
73
+
74
+ const baseArg = process.argv.indexOf('--base');
75
+ let base = baseArg !== -1 ? process.argv[baseArg + 1] : process.env.MUGIWARA_COVERAGE_BASE;
76
+ if (!base) {
77
+ // the mission's own recorded base_sha is the truthful diff origin
78
+ const missionsDir = join(root, '.mugiwara', 'missions');
79
+ if (existsSync(missionsDir)) {
80
+ for (const m of readdirSync(missionsDir)) {
81
+ const f = join(missionsDir, m, 'state.json');
82
+ if (!existsSync(f)) continue;
83
+ const sha = JSON.parse(readFileSync(f, 'utf8')).base_sha;
84
+ if (sha) { base = sha; break; }
85
+ }
86
+ }
87
+ }
88
+ if (!base) {
89
+ for (const ref of ['origin/main', 'main', 'origin/master', 'master']) {
90
+ try { base = git(['merge-base', 'HEAD', ref]); break; } catch { /* next */ }
91
+ }
92
+ }
93
+ if (!base) skip('no diff base could be resolved (no state base_sha, no main/master ref)');
94
+
95
+ let changed: Array<{ status: string; path: string }>;
96
+ try {
97
+ changed = git(['diff', '--name-status', '--diff-filter=AMR', '-M', `${base}`, 'HEAD'])
98
+ .split(/\r?\n/)
99
+ .filter(Boolean)
100
+ .map((l) => {
101
+ const parts = l.split('\t');
102
+ // rename rows are `R096\told\tnew` — the new path is the one to judge
103
+ return { status: parts[0][0], path: parts[parts.length - 1] };
104
+ });
105
+ } catch (e) {
106
+ skip(`git diff against ${base} failed (${(e as Error).message})`);
107
+ }
108
+
109
+ if (!changed.length) skip(`no added or modified files between ${base.slice(0, 7)} and HEAD`);
110
+
111
+ // ---- 3. measure ------------------------------------------------------------
112
+
113
+ const summaryPath = join(root, 'coverage', 'coverage-summary.json');
114
+ const needsRun =
115
+ !existsSync(summaryPath) ||
116
+ statSync(summaryPath).mtimeMs < Math.max(...changed
117
+ .map((c) => join(root, c.path))
118
+ .filter(existsSync)
119
+ .map((p) => statSync(p).mtimeMs), 0);
120
+
121
+ if (needsRun) {
122
+ console.log('coverage-gate: measuring (vitest run --coverage)...');
123
+ const r = spawnSync('npx', ['vitest', 'run', '--coverage', '--silent'], {
124
+ cwd: root, stdio: 'inherit', shell: process.platform === 'win32',
125
+ });
126
+ if (r.status !== 0) {
127
+ console.log('coverage-gate: FAIL — test run did not pass, coverage is not measurable');
128
+ process.exit(1);
129
+ }
130
+ }
131
+ if (!existsSync(summaryPath))
132
+ skip('coverage run produced no coverage-summary.json (json-summary reporter not configured)');
133
+
134
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
135
+ // keys are absolute OS paths; normalise to repo-relative posix to match git
136
+ const measured = new Map<string, number>();
137
+ for (const [k, v] of Object.entries<any>(summary)) {
138
+ if (k === 'total') continue;
139
+ measured.set(relative(root, k).split(sep).join('/'), v.lines?.pct ?? 0);
140
+ }
141
+
142
+ // ---- 4. apply the two thresholds ------------------------------------------
143
+ // Policy as code: mugiwara.policy.yml gates.coverage can RAISE a
144
+ // threshold — the max of config and policy wins, never lower.
145
+
146
+ const { loadPolicy, effectiveThreshold } = await import('../src/policy.ts');
147
+ let policyCoverage: { new?: number; modified?: number } | undefined;
148
+ try {
149
+ const policy = loadPolicy(root);
150
+ policyCoverage = policy?.gates?.coverage;
151
+ if (policy?.gates?.require_human_approval?.length)
152
+ console.log(`coverage-gate: policy requires human approval on: ${policy.gates.require_human_approval.join(', ')}`);
153
+ } catch (e) {
154
+ console.log(`coverage-gate: ${(e as Error).message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ const NEW = effectiveThreshold(threshold('coverage_new', 90), policyCoverage?.new);
159
+ const MOD = effectiveThreshold(threshold('coverage_modified', 80), policyCoverage?.modified);
160
+
161
+ const rows = changed.map((c) => {
162
+ const isNew = c.status === 'A';
163
+ return {
164
+ path: c.path,
165
+ kind: isNew ? 'new' : 'modified',
166
+ limit: isNew ? NEW : MOD,
167
+ pct: measured.get(c.path),
168
+ };
169
+ });
170
+
171
+ const scoped = rows.filter((r) => r.pct !== undefined);
172
+ const unscoped = rows.filter((r) => r.pct === undefined);
173
+
174
+ console.log(`coverage-gate: base ${base.slice(0, 7)} · thresholds new>=${NEW || 'off'} modified>=${MOD || 'off'}`);
175
+ console.log(` ${changed.length} changed file(s), ${scoped.length} within coverage scope, ${unscoped.length} outside it`);
176
+
177
+ const failures = scoped.filter((r) => r.limit > 0 && (r.pct as number) < r.limit);
178
+
179
+ for (const r of scoped.sort((a, b) => (a.pct as number) - (b.pct as number))) {
180
+ const bad = r.limit > 0 && (r.pct as number) < r.limit;
181
+ if (SHOW || bad || (r.pct as number) < 100)
182
+ console.log(` ${bad ? '✗' : '✓'} ${r.path} — ${(r.pct as number).toFixed(2)}% ${r.kind} (limit ${r.limit || 'off'})`);
183
+ }
184
+ if (SHOW && unscoped.length)
185
+ console.log(unscoped.map((r) => ` · ${r.path} — outside coverage scope (not a measured source file)`).join('\n'));
186
+
187
+ if (SHOW) process.exit(0);
188
+ if (failures.length) {
189
+ console.log(`\ncoverage-gate: FAIL — ${failures.length} file(s) below their threshold`);
190
+ console.log('Do not lower the thresholds or exclude files to pass: add the missing tests.');
191
+ process.exit(1);
192
+ }
193
+ console.log('\ncoverage-gate: PASS');
@@ -12,7 +12,7 @@ let passed = 0;
12
12
  let failed = 0;
13
13
 
14
14
  function run(name: string, cmd: string): boolean {
15
- try { execSync(cmd, { cwd: root, stdio: 'pipe', timeout: 60000 }); return true; }
15
+ try { execSync(cmd, { cwd: root, stdio: 'pipe', timeout: 180000 }); return true; }
16
16
  catch { return false; }
17
17
  }
18
18
 
@@ -82,6 +82,28 @@ console.log('\nG5 — conditional-assertion guard');
82
82
  }
83
83
  }
84
84
 
85
+ // --- Cost gate: prove a drifted stated index size goes red -------------------
86
+ // validate-content fails when docs/concepts/cost.md's "**Current:** N chars"
87
+ // disagrees with the measured skill+agent description total. Without this
88
+ // mutation the gate is unproven — a gate that cannot fail is not a gate.
89
+ console.log('\nCost gate — measured vs stated index chars');
90
+ {
91
+ const costFile = join(root, 'docs', 'concepts', 'cost.md');
92
+ const original = readFileSync(costFile, 'utf8');
93
+ try {
94
+ const drifted = original.replace(/\*\*Current:\*\* \d[\d,]* chars/, '**Current:** 1 chars');
95
+ if (drifted === original) {
96
+ console.log(' ⚠ "**Current:** N chars" pattern not found in cost.md — skipping');
97
+ } else {
98
+ writeFileSync(costFile, drifted);
99
+ assert('drifted stated index chars → exit 1', false, () => run('COST', 'bun scripts/validate-content.ts'));
100
+ }
101
+ } finally {
102
+ writeFileSync(costFile, original);
103
+ assert('restored → exit 0', true, () => run('COST', 'bun scripts/validate-content.ts'));
104
+ }
105
+ }
106
+
85
107
  // --- G3: savepoint fixtures — prove test fails when a field is broken ---
86
108
  console.log('\nG3 — savepoint fixtures');
87
109
  if (!existsSync(join(root, 'test', 'savepoint.test.ts'))) {
@@ -316,19 +338,19 @@ if (!existsSync(skillsDoc)) {
316
338
 
317
339
  // --- Index budget ---
318
340
  console.log('\nIndex budget');
319
- const usingSkillFile = join(root, 'content', 'skills', 'using-mugiwara', 'SKILL.md');
320
- if (!existsSync(usingSkillFile)) {
321
- console.log(' ⚠ using-mugiwara not found, skipping');
341
+ const budgetSkillFile = join(root, 'content', 'skills', 'mugiwara-workflow', 'SKILL.md');
342
+ if (!existsSync(budgetSkillFile)) {
343
+ console.log(' ⚠ mugiwara-workflow not found, skipping');
322
344
  } else {
323
- const original = readFileSync(usingSkillFile, 'utf8');
345
+ const original = readFileSync(budgetSkillFile, 'utf8');
324
346
  try {
325
347
  // Push budget way past 5500
326
348
  const longDesc = 'A'.repeat(6000);
327
349
  const broken = original.replace(/description:.*/, `description: ${longDesc}`);
328
- writeFileSync(usingSkillFile, broken);
350
+ writeFileSync(budgetSkillFile, broken);
329
351
  assert('description past budget → exit 1', false, () => run('budget', 'bun scripts/validate-content.ts'));
330
352
  } finally {
331
- writeFileSync(usingSkillFile, original);
353
+ writeFileSync(budgetSkillFile, original);
332
354
  assert('restored → exit 0', true, () => run('budget', 'bun scripts/validate-content.ts --check-manifest --check-docs'));
333
355
  }
334
356
  }
@@ -481,5 +503,94 @@ if (!existsSync(countAgent)) {
481
503
  }
482
504
  }
483
505
 
506
+ // --- CI: closure integrity gate — a secret in the trail must fail the archive ---
507
+ console.log('\nCI — closure integrity gate');
508
+ {
509
+ const tmp = mkdtempSync(join(tmpdir(), 'mugi-ci-'));
510
+ try {
511
+ const mdir = join(tmp, '.mugiwara', 'missions', 'selftest-mission');
512
+ mkdirSync(mdir, { recursive: true });
513
+ const cli = `bun src/cli.ts archive selftest-mission --project ${tmp}`;
514
+ // A mission with no trail at all archives fine (stub report path).
515
+ assert('clean stub → exit 0', true, () => run('CI-clean', cli));
516
+ // A planted secret must turn the gate red.
517
+ writeFileSync(join(mdir, 'report.md'), 'token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456\n');
518
+ writeFileSync(join(mdir, 'state.json'), JSON.stringify({ mission: 'selftest-mission' }));
519
+ assert('planted secret → exit 1', false, () => run('CI-secret', cli));
520
+ unlinkSync(join(mdir, 'report.md'));
521
+ unlinkSync(join(mdir, 'state.json'));
522
+ assert('cleaned → exit 0', true, () => run('CI-clean2', cli));
523
+
524
+ // Context-budget gate: a ceiling that the trail exceeds must fail red.
525
+ writeFileSync(join(mdir, 'state.json'), JSON.stringify({ mission: 'selftest-mission' }));
526
+ writeFileSync(join(mdir, 'report.md'), 'x'.repeat(500) + '\n');
527
+ mkdirSync(join(tmp, '.mugiwara'), { recursive: true });
528
+ writeFileSync(join(tmp, '.mugiwara', 'config'), 'context_budget_chars=10\n');
529
+ assert('over context budget → exit 1', false, () => run('CI-budget', cli));
530
+ unlinkSync(join(tmp, '.mugiwara', 'config'));
531
+ assert('budget unset → exit 0', true, () => run('CI-budget2', cli));
532
+ } finally {
533
+ rmSync(tmp, { recursive: true, force: true });
534
+ }
535
+ }
536
+
537
+ // --- POLICY: an invalid mugiwara.policy.yml fails lane.sh closed ---
538
+ console.log('\nPOLICY — fail-closed policy parsing');
539
+ {
540
+ const repo = mkdtempSync(join(tmpdir(), 'mugi-policy-'));
541
+ // lane.sh must run INSIDE the sandbox repo, not this one
542
+ const runInRepo = (args: string): boolean => {
543
+ try { execSync(`bash ${join(root, 'scripts', 'lane.sh')} ${args}`, { cwd: repo, stdio: 'pipe', timeout: 60000 }); return true; }
544
+ catch { return false; }
545
+ };
546
+ try {
547
+ execSync('git init -q && git config user.email t@t.com && git config user.name T && git commit --allow-empty -qm base', { cwd: repo, stdio: 'pipe' });
548
+ writeFileSync(join(repo, 'changed.ts'), 'export {};\n');
549
+ // no policy file → normal behavior
550
+ assert('no policy → lane runs', true, () => runInRepo('HEAD --json'));
551
+ // unknown root key must fail loud, not silently disable itself
552
+ writeFileSync(join(repo, 'mugiwara.policy.yml'), 'lanez:\n force_full: ["src/**"]\n');
553
+ assert('bogus policy key → exit 1', false, () => runInRepo('HEAD --json'));
554
+ // valid policy parses and can force full
555
+ writeFileSync(join(repo, 'mugiwara.policy.yml'), 'lanes:\n force_full:\n - "**/*.ts"\n');
556
+ assert('valid policy → lane runs', true, () => runInRepo('HEAD --json'));
557
+ } finally {
558
+ rmSync(repo, { recursive: true, force: true });
559
+ }
560
+ }
561
+
562
+ // --- T7: evidence-thin — fabricated PASS wave with empty body must fail ---
563
+ console.log('\nT7 — evidence-thin gate');
564
+ {
565
+ const tmp = mkdtempSync(join(tmpdir(), 'mugi-thin-'));
566
+ try {
567
+ const mdir = join(tmp, '.mugiwara', 'missions', 'selftest-thin');
568
+ mkdirSync(join(mdir, 'flows'), { recursive: true });
569
+ const cli = `bun src/cli.ts archive selftest-thin --project ${tmp}`;
570
+ writeFileSync(join(mdir, 'state.json'), JSON.stringify({ mission: 'selftest-thin', evidence: ['flows/04-gates.md'] }));
571
+ writeFileSync(join(mdir, 'report.md'), 'Verdict: PASS see [evidence](flows/04-gates.md)');
572
+ writeFileSync(join(mdir, 'flows', '04-gates.md'), 'all good'); // no command shape → thin
573
+ assert('fabricated PASS with empty body → exit 1', false, () => run('T7-thin', cli));
574
+ writeFileSync(join(mdir, 'flows', '04-gates.md'), 'ran `bun run test` → 2 passed, exit 0');
575
+ assert('with command output → exit 0', true, () => run('T7-good', cli));
576
+ } finally {
577
+ rmSync(tmp, { recursive: true, force: true });
578
+ }
579
+ }
580
+
581
+ // --- DOCLINKS: a relative .md link that does not resolve must fail the gate ---
582
+ console.log('\nDOCLINKS — doc link resolution');
583
+ {
584
+ const probe = join(root, 'docs', 'tmp-doclinks-probe.md');
585
+ try {
586
+ writeFileSync(probe, '[missing](./nowhere-at-all.md)\n');
587
+ assert('broken doc link → exit 1', false, () => run('DL-bad', 'bun scripts/check-doc-links.ts'));
588
+ unlinkSync(probe);
589
+ assert('cleaned → exit 0', true, () => run('DL-good', 'bun scripts/check-doc-links.ts'));
590
+ } finally {
591
+ if (existsSync(probe)) unlinkSync(probe);
592
+ }
593
+ }
594
+
484
595
  console.log(`\n${passed} passed, ${failed} failed`);
485
596
  process.exit(failed > 0 ? 1 : 0);
@@ -2,12 +2,13 @@
2
2
  $ErrorActionPreference = 'Stop'
3
3
 
4
4
  if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
5
- Write-Error 'mugiwara requires Node.js >= 20. Install it from https://nodejs.org first.'
5
+ Write-Error 'mugiwara requires Node.js >= 20.11. Install it from https://nodejs.org first.'
6
6
  }
7
7
 
8
- $major = [int](node -p 'process.versions.node.split(".")[0]')
9
- if ($major -lt 20) {
10
- Write-Error "mugiwara requires Node.js >= 20 (found $(node --version))."
8
+ # floor must match package.json "engines.node" exactly, and install.sh
9
+ node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>20||(a===20&&b>=11)?0:1)'
10
+ if ($LASTEXITCODE -ne 0) {
11
+ Write-Error "mugiwara requires Node.js >= 20.11 (found $(node --version))."
11
12
  }
12
13
 
13
14
  npx -y @ionivetech/mugiwara@latest @args
@@ -4,13 +4,13 @@ set -euo pipefail
4
4
  PKG="@ionivetech/mugiwara"
5
5
 
6
6
  if ! command -v node >/dev/null 2>&1; then
7
- echo "mugiwara requires Node.js >= 20. Install it first: https://nodejs.org" >&2
7
+ echo "mugiwara requires Node.js >= 20.11. Install it first: https://nodejs.org" >&2
8
8
  exit 1
9
9
  fi
10
10
 
11
- MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
12
- if [ "$MAJOR" -lt 20 ]; then
13
- echo "mugiwara requires Node.js >= 20 (found $(node --version))." >&2
11
+ # floor must match package.json "engines.node" exactly, and install.ps1
12
+ if ! node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>20||(a===20&&b>=11)?0:1)'; then
13
+ echo "mugiwara requires Node.js >= 20.11 (found $(node --version))." >&2
14
14
  exit 1
15
15
  fi
16
16
 
package/scripts/lane.sh CHANGED
@@ -19,13 +19,46 @@ if ! git rev-parse "$BASE" >/dev/null 2>&1; then
19
19
  [ -n "$ALT" ] && BASE="$ALT" || BASE="HEAD~1"
20
20
  fi
21
21
 
22
- CHANGED=$(git diff --name-only "$BASE"..HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || true)
23
- FILE_COUNT=0
24
- [ -n "$CHANGED" ] && FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
22
+ # lane_scope_glob (T5): monorepo scoping when set, count only files matching the glob
23
+ LANE_SCOPE_GLOB=""
24
+ if [ -f .mugiwara/config ]; then
25
+ CFG_SCOPE=$(grep -E '^lane_scope_glob=' .mugiwara/config 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"' | tr -d "'")
26
+ [ -n "$CFG_SCOPE" ] && LANE_SCOPE_GLOB="$CFG_SCOPE"
27
+ fi
25
28
 
29
+ # union of committed + staged + unstaged + untracked (F) — see patterns.sh
30
+ CHANGED=$(changed_files "$BASE")
31
+ # sensitive-path escalation always evaluates the unfiltered set (safety never shrinks)
26
32
  SENSITIVE=$(echo "$CHANGED" | grep -E "$SENSITIVE_PATS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
27
33
  HAS_SENSITIVE=0
28
34
  [ -n "$SENSITIVE" ] && HAS_SENSITIVE=1
35
+ # scoped count for lane sizing (T5)
36
+ SCOPED="$CHANGED"
37
+ if [ -n "$LANE_SCOPE_GLOB" ] && [ -n "$CHANGED" ]; then
38
+ shopt -s extglob globstar 2>/dev/null || true
39
+ SCOPED=""
40
+ while IFS= read -r _f; do
41
+ [ -z "$_f" ] && continue
42
+ # shellcheck disable=SC2053
43
+ if [[ "$_f" == $LANE_SCOPE_GLOB ]]; then
44
+ SCOPED="${SCOPED}${SCOPED:+
45
+ }$_f"
46
+ fi
47
+ done <<< "$CHANGED"
48
+ fi
49
+ FILE_COUNT=0
50
+ [ -n "$SCOPED" ] && FILE_COUNT=$(echo "$SCOPED" | wc -l | tr -d ' ')
51
+ # LOC for the 1-file rule also scopes to the glob (otherwise a single in-scope
52
+ # file with <20 LOC could be inflated by out-of-scope churn)
53
+ if [ -n "$LANE_SCOPE_GLOB" ]; then
54
+ read -r ADDED_INS ADDED_DEL <<EOF
55
+ $( { echo "$SCOPED" | while IFS= read -r _sf; do [ -n "$_sf" ] && git diff --numstat "$BASE"..HEAD -- "$_sf" 2>/dev/null; git diff --numstat HEAD -- "$_sf" 2>/dev/null; done; echo "$SCOPED" | while IFS= read -r _uf; do [ -z "$_uf" ] && continue; if git ls-files --others --exclude-standard -- "$_uf" 2>/dev/null | grep -qx "$_uf"; then printf '%s\t0\t%s\n' "$(wc -l < "$_uf" 2>/dev/null | tr -d ' ' || echo 0)" "$_uf"; fi; done; } | awk '{ if ($1 ~ /^[0-9]+$/) i+=$1; if ($2 ~ /^[0-9]+$/) d+=$2 } END { print (i+0)" "(d+0) }')
56
+ EOF
57
+ else
58
+ read -r ADDED_INS ADDED_DEL <<EOF
59
+ $(changed_loc "$BASE")
60
+ EOF
61
+ fi
29
62
 
30
63
  # lane logic
31
64
  LANE="direct"
@@ -35,7 +68,7 @@ if [ "$FILE_COUNT" -eq 0 ] 2>/dev/null; then
35
68
  LANE="direct"
36
69
  REASON="no changed files"
37
70
  elif [ "$FILE_COUNT" -le 1 ] 2>/dev/null; then
38
- ADDED=$(git diff --numstat "$BASE"..HEAD 2>/dev/null | awk '{s+=$1} END {print s+0}' || echo 0)
71
+ ADDED="${ADDED_INS:-0}"
39
72
  if [ "$ADDED" -lt 20 ] 2>/dev/null; then
40
73
  LANE="direct"
41
74
  REASON="1 file, <20 LOC"
@@ -67,8 +100,9 @@ fi
67
100
  # code surface actually touched. Sensitive-path escalation above still wins.
68
101
  # Product surface for mugiwara: content/, src/, scripts/, test/, hooks/,
69
102
  # .opencode/, .claude/, evals/ — everything else is docs/config/asset.
70
- if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$CHANGED" ]; then
71
- CODE_COUNT=$(echo "$CHANGED" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
103
+ # With lane_scope_glob the check applies to the scoped set sensitive still wins unfiltered.
104
+ if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$SCOPED" ]; then
105
+ CODE_COUNT=$(echo "$SCOPED" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
72
106
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
73
107
  PREV="$LANE"
74
108
  LANE="standard"
@@ -76,6 +110,22 @@ if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$CHANGED" ]; then
76
110
  fi
77
111
  fi
78
112
 
113
+ # policy as code: mugiwara.policy.yml lanes.force_full pushes the lane UP to
114
+ # full — always upward, never downward, and it wins over the docs-only
115
+ # downgrade above. Optional by design: no policy file (or no bun) = no-op.
116
+ if command -v bun >/dev/null 2>&1 && { [ -f mugiwara.policy.yml ] || [ -f mugiwara.policy.yaml ]; }; then
117
+ POLICY_HITS=$(printf '%s\n' "$CHANGED" | bun "$(dirname "$0")/policy-force.ts")
118
+ RC=$?
119
+ if [ "$RC" -ne 0 ]; then
120
+ echo "lane: mugiwara.policy.yml is invalid — fix or remove it (fail-closed)" >&2
121
+ exit 1
122
+ fi
123
+ if [ -n "$POLICY_HITS" ]; then
124
+ LANE="full"
125
+ REASON="policy force_full ($POLICY_HITS)"
126
+ fi
127
+ fi
128
+
79
129
  if [ "$JSON_OUT" -eq 1 ]; then
80
130
  SENS_ARR=""
81
131
  [ -n "$SENSITIVE" ] && SENS_ARR=$(echo "$SENSITIVE" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
@@ -19,3 +19,92 @@ SENSITIVE_PATS="auth/|oauth2?/|payment/|payments/|billing/|crypto/|secrets/|cred
19
19
  # outside this surface are docs/config/asset and never escalate to full on
20
20
  # count alone (path-weighted sizing).
21
21
  PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
22
+
23
+ # Installed-harness rules dirs — what `mugiwara install` writes into a project
24
+ # (~50 files across the 9 targets). In a CONSUMER project these are installed
25
+ # config, no different from the crew's own .mugiwara/ bookkeeping: the first
26
+ # savepoint after an install counted them and sized every fresh mission Lane 3
27
+ # before a single line of the user's own work existed. In MUGIWARA'S OWN repo
28
+ # the same dirs are the product surface (they are in PRODUCT_PAT), so the
29
+ # exclusion is conditional on the repo identity, never unconditional.
30
+ # Unanchored on purpose — call sites anchor with ^ (name lists) or a tab
31
+ # (numstat rows).
32
+ HARNESS_PAT="\.claude/|\.opencode/|\.github/(instructions|agents)/|\.gemini/mugiwara/|\.codex/mugiwara/|\.devin/rules/|\.clinerules/|\.kilo/rules/|\.agents/rules/"
33
+
34
+ # lane_scope_glob — optional monorepo scoping (T5). When set in .mugiwara/config
35
+ # as e.g. lane_scope_glob=packages/app/** lane sizing counts only changed
36
+ # files matching the glob; sensitive-path escalation still evaluates the
37
+ # unfiltered set (safety never shrinks).
38
+
39
+ # harness_excluded — true (exit 0) when harness rules dirs must NOT count
40
+ # toward mission sizing, i.e. this repo is not mugiwara itself. Identity comes
41
+ # from the repo root package.json name ("mugiwara" or "@scope/mugiwara"), the
42
+ # same manifest savepoint already trusts for skill_version.
43
+ harness_excluded() {
44
+ local root
45
+ root=$(git rev-parse --show-toplevel 2>/dev/null || echo .)
46
+ ! grep -qE '"name"[[:space:]]*:[[:space:]]*"(@[^"/]+/)?mugiwara"' "$root/package.json" 2>/dev/null
47
+ }
48
+
49
+ # drop_harness <anchor> — filter stdin, dropping harness paths in consumer
50
+ # repos and passing everything through in mugiwara's own.
51
+ drop_harness() {
52
+ if harness_excluded; then grep -vE "$1($HARNESS_PAT)" || true; else cat; fi
53
+ }
54
+
55
+ # --- working-tree-aware change set (F) -------------------------------------
56
+ # `git diff BASE..HEAD` alone is blind to uncommitted work, and the old
57
+ # `|| git diff --cached` fallback was dead code (|| fires on non-zero exit,
58
+ # never on empty output). With auto_commit=off nothing is committed, so lane
59
+ # sizing and files_touched read 0 while the tree holds real changes.
60
+ # These two helpers are the single source of truth for BOTH lane.sh and
61
+ # savepoint.sh so the two can never drift (lane-integrity case 15).
62
+
63
+ # Path of the crew's bookkeeping dir, relative to the repo root. MUGIWARA_DIR
64
+ # may be absolute (the harness passes a full path); git reports repo-relative
65
+ # paths, so normalize before comparing.
66
+ mugiwara_rel() {
67
+ local d="${MUGIWARA_DIR:-.mugiwara}" root
68
+ case "$d" in
69
+ /*) root=$(git rev-parse --show-toplevel 2>/dev/null || true)
70
+ [ -n "$root" ] && d="${d#"$root"/}"
71
+ # symlinked repo roots (/var vs /private/var on macOS) defeat the
72
+ # prefix strip; fall back to the leaf name, which is what git reports.
73
+ case "$d" in /*) d=$(basename "$d") ;; esac ;;
74
+ esac
75
+ printf '%s' "$d"
76
+ }
77
+
78
+ # changed_files <base-ref> — union of committed (BASE..HEAD), staged, unstaged
79
+ # and untracked files. .gitignore'd files are excluded (git status default),
80
+ # and so is the crew's own .mugiwara/ bookkeeping: savepoint writes state on
81
+ # every wave, and mission state must never size the mission it measures.
82
+ changed_files() {
83
+ local base="${1:-}"
84
+ {
85
+ if [ -n "$base" ] && [ "$base" != "unknown" ]; then
86
+ git diff --name-only "$base"..HEAD 2>/dev/null || true
87
+ fi
88
+ # porcelain covers staged + unstaged + untracked in one pass; strip the
89
+ # 2-char status + space, and keep the destination side of a rename.
90
+ git status --porcelain --untracked-files=all 2>/dev/null | sed 's/^...//; s/^.* -> //' || true
91
+ } | grep -v '^[[:space:]]*$' | grep -v "^$(mugiwara_rel)/" | drop_harness '^' | sort -u
92
+ }
93
+
94
+ # changed_loc <base-ref> — echoes "<insertions> <deletions>" over the same
95
+ # union. Untracked files count every line as an insertion.
96
+ changed_loc() {
97
+ local base="${1:-}" MUGI_REL
98
+ MUGI_REL=$(mugiwara_rel)
99
+ {
100
+ if [ -n "$base" ] && [ "$base" != "unknown" ]; then
101
+ git diff --numstat "$base"..HEAD 2>/dev/null || true
102
+ fi
103
+ git diff --numstat HEAD 2>/dev/null || true
104
+ git ls-files --others --exclude-standard 2>/dev/null | while IFS= read -r f; do
105
+ [ -f "$f" ] || continue
106
+ case "$f" in "$MUGI_REL"/*) continue ;; esac
107
+ printf '%s\t0\t%s\n' "$(wc -l < "$f" 2>/dev/null | tr -d ' ' || echo 0)" "$f"
108
+ done
109
+ } | grep -v " $MUGI_REL/" | drop_harness ' ' | awk '{ if ($1 ~ /^[0-9]+$/) i+=$1; if ($2 ~ /^[0-9]+$/) d+=$2 } END { print (i+0)" "(d+0) }'
110
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/policy-force.ts — policy-as-code helper for the bash lane logic.
3
+ // Reads changed file paths on stdin, prints the mugiwara.policy.yml
4
+ // `lanes.force_full` globs they match (comma-separated). Empty output = no
5
+ // policy hit. Exits 0 even when nothing matches; a missing bun skips the
6
+ // call site entirely (policy is optional by design).
7
+ import { readFileSync } from 'node:fs';
8
+ import { loadPolicy, matchedGlobs } from '../src/policy.ts';
9
+
10
+ const input = readFileSync(0, 'utf8');
11
+ const paths = input.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
12
+ if (!paths.length) process.exit(0);
13
+ let policy;
14
+ try {
15
+ policy = loadPolicy(process.cwd());
16
+ } catch (e) {
17
+ // Fail closed: an unreadable rule must not look like "no rule".
18
+ console.error(`policy-force: ${(e as Error).message}`);
19
+ process.exit(2);
20
+ }
21
+ const hits = policy?.lanes?.force_full?.length ? matchedGlobs(paths, policy.lanes.force_full) : [];
22
+ if (hits.length) process.stdout.write(hits.join(','));
@@ -18,7 +18,7 @@ function tokenize(text: string): string[] {
18
18
  return text.toLowerCase()
19
19
  .replace(/[^a-z0-9\s-]/g, ' ')
20
20
  .split(/\s+/)
21
- .filter(t => t.length > 1 && !['the', 'and', 'for', 'use', 'when', 'that', 'with', 'this', 'from', 'its', 'not', 'are', 'has'].includes(t));
21
+ .filter(t => t.length > 1 && !['the', 'and', 'for', 'use', 'when', 'that', 'with', 'this', 'from', 'its', 'not', 'are', 'has', 'to', 'is', 'it', 'of', 'in', 'on', 'at', 'be', 'or', 'as', 'an'].includes(t));
22
22
  }
23
23
 
24
24
  function buildIndex(): Index {