@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.opencode/mugiwara-helpers.mjs +1 -1
- package/.opencode/plugins/mugiwara.mjs +173 -1
- package/README.md +63 -58
- package/content/agents/luffy-orchestrator.md +16 -1
- package/content/agents/zoro-execution.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
- package/content/skills/mugiwara-execution/SKILL.md +5 -5
- package/content/skills/mugiwara-gates/SKILL.md +1 -0
- package/content/skills/mugiwara-healing/SKILL.md +1 -0
- package/content/skills/mugiwara-lessons/SKILL.md +2 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +15 -20
- package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
- package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
- package/content/skills/mugiwara-orchestration/references/solo-team.md +18 -0
- package/content/skills/mugiwara-planning/SKILL.md +7 -15
- package/content/skills/mugiwara-planning/references/sub-missions.md +14 -0
- package/content/skills/mugiwara-quality/SKILL.md +1 -0
- package/content/skills/mugiwara-review/SKILL.md +2 -0
- package/content/skills/mugiwara-security/SKILL.md +2 -2
- package/content/skills/mugiwara-ship/SKILL.md +12 -0
- package/content/skills/mugiwara-workflow/SKILL.md +3 -3
- package/dist/mugiwara.js +934 -158
- package/gemini-extension.json +1 -1
- package/hooks/engagement-marker.js +9 -1
- package/hooks/engagement-marker.ts +9 -1
- package/hooks/hooks.json +12 -0
- package/hooks/pipeline-guard.js +137 -3
- package/hooks/pipeline-guard.ts +161 -3
- package/hooks/pretool-guard.js +84 -0
- package/hooks/pretool-guard.ts +60 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/references/multi-actor.md +17 -14
- package/references/wave-banners.md +22 -27
- package/scripts/build-hooks.ts +1 -1
- package/scripts/gate-selftest.ts +480 -0
- package/scripts/savepoint.sh +139 -14
- package/scripts/validate-content.ts +345 -2
- package/scripts/write-metrics.ts +25 -1
- package/src/args.ts +1 -1
- package/src/cli.ts +158 -3
- package/src/config.ts +33 -11
- package/src/guards.ts +40 -0
- package/src/initiative.ts +174 -0
- package/src/mission.ts +137 -52
- package/src/targets/claude.ts +1 -0
package/scripts/write-metrics.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
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
|
|
29
|
-
'# enforce=block
|
|
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
|
-
|
|
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
|
+
}
|