@ionivetech/mugiwara 0.6.6 → 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 (110) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -7
  3. package/.codex-plugin/plugin.json +2 -7
  4. package/.cursor-plugin/plugin.json +2 -7
  5. package/.kimi-plugin/plugin.json +2 -7
  6. package/.opencode/plugins/mugiwara.mjs +3 -18
  7. package/AGENTS.md +5 -4
  8. package/GEMINI.md +3 -3
  9. package/README.md +69 -63
  10. package/content/agents/brook-healing.md +8 -4
  11. package/content/agents/chopper-checkpoint.md +7 -3
  12. package/content/agents/eval-runner.md +4 -4
  13. package/content/agents/franky-gates.md +3 -3
  14. package/content/agents/jinbe-security.md +6 -7
  15. package/content/agents/luffy-orchestrator.md +11 -13
  16. package/content/agents/memory-keeper.md +4 -4
  17. package/content/agents/nami-planner.md +4 -4
  18. package/content/agents/resume-coordinator.md +7 -7
  19. package/content/agents/robin-reviewer.md +7 -3
  20. package/content/agents/sanji-quality.md +2 -2
  21. package/content/agents/skeptic-verifier.md +2 -2
  22. package/content/agents/usopp-brainstorm.md +3 -3
  23. package/content/agents/zoro-execution.md +6 -6
  24. package/content/skills/mugiwara-backend/SKILL.md +2 -0
  25. package/content/skills/mugiwara-backend/references/database.md +61 -0
  26. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  27. package/content/skills/mugiwara-checkpoint/SKILL.md +6 -5
  28. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
  29. package/content/skills/mugiwara-execution/SKILL.md +8 -8
  30. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  31. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  32. package/content/skills/mugiwara-frontend/SKILL.md +3 -2
  33. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  34. package/content/skills/mugiwara-gates/SKILL.md +8 -5
  35. package/content/skills/mugiwara-healing/SKILL.md +2 -2
  36. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  37. package/content/skills/mugiwara-orchestration/SKILL.md +7 -7
  38. package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
  39. package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
  40. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
  41. package/content/skills/mugiwara-planning/SKILL.md +3 -18
  42. package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
  43. package/content/skills/mugiwara-quality/SKILL.md +5 -2
  44. package/content/skills/mugiwara-resume/SKILL.md +2 -2
  45. package/content/skills/mugiwara-review/SKILL.md +3 -2
  46. package/content/skills/mugiwara-security/SKILL.md +1 -1
  47. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  48. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  49. package/content/skills/mugiwara-workflow/SKILL.md +9 -12
  50. package/content/skills/mugiwara-workflow/references/workspace-layout.md +39 -38
  51. package/dist/mugiwara.js +904 -462
  52. package/gemini-extension.json +1 -1
  53. package/hooks/auto-savepoint.js +18 -10
  54. package/hooks/auto-savepoint.ts +23 -23
  55. package/hooks/engagement-marker.js +1 -1
  56. package/hooks/engagement-marker.ts +1 -1
  57. package/hooks/pipeline-guard.js +16 -12
  58. package/hooks/pipeline-guard.ts +22 -19
  59. package/hooks/session-start.js +13 -10
  60. package/hooks/session-start.ts +24 -19
  61. package/package.json +2 -2
  62. package/plugin.json +1 -1
  63. package/references/complexity.md +27 -4
  64. package/references/definition-of-done.md +1 -1
  65. package/references/multi-actor.md +0 -3
  66. package/references/prose-style.md +54 -0
  67. package/scripts/check-doc-links.ts +40 -0
  68. package/scripts/conformance.ts +6 -16
  69. package/scripts/coverage-gate.ts +20 -6
  70. package/scripts/gate-selftest.ts +118 -7
  71. package/scripts/lane.sh +54 -8
  72. package/scripts/lib/patterns.sh +5 -0
  73. package/scripts/policy-force.ts +22 -0
  74. package/scripts/retrieval-eval.ts +1 -1
  75. package/scripts/savepoint.sh +136 -58
  76. package/scripts/validate-content.ts +22 -9
  77. package/scripts/verify-install.ts +9 -7
  78. package/src/args.ts +2 -2
  79. package/src/budget.ts +56 -0
  80. package/src/cli.ts +161 -110
  81. package/src/continue.ts +29 -10
  82. package/src/installer.ts +25 -20
  83. package/src/integrity.ts +158 -0
  84. package/src/mission.ts +228 -95
  85. package/src/policy.ts +156 -0
  86. package/src/provenance.ts +116 -0
  87. package/src/rollback.ts +95 -0
  88. package/src/routing.ts +69 -0
  89. package/src/run.ts +2 -2
  90. package/src/sign.ts +61 -0
  91. package/src/targets/claude.ts +2 -2
  92. package/.opencode/commands/mugiwara-execute.md +0 -13
  93. package/.opencode/commands/mugiwara-heal.md +0 -13
  94. package/.opencode/commands/mugiwara-onboard.md +0 -14
  95. package/.opencode/commands/mugiwara-plan.md +0 -17
  96. package/.opencode/commands/mugiwara-ship.md +0 -13
  97. package/.opencode/commands/using-mugiwara.md +0 -20
  98. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  99. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  100. package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
  101. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  102. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  103. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  104. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  105. package/content/skills/using-mugiwara/SKILL.md +0 -51
  106. package/references/token-budget.md +0 -56
  107. package/scripts/evidence.sh +0 -81
  108. package/scripts/initiative.ts +0 -296
  109. package/scripts/mission-report.sh +0 -293
  110. package/src/onboard.ts +0 -207
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/check-doc-links.ts — every relative .md link in README, ROADMAP,
3
+ // docs/, and examples/ must resolve to a file. Born from a shipped defect:
4
+ // glossary linked provenance.md from reference/ and four broken links passed
5
+ // every other gate, because verify-install covers content/ pointers only.
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+
9
+ const root = join(import.meta.dirname, "..");
10
+ function walk(d: string, out: string[] = []): string[] {
11
+ for (const e of readdirSync(d, { withFileTypes: true })) {
12
+ const p = join(d, e.name);
13
+ if (e.isDirectory()) walk(p, out);
14
+ else if (/\.md$/.test(e.name)) out.push(p);
15
+ }
16
+ return out;
17
+ }
18
+ const files: string[] = [];
19
+ for (const r of ["docs", "examples"]) files.push(...walk(join(root, r)));
20
+ for (const r of ["README.md", "ROADMAP.md"]) files.push(resolve(root, r));
21
+
22
+ let bad = 0;
23
+ for (const f of files) {
24
+ const body = readFileSync(f, "utf8");
25
+ for (const m of body.matchAll(/\]\(([^)\s]+)\)/g)) {
26
+ const t = m[1];
27
+ if (/^(https?:|mailto:|#|\/\/)/.test(t)) continue;
28
+ const clean = t.split("#")[0];
29
+ if (!clean.endsWith(".md")) continue;
30
+ if (!existsSync(resolve(f, "..", clean))) {
31
+ bad++;
32
+ console.log(`✗ ${f.replace(root + "/", "")} → ${t}`);
33
+ }
34
+ }
35
+ }
36
+ if (bad) {
37
+ console.log(`check-doc-links: ${bad} broken link(s)`);
38
+ process.exit(1);
39
+ }
40
+ console.log("check-doc-links: all relative .md links resolve");
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  // scripts/conformance.ts — C1: cross-platform conformance suite.
3
3
  // Every installable target: materialize the standard-feature fixture repo,
4
- // install the target, run the four core scripts, and compare a normalized
5
- // snapshot (state fields, report sections, evidence header, gitignore block,
6
- // file count) against test/golden/<target>.json. Exit 1 on any difference
4
+ // install the target, run the core scripts, and compare a normalized
5
+ // snapshot (state fields, gitignore block, file count) against
6
+ // test/golden/<target>.json. Exit 1 on any difference
7
7
  // with a diff; --update-golden regenerates. The snapshots are normalized:
8
8
  // timestamps, hashes, and random filenames are excluded so a golden is stable
9
9
  // across runs.
@@ -114,19 +114,13 @@ function snapshot(targetId: string): Record<string, unknown> {
114
114
  writeFileSync(exclude, (existsSync(exclude) ? readFileSync(exclude, 'utf8') : '') + '\n' + untracked.join('\n') + '\n');
115
115
  }
116
116
 
117
- // 3. run the four core scripts + the gitignore write the CLI does post-install
117
+ // 3. run the core scripts + the gitignore write the CLI does post-install
118
118
  sh(`bash "${root}/scripts/lane.sh" main --json`, dir);
119
119
  sh(`bash "${root}/scripts/savepoint.sh" ${MISSION} "" 1 auto`, dir);
120
- sh(`bash "${root}/scripts/evidence.sh" ${MISSION} lint -- printf 'ok\\n'`, dir);
121
- sh(`bash "${root}/scripts/mission-report.sh" ${MISSION}`, dir);
122
120
  ensureProjectGitignore(dir, { dryRun: false });
123
121
 
124
122
  // 4. normalize + collect
125
- const state = JSON.parse(readFileSync(join(dir, '.mugiwara', 'state', MISSION, 'state.json'), 'utf8'));
126
- const reportFile = readdirSync(join(dir, '.mugiwara', 'reports')).find(f => f.endsWith(`-${MISSION}.md`));
127
- const report = reportFile ? readFileSync(join(dir, '.mugiwara', 'reports', reportFile), 'utf8') : '';
128
- const evFile = readdirSync(join(dir, '.mugiwara', 'results', MISSION)).find(f => f.includes('lint-'));
129
- const ev = evFile ? readFileSync(join(dir, '.mugiwara', 'results', MISSION, evFile), 'utf8') : '';
123
+ const state = JSON.parse(readFileSync(join(dir, '.mugiwara', 'missions', MISSION, 'state.json'), 'utf8'));
130
124
  const gitignore = existsSync(join(dir, '.gitignore')) ? readFileSync(join(dir, '.gitignore'), 'utf8') : '';
131
125
 
132
126
  const countFiles = (p: string): number => {
@@ -161,11 +155,7 @@ function snapshot(targetId: string): Record<string, unknown> {
161
155
  budget_status: state.budget_status,
162
156
  sensitive_paths: state.sensitive_paths,
163
157
  },
164
- report_sections: [...report.matchAll(/^#{2,3} .*$/gm)].map(m => m[0]),
165
- evidence: {
166
- header: ev.split('\n').filter(l => l.startsWith('# ') && !l.startsWith('# At:') && !/^# (Exit|Verdict):/.test(l)),
167
- trailer: ev.split('\n').filter(l => /^# (Exit|Verdict):/.test(l)),
168
- },
158
+ report_sections: [],
169
159
  gitignore_block: gitignore.split('\n').filter(l => l.includes('mugiwara') || l.includes('# ---')),
170
160
  file_count: {
171
161
  skills: countFiles(target.paths({ scope: 'project', projectDir: dir, home: '' }).skillsDir),
@@ -75,10 +75,10 @@ const baseArg = process.argv.indexOf('--base');
75
75
  let base = baseArg !== -1 ? process.argv[baseArg + 1] : process.env.MUGIWARA_COVERAGE_BASE;
76
76
  if (!base) {
77
77
  // the mission's own recorded base_sha is the truthful diff origin
78
- const stateDir = join(root, '.mugiwara', 'state');
79
- if (existsSync(stateDir)) {
80
- for (const m of readdirSync(stateDir)) {
81
- const f = join(stateDir, m, 'state.json');
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
82
  if (!existsSync(f)) continue;
83
83
  const sha = JSON.parse(readFileSync(f, 'utf8')).base_sha;
84
84
  if (sha) { base = sha; break; }
@@ -140,9 +140,23 @@ for (const [k, v] of Object.entries<any>(summary)) {
140
140
  }
141
141
 
142
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.
143
145
 
144
- const NEW = threshold('coverage_new', 90);
145
- const MOD = threshold('coverage_modified', 80);
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);
146
160
 
147
161
  const rows = changed.map((c) => {
148
162
  const isNew = c.status === 'A';
@@ -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);
package/scripts/lane.sh CHANGED
@@ -19,17 +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
+ # 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
28
+
22
29
  # union of committed + staged + unstaged + untracked (F) — see patterns.sh
23
30
  CHANGED=$(changed_files "$BASE")
24
- FILE_COUNT=0
25
- [ -n "$CHANGED" ] && FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
26
- read -r ADDED_INS ADDED_DEL <<EOF
27
- $(changed_loc "$BASE")
28
- EOF
29
-
31
+ # sensitive-path escalation always evaluates the unfiltered set (safety never shrinks)
30
32
  SENSITIVE=$(echo "$CHANGED" | grep -E "$SENSITIVE_PATS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
31
33
  HAS_SENSITIVE=0
32
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
33
62
 
34
63
  # lane logic
35
64
  LANE="direct"
@@ -71,8 +100,9 @@ fi
71
100
  # code surface actually touched. Sensitive-path escalation above still wins.
72
101
  # Product surface for mugiwara: content/, src/, scripts/, test/, hooks/,
73
102
  # .opencode/, .claude/, evals/ — everything else is docs/config/asset.
74
- if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$CHANGED" ]; then
75
- 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)
76
106
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
77
107
  PREV="$LANE"
78
108
  LANE="standard"
@@ -80,6 +110,22 @@ if [ "$LANE" = "full" ] && [ "$HAS_SENSITIVE" -eq 0 ] && [ -n "$CHANGED" ]; then
80
110
  fi
81
111
  fi
82
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
+
83
129
  if [ "$JSON_OUT" -eq 1 ]; then
84
130
  SENS_ARR=""
85
131
  [ -n "$SENSITIVE" ] && SENS_ARR=$(echo "$SENSITIVE" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
@@ -31,6 +31,11 @@ PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^e
31
31
  # (numstat rows).
32
32
  HARNESS_PAT="\.claude/|\.opencode/|\.github/(instructions|agents)/|\.gemini/mugiwara/|\.codex/mugiwara/|\.devin/rules/|\.clinerules/|\.kilo/rules/|\.agents/rules/"
33
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
+
34
39
  # harness_excluded — true (exit 0) when harness rules dirs must NOT count
35
40
  # toward mission sizing, i.e. this repo is not mugiwara itself. Identity comes
36
41
  # from the repo root package.json name ("mugiwara" or "@scope/mugiwara"), the
@@ -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 {