@ionivetech/mugiwara 0.9.0 → 0.9.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 (46) 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 +1 -1
  7. package/.opencode/plugins/mugiwara.mjs +173 -1
  8. package/README.md +4 -4
  9. package/content/agents/luffy-orchestrator.md +15 -1
  10. package/content/agents/zoro-execution.md +1 -1
  11. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
  12. package/content/skills/mugiwara-execution/SKILL.md +5 -5
  13. package/content/skills/mugiwara-gates/SKILL.md +1 -0
  14. package/content/skills/mugiwara-healing/SKILL.md +1 -0
  15. package/content/skills/mugiwara-orchestration/SKILL.md +7 -3
  16. package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
  17. package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
  18. package/content/skills/mugiwara-planning/SKILL.md +1 -1
  19. package/content/skills/mugiwara-planning/references/sub-missions.md +2 -2
  20. package/content/skills/mugiwara-quality/SKILL.md +1 -0
  21. package/content/skills/mugiwara-review/SKILL.md +2 -0
  22. package/content/skills/mugiwara-security/SKILL.md +2 -2
  23. package/content/skills/mugiwara-ship/SKILL.md +12 -0
  24. package/content/skills/mugiwara-workflow/SKILL.md +2 -2
  25. package/dist/mugiwara.js +235 -82
  26. package/gemini-extension.json +1 -1
  27. package/hooks/engagement-marker.js +9 -1
  28. package/hooks/engagement-marker.ts +9 -1
  29. package/hooks/hooks.json +12 -0
  30. package/hooks/pipeline-guard.js +137 -3
  31. package/hooks/pipeline-guard.ts +161 -3
  32. package/hooks/pretool-guard.js +84 -0
  33. package/hooks/pretool-guard.ts +60 -0
  34. package/package.json +1 -1
  35. package/plugin.json +1 -1
  36. package/references/wave-banners.md +22 -27
  37. package/scripts/build-hooks.ts +1 -1
  38. package/scripts/gate-selftest.ts +342 -0
  39. package/scripts/savepoint.sh +16 -4
  40. package/scripts/validate-content.ts +190 -15
  41. package/scripts/write-metrics.ts +25 -1
  42. package/src/cli.ts +15 -0
  43. package/src/config.ts +1 -1
  44. package/src/guards.ts +40 -0
  45. package/src/initiative.ts +174 -0
  46. package/src/targets/claude.ts +1 -0
@@ -187,10 +187,18 @@ for (const doc of ['README.md', 'docs/index.md', 'docs/concepts/agents.md']) {
187
187
  // --- hub-rule gate (F3): every non-Luffy agent carries both hub sections ---
188
188
  for (const f of agentFiles) {
189
189
  const name = f.replace(/\.md$/, '');
190
- if (name === 'luffy-orchestrator') continue;
191
190
  const text = readFileSync(join(agentDir, f), 'utf8');
191
+ // Entry protocol: EVERY agent, Luffy included. Exempting him is what let a
192
+ // captain with no pre-flight checklist ship. (E2)
192
193
  if (!text.includes('## Before you start')) errors.push(`agent ${f}: missing "## Before you start" entry protocol`);
193
- if (!text.includes('## Return to Luffy')) errors.push(`agent ${f}: missing "## Return to Luffy" hub rule`);
194
+ // Return-to-Luffy: every agent EXCEPT Luffy he cannot return to himself.
195
+ if (name !== 'luffy-orchestrator' && !text.includes('## Return to Luffy')) {
196
+ errors.push(`agent ${f}: missing "## Return to Luffy" hub rule`);
197
+ }
198
+ // Luffy carries the routing counterpart instead.
199
+ if (name === 'luffy-orchestrator' && !text.includes('Brainstorm is Usopp')) {
200
+ errors.push('agent luffy-orchestrator: missing the "never do another crew member\'s work" routing rule');
201
+ }
194
202
  }
195
203
 
196
204
  // --- hub-skill gate (F3): every agent lists mugiwara-orchestration (the hub rule's home) ---
@@ -440,20 +448,68 @@ if (integrityArg !== -1) {
440
448
  errors.push(`doc-integrity: README rank-1 ${rankMatch2[1]}% != metrics ${m2.retrieval_rank1}%`);
441
449
  }
442
450
  }
443
- // stale CLI commands: any `mugiwara <word>` where word is not a valid CLI case, appearing as code, is stale
444
- const validCmds = new Set(['install','update','uninstall','list','reset','archive','clean','continue','status','cost','run','savepoint','blame','handoff','sign','migrate','lesson','help','version','mode','off']);
445
- const docsToScan = ['docs/concepts/workflow.md','docs/concepts/config.md','README.md','references/multi-actor.md'];
446
- for (const doc of docsToScan) {
447
- const p = join(import.meta.dirname, '..', doc);
451
+ // N4: a skill that instructs `mugiwara <cmd>` when the CLI has no such case is an
452
+ // instruction the agent cannot follow. This is how `initiative` shipped as a
453
+ // dangling reference. Cases are read from the CLI source, not hardcoded.
454
+ const cliSrc = readFileSync(join(import.meta.dirname, '..', 'src', 'cli.ts'), 'utf8');
455
+ // In-session phrases, not CLI verbs — see mugiwara-workflow.
456
+ const IN_SESSION = new Set(['mode', 'off']);
457
+ const referenced = new Set<string>();
458
+ const walkMarkdown = (dir: string): string[] =>
459
+ listFiles(dir).filter((f) => f.endsWith('.md')).map((f) => join(dir, f));
460
+ for (const dir of ['content', 'docs', 'references']) {
461
+ for (const file of walkMarkdown(join(import.meta.dirname, '..', dir))) {
462
+ const text = readFileSync(file, 'utf8');
463
+ for (const m of text.matchAll(/`mugiwara ([a-z-]+)/g)) referenced.add(m[1]);
464
+ }
465
+ }
466
+ // Also scan repo-root markdown (README, AGENTS) — same defect class.
467
+ for (const file of ['README.md', 'AGENTS.md']) {
468
+ const p = join(import.meta.dirname, '..', file);
448
469
  if (!existsSync(p)) continue;
449
- const txt = readFileSync(p, 'utf8');
450
- for (const m of txt.matchAll(/`mugiwara ([a-z-]+)/g)) {
451
- const cmd = m[1];
452
- if (!validCmds.has(cmd) && cmd !== '--help' && cmd !== '--version') {
453
- errors.push(`doc-integrity: ${doc} contains stale command "mugiwara ${cmd}" not in src/cli.ts`);
454
- }
470
+ for (const m of readFileSync(p, 'utf8').matchAll(/`mugiwara ([a-z-]+)/g)) referenced.add(m[1]);
471
+ }
472
+ for (const cmd of referenced) {
473
+ if (IN_SESSION.has(cmd)) continue;
474
+ if (cmd.startsWith('--')) continue;
475
+ if (!cliSrc.includes(`case '${cmd}'`)) {
476
+ errors.push(`doc-integrity: docs instruct "mugiwara ${cmd}" but src/cli.ts has no case '${cmd}'`);
477
+ }
478
+ }
479
+ // N2 banner-format: no raw ANSI escapes in model-facing instructions. The
480
+ // colour table in wave-banners.md is data for the plugin, not an
481
+ // instruction — it holds hex, never escapes, so no exemption is needed.
482
+ // N8 in-session phrases must never read as slash commands.
483
+ const proseFiles: string[] = [];
484
+ for (const dir of ['content', 'docs', 'references']) {
485
+ proseFiles.push(...walkMarkdown(join(import.meta.dirname, '..', dir)));
486
+ }
487
+ for (const file of ['README.md', 'AGENTS.md']) {
488
+ const p = join(import.meta.dirname, '..', file);
489
+ if (existsSync(p)) proseFiles.push(p);
490
+ }
491
+ for (const file of proseFiles) {
492
+ const text = readFileSync(file, 'utf8');
493
+ if (/\\x1b\[|38;2;|38;5;/.test(text)) {
494
+ errors.push(`doc-integrity: ${file} instructs raw ANSI escapes the model cannot emit — banners are plain headings`);
495
+ }
496
+ // `/mugiwara continue` is a real CLI verb and out of scope — only the mode
497
+ // switch is an in-session phrase, so only its slash forms are flagged.
498
+ if (/`\/(mugiwara mode|mugiwara (guided|semi|auto))/.test(text)) {
499
+ errors.push(`doc-integrity: ${file} writes the in-session mode phrase as a slash command — say "mugiwara mode <level>" in session, no slash, no CLI flag`);
455
500
  }
456
501
  }
502
+ // N5: the flow-summary contract must exist — it is what keeps normal
503
+ // verbosity to one line per stage.
504
+ const orchSkill = readFileSync(join(import.meta.dirname, '..', 'content', 'skills', 'mugiwara-orchestration', 'SKILL.md'), 'utf8');
505
+ if (!orchSkill.includes('## Flow summary line')) {
506
+ errors.push('doc-integrity: mugiwara-orchestration SKILL.md lost its "## Flow summary line" contract');
507
+ }
508
+ // N9: the platform count must stay qualified — 9 installable + 3 marketplace.
509
+ const readme = readFileSync(join(import.meta.dirname, '..', 'README.md'), 'utf8');
510
+ if (readme.includes('12 platforms') && !readme.includes('via marketplace manifest')) {
511
+ errors.push('doc-integrity: README "12 platforms" is unqualified — split 9 via install + 3 via marketplace manifest');
512
+ }
457
513
  }
458
514
  }
459
515
 
@@ -537,7 +593,52 @@ if (process.argv.includes('--check-config')) {
537
593
  errors.push(`config-drift: docs key "${k}" not found in code`);
538
594
  }
539
595
  }
540
- if (!errors.some(e => e.startsWith('config-drift'))) {
596
+ // N6: key parity is not value parity. A documented enum value the code rejects
597
+ // falls back silently — the user gets the default and no error. Compare both
598
+ // directions. (auto_commit is advisory-only by design — no code allowlist
599
+ // exists, so there is nothing to compare.)
600
+ const configMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : '';
601
+ const savepointSh = readFileSync(join(import.meta.dirname, '..', 'scripts', 'savepoint.sh'), 'utf8');
602
+ const parseDocumentedValues = (key: string): string[] => {
603
+ const m = configMd.match(new RegExp(`^\\|\\s*\`${key}\`\\s*\\|\\s*([^|]+)\\|`, 'm'));
604
+ if (!m) return [];
605
+ return m[1].split('/').map((v) => v.trim()).filter(Boolean);
606
+ };
607
+ const parseShellAllowlist = (varName: string): string[] => {
608
+ const m = savepointSh.match(new RegExp(`case "\\$${varName}" in\\s*([^)]+)\\)`));
609
+ if (!m) return [];
610
+ return m[1].split(/[|\s]+/).map((v) => v.trim()).filter(Boolean);
611
+ };
612
+ const parseTsUnion = (file: string, typeName: string, extra: string[] = [], exclude: RegExp | null = null): string[] => {
613
+ const p = join(import.meta.dirname, '..', file);
614
+ if (!existsSync(p)) return [];
615
+ const src = readFileSync(p, 'utf8');
616
+ const m = src.match(new RegExp(`type ${typeName} = ([^;]+);`));
617
+ if (!m) return [];
618
+ const vals = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
619
+ return [...new Set([...vals, ...extra])].filter((v) => !(exclude && exclude.test(v)));
620
+ };
621
+ const ENUM_CHECKS: Array<{ key: string; accepted: string[] }> = [
622
+ { key: 'mode', accepted: parseShellAllowlist('MODE') },
623
+ { key: 'verbosity', accepted: parseShellAllowlist('VERBOSITY') },
624
+ { key: 'review_depth', accepted: parseShellAllowlist('DEPTH_REVIEW') },
625
+ { key: 'quality_depth', accepted: parseShellAllowlist('DEPTH_QUALITY') },
626
+ { key: 'verify_merged', accepted: parseShellAllowlist('DEPTH_VERIFY') },
627
+ // sign allowlist lives in TypeScript: read the exported union, not grep.
628
+ // 'minisign-fail' is internal (never a valid config value); 'auto' is an
629
+ // explicit resolveBackend case, so it counts as accepted.
630
+ { key: 'sign', accepted: parseTsUnion('src/sign.ts', 'BackendChoice', ['auto'], /-fail$/) },
631
+ { key: 'enforce', accepted: parseTsUnion('hooks/pipeline-guard.ts', 'Enforce') },
632
+ ];
633
+ for (const { key, accepted } of ENUM_CHECKS) {
634
+ const documented = parseDocumentedValues(key);
635
+ if (!documented.length || !accepted.length) continue;
636
+ const missing = documented.filter((v) => !accepted.includes(v));
637
+ const undocumented = accepted.filter((v) => !documented.includes(v));
638
+ if (missing.length) errors.push(`config ${key}: documented but rejected by code: ${missing.join(', ')}`);
639
+ if (undocumented.length) errors.push(`config ${key}: accepted by code but undocumented: ${undocumented.join(', ')}`);
640
+ }
641
+ if (!errors.some(e => e.startsWith('config-drift') || e.startsWith('config '))) {
541
642
  console.log(`✓ config in sync: ${defaultKeys.length} keys (${defaultKeys.join(', ')})`);
542
643
  }
543
644
  }
@@ -570,7 +671,9 @@ if (process.argv.includes('--check-wiring')) {
570
671
  if (full.endsWith(`/src/${f}`) || full === join(srcDir, f)) continue;
571
672
  try {
572
673
  const txt = readFileSync(full, 'utf8');
573
- if (txt.includes(`'./${stem}`) || txt.includes(`"./${stem}`) || txt.includes(`'./${stem}.ts'`) || txt.includes(`"./${stem}.ts"`)) {
674
+ // hooks import shared code as '../src/<stem>' (bundled by build-hooks)
675
+ // — that is a first-class wire, not a dangling module.
676
+ if (txt.includes(`'./${stem}`) || txt.includes(`"./${stem}`) || txt.includes(`'./${stem}.ts'`) || txt.includes(`"./${stem}.ts"`) || txt.includes(`'../src/${stem}.ts'`) || txt.includes(`"../src/${stem}.ts"`)) {
574
677
  found = true;
575
678
  break;
576
679
  }
@@ -645,6 +748,78 @@ if (process.argv.includes('--check-readme-metrics')) {
645
748
  }
646
749
  }
647
750
 
751
+ // --- invariant-mechanism gate (E-gaps 5.2): every never/always/MUST has a mechanism ---
752
+ // A rule with no machine behind it and no prose-only entry is a gap, not a
753
+ // rule. Concepts live in docs/concepts/enforcement.md; each concept below
754
+ // must have its anchor there, and every never/always/MUST line in content/
755
+ // must match at least one concept. Adding a rule = table row + concept here
756
+ // + mutation in gate-selftest.ts.
757
+ if (process.argv.includes('--check-invariants')) {
758
+ const enfPath = join(import.meta.dirname, '..', 'docs', 'concepts', 'enforcement.md');
759
+ const enf = existsSync(enfPath) ? readFileSync(enfPath, 'utf8') : '';
760
+ if (!enf) errors.push('invariant gate: docs/concepts/enforcement.md is missing');
761
+ const CONCEPTS: Array<{ id: string; re: RegExp; anchor: string }> = [
762
+ { id: 'INV-triage', re: /triage|savepoint|flow 0/i, anchor: 'hooks/pipeline-guard.js' },
763
+ { id: 'INV-write-scope', re: /write-scope|delegat.*zoro|another crew member|crew member's (work|job)|dispatch another crew|one role at a time|embodies one|never.*mutat|mode: all|subagent|never forward|never dispatch|never.*route|never execute source/i, anchor: 'INV-write-scope' },
764
+ { id: 'INV-luffy-hub', re: /return to luffy|luffy routes?|routes? .*luffy|orchestrator|route reasons|check-in|decision log|next_action|flow stage|omitted|in-flight|exits 2/i, anchor: 'INV-hub' },
765
+ { id: 'INV-plan-nami', re: /only nami|nami's|without a GO|executor without/i, anchor: 'INV-plan-nami' },
766
+ { id: 'INV-banner', re: /banner/i, anchor: 'INV-banner' },
767
+ { id: 'INV-no-deploy', re: /deploys?|merging?|creates a PR|gh pr|push.*branch|terminal step|reaches a user|ship.*user/i, anchor: 'hooks/pretool-guard.js' },
768
+ { id: 'INV-heal-cap', re: /heal|4-phase|reproduce.*localize/i, anchor: 'INV-heal-cap' },
769
+ { id: 'INV-lane', re: /\blane\b|mission split|sub-mission|parallel.*safe|never.*parallel|\[PARALLEL\]|shortcut|full pipeline/i, anchor: 'INV-lane' },
770
+ { id: 'INV-evidence', re: /evidence|re-run|re run|claim|never validate|doubt|fresh (context|agent)|adversarial|no pass/i, anchor: 'INV-evidence' },
771
+ { id: 'INV-mode', re: /auto.?commit|auto mode|guided|semi|mode.*flip|steps cap|verbosity|auto never|never.*auto|never ask/i, anchor: 'INV-mode' },
772
+ { id: 'INV-quality', re: /weaken|threshold|coverage|lint|fake pass|silent.*pass|duplicat|complexity|dead code|strict|sonar|maintainability|assert green|failing suite|default-on|trigger|may raise|fixed numbers|invent tooling/i, anchor: 'INV-quality' },
773
+ { id: 'INV-tests', re: /\btdd\b|failing first|immutable|oracle|user.?test|flaky|intermittent|never.*test\b|gherkin|feature file|banned|translate-or-command|long unreachable|can never prove|never saw|small steps|never create inte|integration tests/i, anchor: 'INV-tests' },
774
+ { id: 'INV-resume', re: /restart|resume|continue\.json|continue from|never restarts?|never scan|state proves/i, anchor: 'INV-resume' },
775
+ { id: 'INV-english', re: /always english|one language only|conversational language/i, anchor: 'INV-english' },
776
+ { id: 'INV-plan-discipline', re: /zero-question|unverified path|\btbd\b|plan above or below|40-file|task index|stranger must|plan.*hole|regression|correctness.*break|never.*plan|never appended|2000\+ lines/i, anchor: 'INV-plan-discipline' },
777
+ { id: 'INV-security-contract', re: /secret|sanitiz|authz|\.safeParse|trust|inject|data, never|finding, not|dangerouslySetInnerHTML|owasp|stride|exploit|permission|pii|never trust|minor by default|severity|gets the matrix|expiry|revoke/i, anchor: 'INV-security-contract' },
778
+ { id: 'INV-git-hygiene', re: /commit|git add|revert|broken tree|staging|micro-commit|never.*tree|force-add|gitignore/i, anchor: 'INV-git-hygiene' },
779
+ { id: 'INV-conduct', re: /ego|yes-man|interrogat|trade-off|assume silently|batched question|sparring|recommendation|reconsider|pushes back|silently defer|verdict only/i, anchor: 'INV-conduct' },
780
+ { id: 'INV-mirror', re: /todo.*mirror|same response|transcript.*sufficient|task N\/M|echoing raw|compact.*table|report table|evidence link|never changes|always visible|audit surface|audit trail|never narrows|one-liner|mid-argument|overwrite|detailed summary|lags|never seeded|list never|must always see|never depends/i, anchor: 'INV-mirror' },
781
+ { id: 'INV-trust', re: /redefine.*rule|artifact trust|untrusted|HIGH trust|LOW-trust|instruction.*data|verbatim instructions|lesson/i, anchor: 'INV-trust' },
782
+ { id: 'INV-role', re: /never implements|never fixes|outside your role|luffy's, always|who never|never does what|11th member|finding yourself|coordinator|auditor/i, anchor: 'INV-role' },
783
+ { id: 'INV-role-conduct', re: /never refuse|never file|not verdicts|input, not|plain |generic assistant|embodies roles|fix the SKILL, never/i, anchor: 'INV-role-conduct' },
784
+ { id: 'INV-execution-misc', re: /inline.*main thread|worker|sequential|main thread IS the crew|frame persists|never drop the roles|inspection.*only|no network|no shell|read-only|pre-flow|never dispatch|wave|dispatch.*flow|never create config|never print|control-command|mid-task|posture/i, anchor: 'INV-execution-model' },
785
+ { id: 'INV-debug', re: /repro|root cause|symptom|minimal change|one theory|no debugging/i, anchor: 'INV-debug' },
786
+ { id: 'INV-a11y', re: /alt=|outline|aria|reduced-motion|contrast|gray-100|role\/label|focus|color-only/i, anchor: 'INV-a11y' },
787
+ { id: 'INV-code-facts', re: /operator|operand|almost always a bug|≠/i, anchor: 'INV-code-facts' },
788
+ { id: 'INV-contract', re: /contract|additive|versions|bump|deprecated/i, anchor: 'INV-contract' },
789
+ { id: 'INV-backend', re: /migration|ad-hoc|atomic|pagination|unbounded|N\+1|eager-load|invalidation|buffer whole|timeouts|cancellation|hang/i, anchor: 'INV-backend' },
790
+ ];
791
+ for (const c of CONCEPTS) {
792
+ if (enf && !enf.includes(c.anchor)) errors.push(`invariant gate: concept ${c.id} has no mechanism row in enforcement.md (anchor "${c.anchor}")`);
793
+ }
794
+ // The matrix documents the per-tier side of the hook concepts — a removed
795
+ // guard row must fail this gate, not slip through as prose.
796
+ for (const row of ['Irreversible-command guard', 'Turn-end enforcement']) {
797
+ const matrix = readFileSync(join(import.meta.dirname, '..', 'docs', 'reference', 'harness-matrix.md'), 'utf8');
798
+ if (!matrix.includes(row)) errors.push(`invariant gate: harness-matrix.md lost its "${row}" row`);
799
+ }
800
+ const lineRe = /\bnever\b|\balways\b|MUST/;
801
+ const scanRoots = [join(root, 'skills'), join(root, 'agents')];
802
+ const unreg: string[] = [];
803
+ const seen = new Set<string>();
804
+ const walkInv = (dir: string) => {
805
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
806
+ const full = join(dir, e.name);
807
+ if (e.isDirectory()) { walkInv(full); continue; }
808
+ if (!e.name.endsWith('.md')) continue;
809
+ for (const line of readFileSync(full, 'utf8').split(/\r?\n/)) {
810
+ if (!lineRe.test(line)) continue;
811
+ const key = line.trim();
812
+ if (seen.has(key)) continue;
813
+ seen.add(key);
814
+ if (!CONCEPTS.some((c) => c.re.test(line))) unreg.push(`${full.replace(root + '/', '')}: ${key.slice(0, 100)}`);
815
+ }
816
+ }
817
+ };
818
+ for (const d of scanRoots) walkInv(d);
819
+ for (const u of unreg) errors.push(`invariant without mechanism: ${u} — add a concept row in enforcement.md + a bucket above`);
820
+ if (!unreg.length && enf) console.log(`✓ invariants: every never/always/MUST maps to a mechanism row`);
821
+ }
822
+
648
823
  // Conditional-assertion guard: an expect() reachable only inside a truthiness
649
824
  // check silently passes when the value is absent. This class produced 9 defects.
650
825
  // Allowed: checks keyed on a declared invariant (tier, fixture keys).
@@ -3,7 +3,7 @@
3
3
  // Deterministic, no network. Runs retrieval-eval --json and verify-install --json.
4
4
 
5
5
  import { execSync } from 'node:child_process';
6
- import { mkdirSync, writeFileSync } from 'node:fs';
6
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
 
9
9
  const root = join(import.meta.dirname, '..');
@@ -71,3 +71,27 @@ const outPath = join(outDir, 'latest.json');
71
71
  writeFileSync(outPath, JSON.stringify(metrics, null, 2) + '\n');
72
72
  console.log(`✓ wrote ${outPath}`);
73
73
  console.log(JSON.stringify(metrics, null, 2));
74
+
75
+ // Sync the README metrics table in the same run, so neither AI nor human
76
+ // opens a PR with stale numbers (the classic first-push CI red). The
77
+ // --check-readme-metrics gate stays as the backstop: if these patterns stop
78
+ // matching, it fails loudly instead of silently drifting.
79
+ {
80
+ const readmePath = join(root, 'README.md');
81
+ const before = readFileSync(readmePath, 'utf8');
82
+ let after = before;
83
+ after = after.replace(
84
+ /\*\*[\d.]+%\*\*, \d+ probes/,
85
+ `**${rank1Str}**, ${probes} probes`,
86
+ );
87
+ after = after.replace(
88
+ /\*\*\d+\/\d+\*\*(, \d+ targets)/,
89
+ `**${pointersTotal}/${pointersTotal}**$1`,
90
+ );
91
+ if (after !== before) {
92
+ writeFileSync(readmePath, after);
93
+ console.log('✓ README metrics table synced');
94
+ } else {
95
+ console.log('✓ README metrics table already current');
96
+ }
97
+ }
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ import { ensureConfig } from './config.ts';
19
19
  import { costEnvelope } from './cost.ts';
20
20
  import { computeLiveSlop } from './slop.ts';
21
21
  import { loadRegistry } from './evidence.ts';
22
+ import { runInitiative } from './initiative.ts';
22
23
  import { buildCostLedger, toCostJSON } from './reporting.ts';
23
24
  import { enforceHarnessPolicy } from './policy.ts';
24
25
 
@@ -82,6 +83,7 @@ export async function run(argv: string[]): Promise<void> {
82
83
  case 'sign': return signCmd(flags, _);
83
84
  case 'migrate': return migrateCmd(flags, _);
84
85
  case 'lesson': return lessonCmd(flags, _);
86
+ case 'initiative': return initiativeCmd(flags, _);
85
87
  default: throw new Error(`Unknown command: ${command}`);
86
88
  }
87
89
  }
@@ -197,7 +199,13 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
197
199
  targetIds = idx.includes(0) ? [...TARGET_IDS] : idx.map(i => TARGET_IDS[i - 1]);
198
200
  }
199
201
  }
202
+ const MARKETPLACE = new Set(['cursor', 'kimi', 'pi']);
200
203
  for (const id of targetIds) {
204
+ if (MARKETPLACE.has(id)) {
205
+ console.error(`mugiwara: ${id} installs through its marketplace manifest, not --target.`);
206
+ console.error(' See docs/reference/harness-matrix.md — marketplace row.');
207
+ process.exit(1);
208
+ }
201
209
  if (!targets[id]) throw new Error(`Unknown target: ${id} (valid: ${TARGET_IDS.join(', ')}, all)`);
202
210
  }
203
211
 
@@ -604,6 +612,13 @@ function lessonCmd(flags: Args['flags'], positionals: string[]): void {
604
612
  console.log(`lesson appended: ${line}`);
605
613
  }
606
614
 
615
+ /** `mugiwara initiative <status|conflict-check> <plan>` — sub-mission checks. */
616
+ function initiativeCmd(_flags: Args['flags'], positionals: string[]): void {
617
+ const r = runInitiative(positionals[1], positionals[2]);
618
+ process.stdout.write(r.output);
619
+ if (r.code !== 0) process.exit(r.code);
620
+ }
621
+
607
622
  export function migrateCmd(flags: Args['flags'], positionals: string[] = []): void {
608
623
  const projectDir = resolveProjectDir(str(flags.project));
609
624
  const dryRun = flag(flags.dryRun);
package/src/config.ts CHANGED
@@ -23,7 +23,7 @@ export const DEFAULT_CONFIG = [
23
23
  '# -- Git --------------------------------------------------',
24
24
  'branch=feature/{type}-{issue}-{slug}',
25
25
  'commit=conventional',
26
- 'auto_commit=on # on | off — off hands you an uncommitted tree in guided/semi',
26
+ 'auto_commit=off # on | off — off hands you an uncommitted tree in guided/semi',
27
27
  '',
28
28
  '# -- Gates ------------------------------------------------',
29
29
  'coverage_new=85',
package/src/guards.ts ADDED
@@ -0,0 +1,40 @@
1
+ // src/guards.ts — shared irreversible-command predicates (E4, E5).
2
+ //
3
+ // Single source of truth for the FORBIDDEN table. hooks/pretool-guard.ts
4
+ // imports it (bundled into the .js by build-hooks). The opencode plugin
5
+ // (.opencode/plugins/mugiwara.mjs) embeds a copy of the table delimited by
6
+ // the same GUARDS-TABLE markers — test/plugin.test.ts asserts the two blocks
7
+ // are byte-identical, so a drift fails CI instead of silently forking
8
+ // enforcement across harnesses.
9
+
10
+ export const FORBIDDEN: Array<[RegExp, string]> = [
11
+ // GUARDS-TABLE-START
12
+ [/\bgh\s+pr\s+(create|merge|ready)\b/, 'opening or merging a PR'],
13
+ [/\bgh\s+release\s+create\b/, 'creating a release'],
14
+ [/\bgit\s+merge\b/, 'merging a branch'],
15
+ [/\bgit\s+push\b[^|;&]*\b(main|master|production|release)\b/, 'pushing to a protected branch'],
16
+ [/\bgit\s+push\b[^|;&]*--force/, 'force-pushing'],
17
+ [/\bnpm\s+publish\b|\byarn\s+publish\b|\bpnpm\s+publish\b/, 'publishing a package'],
18
+ [/\bkubectl\s+(apply|delete|rollout)\b/, 'changing a cluster'],
19
+ [/\bterraform\s+(apply|destroy)\b/, 'changing infrastructure'],
20
+ [/\bdocker\s+push\b/, 'pushing an image'],
21
+ [/\baws\s+\w+\s+(create|delete|update|put)\b/, 'changing cloud resources'],
22
+ // GUARDS-TABLE-END
23
+ ];
24
+
25
+ /** The refused action for a shell command, or null when it may run. */
26
+ export function checkCommand(command: string): string | null {
27
+ for (const [re, action] of FORBIDDEN) {
28
+ if (re.test(command)) return action;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Deny message: names the action, the human terminal step, the escape hatch. */
34
+ export function refusalMessage(action: string): string {
35
+ return (
36
+ `Mugiwara: refusing to ${action}. The crew never creates a PR, merges, or ` +
37
+ `deploys — the human does, from the branch and the verdict the crew hands over. ` +
38
+ `Run it yourself, or set enforce=off in .mugiwara/config.`
39
+ );
40
+ }
@@ -0,0 +1,174 @@
1
+ // src/initiative.ts — sub-mission dashboard + conflict detection (N1).
2
+ //
3
+ // Flow 2 produces a `## Sub-missions` table on team missions; nothing checked
4
+ // it after the original command was deleted. `status` renders the dashboard,
5
+ // `conflict-check` exits 1 when one file is touched by two sub-missions.
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+
8
+ export interface SubMission {
9
+ id: string;
10
+ name: string;
11
+ assignee: string;
12
+ branch: string;
13
+ status: string;
14
+ dependsOn: string;
15
+ touchedFiles: string[];
16
+ }
17
+
18
+ /** Canonical header — printed as the hint when a table fails to parse. */
19
+ export const SUB_MISSIONS_HEADER =
20
+ '| ID | Name | Assignee | Branch | Status | Depends On | Touched Files |';
21
+
22
+ /** Split a Touched Files cell on commas AND whitespace; no trailing commas survive. */
23
+ export function splitTouchedFiles(cell: string): string[] {
24
+ return cell
25
+ .split(/[,\s]+/)
26
+ .map((s) => s.trim().replace(/,+$/, ''))
27
+ .filter(Boolean);
28
+ }
29
+
30
+ export interface ParseResult {
31
+ /** True when a `## Sub-missions` section exists (any case). */
32
+ hasSection: boolean;
33
+ rows: SubMission[];
34
+ }
35
+
36
+ function splitRow(line: string): string[] {
37
+ let t = line.trim();
38
+ if (t.startsWith('|')) t = t.slice(1);
39
+ if (t.endsWith('|')) t = t.slice(0, -1);
40
+ return t.split('|').map((c) => c.trim());
41
+ }
42
+
43
+ /**
44
+ * Parse the `## Sub-missions` table. Header match is case-insensitive
45
+ * (`| id | name |` and `| ID | Name |` both work). Returns zero rows — not an
46
+ * error here — when the section exists but no data rows parse; the caller
47
+ * decides what that means (conflict-check treats it as a defect, never as a
48
+ * solo mission).
49
+ */
50
+ export function parseSubMissions(planText: string): ParseResult {
51
+ const lines = planText.split('\n');
52
+ const sectionIdx = lines.findIndex((l) => /^##\s+sub-missions\s*$/i.test(l.trim()));
53
+ if (sectionIdx < 0) return { hasSection: false, rows: [] };
54
+ const endIdx = lines.findIndex((l, i) => i > sectionIdx && /^##\s+\S/.test(l.trim()));
55
+ const body = lines.slice(sectionIdx + 1, endIdx < 0 ? undefined : endIdx);
56
+
57
+ const isTableLine = (l: string): boolean => /^\s*\|.*\|\s*$/.test(l);
58
+ const isSeparator = (l: string): boolean => /^\s*\|?[\s:|-]+\|?[\s:|.-]*$/.test(l) && /-/.test(l);
59
+ const table = body.filter((l) => isTableLine(l));
60
+ if (!table.length) return { hasSection: true, rows: [] };
61
+ const header = splitRow(table[0]).map((c) => c.toLowerCase());
62
+ // Case-insensitive header match: must at least identify id + name columns.
63
+ const idIdx = header.findIndex((c) => c === 'id');
64
+ const nameIdx = header.findIndex((c) => c === 'name');
65
+ if (idIdx < 0 || nameIdx < 0) return { hasSection: true, rows: [] };
66
+ const col = (name: string, fallback: number): number => {
67
+ const i = header.findIndex((c) => c === name);
68
+ return i < 0 ? fallback : i;
69
+ };
70
+ const touchedIdx = header.findIndex((c) => /touch/.test(c));
71
+ const dataStart = table.length > 1 && isSeparator(table[1]) ? 2 : 1;
72
+ const rows: SubMission[] = [];
73
+ for (const line of table.slice(dataStart)) {
74
+ if (isSeparator(line)) continue;
75
+ const cells = splitRow(line);
76
+ const id = cells[idIdx] ?? '';
77
+ if (!id) continue;
78
+ rows.push({
79
+ id,
80
+ name: cells[nameIdx] ?? '',
81
+ assignee: cells[col('assignee', 2)] ?? '',
82
+ branch: cells[col('branch', 3)] ?? '',
83
+ status: cells[col('status', 4)] ?? '',
84
+ dependsOn: cells[col('depends on', 5)] ?? '',
85
+ touchedFiles: touchedIdx < 0 ? [] : splitTouchedFiles(cells[touchedIdx] ?? ''),
86
+ });
87
+ }
88
+ return { hasSection: true, rows };
89
+ }
90
+
91
+ const DONE = /(\[x\]|done|complete|merged|closed)/i;
92
+
93
+ /** Rows whose Depends On names an unfinished sub-mission. */
94
+ export function blockedRows(rows: SubMission[]): Array<{ id: string; blockedBy: string }> {
95
+ const statusOf = new Map(rows.map((r) => [r.id.toLowerCase(), r.status]));
96
+ const out: Array<{ id: string; blockedBy: string }> = [];
97
+ for (const r of rows) {
98
+ const dep = r.dependsOn.trim();
99
+ if (!dep || dep === '-') continue;
100
+ for (const part of dep.split(/[,\s]+/).filter(Boolean)) {
101
+ const st = statusOf.get(part.toLowerCase());
102
+ if (st !== undefined && !DONE.test(st)) {
103
+ out.push({ id: r.id, blockedBy: part });
104
+ break;
105
+ }
106
+ if (st === undefined && !DONE.test(dep)) {
107
+ out.push({ id: r.id, blockedBy: part });
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ export interface Conflict {
116
+ file: string;
117
+ ids: string[];
118
+ }
119
+
120
+ /** Files touched by more than one sub-mission. */
121
+ export function findConflicts(rows: SubMission[]): Conflict[] {
122
+ const owners = new Map<string, string[]>();
123
+ for (const r of rows) {
124
+ for (const f of r.touchedFiles) {
125
+ const list = owners.get(f) ?? [];
126
+ if (!list.includes(r.id)) list.push(r.id);
127
+ owners.set(f, list);
128
+ }
129
+ }
130
+ return [...owners.entries()]
131
+ .filter(([, ids]) => ids.length > 1)
132
+ .map(([file, ids]) => ({ file, ids }));
133
+ }
134
+
135
+ export interface InitiativeResult {
136
+ code: number;
137
+ output: string;
138
+ }
139
+
140
+ export function runInitiative(sub: string | undefined, planPath: string | undefined): InitiativeResult {
141
+ if (!sub || (sub !== 'status' && sub !== 'conflict-check')) {
142
+ return { code: 1, output: 'usage: mugiwara initiative <status|conflict-check> <plan>\n' };
143
+ }
144
+ if (!planPath) {
145
+ return { code: 1, output: `usage: mugiwara initiative ${sub} <plan>\n` };
146
+ }
147
+ if (!existsSync(planPath)) {
148
+ return { code: 1, output: `mugiwara: plan not found: ${planPath}\n` };
149
+ }
150
+ const { hasSection, rows } = parseSubMissions(readFileSync(planPath, 'utf8'));
151
+ if (!hasSection) {
152
+ return { code: 0, output: 'solo mission (no ## Sub-missions section)\n' };
153
+ }
154
+ if (!rows.length) {
155
+ return {
156
+ code: 1,
157
+ output: `mugiwara: ## Sub-missions section present but no rows parsed — expected header:\n${SUB_MISSIONS_HEADER}\n`,
158
+ };
159
+ }
160
+ if (sub === 'status') {
161
+ const blocked = new Map(blockedRows(rows).map((b) => [b.id, b.blockedBy]));
162
+ const lines = ['id | name | assignee | branch | status | blocked-by'];
163
+ for (const r of rows) {
164
+ lines.push(
165
+ `${r.id} | ${r.name} | ${r.assignee} | ${r.branch} | ${r.status} | ${blocked.get(r.id) ?? '-'}`,
166
+ );
167
+ }
168
+ return { code: 0, output: lines.join('\n') + '\n' };
169
+ }
170
+ const conflicts = findConflicts(rows);
171
+ if (!conflicts.length) return { code: 0, output: 'no conflicts: no file is touched by two sub-missions\n' };
172
+ const lines = conflicts.map((c) => `conflict: ${c.file} touched by ${c.ids.join(', ')}`);
173
+ return { code: 1, output: lines.join('\n') + '\n' };
174
+ }
@@ -62,6 +62,7 @@ function wireSettings(root: string, hooksDir: string): { written: string[]; note
62
62
  const events: Record<string, { file: string; timeout: number; matcher?: string }> = {
63
63
  SessionStart: { file: 'session-start.js', timeout: 10 },
64
64
  UserPromptSubmit: { file: 'mugiwara-mode-tracker.js', timeout: 5 },
65
+ PreToolUse: { file: 'pretool-guard.js', timeout: 10, matcher: 'Bash' },
65
66
  Stop: { file: 'auto-savepoint.js', timeout: 20 },
66
67
  SubagentStop: { file: 'pipeline-guard.js', timeout: 15 },
67
68
  // matcher scopes the marker to crew invocations — without it the hook would