@ionivetech/mugiwara 0.6.4 → 0.6.6

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 (115) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.codex-plugin/plugin.json +48 -2
  4. package/.cursor-plugin/plugin.json +48 -2
  5. package/.kimi-plugin/plugin.json +48 -2
  6. package/.opencode/commands/mugiwara-continue.md +44 -31
  7. package/.opencode/commands/mugiwara-execute.md +1 -1
  8. package/.opencode/commands/mugiwara-heal.md +1 -1
  9. package/.opencode/commands/mugiwara-onboard.md +8 -9
  10. package/.opencode/commands/mugiwara-plan.md +1 -1
  11. package/.opencode/commands/mugiwara-review.md +1 -1
  12. package/.opencode/commands/mugiwara-security.md +1 -1
  13. package/.opencode/commands/mugiwara-ship.md +1 -1
  14. package/.opencode/commands/mugiwara.md +2 -2
  15. package/.opencode/mugiwara-helpers.mjs +3 -0
  16. package/.opencode/plugins/mugiwara.mjs +1 -1
  17. package/AGENTS.md +12 -2
  18. package/README.md +53 -32
  19. package/content/agents/brook-healing.md +5 -8
  20. package/content/agents/chopper-checkpoint.md +8 -11
  21. package/content/agents/eval-runner.md +2 -5
  22. package/content/agents/franky-gates.md +4 -7
  23. package/content/agents/jinbe-security.md +4 -7
  24. package/content/agents/luffy-orchestrator.md +13 -13
  25. package/content/agents/memory-keeper.md +5 -8
  26. package/content/agents/nami-planner.md +4 -7
  27. package/content/agents/resume-coordinator.md +7 -10
  28. package/content/agents/robin-reviewer.md +6 -9
  29. package/content/agents/sanji-quality.md +7 -10
  30. package/content/agents/skeptic-verifier.md +5 -8
  31. package/content/agents/usopp-brainstorm.md +3 -6
  32. package/content/agents/zoro-execution.md +7 -10
  33. package/content/skills/mugiwara-backend/SKILL.md +2 -0
  34. package/content/skills/mugiwara-checkpoint/SKILL.md +14 -11
  35. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +4 -4
  36. package/content/skills/mugiwara-context-budget/SKILL.md +5 -3
  37. package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
  38. package/content/skills/mugiwara-execution/SKILL.md +27 -27
  39. package/content/skills/mugiwara-execution/references/dispatch.md +2 -2
  40. package/content/skills/mugiwara-execution/references/resume-batching.md +1 -1
  41. package/content/skills/mugiwara-frontend/SKILL.md +2 -4
  42. package/content/skills/mugiwara-gates/SKILL.md +9 -7
  43. package/content/skills/mugiwara-git/SKILL.md +7 -2
  44. package/content/skills/mugiwara-healing/SKILL.md +6 -4
  45. package/content/skills/mugiwara-healing/references/workers.md +2 -2
  46. package/content/skills/mugiwara-lessons/SKILL.md +1 -0
  47. package/content/skills/mugiwara-orchestration/SKILL.md +34 -35
  48. package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
  49. package/content/skills/mugiwara-orchestration/references/closure.md +5 -5
  50. package/content/skills/mugiwara-orchestration/references/output-contract.md +77 -0
  51. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +22 -22
  52. package/content/skills/mugiwara-planning/SKILL.md +3 -1
  53. package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
  54. package/content/skills/mugiwara-pr/SKILL.md +6 -6
  55. package/content/skills/mugiwara-pr/references/verdict-format.md +3 -3
  56. package/content/skills/mugiwara-quality/SKILL.md +8 -7
  57. package/content/skills/mugiwara-resume/SKILL.md +19 -20
  58. package/content/skills/mugiwara-review/SKILL.md +6 -1
  59. package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
  60. package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
  61. package/content/skills/mugiwara-security/SKILL.md +5 -1
  62. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  63. package/content/skills/mugiwara-ship/references/cleanup.md +1 -1
  64. package/content/skills/mugiwara-testcases/SKILL.md +5 -3
  65. package/content/skills/mugiwara-workflow/SKILL.md +38 -39
  66. package/content/skills/mugiwara-workflow/references/workspace-layout.md +15 -15
  67. package/content/skills/using-mugiwara/SKILL.md +5 -5
  68. package/dist/mugiwara.js +807 -31
  69. package/gemini-extension.json +1 -1
  70. package/hooks/auto-savepoint.js +106 -0
  71. package/hooks/auto-savepoint.ts +102 -0
  72. package/hooks/engagement-marker.js +59 -0
  73. package/hooks/engagement-marker.ts +97 -0
  74. package/hooks/hooks.json +41 -2
  75. package/hooks/mugiwara-mode-tracker.js +79 -0
  76. package/hooks/pipeline-guard.js +182 -0
  77. package/hooks/pipeline-guard.ts +267 -0
  78. package/hooks/session-start.js +106 -0
  79. package/hooks/session-start.ts +44 -32
  80. package/package.json +7 -3
  81. package/plugin.json +1 -1
  82. package/references/agent-protocol.md +15 -0
  83. package/references/complexity.md +54 -0
  84. package/references/definition-of-done.md +1 -1
  85. package/references/multi-actor.md +5 -5
  86. package/references/skill-versioning.md +1 -1
  87. package/references/token-budget.md +30 -8
  88. package/references/wave-banners.md +21 -31
  89. package/scripts/build-hooks.ts +56 -0
  90. package/scripts/conformance.ts +232 -0
  91. package/scripts/coverage-gate.ts +179 -0
  92. package/scripts/evidence.sh +17 -4
  93. package/scripts/gate-selftest.ts +14 -0
  94. package/scripts/initiative.ts +51 -17
  95. package/scripts/install.ps1 +5 -4
  96. package/scripts/install.sh +4 -4
  97. package/scripts/lane.sh +6 -2
  98. package/scripts/lib/patterns.sh +84 -0
  99. package/scripts/mission-report.sh +12 -11
  100. package/scripts/savepoint.sh +152 -37
  101. package/scripts/validate-content.ts +22 -2
  102. package/scripts/verify-install.ts +154 -44
  103. package/src/args.ts +1 -1
  104. package/src/cli.ts +217 -0
  105. package/src/continue.ts +243 -0
  106. package/src/installer.ts +39 -0
  107. package/src/onboard.ts +207 -0
  108. package/src/run.ts +82 -0
  109. package/src/targets/claude.ts +140 -2
  110. package/src/targets/copilot.ts +20 -1
  111. package/src/targets/generic.ts +9 -4
  112. package/content/agents/onboarding-guide.md +0 -124
  113. package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
  114. package/scripts/onboard.ts +0 -266
  115. package/scripts/probe.ts +0 -40
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/conformance.ts — C1: cross-platform conformance suite.
3
+ // Every installable target: materialize the standard-feature fixture repo,
4
+ // install the target, run the four core scripts, and compare a normalized
5
+ // snapshot (state fields, report sections, evidence header, gitignore block,
6
+ // file count) against test/golden/<target>.json. Exit 1 on any difference
7
+ // with a diff; --update-golden regenerates. The snapshots are normalized:
8
+ // timestamps, hashes, and random filenames are excluded so a golden is stable
9
+ // across runs.
10
+
11
+ import { execSync } from 'node:child_process';
12
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { installTo, ensureProjectGitignore } from '../src/installer.ts';
16
+ import { targets, TARGET_IDS } from '../src/targets/index.ts';
17
+
18
+ const root = join(import.meta.dirname, '..');
19
+ const goldenDir = join(root, 'test', 'golden');
20
+ const UPDATE = process.argv.includes('--update-golden');
21
+
22
+ // tier mapping per docs/reference/harness-matrix.md (target defs carry tier only
23
+ // on some; the matrix is the source of truth for the rest)
24
+ const TIER_OF: Record<string, number> = {
25
+ claude: 1, opencode: 1,
26
+ gemini: 2, codex: 2, copilot: 2,
27
+ windsurf: 3, cline: 3, kilo: 3, antigravity: 3,
28
+ };
29
+
30
+ const TARGETS = TARGET_IDS.map(id => ({ id, tier: TIER_OF[id] ?? 2 }));
31
+
32
+ // marketplace-plugin platforms: installed from the repo itself via the host's
33
+ // plugin system (no rules-dir install). Conformance = the manifest an
34
+ // operator's marketplace will consume: it parses, its version matches the
35
+ // package, its pointers resolve, and its metadata set-equals content/.
36
+ const MARKETPLACE = [
37
+ { id: 'cursor', manifest: '.cursor-plugin/plugin.json' },
38
+ { id: 'kimi', manifest: '.kimi-plugin/plugin.json' },
39
+ { id: 'pi', manifest: null, piField: true },
40
+ ];
41
+
42
+ function marketplaceSnapshot(id: string, manifestRel: string | null, piField: boolean): Record<string, unknown> {
43
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
44
+ const skillDirs = readdirSync(join(root, 'content', 'skills'), { withFileTypes: true })
45
+ .filter(e => e.isDirectory()).map(e => e.name).sort();
46
+ const agentFiles = readdirSync(join(root, 'content', 'agents'))
47
+ .filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')).sort();
48
+
49
+ const base = {
50
+ target: id,
51
+ kind: 'marketplace-plugin',
52
+ version_matches_package: false,
53
+ skills_pointer_resolves: false,
54
+ skills_count: 0,
55
+ agents_metadata_set_equal: false,
56
+ };
57
+
58
+ if (piField) {
59
+ const pi = packageJson.pi;
60
+ const list = pi?.skills;
61
+ const pointers = Array.isArray(list) ? list : [];
62
+ base.skills_pointer_resolves = pointers.every(p => existsSync(join(root, p)));
63
+ base.skills_count = existsSync(join(root, 'content', 'skills')) ? skillDirs.length : 0;
64
+ base.version_matches_package = true; // package.json is the package itself
65
+ base.agents_metadata_set_equal = true; // pi loads skills only; agents ship in the same tree
66
+ return base;
67
+ }
68
+
69
+ const m = JSON.parse(readFileSync(join(root, manifestRel!), 'utf8'));
70
+ const skillsPtr = m.skills;
71
+ const skillsDir = typeof skillsPtr === 'string' ? join(root, skillsPtr) : null;
72
+ const actual = skillsDir && existsSync(skillsDir)
73
+ ? readdirSync(skillsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name).sort()
74
+ : [];
75
+ const mSkills = Array.isArray(m.metadata?.skills) ? [...m.metadata.skills].sort() : [];
76
+ const mAgents = Array.isArray(m.metadata?.agents) ? [...m.metadata.agents].sort() : [];
77
+
78
+ return {
79
+ ...base,
80
+ version_matches_package: m.version === packageJson.version,
81
+ skills_pointer_resolves: skillsDir !== null && existsSync(skillsDir) && actual.length === skillDirs.length,
82
+ skills_count: actual.length,
83
+ agents_metadata_set_equal: JSON.stringify(mAgents) === JSON.stringify(agentFiles),
84
+ };
85
+ }
86
+
87
+ const MISSION = 'conform';
88
+
89
+ function sh(cmd: string, cwd: string) {
90
+ execSync(cmd, { cwd, stdio: 'pipe', env: { ...process.env, MUGIWARA_DIR: join(cwd, '.mugiwara') } });
91
+ }
92
+
93
+ function snapshot(targetId: string): Record<string, unknown> {
94
+ const dir = mkdtempSync(join(tmpdir(), `mugi-conform-${targetId}-`));
95
+ try {
96
+ // 1. materialize the standard-feature fixture repo (trunk + feat branch)
97
+ sh(`bun scripts/setup-fixtures.ts standard-feature "${dir}"`, root);
98
+
99
+ // 2. install the target into the project
100
+ const target = targets[targetId];
101
+ if (!target) throw new Error(`unknown target ${targetId}`);
102
+ installTo(target, { scope: 'project', projectDir: dir, force: true, dryRun: false });
103
+
104
+ // The installer's own output is harness config, not mission work — but it
105
+ // lands as untracked files, and savepoint/lane now count the working tree
106
+ // (F). Park it in .git/info/exclude so the snapshot keeps measuring the
107
+ // fixture's 4-file mission diff instead of "how many files does this
108
+ // target install". Local-only: .gitignore (which the golden snapshots)
109
+ // is untouched.
110
+ const untracked = execSync('git status --porcelain --untracked-files=normal', { cwd: dir, encoding: 'utf8' })
111
+ .split('\n').filter(Boolean).map(l => l.slice(3)).filter(Boolean);
112
+ if (untracked.length) {
113
+ const exclude = join(dir, '.git', 'info', 'exclude');
114
+ writeFileSync(exclude, (existsSync(exclude) ? readFileSync(exclude, 'utf8') : '') + '\n' + untracked.join('\n') + '\n');
115
+ }
116
+
117
+ // 3. run the four core scripts + the gitignore write the CLI does post-install
118
+ sh(`bash "${root}/scripts/lane.sh" main --json`, dir);
119
+ sh(`bash "${root}/scripts/savepoint.sh" ${MISSION} "" 1 auto`, dir);
120
+ sh(`bash "${root}/scripts/evidence.sh" ${MISSION} lint -- printf 'ok\\n'`, dir);
121
+ sh(`bash "${root}/scripts/mission-report.sh" ${MISSION}`, dir);
122
+ ensureProjectGitignore(dir, { dryRun: false });
123
+
124
+ // 4. normalize + collect
125
+ const state = JSON.parse(readFileSync(join(dir, '.mugiwara', 'state', MISSION, 'state.json'), 'utf8'));
126
+ const reportFile = readdirSync(join(dir, '.mugiwara', 'reports')).find(f => f.endsWith(`-${MISSION}.md`));
127
+ const report = reportFile ? readFileSync(join(dir, '.mugiwara', 'reports', reportFile), 'utf8') : '';
128
+ const evFile = readdirSync(join(dir, '.mugiwara', 'results', MISSION)).find(f => f.includes('lint-'));
129
+ const ev = evFile ? readFileSync(join(dir, '.mugiwara', 'results', MISSION, evFile), 'utf8') : '';
130
+ const gitignore = existsSync(join(dir, '.gitignore')) ? readFileSync(join(dir, '.gitignore'), 'utf8') : '';
131
+
132
+ const countFiles = (p: string): number => {
133
+ if (!existsSync(p)) return 0;
134
+ return readdirSync(p, { recursive: true, withFileTypes: true }).filter(e => e.isFile()).length;
135
+ };
136
+
137
+ return {
138
+ target: targetId,
139
+ tier: TIER_OF[targetId] ?? 2,
140
+ state: {
141
+ mission: state.mission,
142
+ member: state.member,
143
+ lane: state.lane,
144
+ lane_reason: state.lane_reason,
145
+ files_touched: state.files_touched,
146
+ loc_delta: state.loc_delta,
147
+ loc_ins: state.loc_ins,
148
+ loc_del: state.loc_del,
149
+ loc_churn: state.loc_churn,
150
+ mode: state.mode,
151
+ verbosity: state.verbosity,
152
+ tasks_done: state.tasks?.done,
153
+ tasks_total: state.tasks?.total,
154
+ blockers_open: state.blockers_open,
155
+ heal_cycle: state.heal_cycle,
156
+ heal_max_cycles: state.heal_max_cycles,
157
+ heal_halt: state.heal_halt,
158
+ delegate_threshold: state.delegate_threshold,
159
+ delegate_due: state.delegate_due,
160
+ tokens_source: state.tokens_source,
161
+ budget_status: state.budget_status,
162
+ sensitive_paths: state.sensitive_paths,
163
+ },
164
+ report_sections: [...report.matchAll(/^#{2,3} .*$/gm)].map(m => m[0]),
165
+ evidence: {
166
+ header: ev.split('\n').filter(l => l.startsWith('# ') && !l.startsWith('# At:') && !/^# (Exit|Verdict):/.test(l)),
167
+ trailer: ev.split('\n').filter(l => /^# (Exit|Verdict):/.test(l)),
168
+ },
169
+ gitignore_block: gitignore.split('\n').filter(l => l.includes('mugiwara') || l.includes('# ---')),
170
+ file_count: {
171
+ skills: countFiles(target.paths({ scope: 'project', projectDir: dir, home: '' }).skillsDir),
172
+ agents: countFiles(target.paths({ scope: 'project', projectDir: dir, home: '' }).agentsDir),
173
+ state: countFiles(join(dir, '.mugiwara', 'state')),
174
+ evidence: countFiles(join(dir, '.mugiwara', 'results', MISSION)),
175
+ },
176
+ };
177
+ } finally {
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ }
181
+
182
+ function diff(a: unknown, b: unknown, path = ''): string[] {
183
+ const out: string[] = [];
184
+ if (JSON.stringify(a) === JSON.stringify(b)) return out;
185
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') {
186
+ out.push(`${path}: ${JSON.stringify(a)} ≠ ${JSON.stringify(b)}`);
187
+ return out;
188
+ }
189
+ for (const k of new Set([...Object.keys(a as object), ...Object.keys(b as object)])) {
190
+ out.push(...diff((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], `${path}.${k}`));
191
+ }
192
+ return out;
193
+ }
194
+
195
+ let failed = false;
196
+ mkdirSync(goldenDir, { recursive: true });
197
+
198
+ const ALL = [
199
+ ...TARGETS.map(t => ({ id: t.id, label: `tier ${t.tier}`, snap: () => snapshot(t.id) })),
200
+ ...MARKETPLACE.map(m => ({ id: m.id, label: 'marketplace', snap: () => marketplaceSnapshot(m.id, m.manifest, m.piField === true) })),
201
+ ];
202
+
203
+ for (const t of ALL) {
204
+ const snap = t.snap();
205
+ const goldenFile = join(goldenDir, `${t.id}.json`);
206
+ if (UPDATE) {
207
+ writeFileSync(goldenFile, JSON.stringify(snap, null, 2) + '\n');
208
+ console.log(`✓ golden updated: test/golden/${t.id}.json`);
209
+ continue;
210
+ }
211
+ if (!existsSync(goldenFile)) {
212
+ console.log(`✗ ${t.id}: golden missing — run with --update-golden first`);
213
+ failed = true;
214
+ continue;
215
+ }
216
+ const golden = JSON.parse(readFileSync(goldenFile, 'utf8'));
217
+ const diffs = diff(snap, golden);
218
+ if (diffs.length > 0) {
219
+ failed = true;
220
+ console.log(`✗ ${t.id} (${t.label}) differs:`);
221
+ for (const d of diffs) console.log(` ${d}`);
222
+ } else {
223
+ console.log(`✓ ${t.id} (${t.label}) conforms`);
224
+ }
225
+ }
226
+
227
+ if (UPDATE) {
228
+ console.log('goldens regenerated — review the diff before committing');
229
+ process.exit(0);
230
+ }
231
+ if (failed) process.exit(1);
232
+ console.log(`✓ ${ALL.length} platforms pass conformance`);
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/coverage-gate.ts — enforce the `coverage_new` / `coverage_modified`
3
+ // thresholds declared in `.mugiwara/config` against the files THIS diff adds
4
+ // and changes, not against a global project number. Two keys exist precisely
5
+ // because the two populations are judged differently (mugiwara-gates SKILL.md
6
+ // §Coverage gate); a global percentage would collapse them into one and let an
7
+ // untested new file hide behind an old well-tested one.
8
+ //
9
+ // Run: bun scripts/coverage-gate.ts enforce (gate step)
10
+ // bun scripts/coverage-gate.ts --base X compare against ref X
11
+ // bun scripts/coverage-gate.ts --show print per-file numbers, never fail
12
+ //
13
+ // Exit 0 = pass or recorded SKIP. Exit 1 = a threshold was missed.
14
+ // A repo with no coverage tooling or no test suite records a SKIP with its
15
+ // reason — never a fake pass (decision log row 50).
16
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { execFileSync, spawnSync } from 'node:child_process';
18
+ import { homedir } from 'node:os';
19
+ import { join, relative, sep } from 'node:path';
20
+
21
+ const root = join(import.meta.dirname, '..');
22
+ const SHOW = process.argv.includes('--show');
23
+
24
+ /** `.mugiwara/config` → project, then `~`. Returns undefined if the key is set nowhere. */
25
+ function config(key: string): string | undefined {
26
+ for (const base of [root, homedir()]) {
27
+ const file = join(base, '.mugiwara', 'config');
28
+ if (!existsSync(file)) continue;
29
+ // \r?\n: a config written on Windows is still a config
30
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
31
+ const t = line.trim();
32
+ if (!t || t.startsWith('#')) continue;
33
+ const eq = t.indexOf('=');
34
+ if (eq === -1) continue;
35
+ if (t.slice(0, eq).trim() !== key) continue;
36
+ return t.slice(eq + 1).trim();
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /** Documented defaults: new >= 90, modified >= 80. A key set to 0 = no threshold. */
43
+ function threshold(key: string, fallback: number): number {
44
+ const raw = config(key);
45
+ if (raw === undefined || raw === '') return fallback;
46
+ const n = Number(raw);
47
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
48
+ console.log(`coverage-gate: ignoring unparseable ${key}="${raw}", using ${fallback}`);
49
+ return fallback;
50
+ }
51
+ return n;
52
+ }
53
+
54
+ function skip(reason: string): never {
55
+ console.log(`coverage-gate: SKIP — ${reason}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ function git(args: string[]): string {
60
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
61
+ }
62
+
63
+ // ---- 1. is there anything to measure at all? -------------------------------
64
+
65
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
66
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
67
+ if (!deps['@vitest/coverage-v8'] && !deps['@vitest/coverage-istanbul'])
68
+ skip('no coverage tooling installed (no @vitest/coverage-* in package.json)');
69
+ if (!existsSync(join(root, 'test')))
70
+ skip('no test suite found (no test/ directory)');
71
+
72
+ // ---- 2. which files does this diff add and change? ------------------------
73
+
74
+ const baseArg = process.argv.indexOf('--base');
75
+ let base = baseArg !== -1 ? process.argv[baseArg + 1] : process.env.MUGIWARA_COVERAGE_BASE;
76
+ if (!base) {
77
+ // the mission's own recorded base_sha is the truthful diff origin
78
+ const stateDir = join(root, '.mugiwara', 'state');
79
+ if (existsSync(stateDir)) {
80
+ for (const m of readdirSync(stateDir)) {
81
+ const f = join(stateDir, m, 'state.json');
82
+ if (!existsSync(f)) continue;
83
+ const sha = JSON.parse(readFileSync(f, 'utf8')).base_sha;
84
+ if (sha) { base = sha; break; }
85
+ }
86
+ }
87
+ }
88
+ if (!base) {
89
+ for (const ref of ['origin/main', 'main', 'origin/master', 'master']) {
90
+ try { base = git(['merge-base', 'HEAD', ref]); break; } catch { /* next */ }
91
+ }
92
+ }
93
+ if (!base) skip('no diff base could be resolved (no state base_sha, no main/master ref)');
94
+
95
+ let changed: Array<{ status: string; path: string }>;
96
+ try {
97
+ changed = git(['diff', '--name-status', '--diff-filter=AMR', '-M', `${base}`, 'HEAD'])
98
+ .split(/\r?\n/)
99
+ .filter(Boolean)
100
+ .map((l) => {
101
+ const parts = l.split('\t');
102
+ // rename rows are `R096\told\tnew` — the new path is the one to judge
103
+ return { status: parts[0][0], path: parts[parts.length - 1] };
104
+ });
105
+ } catch (e) {
106
+ skip(`git diff against ${base} failed (${(e as Error).message})`);
107
+ }
108
+
109
+ if (!changed.length) skip(`no added or modified files between ${base.slice(0, 7)} and HEAD`);
110
+
111
+ // ---- 3. measure ------------------------------------------------------------
112
+
113
+ const summaryPath = join(root, 'coverage', 'coverage-summary.json');
114
+ const needsRun =
115
+ !existsSync(summaryPath) ||
116
+ statSync(summaryPath).mtimeMs < Math.max(...changed
117
+ .map((c) => join(root, c.path))
118
+ .filter(existsSync)
119
+ .map((p) => statSync(p).mtimeMs), 0);
120
+
121
+ if (needsRun) {
122
+ console.log('coverage-gate: measuring (vitest run --coverage)...');
123
+ const r = spawnSync('npx', ['vitest', 'run', '--coverage', '--silent'], {
124
+ cwd: root, stdio: 'inherit', shell: process.platform === 'win32',
125
+ });
126
+ if (r.status !== 0) {
127
+ console.log('coverage-gate: FAIL — test run did not pass, coverage is not measurable');
128
+ process.exit(1);
129
+ }
130
+ }
131
+ if (!existsSync(summaryPath))
132
+ skip('coverage run produced no coverage-summary.json (json-summary reporter not configured)');
133
+
134
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
135
+ // keys are absolute OS paths; normalise to repo-relative posix to match git
136
+ const measured = new Map<string, number>();
137
+ for (const [k, v] of Object.entries<any>(summary)) {
138
+ if (k === 'total') continue;
139
+ measured.set(relative(root, k).split(sep).join('/'), v.lines?.pct ?? 0);
140
+ }
141
+
142
+ // ---- 4. apply the two thresholds ------------------------------------------
143
+
144
+ const NEW = threshold('coverage_new', 90);
145
+ const MOD = threshold('coverage_modified', 80);
146
+
147
+ const rows = changed.map((c) => {
148
+ const isNew = c.status === 'A';
149
+ return {
150
+ path: c.path,
151
+ kind: isNew ? 'new' : 'modified',
152
+ limit: isNew ? NEW : MOD,
153
+ pct: measured.get(c.path),
154
+ };
155
+ });
156
+
157
+ const scoped = rows.filter((r) => r.pct !== undefined);
158
+ const unscoped = rows.filter((r) => r.pct === undefined);
159
+
160
+ console.log(`coverage-gate: base ${base.slice(0, 7)} · thresholds new>=${NEW || 'off'} modified>=${MOD || 'off'}`);
161
+ console.log(` ${changed.length} changed file(s), ${scoped.length} within coverage scope, ${unscoped.length} outside it`);
162
+
163
+ const failures = scoped.filter((r) => r.limit > 0 && (r.pct as number) < r.limit);
164
+
165
+ for (const r of scoped.sort((a, b) => (a.pct as number) - (b.pct as number))) {
166
+ const bad = r.limit > 0 && (r.pct as number) < r.limit;
167
+ if (SHOW || bad || (r.pct as number) < 100)
168
+ console.log(` ${bad ? '✗' : '✓'} ${r.path} — ${(r.pct as number).toFixed(2)}% ${r.kind} (limit ${r.limit || 'off'})`);
169
+ }
170
+ if (SHOW && unscoped.length)
171
+ console.log(unscoped.map((r) => ` · ${r.path} — outside coverage scope (not a measured source file)`).join('\n'));
172
+
173
+ if (SHOW) process.exit(0);
174
+ if (failures.length) {
175
+ console.log(`\ncoverage-gate: FAIL — ${failures.length} file(s) below their threshold`);
176
+ console.log('Do not lower the thresholds or exclude files to pass: add the missing tests.');
177
+ process.exit(1);
178
+ }
179
+ console.log('\ncoverage-gate: PASS');
@@ -40,20 +40,33 @@ TIMESTAMP=$(date +%Y%m%d-%H%M%S)
40
40
  HASH=$(echo "${LABEL}-${TIMESTAMP}-$$-${RANDOM}" | (sha256sum 2>/dev/null || shasum -a 256 2>/dev/null || openssl sha256) | cut -c1-12 2>/dev/null || echo "${TIMESTAMP}")
41
41
  EVIDENCE_FILE="$RESULTS_DIR/${LABEL}-${HASH}.log"
42
42
 
43
+ # Neutralize forged verdict/exit header lines in captured output. The agent
44
+ # trusts the real trailer only (# Exit:/# Verdict: appended after the block);
45
+ # attacker output may try to impersonate it under any spelling — leading
46
+ # whitespace, ANSI prefix, no space, CRLF. Idempotent: already-neutralized
47
+ # #-Verdict: / #-Exit: lines pass through unchanged.
48
+ SANITIZE='s/^[[:space:]]*(\x1b\[[0-9;]*m)*#[[:space:]]*(Verdict|Exit):/#-\2:/'
49
+
50
+ # command line echoed in the header — collapse embedded newlines so an arg
51
+ # cannot forge a header line inside "# Command:" (the header block is written
52
+ # verbatim, outside the sanitizer).
53
+ COMMAND_LINE=$(printf '%s ' "$@" 2>/dev/null | tr '\n\r' ' ' | sed 's/ *$//')
54
+
43
55
  {
44
56
  echo "# Evidence: $LABEL"
45
57
  echo "# At: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
46
- echo "# Command: ${*:-<stdin pipeline>}"
58
+ echo "# Command: ${COMMAND_LINE:-<stdin pipeline>}"
47
59
  echo "# ---"
48
60
  echo
49
61
 
50
62
  if [ $# -gt 0 ]; then
51
- "$@" 2>&1
63
+ "$@" 2>&1 | sed -E "$SANITIZE"
64
+ EXIT_CODE=${PIPESTATUS[0]}
52
65
  else
53
- cat
66
+ sed -E "$SANITIZE"
67
+ EXIT_CODE=0
54
68
  fi
55
69
  } > "$EVIDENCE_FILE"
56
- EXIT_CODE=$?
57
70
 
58
71
  # trailer: exit code + verdict (D6). The verdict is PASS/FAIL derived from the
59
72
  # exit code — the check's own outcome, not the harness's opinion.
@@ -68,6 +68,20 @@ if (!existsSync(join(root, 'scripts', 'validate-content.ts'))) {
68
68
  }
69
69
  }
70
70
 
71
+ // --- G5: conditional-assertion guard — prove expect-in-conditional goes red ---
72
+ console.log('\nG5 — conditional-assertion guard');
73
+ {
74
+ const testFile = join(root, 'test', 'targets.test.ts');
75
+ const original = readFileSync(testFile, 'utf8');
76
+ try {
77
+ writeFileSync(testFile, `${original}\nif (x) { expect(1).toBe(1); }\n`);
78
+ assert('expect() in non-invariant conditional → exit 1', false, () => run('G5', 'bun scripts/validate-content.ts'));
79
+ } finally {
80
+ writeFileSync(testFile, original);
81
+ assert('restored → exit 0', true, () => run('G5', 'bun scripts/validate-content.ts'));
82
+ }
83
+ }
84
+
71
85
  // --- G3: savepoint fixtures — prove test fails when a field is broken ---
72
86
  console.log('\nG3 — savepoint fixtures');
73
87
  if (!existsSync(join(root, 'test', 'savepoint.test.ts'))) {
@@ -55,40 +55,47 @@ function assertPlanContent(content: string, planFile: string): void {
55
55
  }
56
56
  }
57
57
 
58
- function parseSubMissions(content: string): SubMission[] {
58
+ function parseSubMissions(content: string): { subs: SubMission[]; hasSection: boolean } {
59
59
  const lines = content.split('\n');
60
60
  let inTable = false;
61
61
  let inSubSection = false;
62
+ let hasSection = false;
62
63
  const subs: SubMission[] = [];
63
64
 
64
65
  for (let i = 0; i < lines.length; i++) {
65
66
  const line = lines[i];
66
- if (line.startsWith('## Sub-missions')) {
67
+ if (line.trim().toLowerCase().startsWith('## sub-missions')) {
67
68
  inSubSection = true;
69
+ hasSection = true;
68
70
  continue;
69
71
  }
70
- if (inSubSection && line.startsWith('## ') && !line.startsWith('## Sub-missions')) {
72
+ if (inSubSection && line.startsWith('## ') && !line.trim().toLowerCase().startsWith('## sub-missions')) {
71
73
  break;
72
74
  }
73
75
  if (!inSubSection) continue;
74
76
 
75
77
  const trimmed = line.trim();
76
- if (trimmed.startsWith('| ID ') && trimmed.includes('| Name ')) {
78
+ const lower = trimmed.toLowerCase();
79
+ if (lower.startsWith('| id ') && lower.includes('| name ')) {
77
80
  inTable = true;
78
81
  continue;
79
82
  }
80
83
  if (inTable && trimmed.startsWith('|---')) continue;
81
84
  if (inTable && trimmed.startsWith('|')) {
82
- const cols = trimmed.split('|').map(c => c.trim()).filter(Boolean);
85
+ // Keep interior empty cells (e.g. empty "depends on"): filter(Boolean)
86
+ // would shift columns and drop the touched-files tail.
87
+ const cols = trimmed.split('|').map(c => c.trim());
88
+ while (cols.length && cols[0] === '') cols.shift();
89
+ while (cols.length && cols[cols.length - 1] === '') cols.pop();
83
90
  if (cols.length >= 6) {
84
91
  subs.push({
85
92
  id: cols[0],
86
93
  name: cols[1],
87
94
  assignee: cols[2],
88
95
  branch: cols[3],
89
- status: cols[4],
96
+ status: cols[4].replace('[X]', '[x]'),
90
97
  dependsOn: cols[5],
91
- touchedFiles: cols.slice(6).join(' ').split(/\s+/).filter(Boolean),
98
+ touchedFiles: cols.slice(6).join(' ').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
92
99
  });
93
100
  }
94
101
  }
@@ -97,13 +104,23 @@ function parseSubMissions(content: string): SubMission[] {
97
104
  }
98
105
  }
99
106
 
100
- return subs;
107
+ return { subs, hasSection };
108
+ }
109
+
110
+ function assertRowsParsed(planFile: string, content: string): { subs: SubMission[]; hasSection: boolean } {
111
+ const { subs, hasSection } = parseSubMissions(content);
112
+ if (hasSection && subs.length === 0) {
113
+ console.error('initiative: "## Sub-missions" section found but no rows parsed.');
114
+ console.error(' Expected header: | ID | Name | Assignee | Branch | Status | Depends On | Touched Files |');
115
+ process.exit(1);
116
+ }
117
+ return { subs, hasSection };
101
118
  }
102
119
 
103
120
  function cmdStatus(planFile: string): void {
104
121
  const content = readFileSync(planFile, 'utf8');
105
122
  assertPlanContent(content, planFile);
106
- const subs = parseSubMissions(content);
123
+ const { subs } = assertRowsParsed(planFile, content);
107
124
 
108
125
  if (subs.length === 0) {
109
126
  console.log('No sub-missions found — solo mission.');
@@ -117,14 +134,24 @@ function cmdStatus(planFile: string): void {
117
134
  const filled = Math.round((doneCount / total) * barLen);
118
135
  const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
119
136
 
137
+ // dependency blocking: a sub whose dependency is not done is blocked
138
+ const statusById = new Map(subs.map(s => [s.id, s.status]));
139
+ const blockedBy = new Map<string, string>();
140
+ for (const s of subs) {
141
+ if (s.dependsOn && statusById.has(s.dependsOn) && statusById.get(s.dependsOn) !== '[x]') {
142
+ blockedBy.set(s.id, s.dependsOn);
143
+ }
144
+ }
145
+
120
146
  console.log(`\n${doneCount}/${total} done [${bar}] ${pct}%\n`);
121
147
  console.log(`${'ID'.padEnd(8)} ${'Name'.padEnd(20)} ${'Assignee'.padEnd(12)} ${'Branch'.padEnd(28)} ${'Status'.padEnd(8)} ${'Depends'.padEnd(10)} ${'Files'}`);
122
148
  console.log('─'.repeat(120));
123
149
 
124
150
  for (const s of subs) {
125
151
  const icon = { '[ ]': '◻', '[~]': '◉', '[x]': '✓', '[!]': '✗' }[s.status] || '?';
152
+ const blocked = blockedBy.has(s.id) ? ` ⛔ blocked-by ${blockedBy.get(s.id)}` : '';
126
153
  console.log(
127
- `${s.id.padEnd(8)} ${s.name.padEnd(20)} ${s.assignee.padEnd(12)} ${s.branch.padEnd(28)} ${icon} ${s.status.padEnd(3)} ${s.dependsOn.padEnd(10)} ${s.touchedFiles.join(', ')}`
154
+ `${s.id.padEnd(8)} ${s.name.padEnd(20)} ${s.assignee.padEnd(12)} ${s.branch.padEnd(28)} ${icon} ${s.status.padEnd(3)} ${s.dependsOn.padEnd(10)} ${s.touchedFiles.join(', ')}${blocked}`
128
155
  );
129
156
  }
130
157
  console.log();
@@ -133,7 +160,7 @@ function cmdStatus(planFile: string): void {
133
160
  function cmdConflictCheck(planFile: string): void {
134
161
  const content = readFileSync(planFile, 'utf8');
135
162
  assertPlanContent(content, planFile);
136
- const subs = parseSubMissions(content);
163
+ const { subs } = assertRowsParsed(planFile, content);
137
164
 
138
165
  if (subs.length === 0) {
139
166
  console.log('No sub-missions — no conflicts possible.');
@@ -168,6 +195,7 @@ function cmdConflictCheck(planFile: string): void {
168
195
  console.log(`\n${conflicts.length} file conflict(s) detected:\n`);
169
196
  console.log(conflicts.join('\n'));
170
197
  console.log();
198
+ process.exit(1);
171
199
  }
172
200
  }
173
201
 
@@ -187,28 +215,34 @@ function cmdSetStatus(planFile: string, id: string, status: string): void {
187
215
 
188
216
  for (let i = 0; i < lines.length; i++) {
189
217
  const line = lines[i];
190
- if (line.startsWith('## Sub-missions')) {
218
+ if (line.trim().toLowerCase().startsWith('## sub-missions')) {
191
219
  inSubSection = true;
192
220
  continue;
193
221
  }
194
- if (inSubSection && line.startsWith('## ') && !line.startsWith('## Sub-missions')) {
222
+ if (inSubSection && line.startsWith('## ') && !line.trim().toLowerCase().startsWith('## sub-missions')) {
195
223
  break;
196
224
  }
197
225
  if (!inSubSection) continue;
198
226
 
199
227
  const trimmed = line.trim();
200
- if (trimmed.startsWith('| ID ')) { inTable = true; continue; }
228
+ if (trimmed.toLowerCase().startsWith('| id ')) { inTable = true; continue; }
201
229
  if (!inTable || !trimmed.startsWith('|')) continue;
202
230
  if (trimmed.startsWith('|---')) continue;
203
231
 
204
- const cols = trimmed.split('|').map(c => c.trim()).filter(Boolean);
232
+ // same cell handling as parseSubMissions: interior empty cells preserved
233
+ const cols = trimmed.split('|').map(c => c.trim());
234
+ while (cols.length && cols[0] === '') cols.shift();
235
+ while (cols.length && cols[cols.length - 1] === '') cols.pop();
205
236
  if (cols[0] === id) {
206
- const oldMarker = cols[4];
237
+ let oldMarker = cols[4];
238
+ if (oldMarker === '[X]') oldMarker = '[x]';
207
239
  if (!Object.values(STATUS_MARKERS).includes(oldMarker)) {
208
240
  console.error(`Row ${id}: status column "${oldMarker}" is not a recognized marker`);
209
241
  process.exit(1);
210
242
  }
211
- lines[i] = lines[i].replace(oldMarker, newMarker);
243
+ // rewrite only the status cell — a marker string elsewhere in the row
244
+ // (name, touched files) must not be clobbered by a row-wide replace
245
+ lines[i] = `| ${cols.slice(0, 4).join(' | ')} | ${newMarker} | ${cols.slice(5).join(' | ')} |`;
212
246
  found = true;
213
247
  break;
214
248
  }
@@ -2,12 +2,13 @@
2
2
  $ErrorActionPreference = 'Stop'
3
3
 
4
4
  if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
5
- Write-Error 'mugiwara requires Node.js >= 20. Install it from https://nodejs.org first.'
5
+ Write-Error 'mugiwara requires Node.js >= 20.11. Install it from https://nodejs.org first.'
6
6
  }
7
7
 
8
- $major = [int](node -p 'process.versions.node.split(".")[0]')
9
- if ($major -lt 20) {
10
- Write-Error "mugiwara requires Node.js >= 20 (found $(node --version))."
8
+ # floor must match package.json "engines.node" exactly, and install.sh
9
+ node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>20||(a===20&&b>=11)?0:1)'
10
+ if ($LASTEXITCODE -ne 0) {
11
+ Write-Error "mugiwara requires Node.js >= 20.11 (found $(node --version))."
11
12
  }
12
13
 
13
14
  npx -y @ionivetech/mugiwara@latest @args
@@ -4,13 +4,13 @@ set -euo pipefail
4
4
  PKG="@ionivetech/mugiwara"
5
5
 
6
6
  if ! command -v node >/dev/null 2>&1; then
7
- echo "mugiwara requires Node.js >= 20. Install it first: https://nodejs.org" >&2
7
+ echo "mugiwara requires Node.js >= 20.11. Install it first: https://nodejs.org" >&2
8
8
  exit 1
9
9
  fi
10
10
 
11
- MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
12
- if [ "$MAJOR" -lt 20 ]; then
13
- echo "mugiwara requires Node.js >= 20 (found $(node --version))." >&2
11
+ # floor must match package.json "engines.node" exactly, and install.ps1
12
+ if ! node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>20||(a===20&&b>=11)?0:1)'; then
13
+ echo "mugiwara requires Node.js >= 20.11 (found $(node --version))." >&2
14
14
  exit 1
15
15
  fi
16
16