@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.
- 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 +4 -4
- package/content/agents/luffy-orchestrator.md +15 -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-orchestration/SKILL.md +7 -3
- 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-planning/SKILL.md +1 -1
- package/content/skills/mugiwara-planning/references/sub-missions.md +2 -2
- 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 +2 -2
- package/dist/mugiwara.js +235 -82
- 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/wave-banners.md +22 -27
- package/scripts/build-hooks.ts +1 -1
- package/scripts/gate-selftest.ts +342 -0
- package/scripts/savepoint.sh +16 -4
- package/scripts/validate-content.ts +190 -15
- package/scripts/write-metrics.ts +25 -1
- package/src/cli.ts +15 -0
- package/src/config.ts +1 -1
- package/src/guards.ts +40 -0
- package/src/initiative.ts +174 -0
- 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
|
-
|
|
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
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
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/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=
|
|
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
|
+
}
|
package/src/targets/claude.ts
CHANGED
|
@@ -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
|