@ionivetech/mugiwara 0.6.3 → 0.6.5

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 (38) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +49 -2
  4. package/.cursor-plugin/plugin.json +49 -2
  5. package/.kimi-plugin/plugin.json +49 -2
  6. package/.opencode/mugiwara-helpers.mjs +4 -0
  7. package/.opencode/plugins/mugiwara.mjs +40 -7
  8. package/AGENTS.md +11 -0
  9. package/README.md +21 -1
  10. package/content/agents/zoro-execution.md +1 -1
  11. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
  12. package/content/skills/mugiwara-execution/SKILL.md +18 -17
  13. package/content/skills/mugiwara-gates/SKILL.md +1 -0
  14. package/content/skills/mugiwara-lessons/SKILL.md +1 -0
  15. package/content/skills/mugiwara-orchestration/SKILL.md +16 -16
  16. package/content/skills/mugiwara-orchestration/references/check-ins.md +15 -4
  17. package/content/skills/mugiwara-orchestration/references/closure.md +9 -0
  18. package/content/skills/mugiwara-orchestration/references/output-contract.md +77 -0
  19. package/content/skills/mugiwara-pr/SKILL.md +11 -18
  20. package/content/skills/mugiwara-pr/references/verdict-format.md +31 -0
  21. package/content/skills/mugiwara-quality/SKILL.md +1 -0
  22. package/content/skills/mugiwara-resume/SKILL.md +1 -0
  23. package/content/skills/mugiwara-review/SKILL.md +1 -0
  24. package/content/skills/mugiwara-workflow/SKILL.md +14 -11
  25. package/dist/mugiwara.js +42 -22
  26. package/gemini-extension.json +1 -1
  27. package/package.json +2 -2
  28. package/plugin.json +1 -1
  29. package/references/wave-banners.md +65 -0
  30. package/scripts/conformance.ts +215 -0
  31. package/scripts/evidence.sh +17 -4
  32. package/scripts/gate-selftest.ts +45 -0
  33. package/scripts/initiative.ts +51 -17
  34. package/scripts/lane.sh +1 -1
  35. package/scripts/lib/patterns.sh +8 -2
  36. package/scripts/savepoint.sh +12 -1
  37. package/scripts/validate-content.ts +22 -2
  38. package/src/targets/opencode.ts +35 -6
@@ -0,0 +1,215 @@
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
+ // 3. run the four core scripts + the gitignore write the CLI does post-install
105
+ sh(`bash "${root}/scripts/lane.sh" main --json`, dir);
106
+ sh(`bash "${root}/scripts/savepoint.sh" ${MISSION} "" 1 auto`, dir);
107
+ sh(`bash "${root}/scripts/evidence.sh" ${MISSION} lint -- printf 'ok\\n'`, dir);
108
+ sh(`bash "${root}/scripts/mission-report.sh" ${MISSION}`, dir);
109
+ ensureProjectGitignore(dir, { dryRun: false });
110
+
111
+ // 4. normalize + collect
112
+ const state = JSON.parse(readFileSync(join(dir, '.mugiwara', 'state', MISSION, 'state.json'), 'utf8'));
113
+ const reportFile = readdirSync(join(dir, '.mugiwara', 'reports')).find(f => f.endsWith(`-${MISSION}.md`));
114
+ const report = reportFile ? readFileSync(join(dir, '.mugiwara', 'reports', reportFile), 'utf8') : '';
115
+ const evFile = readdirSync(join(dir, '.mugiwara', 'results', MISSION)).find(f => f.includes('lint-'));
116
+ const ev = evFile ? readFileSync(join(dir, '.mugiwara', 'results', MISSION, evFile), 'utf8') : '';
117
+ const gitignore = existsSync(join(dir, '.gitignore')) ? readFileSync(join(dir, '.gitignore'), 'utf8') : '';
118
+
119
+ const countFiles = (p: string): number => {
120
+ if (!existsSync(p)) return 0;
121
+ return readdirSync(p, { recursive: true, withFileTypes: true }).filter(e => e.isFile()).length;
122
+ };
123
+
124
+ return {
125
+ target: targetId,
126
+ tier: TIER_OF[targetId] ?? 2,
127
+ state: {
128
+ mission: state.mission,
129
+ member: state.member,
130
+ lane: state.lane,
131
+ lane_reason: state.lane_reason,
132
+ files_touched: state.files_touched,
133
+ loc_delta: state.loc_delta,
134
+ loc_ins: state.loc_ins,
135
+ loc_del: state.loc_del,
136
+ loc_churn: state.loc_churn,
137
+ mode: state.mode,
138
+ verbosity: state.verbosity,
139
+ tasks_done: state.tasks?.done,
140
+ tasks_total: state.tasks?.total,
141
+ blockers_open: state.blockers_open,
142
+ heal_cycle: state.heal_cycle,
143
+ tokens_source: state.tokens_source,
144
+ budget_status: state.budget_status,
145
+ sensitive_paths: state.sensitive_paths,
146
+ },
147
+ report_sections: [...report.matchAll(/^#{2,3} .*$/gm)].map(m => m[0]),
148
+ evidence: {
149
+ header: ev.split('\n').filter(l => l.startsWith('# ') && !l.startsWith('# At:') && !/^# (Exit|Verdict):/.test(l)),
150
+ trailer: ev.split('\n').filter(l => /^# (Exit|Verdict):/.test(l)),
151
+ },
152
+ gitignore_block: gitignore.split('\n').filter(l => l.includes('mugiwara') || l.includes('# ---')),
153
+ file_count: {
154
+ skills: countFiles(target.paths({ scope: 'project', projectDir: dir, home: '' }).skillsDir),
155
+ agents: countFiles(target.paths({ scope: 'project', projectDir: dir, home: '' }).agentsDir),
156
+ state: countFiles(join(dir, '.mugiwara', 'state')),
157
+ evidence: countFiles(join(dir, '.mugiwara', 'results', MISSION)),
158
+ },
159
+ };
160
+ } finally {
161
+ rmSync(dir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ function diff(a: unknown, b: unknown, path = ''): string[] {
166
+ const out: string[] = [];
167
+ if (JSON.stringify(a) === JSON.stringify(b)) return out;
168
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') {
169
+ out.push(`${path}: ${JSON.stringify(a)} ≠ ${JSON.stringify(b)}`);
170
+ return out;
171
+ }
172
+ for (const k of new Set([...Object.keys(a as object), ...Object.keys(b as object)])) {
173
+ out.push(...diff((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], `${path}.${k}`));
174
+ }
175
+ return out;
176
+ }
177
+
178
+ let failed = false;
179
+ mkdirSync(goldenDir, { recursive: true });
180
+
181
+ const ALL = [
182
+ ...TARGETS.map(t => ({ id: t.id, label: `tier ${t.tier}`, snap: () => snapshot(t.id) })),
183
+ ...MARKETPLACE.map(m => ({ id: m.id, label: 'marketplace', snap: () => marketplaceSnapshot(m.id, m.manifest, m.piField === true) })),
184
+ ];
185
+
186
+ for (const t of ALL) {
187
+ const snap = t.snap();
188
+ const goldenFile = join(goldenDir, `${t.id}.json`);
189
+ if (UPDATE) {
190
+ writeFileSync(goldenFile, JSON.stringify(snap, null, 2) + '\n');
191
+ console.log(`✓ golden updated: test/golden/${t.id}.json`);
192
+ continue;
193
+ }
194
+ if (!existsSync(goldenFile)) {
195
+ console.log(`✗ ${t.id}: golden missing — run with --update-golden first`);
196
+ failed = true;
197
+ continue;
198
+ }
199
+ const golden = JSON.parse(readFileSync(goldenFile, 'utf8'));
200
+ const diffs = diff(snap, golden);
201
+ if (diffs.length > 0) {
202
+ failed = true;
203
+ console.log(`✗ ${t.id} (${t.label}) differs:`);
204
+ for (const d of diffs) console.log(` ${d}`);
205
+ } else {
206
+ console.log(`✓ ${t.id} (${t.label}) conforms`);
207
+ }
208
+ }
209
+
210
+ if (UPDATE) {
211
+ console.log('goldens regenerated — review the diff before committing');
212
+ process.exit(0);
213
+ }
214
+ if (failed) process.exit(1);
215
+ console.log(`✓ ${ALL.length} platforms pass conformance`);
@@ -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'))) {
@@ -163,6 +177,37 @@ if (!existsSync(patternsFile)) {
163
177
  }
164
178
  }
165
179
 
180
+ // --- D3b mutation: strip the NEW pattern categories → lane-integrity red ---
181
+ console.log('\nD3b — new category strip mutation');
182
+ if (!existsSync(patternsFile)) {
183
+ console.log(' ⚠ patterns.sh not found, skipping');
184
+ } else {
185
+ const original = readFileSync(patternsFile, 'utf8');
186
+ try {
187
+ // derive the broken list from the LIVE source, stripping the exact tokens
188
+ // of the v0.6.4 D3 families — a hard-coded baseline rots the moment a
189
+ // category is added (G3). New family tokens must be added here too.
190
+ const D3B_FAMILY_TOKENS = new Set([
191
+ // raw regex tokens as they appear in live SENSITIVE_PATS (backslashes included)
192
+ 'oauth2?/', 'credential', 'sessions?/', 'tokens?/', 'rbac', 'permissions?/', 'acls?/', 'iam/',
193
+ '\\.p12$', '\\.key$', '\\.pem$', 'migrate/', 'Dockerfile', 'docker-compose', '\\.github/workflows/',
194
+ 'webhooks?/', 'secret/', 'secrets?\\.ya?ml$', '\\.tfvars$', '\\.env$', '\\.env\\.',
195
+ ]);
196
+ const live = original.match(/SENSITIVE_PATS="([^"]+)"/)?.[1] ?? '';
197
+ const broken = live.split('|').filter(t => !D3B_FAMILY_TOKENS.has(t)).join('|');
198
+ const brokenLine = `SENSITIVE_PATS="${broken}"`;
199
+ if (broken === live || !live) {
200
+ console.log(' ⚠ D3b: no D3 family tokens found in live SENSITIVE_PATS — skipping');
201
+ } else {
202
+ writeFileSync(patternsFile, original.replace(/SENSITIVE_PATS="[^"]*"/, brokenLine));
203
+ assert('missing new categories → lane-integrity fails', false, () => run('D3b', 'bun run test -- lane-integrity -t "sensitive-paths"'));
204
+ }
205
+ } finally {
206
+ writeFileSync(patternsFile, original);
207
+ assert('restored → lane-integrity passes', true, () => run('D3b', 'bun run test -- lane-integrity -t "sensitive-paths"'));
208
+ }
209
+ }
210
+
166
211
  // --- D4 mutation: zero LOC_TOKENS → lane-integrity red ---
167
212
  console.log('\nD4 — churn token mutation');
168
213
  if (!existsSync(savepointFile)) {
@@ -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
  }
package/scripts/lane.sh CHANGED
@@ -23,7 +23,7 @@ CHANGED=$(git diff --name-only "$BASE"..HEAD 2>/dev/null || git diff --name-only
23
23
  FILE_COUNT=0
24
24
  [ -n "$CHANGED" ] && FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
25
25
 
26
- SENSITIVE=$(echo "$CHANGED" | grep -E "$SENSITIVE_PATS" 2>/dev/null | head -5 | tr '\n' ',' | sed 's/,$//' || true)
26
+ SENSITIVE=$(echo "$CHANGED" | grep -E "$SENSITIVE_PATS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
27
27
  HAS_SENSITIVE=0
28
28
  [ -n "$SENSITIVE" ] && HAS_SENSITIVE=1
29
29
 
@@ -4,10 +4,16 @@
4
4
 
5
5
  # Sensitive-path escalation patterns. A changed file matching any of these
6
6
  # always escalates to Lane 3 (Full), regardless of file count. Plural forms
7
- # included (payments/, migrations/) the singular-only list missed them (D3).
7
+ # included (payments/, migrations/) and, since v0.6.4, the D3 categories:
8
+ # oauth, credential(s), session(s)/, token(s)/, rbac, permission(s), acl(s)/,
9
+ # iam/, cert keys (.pem/.key/.p12), migrate/, Dockerfile, docker-compose,
10
+ # .github/workflows/, webhooks?/, secret yaml, .tfvars, and .env variants
11
+ # (.env.local, .env.production — .env$ alone missed them, D3 follow-up).
12
+ # Dir-anchored (not bare): oauth2?/, permissions?/, tokens?/, sessions?/,
13
+ # acls?/ — bare forms over-matched docs (oauth-guide.md, permissionless.ts).
8
14
  # Deliberately NOT matched: package.json (dependency churn is policy-as-code,
9
15
  # not a sensitive lane trigger), authors/ (contains "auth" but never auth/).
10
- SENSITIVE_PATS="auth/|payment/|payments/|billing/|crypto/|secrets/|\.env$|config/.*key|migration/|migrations/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$"
16
+ SENSITIVE_PATS="auth/|oauth2?/|payment/|payments/|billing/|crypto/|secrets/|credential|sessions?/|tokens?/|rbac|permissions?/|acls?/|iam/|\.env$|\.env\.|config/.*key|\.p12$|\.key$|\.pem$|migration/|migrations/|migrate/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$|Dockerfile|docker-compose|\.github/workflows/|webhooks?/|secret/|secrets?\.ya?ml$|\.tfvars$"
11
17
 
12
18
  # Product surface: paths that count toward file-based lane sizing. Changes
13
19
  # outside this surface are docs/config/asset and never escalate to full on
@@ -42,6 +42,16 @@ MISSION="${1:-${STATE_MISSION:-}}"
42
42
  MEMBER="${2:-${STATE_MEMBER:-}}"
43
43
  WAVE="${3:-${STATE_WAVE:-1}}"
44
44
  MODE="${4:-${STATE_MODE:-guided}}"
45
+ # verbosity from config (project .mugiwara/config), default normal; env override
46
+ VERBOSITY="${STATE_VERBOSITY:-normal}"
47
+ if [ -f "$MUGIWARA_DIR/config" ]; then
48
+ CFG_VERBOSITY=$(grep -E '^verbosity=' "$MUGIWARA_DIR/config" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')
49
+ [ -n "$CFG_VERBOSITY" ] && VERBOSITY="$CFG_VERBOSITY"
50
+ fi
51
+ case "$VERBOSITY" in
52
+ normal|full) ;;
53
+ *) VERBOSITY="normal" ;;
54
+ esac
45
55
  ACTOR="${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}"
46
56
  BRANCH="$(git branch --show-current 2>/dev/null || echo 'unknown')"
47
57
 
@@ -296,6 +306,7 @@ const data = {
296
306
  lane_rose: process.argv[25] === 'true',
297
307
  wave: parseInt(process.argv[6], 10),
298
308
  mode: process.argv[7],
309
+ verbosity: process.argv[32] || 'normal',
299
310
  base_sha: process.argv[8],
300
311
  head_sha: process.argv[9],
301
312
  files_touched: parseInt(process.argv[10], 10),
@@ -324,7 +335,7 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
324
335
  "$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
325
336
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
326
337
  "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE" "$LANE_PEAK" \
327
- "$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER"
338
+ "$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER" "$VERBOSITY"
328
339
 
329
340
  if [ "$LANE_ROSE" = true ]; then
330
341
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
@@ -207,10 +207,15 @@ for (const f of [...agentFiles, ...skillDirs.map(d => join(root, 'skills', d, 'S
207
207
  }
208
208
  }
209
209
 
210
- // --- manifest-sync check: .claude-plugin/plugin.json must set-equal content/ ---
210
+ // --- manifest-sync check: every marketplace plugin.json set-equal content/ ---
211
211
  const manifestArg = process.argv.indexOf('--check-manifest');
212
212
  if (manifestArg !== -1) {
213
- const manifests = ['.claude-plugin/plugin.json'];
213
+ const manifests = [
214
+ '.claude-plugin/plugin.json',
215
+ '.codex-plugin/plugin.json',
216
+ '.cursor-plugin/plugin.json',
217
+ '.kimi-plugin/plugin.json',
218
+ ];
214
219
  let manifestErrors = 0;
215
220
 
216
221
  for (const mp of manifests) {
@@ -380,5 +385,20 @@ if (integrityArg !== -1) {
380
385
  }
381
386
  }
382
387
 
388
+ // Conditional-assertion guard: an expect() reachable only inside a truthiness
389
+ // check silently passes when the value is absent. This class produced 9 defects.
390
+ // Allowed: checks keyed on a declared invariant (tier, fixture keys).
391
+ const ALLOWED_COND = /if \((?:t\.tier === 3|keys\.length|label === 'exact'|fx\.expect\.(?:lane|sensitive_paths_(?:min|max)))/;
392
+ const repoRoot = join(import.meta.dirname, '..');
393
+ for (const f of readdirSync(join(repoRoot, 'test')).filter(x => x.endsWith('.test.ts'))) {
394
+ const src = readFileSync(join(repoRoot, 'test', f), 'utf8');
395
+ // matches both braced and brace-less conditionals whose body reaches an expect()
396
+ for (const m of src.matchAll(/if \([^)]+\)(?:\s*\{[^}]{0,400}?)?expect\(/gs)) {
397
+ if (!ALLOWED_COND.test(m[0])) {
398
+ errors.push(`test/${f}: expect() inside a conditional — assert presence explicitly instead:\n ${m[0].slice(0, 80)}`);
399
+ }
400
+ }
401
+ }
402
+
383
403
  if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
384
404
  console.log(`✓ content valid: ${skillDirs.length} skills, ${agentFiles.length} agents`);
@@ -1,5 +1,5 @@
1
1
  // src/targets/opencode.ts
2
- import { existsSync, readdirSync, copyFileSync, mkdirSync } from 'node:fs';
2
+ import { existsSync, readdirSync, copyFileSync, mkdirSync, readFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { stringifyFrontmatter, type FrontmatterData } from '../frontmatter.ts';
@@ -7,6 +7,7 @@ import type { Target } from '../installer.ts';
7
7
 
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
9
  const COMMANDS_SRC = join(here, '..', '..', '.opencode', 'commands');
10
+ const BANNER_TABLE = join(here, '..', '..', 'references', 'wave-banners.md');
10
11
 
11
12
  type CrewConfig = {
12
13
  color: string;
@@ -15,23 +16,47 @@ type CrewConfig = {
15
16
  permission?: Record<string, string>;
16
17
  };
17
18
 
19
+ // Colors are fallbacks — the wave-banners table is the source of truth.
20
+ // Temperature/steps stay here (runtime tuning, not banner material).
18
21
  const CREW: Record<string, CrewConfig> = {
19
22
  'luffy-orchestrator': { color: '#ef4444', temperature: 0.2, steps: 15 },
20
- 'usopp-brainstorm': { color: '#f59e0b', temperature: 0.6, steps: 15 },
23
+ 'usopp-brainstorm': { color: '#b45309', temperature: 0.6, steps: 15 },
21
24
  'nami-planner': { color: '#f97316', temperature: 0.2, steps: 15 },
22
25
  'zoro-execution': { color: '#22c55e', temperature: 0.1, steps: 30 },
23
- 'chopper-checkpoint': { color: '#3b82f6', temperature: 0.1, steps: 15 },
24
- 'sanji-quality': { color: '#a855f7', temperature: 0.1, steps: 10 },
26
+ 'chopper-checkpoint': { color: '#60a5fa', temperature: 0.1, steps: 15 },
27
+ 'sanji-quality': { color: '#facc15', temperature: 0.1, steps: 10 },
25
28
  'franky-gates': { color: '#06b6d4', temperature: 0.1, steps: 10 },
26
29
  'robin-reviewer': { color: '#8b5cf6', temperature: 0.2, steps: 15 },
27
30
  'jinbe-security': { color: '#6366f1', temperature: 0.2, steps: 15 },
28
- 'brook-healing': { color: '#ec4899', temperature: 0.1, steps: 20 },
31
+ 'brook-healing': { color: '#2dd4bf', temperature: 0.1, steps: 20 },
29
32
  'skeptic-verifier': { color: '#64748b', temperature: 0.1, steps: 12 },
30
33
  'eval-runner': { color: '#14b8a6', temperature: 0.2, steps: 15 },
31
34
  'resume-coordinator': { color: '#d97706', temperature: 0.2, steps: 10 },
32
35
  'memory-keeper': { color: '#d946ef', temperature: 0.2, steps: 8 },
36
+ 'onboarding-guide': { color: '#0ea5e9', temperature: 0.3, steps: 15 },
33
37
  };
34
38
 
39
+ // Crew colors from the wave-banners table (single source of truth). Returns
40
+ // {} on any failure — callers fall back to the CREW map. Same table shape as
41
+ // the opencode plugin parses: | agent-id | role | hex | ansi-256 | emoji |
42
+ function readBannerColors(): Record<string, string> {
43
+ try {
44
+ if (!existsSync(BANNER_TABLE)) return {};
45
+ const text = readFileSync(BANNER_TABLE, 'utf8');
46
+ // null-prototype + CRLF-tolerant: agent ids are trusted repo content, but
47
+ // a future `__proto__` id must never write the object's prototype
48
+ const colors: Record<string, string> = Object.create(null);
49
+ for (const m of text.matchAll(/^\| ([\w-]+) \| [^|]+ \| (#[0-9a-f]{6}) \| (\d+) \| (\S+) \|\r?$/gm)) {
50
+ colors[m[1]] = m[2];
51
+ }
52
+ return colors;
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ const BANNER_COLORS = readBannerColors();
59
+
35
60
  // write-scope is the single source of truth (content/agents/*.md frontmatter),
36
61
  // but it is a RULE for user-facing crew agents: they run inline in the main
37
62
  // thread, so a runtime permission bound to the active-agent identity would
@@ -50,8 +75,12 @@ function permissionFromScope(scope: string | undefined): Record<string, string |
50
75
  function agentFrontmatter(name: string, description: string, internal: boolean, writeScope?: string) {
51
76
  const crew = CREW[name];
52
77
  const lines = [`description: ${description}`, `mode: all`];
78
+ // color: wave-banners table wins; CREW fallback for agents the table lacks.
79
+ // temperature/steps only exist for CREW members (runtime tuning).
80
+ const color = BANNER_COLORS[name] ?? crew?.color;
81
+ if (color) lines.push(`color: '${color}'`);
53
82
  if (crew) {
54
- lines.push(`color: '${crew.color}'`, `temperature: ${crew.temperature}`, `steps: ${crew.steps}`);
83
+ lines.push(`temperature: ${crew.temperature}`, `steps: ${crew.steps}`);
55
84
  const perm = internal ? permissionFromScope(writeScope) : undefined;
56
85
  if (perm) {
57
86
  lines.push('permission:');