@ionivetech/mugiwara 0.8.2 → 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 (51) 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 +63 -58
  9. package/content/agents/luffy-orchestrator.md +16 -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-lessons/SKILL.md +2 -0
  16. package/content/skills/mugiwara-orchestration/SKILL.md +15 -20
  17. package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
  18. package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
  19. package/content/skills/mugiwara-orchestration/references/solo-team.md +18 -0
  20. package/content/skills/mugiwara-planning/SKILL.md +7 -15
  21. package/content/skills/mugiwara-planning/references/sub-missions.md +14 -0
  22. package/content/skills/mugiwara-quality/SKILL.md +1 -0
  23. package/content/skills/mugiwara-review/SKILL.md +2 -0
  24. package/content/skills/mugiwara-security/SKILL.md +2 -2
  25. package/content/skills/mugiwara-ship/SKILL.md +12 -0
  26. package/content/skills/mugiwara-workflow/SKILL.md +3 -3
  27. package/dist/mugiwara.js +934 -158
  28. package/gemini-extension.json +1 -1
  29. package/hooks/engagement-marker.js +9 -1
  30. package/hooks/engagement-marker.ts +9 -1
  31. package/hooks/hooks.json +12 -0
  32. package/hooks/pipeline-guard.js +137 -3
  33. package/hooks/pipeline-guard.ts +161 -3
  34. package/hooks/pretool-guard.js +84 -0
  35. package/hooks/pretool-guard.ts +60 -0
  36. package/package.json +1 -1
  37. package/plugin.json +1 -1
  38. package/references/multi-actor.md +17 -14
  39. package/references/wave-banners.md +22 -27
  40. package/scripts/build-hooks.ts +1 -1
  41. package/scripts/gate-selftest.ts +480 -0
  42. package/scripts/savepoint.sh +139 -14
  43. package/scripts/validate-content.ts +345 -2
  44. package/scripts/write-metrics.ts +25 -1
  45. package/src/args.ts +1 -1
  46. package/src/cli.ts +158 -3
  47. package/src/config.ts +33 -11
  48. package/src/guards.ts +40 -0
  49. package/src/initiative.ts +174 -0
  50. package/src/mission.ts +137 -52
  51. package/src/targets/claude.ts +1 -0
@@ -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/args.ts CHANGED
@@ -6,7 +6,7 @@ export type Args = {
6
6
  flags: Record<string, FlagValue>;
7
7
  };
8
8
 
9
- const VALUE_FLAGS: Record<string, string> = { '--project': 'project', '--target': 'target', '--before': 'before', '--backend': 'backend', '--mission': 'mission' };
9
+ const VALUE_FLAGS: Record<string, string> = { '--project': 'project', '--target': 'target', '--before': 'before', '--backend': 'backend', '--mission': 'mission', '--to-team': 'toTeam', '--to-solo': 'toSolo' };
10
10
  const BOOL_FLAGS: Record<string, string> = {
11
11
  '--global': 'global', '--yes': 'yes', '-y': 'yes', '--force': 'force',
12
12
  '--dry-run': 'dryRun', '--keep-logs': 'keepLogs', '--check': 'check', '--all': 'all', '--verify': 'verify',
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
 
@@ -80,7 +81,9 @@ export async function run(argv: string[]): Promise<void> {
80
81
  case 'blame': return blameCmd(flags, _);
81
82
  case 'handoff': return handoffCmd(flags, _);
82
83
  case 'sign': return signCmd(flags, _);
83
- case 'migrate': return migrateCmd(flags);
84
+ case 'migrate': return migrateCmd(flags, _);
85
+ case 'lesson': return lessonCmd(flags, _);
86
+ case 'initiative': return initiativeCmd(flags, _);
84
87
  default: throw new Error(`Unknown command: ${command}`);
85
88
  }
86
89
  }
@@ -196,7 +199,13 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
196
199
  targetIds = idx.includes(0) ? [...TARGET_IDS] : idx.map(i => TARGET_IDS[i - 1]);
197
200
  }
198
201
  }
202
+ const MARKETPLACE = new Set(['cursor', 'kimi', 'pi']);
199
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
+ }
200
209
  if (!targets[id]) throw new Error(`Unknown target: ${id} (valid: ${TARGET_IDS.join(', ')}, all)`);
201
210
  }
202
211
 
@@ -581,9 +590,150 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
581
590
  console.log(`\nwritten: ${out}`);
582
591
  }
583
592
 
584
- export function migrateCmd(flags: Args['flags']): void {
593
+ function lessonCmd(flags: Args['flags'], positionals: string[]): void {
594
+ const projectDir = resolveProjectDir(str(flags.project));
595
+ const text = positionals.slice(1).join(' ').trim();
596
+ if (!text) { console.error('usage: mugiwara lesson "<text>" [--project <dir>]'); process.exit(1); }
597
+ const file = join(projectDir, '.mugiwara', 'lessons.md');
598
+ const date = new Date().toISOString().slice(0, 10);
599
+ const sanitized = text.replace(/\|/g, '/').replace(/\r?\n/g, ' ').trim();
600
+ const line = `| ${date} | manual | general | ${sanitized} |`;
601
+ const header = '| Date | Mission | Area | Lesson |\n|---|---|---|---|';
602
+ let existing = '';
603
+ try { existing = readFileSync(file, 'utf8'); } catch {}
604
+ if (!existing) {
605
+ mkdirSync(join(projectDir, '.mugiwara'), { recursive: true });
606
+ writeFileSync(file, header + '\n' + line + '\n');
607
+ } else {
608
+ // ensure file ends with newline
609
+ const needsNewline = !existing.endsWith('\n');
610
+ writeFileSync(file, existing + (needsNewline ? '\n' : '') + line + '\n');
611
+ }
612
+ console.log(`lesson appended: ${line}`);
613
+ }
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
+
622
+ export function migrateCmd(flags: Args['flags'], positionals: string[] = []): void {
585
623
  const projectDir = resolveProjectDir(str(flags.project));
586
624
  const dryRun = flag(flags.dryRun);
625
+ // --to-team / --to-solo: solo<->team layout switch (W4). Moves, not copies.
626
+ const toTeam = str(flags.toTeam);
627
+ const toSolo = str(flags.toSolo);
628
+ if (toTeam || toSolo) {
629
+ const member = (toTeam ?? toSolo) as string;
630
+ if (!/^[A-Za-z0-9._-]+$/.test(member) || /^\.+$/.test(member) || member === 'state' || member === 'continue') {
631
+ console.error(`invalid member name "${member}" (allowlist: [a-zA-Z0-9._-], not a dot-path, not state/continue)`);
632
+ process.exit(1);
633
+ }
634
+ if (toTeam && toSolo) {
635
+ console.error('use either --to-team or --to-solo, not both');
636
+ process.exit(1);
637
+ }
638
+ const missionsRootInner = join(projectDir, '.mugiwara', 'missions');
639
+ let mission = str(flags.mission) ?? (positionals[1] ? String(positionals[1]) : null);
640
+ const inferMission = (): string | null => {
641
+ if (!existsSync(missionsRootInner)) return null;
642
+ const all = readdirSync(missionsRootInner, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
643
+ if (mission && all.includes(mission)) return mission;
644
+ if (mission) return mission;
645
+ // try to find candidate missions for the requested operation
646
+ if (toTeam) {
647
+ const candidates = all.filter(m => existsSync(join(missionsRootInner, m, 'state.json')));
648
+ if (candidates.length === 1) return candidates[0];
649
+ if (candidates.length === 0) {
650
+ console.error('no solo mission with state.json found for --to-team');
651
+ process.exit(1);
652
+ }
653
+ console.error(`multiple solo missions: ${candidates.join(', ')} — specify --mission <id>`);
654
+ process.exit(1);
655
+ } else {
656
+ const candidates = all.filter(m => existsSync(join(missionsRootInner, m, `${member}.json`)));
657
+ if (candidates.length === 1) return candidates[0];
658
+ if (candidates.length === 0) {
659
+ console.error(`no mission with ${member}.json found for --to-solo`);
660
+ process.exit(1);
661
+ }
662
+ console.error(`multiple missions with ${member}.json: ${candidates.join(', ')} — specify --mission <id>`);
663
+ process.exit(1);
664
+ }
665
+ return null;
666
+ };
667
+ const targetMission = inferMission();
668
+ if (!targetMission) {
669
+ console.error('could not infer mission — specify --mission <id>');
670
+ process.exit(1);
671
+ }
672
+ const dir = join(missionsRootInner, targetMission);
673
+ if (toTeam) {
674
+ const srcState = join(dir, 'state.json');
675
+ const srcContinue = join(dir, 'continue.json');
676
+ const destState = join(dir, `${member}.json`);
677
+ const destContinue = join(dir, `continue-${member}.json`);
678
+ if (!existsSync(srcState)) {
679
+ console.error(`mission "${targetMission}" has no state.json — already team or not found`);
680
+ process.exit(1);
681
+ }
682
+ if (existsSync(destState)) {
683
+ console.error(`destination ${destState} already exists`);
684
+ process.exit(1);
685
+ }
686
+ const toMove: Array<{ src: string; dest: string }> = [{ src: srcState, dest: destState }];
687
+ if (existsSync(srcContinue)) toMove.push({ src: srcContinue, dest: destContinue });
688
+ for (const m of toMove) {
689
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
690
+ if (!dryRun) {
691
+ mkdirSync(dirname(m.dest), { recursive: true });
692
+ try { renameSync(m.src, m.dest); } catch { /* fallback copy */
693
+ try { writeFileSync(m.dest, readFileSync(m.src)); rmSync(m.src, { force: true }); } catch {}
694
+ }
695
+ }
696
+ }
697
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${toMove.length} file(s)${dryRun ? ' (dry run)' : ''}`);
698
+ return;
699
+ } else {
700
+ // --to-solo
701
+ const srcState = join(dir, `${member}.json`);
702
+ const srcContinue = join(dir, `continue-${member}.json`);
703
+ const destState = join(dir, 'state.json');
704
+ const destContinue = join(dir, 'continue.json');
705
+ if (!existsSync(srcState)) {
706
+ console.error(`mission "${targetMission}" has no ${member}.json`);
707
+ process.exit(1);
708
+ }
709
+ const files = readdirSync(dir).filter(f => {
710
+ const stem = f.replace(/\.json$/, '');
711
+ return f.endsWith('.json') && stem !== 'continue' && !stem.startsWith('continue-');
712
+ });
713
+ const members = files.filter(f => f !== 'state.json');
714
+ if (members.length > 1) {
715
+ console.error(`mission "${targetMission}" has ${members.length} members (${members.join(', ')}) — refusing --to-solo (would orphan)`);
716
+ process.exit(1);
717
+ }
718
+ if (existsSync(destState)) {
719
+ console.error(`destination ${destState} already exists`);
720
+ process.exit(1);
721
+ }
722
+ const toMove: Array<{ src: string; dest: string }> = [{ src: srcState, dest: destState }];
723
+ if (existsSync(srcContinue)) toMove.push({ src: srcContinue, dest: destContinue });
724
+ for (const m of toMove) {
725
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
726
+ if (!dryRun) {
727
+ mkdirSync(dirname(m.dest), { recursive: true });
728
+ try { renameSync(m.src, m.dest); } catch {
729
+ try { writeFileSync(m.dest, readFileSync(m.src)); rmSync(m.src, { force: true }); } catch {}
730
+ }
731
+ }
732
+ }
733
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${toMove.length} file(s)${dryRun ? ' (dry run)' : ''}`);
734
+ return;
735
+ }
736
+ }
587
737
  const legacyState = join(projectDir, '.mugiwara', 'state');
588
738
  const legacyContinue = join(projectDir, '.mugiwara', 'continue');
589
739
  const missionsRoot = join(projectDir, '.mugiwara', 'missions');
@@ -718,7 +868,12 @@ Usage:
718
868
  mugiwara sign --gen-key [--backend pure|minisign]
719
869
  create signing keys (pure ed25519 default)
720
870
  mugiwara migrate [--dry-run] [--project <dir>]
721
- move legacy .mugiwara/state/ layout to .mugiwara/missions/
871
+ move legacy .mugiwara/state/ layout to .mugiwara/missions/
872
+ mugiwara migrate --to-team <member> [--mission <id>] [--dry-run]
873
+ move state.json -> <member>.json (solo -> team)
874
+ mugiwara migrate --to-solo <member> [--mission <id>] [--dry-run]
875
+ move <member>.json -> state.json (team -> solo; refuses if >1 member)
876
+ mugiwara lesson "<text>" append a dated row to .mugiwara/lessons.md
722
877
  mugiwara run <script> [args...]
723
878
  run a bundled harness script here (${RUNNABLE.join(', ')})
724
879
  mugiwara savepoint <mission> [member] [flow] [mode]
package/src/config.ts CHANGED
@@ -9,24 +9,43 @@ import { join } from 'node:path';
9
9
 
10
10
  /** The default config body, identical to what the installer has always written. */
11
11
  export const DEFAULT_CONFIG = [
12
- 'mode=guided',
12
+ '# Mugiwara config. Project overrides ~/.mugiwara/config.',
13
+ '# Every key here is read by code. Delete a line to take its default.',
14
+ '',
15
+ '# -- Autonomy ---------------------------------------------',
16
+ 'mode=guided # guided | semi | auto — how much the crew does without asking',
17
+ 'verbosity=normal # normal | full — how much the crew echoes',
18
+ '',
19
+ '# -- Team -------------------------------------------------',
20
+ '# team_member= # your member id; set it and state isolates per person',
21
+ '# team_members=1 # how many people on this mission; >1 enables team-scoped posture',
22
+ '',
23
+ '# -- Git --------------------------------------------------',
13
24
  'branch=feature/{type}-{issue}-{slug}',
14
25
  'commit=conventional',
15
- 'auto_commit=on',
26
+ 'auto_commit=off # on | off — off hands you an uncommitted tree in guided/semi',
27
+ '',
28
+ '# -- Gates ------------------------------------------------',
16
29
  'coverage_new=85',
17
30
  'coverage_modified=90',
18
- 'review_depth=full',
31
+ 'review_depth=full # full | standard | quick',
19
32
  'quality_depth=full',
20
33
  'verify_merged=off',
21
- 'delegate_threshold=60',
22
- 'heal_max_cycles=3',
23
- 'verbosity=normal',
24
- '# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
25
- '# investigation_max_passes=2 # optional: cap investigation passes (spec §13)',
34
+ '',
35
+ '# -- Limits -----------------------------------------------',
36
+ 'delegate_threshold=60 # % of budget before delegation is advised',
37
+ 'heal_max_cycles=3 # heal loop halts here and escalates',
38
+ '',
39
+ '# -- Monorepo ---------------------------------------------',
40
+ '# lane_scope_glob=packages/api/** # count only matching files when sizing the lane',
41
+ '',
42
+ '# -- Optional ---------------------------------------------',
43
+ '# context_budget_chars=150000 # fail archive if the trail exceeds this',
44
+ '# investigation_max_passes=2',
26
45
  '# investigation_max_unrelated_files=5',
27
46
  '# investigation_repeated_read_threshold=2',
28
- '# sign=auto # optional: auto | minisign | pure | off — report attestation',
29
- '# enforce=block # optional: off | warn | block — pipeline-guard policy',
47
+ '# sign=auto # auto | minisign | pure | off',
48
+ '# enforce=block # off | warn | block — pipeline-guard policy',
30
49
  ].join('\n') + '\n';
31
50
 
32
51
  /** Config file path candidates: project first, then user home. */
@@ -57,7 +76,10 @@ export function readConfig(projectDir: string): Record<string, string> {
57
76
  const key = t.slice(0, eq).trim();
58
77
  if (!key) continue;
59
78
  if (key in out) continue; // project value already set — keep it
60
- out[key] = t.slice(eq + 1).trim();
79
+ let rawVal = t.slice(eq + 1).trim();
80
+ const hash = rawVal.indexOf('#');
81
+ if (hash !== -1) rawVal = rawVal.slice(0, hash).trim();
82
+ out[key] = rawVal;
61
83
  }
62
84
  }
63
85
  return out;
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
+ }