@ionivetech/mugiwara 0.6.4 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mugiwara",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, healing.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ionivetech/mugiwara",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, self-healing. Installs into Claude Code, opencode, Copilot, Gemini, Codex, Cursor, Kimi, pi, Windsurf, Cline, Kilo, Antigravity.",
5
5
  "homepage": "https://github.com/ionivetech/mugiwara#readme",
6
6
  "repository": {
@@ -59,7 +59,7 @@
59
59
  "retrieval-eval": "bun scripts/retrieval-eval.ts",
60
60
  "verify-install": "bun scripts/verify-install.ts",
61
61
  "sync-version": "bun scripts/sync-version.ts",
62
- "gate": "bun run typecheck && bun run test && bun run build && bun scripts/validate-content.ts --check-manifest --check-docs --check-doc-integrity && bun scripts/lane-base.ts && bun run verify-pack && bun scripts/run-evals.ts && bun scripts/retrieval-eval.ts && bun scripts/verify-install.ts",
62
+ "gate": "bun run typecheck && bun run test && bun run build && bun scripts/validate-content.ts --check-manifest --check-docs --check-doc-integrity && bun scripts/lane-base.ts && bun run verify-pack && bun scripts/run-evals.ts && bun scripts/retrieval-eval.ts && bun scripts/verify-install.ts && bun scripts/conformance.ts",
63
63
  "verify-pack": "npm pack --dry-run 2>&1 | node -e \"let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{if(!s.includes('ionivetech-mugiwara')){console.error('npm pack failed');process.exit(1)};console.log('npm package clean')})\"",
64
64
  "prepack": "bun run build && bun run sync-version"
65
65
  },
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mugiwara",
3
3
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, healing.",
4
- "version": "0.6.4",
4
+ "version": "0.6.5",
5
5
  "author": {
6
6
  "name": "ionivetech"
7
7
  },
@@ -5,16 +5,19 @@ plugin and the opencode-target generator derive agent UI colors from the table
5
5
  below — change a color HERE, never in code. This file is machine-parsed:
6
6
  keep the table format exact (one row per agent, pipes, no extra columns).
7
7
 
8
- ## Terminal format (ANSI truecolor)
8
+ ## Banner format (one form)
9
9
 
10
10
  Every wave opens with the banner line in the owning agent's color, and closes
11
- with a handoff line. Wrap the whole line in the ANSI truecolor of the agent:
11
+ with a handoff line:
12
12
 
13
13
  ```
14
- \x1b[38;2;R;G;Bm==================== WAVE 3 — ZORO (EXECUTION) ====================\x1b[0m
14
+ ===== ⚔️ WAVE 3 — ZORO (EXECUTION) =====
15
15
  → Wave 4 — Chopper (Checkpoint)
16
16
  ```
17
17
 
18
+ - Terminal: wrap the whole line in ANSI truecolor `\x1b[38;2;R;G;Bm` ... `\x1b[0m`.
19
+ - Markdown UI: emit the plain equals line, no ANSI (UIs strip or garble escapes).
20
+ - The crew emoji leads the line, before the `WAVE N` text.
18
21
  - RGB values come from the `hex` column below; R/G/B are the hex channels in
19
22
  decimal (truecolor `38;2;R;G;B`). If the terminal lacks truecolor, use the
20
23
  `ansi-256` index: `\x1b[38;5;Nm`.
@@ -22,20 +25,8 @@ with a handoff line. Wrap the whole line in the ANSI truecolor of the agent:
22
25
  - The literal `WAVE N —` text must stay exact: `scripts/savepoint.sh` counts
23
26
  heal cycles by grepping `wave 8` (case-insensitive) in the trace. A banner
24
27
  that drops the literal silently resets the heal loop.
25
- - Never convey the wave by color alone — the crew name and emoji always
26
- accompany the color.
27
-
28
- ## UI format (markdown UIs)
29
-
30
- Rich UIs (Claude Code UI, VSCode, Codex) strip or garble ANSI escapes. Emit
31
- the emoji-heading form instead — no ANSI, no equals line:
32
-
33
- ```
34
- ## ⚔️ WAVE 3 — ZORO (EXECUTION)
35
- → Wave 4 — Chopper (Checkpoint)
36
- ```
37
-
38
- Same literal `WAVE N —` rule applies.
28
+ - Never convey the wave by color alone — the crew name always accompanies
29
+ the color.
39
30
 
40
31
  ## Crew colors
41
32
 
@@ -65,8 +56,7 @@ wizard agents — their banners appear only when a wave or worker names them.
65
56
  1. Banner before EVERY wave; handoff after it. No wave starts without its
66
57
  banner (orchestration red flag).
67
58
  2. The color comes from this table only — never invent a hex mid-mission.
68
- 3. Terminal variant when the session looks like a terminal; UI variant for
69
- markdown-rendering UIs. When unsure, the UI variant is safe everywhere.
59
+ 3. One form everywhere: equals line `===== <emoji> WAVE N — <CREW> (ROLE) =====` — five `=` per side, the crew emoji from the table leading the line, ANSI-wrapped in terminals, plain in markdown-rendering UIs. When unsure, the plain form is safe everywhere.
70
60
  4. Only the crew table's colors and the two SGR forms above (truecolor,
71
61
  256-index) may appear in a banner — never other escape families (OSC,
72
62
  title, cursor, other SGR codes). The banner is a fixed template, not a
@@ -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'))) {
@@ -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
  }
@@ -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`);
@@ -33,6 +33,7 @@ const CREW: Record<string, CrewConfig> = {
33
33
  'eval-runner': { color: '#14b8a6', temperature: 0.2, steps: 15 },
34
34
  'resume-coordinator': { color: '#d97706', temperature: 0.2, steps: 10 },
35
35
  'memory-keeper': { color: '#d946ef', temperature: 0.2, steps: 8 },
36
+ 'onboarding-guide': { color: '#0ea5e9', temperature: 0.3, steps: 15 },
36
37
  };
37
38
 
38
39
  // Crew colors from the wave-banners table (single source of truth). Returns