@ionivetech/mugiwara 0.6.1 → 0.6.3

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 (83) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/commands/mugiwara-continue.md +42 -10
  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 +15 -0
  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/mugiwara-helpers.mjs +0 -1
  15. package/.opencode/plugins/mugiwara.mjs +33 -22
  16. package/AGENTS.md +16 -5
  17. package/README.md +80 -27
  18. package/content/agents/brook-healing.md +1 -1
  19. package/content/agents/chopper-checkpoint.md +1 -1
  20. package/content/agents/eval-runner.md +1 -1
  21. package/content/agents/franky-gates.md +1 -1
  22. package/content/agents/jinbe-security.md +1 -1
  23. package/content/agents/luffy-orchestrator.md +14 -4
  24. package/content/agents/memory-keeper.md +1 -1
  25. package/content/agents/nami-planner.md +2 -2
  26. package/content/agents/onboarding-guide.md +24 -45
  27. package/content/agents/resume-coordinator.md +11 -11
  28. package/content/agents/robin-reviewer.md +1 -1
  29. package/content/agents/sanji-quality.md +1 -1
  30. package/content/agents/skeptic-verifier.md +1 -1
  31. package/content/agents/usopp-brainstorm.md +2 -2
  32. package/content/agents/zoro-execution.md +6 -2
  33. package/content/skills/mugiwara-agent-security/SKILL.md +1 -18
  34. package/content/skills/mugiwara-agent-security/references/checklist.md +20 -0
  35. package/content/skills/mugiwara-backend/SKILL.md +1 -1
  36. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -1
  37. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -1
  38. package/content/skills/mugiwara-claim-audit/SKILL.md +1 -1
  39. package/content/skills/mugiwara-execution/SKILL.md +22 -22
  40. package/content/skills/mugiwara-execution/references/dispatch.md +4 -3
  41. package/content/skills/mugiwara-execution/references/resume-batching.md +4 -4
  42. package/content/skills/mugiwara-frontend/SKILL.md +1 -1
  43. package/content/skills/mugiwara-healing/SKILL.md +3 -38
  44. package/content/skills/mugiwara-healing/references/workers.md +38 -0
  45. package/content/skills/mugiwara-orchestration/SKILL.md +30 -33
  46. package/content/skills/mugiwara-orchestration/references/check-ins.md +42 -0
  47. package/content/skills/mugiwara-orchestration/references/closure.md +1 -1
  48. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +9 -12
  49. package/content/skills/mugiwara-planning/SKILL.md +7 -11
  50. package/content/skills/mugiwara-planning/references/plan-template.md +4 -4
  51. package/content/skills/mugiwara-pr/SKILL.md +11 -9
  52. package/content/skills/mugiwara-resume/SKILL.md +37 -22
  53. package/content/skills/mugiwara-ship/SKILL.md +2 -24
  54. package/content/skills/mugiwara-ship/references/cleanup.md +25 -0
  55. package/content/skills/mugiwara-sunset/SKILL.md +1 -1
  56. package/content/skills/mugiwara-workflow/SKILL.md +4 -2
  57. package/content/skills/mugiwara-workflow/references/workspace-layout.md +16 -7
  58. package/content/skills/using-mugiwara/SKILL.md +5 -1
  59. package/dist/mugiwara.js +107 -37
  60. package/gemini-extension.json +1 -1
  61. package/hooks/session-start.ts +113 -2
  62. package/package.json +3 -3
  63. package/plugin.json +1 -1
  64. package/references/multi-actor.md +39 -14
  65. package/references/skill-versioning.md +3 -3
  66. package/references/token-budget.md +1 -1
  67. package/scripts/evidence.sh +9 -0
  68. package/scripts/gate-selftest.ts +123 -0
  69. package/scripts/lane-base.ts +114 -0
  70. package/scripts/lane.sh +4 -2
  71. package/scripts/lib/lane-base.sh +19 -0
  72. package/scripts/lib/patterns.sh +15 -0
  73. package/scripts/mission-report.sh +21 -5
  74. package/scripts/onboard.ts +3 -29
  75. package/scripts/release-notes.ts +152 -75
  76. package/scripts/savepoint.sh +178 -56
  77. package/scripts/setup-fixtures.ts +108 -0
  78. package/scripts/validate-content.ts +61 -0
  79. package/src/cli.ts +5 -1
  80. package/src/installer.ts +70 -8
  81. package/src/mission.ts +39 -24
  82. package/src/targets/claude.ts +5 -3
  83. package/src/targets/opencode.ts +12 -8
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/setup-fixtures.ts — materialize test/fixtures/*.json into real git
3
+ // repos in a temp dir. Each fixture = a JSON manifest with base/branch file
4
+ // trees. The script writes the base tree on the trunk branch, creates the
5
+ // feature branch, applies the branch tree, and commits. Used by tests to get
6
+ // a deterministic repo per scenario. Never touches the main repo's .git.
7
+ //
8
+ // Usage:
9
+ // bun scripts/setup-fixtures.ts <fixture> <targetDir>
10
+ // bun scripts/setup-fixtures.ts --list
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const fixturesDir = join(import.meta.dirname, '..', 'test', 'fixtures');
17
+
18
+ function fixtureNames(): string[] {
19
+ return readdirSync(fixturesDir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
20
+ }
21
+
22
+ if (process.argv[2] === '--list') {
23
+ console.log(fixtureNames().join('\n'));
24
+ process.exit(0);
25
+ }
26
+
27
+ const name = process.argv[2];
28
+ const target = process.argv[3];
29
+ if (!name || !target) {
30
+ console.error('usage: bun scripts/setup-fixtures.ts <fixture> <targetDir>');
31
+ process.exit(1);
32
+ }
33
+
34
+ type Fixture = {
35
+ name: string;
36
+ baseBranch: string;
37
+ base: Record<string, string>;
38
+ branch: Record<string, string>;
39
+ binary?: string[];
40
+ gitInit?: boolean;
41
+ note?: string;
42
+ };
43
+
44
+ const file = join(fixturesDir, name + '.json');
45
+ if (!existsSync(file)) {
46
+ console.error(`fixture not found: ${name} (have: ${fixtureNames().join(', ')})`);
47
+ process.exit(1);
48
+ }
49
+ const fx = JSON.parse(readFileSync(file, 'utf8')) as Fixture;
50
+
51
+ // target must be a fresh empty dir
52
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
53
+ mkdirSync(target, { recursive: true });
54
+
55
+ if (fx.gitInit === false) {
56
+ // no-git fixture: just an empty dir with one file, no .git ever created
57
+ mkdirSync(join(target, 'src'), { recursive: true });
58
+ writeFileSync(join(target, 'src', 'main.ts'), 'export const main = () => 1;\n');
59
+ console.log(`fixture ${name}: materialized (no .git)`);
60
+ process.exit(0);
61
+ }
62
+
63
+ const writeTree = (tree: Record<string, string>) => {
64
+ for (const [path, content] of Object.entries(tree)) {
65
+ const full = join(target, path);
66
+ mkdirSync(join(full, '..'), { recursive: true });
67
+ writeFileSync(full, content);
68
+ }
69
+ };
70
+
71
+ // names feed unquoted git branch args — validate before interpolation (F3).
72
+ // Fixture files are repo-owned, but `name` is a CLI arg and `trunk` comes from
73
+ // fixture JSON; both must be safe git ref names.
74
+ const SAFE_REF = /^[A-Za-z0-9._/-]+$/;
75
+ if (!SAFE_REF.test(name) || !SAFE_REF.test(fx.baseBranch || '')) {
76
+ console.error(`fixture ${name}: unsafe branch name rejected`);
77
+ process.exit(1);
78
+ }
79
+
80
+ execSync('git init -q', { cwd: target });
81
+ execSync('git config user.email test@test.com && git config user.name Test', { cwd: target });
82
+
83
+ const trunk = fx.baseBranch || 'main';
84
+ // branch named after trunk if trunk isn't main (so savepoint base-resolve paths differ)
85
+ execSync(`git checkout -q -b ${trunk}`, { cwd: target });
86
+
87
+ // base commit on trunk
88
+ writeTree(fx.base);
89
+ for (const rel of fx.binary ?? []) {
90
+ if (fx.base[rel]) writeFileSync(join(target, rel), fx.base[rel]);
91
+ }
92
+ execSync('git add -A && git commit -q -m base', { cwd: target });
93
+
94
+ // feature branch applies the branch tree (files removed from base not in
95
+ // branch get deleted; contents updated; new files added)
96
+ execSync(`git checkout -q -b feat-${fx.name}`, { cwd: target });
97
+ for (const rel of Object.keys(fx.base)) {
98
+ if (!(rel in fx.branch)) {
99
+ execSync(`git rm -q --ignore-unmatch "${rel}"`, { cwd: target });
100
+ }
101
+ }
102
+ writeTree(fx.branch);
103
+ for (const rel of fx.binary ?? []) {
104
+ if (fx.branch[rel]) writeFileSync(join(target, rel), fx.branch[rel]);
105
+ }
106
+ execSync('git add -A && git commit -q -m wip', { cwd: target });
107
+
108
+ console.log(`fixture ${name}: materialized at ${target} (trunk=${trunk}, branch=feat-${fx.name})`);
@@ -319,5 +319,66 @@ const agentsDoc = join(docsDir, 'concepts', 'agents.md');
319
319
  if (docErrors === 0) console.log('✓ docs in sync with content/');
320
320
  }
321
321
 
322
+ // --- doc-integrity check: documented thresholds must match source constants ---
323
+ // Docs are claims; a claim that drifts from the source is a lie. LANE_BASE +
324
+ // BUDGET live in scripts/lib/lane-base.sh (validated by lane-base.ts); the
325
+ // cost docs must cite the same numbers. Evidence logs carry an exit code /
326
+ // verdict claim — a test must back it (lane-integrity cases 22-24).
327
+ const integrityArg = process.argv.indexOf('--check-doc-integrity');
328
+ if (integrityArg !== -1) {
329
+ const src = join(import.meta.dirname, '..', 'scripts', 'lib', 'lane-base.sh');
330
+ if (!existsSync(src)) {
331
+ errors.push('doc-integrity: scripts/lib/lane-base.sh not found — cannot verify thresholds');
332
+ } else {
333
+ const constants = readFileSync(src, 'utf8');
334
+ const docs = ['docs/concepts/cost.md', 'docs/concepts/lanes.md', 'README.md'];
335
+ const expected: [string, string][] = [
336
+ ['lean', '7,000'], ['standard', '13,000'], ['full', '23,000'],
337
+ ['lean', '12,000'], ['standard', '25,000'], ['full', '50,000'],
338
+ ];
339
+ for (const doc of docs) {
340
+ const p = join(import.meta.dirname, '..', doc);
341
+ if (!existsSync(p)) { errors.push(`doc-integrity: ${doc} not found`); continue; }
342
+ const text = readFileSync(p, 'utf8');
343
+ for (const [lane, num] of expected) {
344
+ // each doc must cite the budget/LANE_BASE number for that lane; accept
345
+ // both the comma form (7,000) and the k-shorthand (7k / ~7k)
346
+ const compact = num.replace(',', '').replace(/0+$/, '');
347
+ const kForm = /^(\d+)000$/.exec(num);
348
+ const variants = [num, compact, ...(kForm ? [`${kForm[1]}k`, `~${kForm[1]}k`] : [])];
349
+ if (!variants.some(v => text.includes(v))) {
350
+ errors.push(`doc-integrity: ${doc} missing ${lane} threshold ${num} (source: ${src})`);
351
+ }
352
+ }
353
+ // warn/stop are 1.5x / 3x of BUDGET (code: WARN_AT=BUDGET*3/2,
354
+ // STOP_AT=BUDGET*3) — presence is not enough, the arithmetic must be
355
+ // right. A doc claiming 1.5x/3x of LANE_BASE is the exact lie this gate
356
+ // exists to catch. Checked on the authoritative cost doc only.
357
+ if (doc === 'docs/concepts/cost.md') {
358
+ const budgets: [string, number][] = [['lean', 12000], ['standard', 25000], ['full', 50000]];
359
+ for (const [lane, budget] of budgets) {
360
+ const warn = budget * 3 / 2;
361
+ const stop = budget * 3;
362
+ const warnForms = [`${warn}`, `${Math.round(warn / 1000)}k`, `${warn.toLocaleString()}`, `${warn / 1000}k`];
363
+ const stopForms = [`${stop}`, `${stop / 1000}k`, `${stop.toLocaleString()}`];
364
+ const hasWarn = warnForms.some(f => text.includes(f));
365
+ const hasStop = stopForms.some(f => text.includes(f));
366
+ if (!hasWarn || !hasStop) {
367
+ errors.push(`doc-integrity: ${doc} ${lane} warn/stop must be 1.5x/3x of budget ${budget} (warn ${warn}, stop ${stop})`);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ if (!constants.includes('LANE_BASE_lean=7000')) errors.push('doc-integrity: source lane-base.sh lean base drifted (expected 7000)');
373
+ if (!constants.includes('BUDGET_full=50000')) errors.push('doc-integrity: source lane-base.sh full budget drifted (expected 50000)');
374
+ }
375
+ // evidence exit-code claim must be test-backed
376
+ const evTest = join(import.meta.dirname, '..', 'test', 'lane-integrity.test.ts');
377
+ const testText = existsSync(evTest) ? readFileSync(evTest, 'utf8') : '';
378
+ if (!testText.includes('# Exit:') || !testText.includes('# Verdict:')) {
379
+ errors.push('doc-integrity: audit-trail claims evidence logs carry exit code + verdict, but no test asserts them');
380
+ }
381
+ }
382
+
322
383
  if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
323
384
  console.log(`✓ content valid: ${skillDirs.length} skills, ${agentFiles.length} agents`);
package/src/cli.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs, type FlagValue, type Args } from './args.ts';
8
8
  import { createRl, choose, multiChoose, confirm } from './prompt.ts';
9
9
  import { targets, TARGET_IDS } from './targets/index.ts';
10
- import { installTo, removeInstalled, VERSION, ensureProjectGitignore } from './installer.ts';
10
+ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProjectGitignore } from './installer.ts';
11
11
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
12
12
  import { resetMission, archiveMission } from './mission.ts';
13
13
 
@@ -151,6 +151,10 @@ async function uninstall(flags: Args['flags']): Promise<void> {
151
151
  }
152
152
  const removed = removeInstalled(manifest, { dryRun: flag(flags.dryRun) });
153
153
  if (!flag(flags.dryRun)) {
154
+ if (scope === 'project') {
155
+ const gi = removeProjectGitignore(projectDir);
156
+ for (const n of gi.notes) console.log(` note: ${n}`);
157
+ }
154
158
  rmSync(file);
155
159
  const mugiDir = join(scope === 'global' ? home : projectDir, '.mugiwara');
156
160
  if (existsSync(mugiDir) && readdirSync(mugiDir).length === 0) {
package/src/installer.ts CHANGED
@@ -182,28 +182,90 @@ function assertNotSymlink(file: string): void {
182
182
  }
183
183
  }
184
184
 
185
+ const GITIGNORE_BLOCK_START = '# >>> mugiwara >>>';
186
+ const GITIGNORE_BLOCK_END = '# <<< mugiwara <<<';
187
+ // legacy marker from v0.6.2 — block was undelimited, single '# mugiwara'
188
+ // header. Kept for detection so an old install is not double-appended.
185
189
  const GITIGNORE_MARKER = '# mugiwara';
186
- const GITIGNORE_BLOCK = `# mugiwara — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
190
+ const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
187
191
  # Ignore session state and regenerated files.
188
- .mugiwara/state.json
189
- .mugiwara/state-*.json
192
+ .mugiwara/state/
193
+ .mugiwara/continue/
190
194
  .mugiwara/config
191
- .mugiwara/continue.md
192
195
  .mugiwara/refs/
196
+ # <<< mugiwara <<<
193
197
  `;
194
198
 
195
199
  export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: boolean } = {}): { appended: boolean; notes: string[] } {
196
200
  const { dryRun = false } = opts;
197
201
  const path = join(projectDir, '.gitignore');
198
202
  assertNotSymlink(path);
199
- if (existsSync(path) && readFileSync(path, 'utf8').includes(GITIGNORE_MARKER)) {
200
- return { appended: false, notes: [] };
203
+ let existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
204
+ if (existing) {
205
+ // Delimited block present with the current entries → nothing to do.
206
+ if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/state/')) {
207
+ return { appended: false, notes: [] };
208
+ }
209
+ // Legacy (v0.6.2) or outdated delimited block → upgrade in place:
210
+ // strip the old mugiwara entries (both forms), then append the new block.
211
+ // Without this, upgraded projects keep committing per-wave state/continue
212
+ // JSON to git — session state must stay ignored (Robin MAJOR, Jinbe Low).
213
+ const hadOld = existing.includes(GITIGNORE_MARKER) || existing.includes(GITIGNORE_BLOCK_START);
214
+ if (hadOld) {
215
+ const clean = removeProjectGitignore(projectDir, { dryRun });
216
+ existing = clean.removed ? (existsSync(path) ? readFileSync(path, 'utf8') : '') : existing;
217
+ }
201
218
  }
202
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
203
219
  const separator = existing.length && !existing.endsWith('\n') ? '\n' : '';
204
220
  if (!dryRun) {
205
221
  mkdirSync(dirname(path), { recursive: true });
206
222
  writeFileSync(path, existing + separator + GITIGNORE_BLOCK);
207
223
  }
208
- return { appended: true, notes: [`.gitignore ${dryRun ? 'would append' : 'appended'} mugiwara audit-trail block`] };
224
+ return { appended: true, notes: [`.gitignore ${dryRun ? 'would upgrade+append' : 'upgraded'} mugiwara audit-trail block`] };
225
+ }
226
+
227
+ // remove the delimited mugiwara block, preserving user lines. Handles both
228
+ // the current delimited block and the legacy undelimited block (v0.6.2).
229
+ export function removeProjectGitignore(projectDir: string, { dryRun = false }: { dryRun?: boolean } = {}): { removed: boolean; notes: string[] } {
230
+ const path = join(projectDir, '.gitignore');
231
+ assertNotSymlink(path);
232
+ if (!existsSync(path)) return { removed: false, notes: [] };
233
+ const current = readFileSync(path, 'utf8');
234
+
235
+ const delimited = current.includes(GITIGNORE_BLOCK_START);
236
+ let cleaned: string;
237
+ if (delimited) {
238
+ // strip everything between (and including) the delimiters. Both
239
+ // delimiters must be present — a missing END (hand-edit) with end=-1
240
+ // would slice() from char 18 and truncate the user's file.
241
+ const start = current.indexOf(GITIGNORE_BLOCK_START);
242
+ const end = current.indexOf(GITIGNORE_BLOCK_END);
243
+ if (end < start) return { removed: false, notes: ['delimiter mismatch — .gitignore left untouched'] };
244
+ cleaned = current.slice(0, start) + current.slice(end + GITIGNORE_BLOCK_END.length);
245
+ } else if (current.includes(GITIGNORE_MARKER)) {
246
+ // legacy (v0.6.2): strip the exact undelimited block — the header, the
247
+ // explanatory comment, and the five known lines. Prefix-matching would
248
+ // delete user-owned lines that merely start with a mugiwara path.
249
+ const LEGACY_LINES = new Set([
250
+ '.mugiwara/state.json',
251
+ '.mugiwara/state-*.json',
252
+ '.mugiwara/config',
253
+ '.mugiwara/continue.md',
254
+ '.mugiwara/refs/',
255
+ ]);
256
+ const lines = current.split('\n').filter(l => {
257
+ const t = l.trim();
258
+ if (t.startsWith(GITIGNORE_MARKER)) return false; // header + comment
259
+ return !LEGACY_LINES.has(t);
260
+ });
261
+ cleaned = lines.join('\n');
262
+ } else {
263
+ return { removed: false, notes: [] };
264
+ }
265
+
266
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '\n');
267
+ if (cleaned.trim() === '') cleaned = '';
268
+ if (cleaned === current) return { removed: false, notes: [] };
269
+ if (!dryRun) writeFileSync(path, cleaned);
270
+ return { removed: true, notes: [`.gitignore ${dryRun ? 'would strip' : 'stripped'} mugiwara block`] };
209
271
  }
package/src/mission.ts CHANGED
@@ -4,12 +4,23 @@ import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, appendFileSyn
4
4
  import { join } from 'node:path';
5
5
 
6
6
  function activeActor(projectDir: string): string | null {
7
- const stateFile = join(projectDir, '.mugiwara', 'state.json');
8
- if (!existsSync(stateFile)) return null;
9
- try {
10
- const state = JSON.parse(readFileSync(stateFile, 'utf8'));
11
- return state.actor || null;
12
- } catch { return null; }
7
+ // state now lives at .mugiwara/state/<mission>/[member].json — scan the
8
+ // latest state file for its actor
9
+ const stateDir = join(projectDir, '.mugiwara', 'state');
10
+ if (!existsSync(stateDir)) return null;
11
+ const missions = readdirSync(stateDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
12
+ let latest: { actor: string; updated: number } | null = null;
13
+ for (const mission of missions) {
14
+ const d = join(stateDir, mission);
15
+ for (const f of readdirSync(d).filter(f => f.endsWith('.json'))) {
16
+ try {
17
+ const s = JSON.parse(readFileSync(join(d, f), 'utf8'));
18
+ const t = Date.parse(s.updated_at || '') || 0;
19
+ if (!latest || t > latest.updated) latest = { actor: s.actor || null, updated: t };
20
+ } catch { /* corrupt — skip */ }
21
+ }
22
+ }
23
+ return latest ? latest.actor : null;
13
24
  }
14
25
 
15
26
  export function resetMission(projectDir: string, keepLogs: boolean, force?: boolean): { removed: string[]; kept: string[]; blocked?: string } {
@@ -30,7 +41,12 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
30
41
  const p = join(root, dir);
31
42
  if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
32
43
  }
33
- // mission state files — state.json + branch-specific state-*.json
44
+ // mission state + continue folders — state/<mission>/, continue/<mission>/
45
+ for (const dir of ['state', 'continue']) {
46
+ const p = join(root, dir);
47
+ if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
48
+ }
49
+ // legacy flat state files — state.json + branch-specific state-*.json
34
50
  for (const f of readdirSync(root)) {
35
51
  if (/^state(-.+)?\.json$/.test(f)) {
36
52
  const p = join(root, f);
@@ -80,15 +96,13 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
80
96
  if (f) report = join('reports', f);
81
97
  }
82
98
 
83
- // remove per-mission wave intermediates from results/<mission>/, EXCEPT
84
- // 06-closure.md and 07-pr-verdict.md (PR material + closure stay)
99
+ // step results 01..05 + todos.md are evidence — kept; archive removes
100
+ // step results 01..05 + todos.md are evidence kept; archive removes
101
+ // only spec/review/issues/logs + continue/<mission>/ + state/<mission>/
85
102
  const resultsDir = join(root, 'results', mission);
86
103
  if (existsSync(resultsDir)) {
87
104
  for (const f of readdirSync(resultsDir)) {
88
- if (f === '06-closure.md' || f === '07-pr-verdict.md') { kept.push(join('results', mission, f)); continue; }
89
- const p = join(resultsDir, f);
90
- if (!dryRun) rmSync(p, { recursive: true, force: true });
91
- removed.push(join('results', mission, f));
105
+ kept.push(join('results', mission, f));
92
106
  }
93
107
  }
94
108
 
@@ -124,21 +138,22 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
124
138
  }
125
139
  }
126
140
 
127
- // continue.md is a session handoff — only remove it if it belongs to THIS
128
- // mission (its content references the mission name); otherwise leave it.
129
- const cont = join(root, 'continue.md');
130
- if (existsSync(cont)) {
131
- try {
132
- if (readFileSync(cont, 'utf8').includes(mission)) {
133
- if (!dryRun) rmSync(cont);
134
- removed.push('continue.md');
135
- }
136
- } catch { /* unreadable leave it */ }
141
+ // continue/<mission>/ is a session handoff — remove this mission's folder
142
+ const contDir = join(root, 'continue', mission);
143
+ if (existsSync(contDir)) {
144
+ if (!dryRun) rmSync(contDir, { recursive: true, force: true });
145
+ removed.push(join('continue', mission));
146
+ }
147
+ // state/<mission>/ — remove this mission's computed state (archived)
148
+ const stateDir = join(root, 'state', mission);
149
+ if (existsSync(stateDir)) {
150
+ if (!dryRun) rmSync(stateDir, { recursive: true, force: true });
151
+ removed.push(join('state', mission));
137
152
  }
138
153
 
139
154
  // kept: report + the audit-trail survivors
140
155
  if (report) kept.push(report);
141
- for (const k of ['plans', 'config', 'state.json', join('logs', 'lessons.md')]) {
156
+ for (const k of ['plans', 'config', join('logs', 'lessons.md')]) {
142
157
  if (existsSync(join(root, k))) kept.push(k);
143
158
  }
144
159
 
@@ -10,8 +10,10 @@ const HOOKS_SRC = join(here, '..', '..', 'hooks');
10
10
  const COMMANDS_SRC = join(here, '..', '..', '.claude', 'commands');
11
11
 
12
12
  // Claude Code has no path-scoped permission. write-scope maps to a partial
13
- // `tools:` list: artifacts agents lose Edit (cannot modify existing source)
14
- // but keep Write (must create .mugiwara/**); source agents get the default set.
13
+ // `tools:` list applied to internal subagent-only agents only: artifacts
14
+ // agents lose Edit (cannot modify existing source) but keep Write (must create
15
+ // .mugiwara/**). User-facing crew run inline in the main thread and keep the
16
+ // default toolset (incl. Edit); discipline is enforced by rules, not tools.
15
17
  function toolsFromScope(scope?: string): string | undefined {
16
18
  if (scope === 'artifacts') return 'Read, Grep, Glob, Write, Bash, WebFetch, WebSearch';
17
19
  return undefined;
@@ -35,7 +37,7 @@ export const target: Target = {
35
37
  transformAgent(data: FrontmatterData, body: string) {
36
38
  const fm: FrontmatterData = { name: data.name, description: data.description };
37
39
  if (data.tools) fm.tools = data.tools;
38
- else {
40
+ else if (data['internal-agent'] === 'true') {
39
41
  const generated = toolsFromScope(data['write-scope']);
40
42
  if (generated) fm.tools = generated;
41
43
  }
@@ -32,23 +32,27 @@ const CREW: Record<string, CrewConfig> = {
32
32
  'memory-keeper': { color: '#d946ef', temperature: 0.2, steps: 8 },
33
33
  };
34
34
 
35
- // write-scope is the single source of truth (content/agents/*.md frontmatter).
36
- // The path boundary (artifacts vs source) IS expressible in opencode:
37
- // permission.edit accepts glob/pattern -> action, last match wins. Artifacts
38
- // agents get deny-all-edit except .mugiwara/**; source agents (zoro, brook)
39
- // get full edit allow. Derived at install time from the frontmatter field.
35
+ // write-scope is the single source of truth (content/agents/*.md frontmatter),
36
+ // but it is a RULE for user-facing crew agents: they run inline in the main
37
+ // thread, so a runtime permission bound to the active-agent identity would
38
+ // force tab-switching per wave and break auto mode + resume. Runtime
39
+ // enforcement stays for internal subagent-only agents (internal: true) the
40
+ // path boundary IS expressible in opencode: permission.edit accepts
41
+ // glob/pattern -> action, last match wins. Artifacts internal agents get
42
+ // deny-all-edit except .mugiwara/**; source internal agents get full edit
43
+ // allow. Derived at install time from the frontmatter field.
40
44
  function permissionFromScope(scope: string | undefined): Record<string, string | Record<string, string>> | undefined {
41
45
  if (scope === 'source') return { edit: 'allow' };
42
46
  if (scope === 'artifacts') return { edit: { '*': 'deny', '.mugiwara/**': 'allow' } };
43
47
  return undefined;
44
48
  }
45
49
 
46
- function agentFrontmatter(name: string, description: string, writeScope?: string) {
50
+ function agentFrontmatter(name: string, description: string, internal: boolean, writeScope?: string) {
47
51
  const crew = CREW[name];
48
52
  const lines = [`description: ${description}`, `mode: all`];
49
53
  if (crew) {
50
54
  lines.push(`color: '${crew.color}'`, `temperature: ${crew.temperature}`, `steps: ${crew.steps}`);
51
- const perm = permissionFromScope(writeScope);
55
+ const perm = internal ? permissionFromScope(writeScope) : undefined;
52
56
  if (perm) {
53
57
  lines.push('permission:');
54
58
  for (const [k, v] of Object.entries(perm)) {
@@ -81,7 +85,7 @@ export const target: Target = {
81
85
  },
82
86
  transformAgent(data: FrontmatterData, body: string) {
83
87
  const desc = data['internal-agent'] === 'true' ? `[INTERNAL] ${data.description}` : data.description;
84
- const fm = agentFrontmatter(data.name, desc, data['write-scope']);
88
+ const fm = agentFrontmatter(data.name, desc, data['internal-agent'] === 'true', data['write-scope']);
85
89
  return { relPath: `${data.name}.md`, text: `---\n${fm}\n---\n${body}` };
86
90
  },
87
91
  refsDir({ scope, projectDir, home }, skillName: string) {