@ionivetech/mugiwara 0.8.0 → 0.8.2

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 (62) 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/README.md +2 -2
  7. package/content/agents/brook-healing.md +1 -1
  8. package/content/agents/memory-keeper.md +5 -0
  9. package/content/agents/usopp-brainstorm.md +3 -2
  10. package/content/agents/zoro-execution.md +4 -3
  11. package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
  12. package/content/skills/mugiwara-checkpoint/SKILL.md +2 -0
  13. package/content/skills/mugiwara-execution/SKILL.md +4 -3
  14. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  15. package/content/skills/mugiwara-gates/SKILL.md +6 -0
  16. package/content/skills/mugiwara-healing/SKILL.md +5 -1
  17. package/content/skills/mugiwara-lessons/SKILL.md +3 -0
  18. package/content/skills/mugiwara-orchestration/SKILL.md +7 -6
  19. package/content/skills/mugiwara-planning/SKILL.md +2 -0
  20. package/content/skills/mugiwara-quality/SKILL.md +3 -14
  21. package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
  22. package/content/skills/mugiwara-resume/SKILL.md +3 -14
  23. package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
  24. package/content/skills/mugiwara-review/SKILL.md +3 -15
  25. package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
  26. package/content/skills/mugiwara-security/SKILL.md +1 -0
  27. package/content/skills/mugiwara-ship/SKILL.md +2 -0
  28. package/content/skills/mugiwara-workflow/SKILL.md +28 -25
  29. package/dist/mugiwara.js +1323 -402
  30. package/gemini-extension.json +1 -1
  31. package/hooks/mugiwara-mode-tracker.js +24 -4
  32. package/hooks/mugiwara-mode-tracker.ts +36 -7
  33. package/hooks/session-start.js +6 -1
  34. package/hooks/session-start.ts +8 -1
  35. package/package.json +2 -2
  36. package/plugin.json +1 -1
  37. package/references/cost-governor.md +104 -0
  38. package/references/wave-banners.md +1 -2
  39. package/scripts/gate-selftest.ts +239 -21
  40. package/scripts/lane-base.ts +16 -0
  41. package/scripts/lane.sh +5 -1
  42. package/scripts/lib/lane-base.sh +1 -1
  43. package/scripts/savepoint.sh +48 -5
  44. package/scripts/validate-content.ts +60 -0
  45. package/scripts/verify-install.ts +20 -0
  46. package/scripts/write-metrics.ts +73 -0
  47. package/src/budget.ts +11 -0
  48. package/src/cli.ts +185 -28
  49. package/src/config.ts +6 -0
  50. package/src/continue.ts +36 -1
  51. package/src/cost.ts +4 -1
  52. package/src/installer.ts +27 -4
  53. package/src/integrity.ts +105 -25
  54. package/src/mission.ts +123 -7
  55. package/src/policy.ts +372 -4
  56. package/src/provenance.ts +29 -9
  57. package/src/sign.ts +45 -3
  58. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +0 -5
  59. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +0 -53
  60. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +0 -5
  61. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +0 -14
  62. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +0 -14
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // src/cli.ts
3
- import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
@@ -12,7 +12,7 @@ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProj
12
12
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
13
13
  import { resetMission, archiveMission } from './mission.ts';
14
14
  import { runScript, RUNNABLE } from './run.ts';
15
- import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor } from './continue.ts';
15
+ import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor, hasLegacyLayout, CURRENT_SCHEMA_VERSION, unreadableStateFiles } from './continue.ts';
16
16
  import { blamePath } from './provenance.ts';
17
17
  import { signReport, verifyReport, ensurePureKey, hasMinisign } from './sign.ts';
18
18
  import { ensureConfig } from './config.ts';
@@ -20,31 +20,50 @@ import { costEnvelope } from './cost.ts';
20
20
  import { computeLiveSlop } from './slop.ts';
21
21
  import { loadRegistry } from './evidence.ts';
22
22
  import { buildCostLedger, toCostJSON } from './reporting.ts';
23
+ import { enforceHarnessPolicy } from './policy.ts';
23
24
 
24
25
  const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
25
26
  const flag = (v: FlagValue): boolean => v === true;
26
27
 
28
+ // Anchor to the repo root so running from a package subdirectory does not
29
+ // create a shadow .mugiwara/ there. (B4)
30
+ function resolveProjectDir(explicit?: string): string {
31
+ if (explicit) return resolve(explicit);
32
+ try {
33
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
34
+ if (root) return root;
35
+ } catch { /* not a git repo — fall through */ }
36
+ return process.cwd();
37
+ }
38
+
27
39
  export async function run(argv: string[]): Promise<void> {
28
40
  const { command, flags, _ } = parseArgs(argv);
29
41
  if (flag(flags.help) || command === 'help') return help();
30
42
  if (flag(flags.version)) { console.log(`mugiwara ${VERSION}`); return; }
31
- // `continue` and `status` are read-only position commands: dispatch before
32
- // config bootstrap so a fresh project never gets a .mugiwara/config created
33
- // and no setup chatter is printed before missions/members are listed.
34
- if (command === 'continue' || command === 'status') {
35
- return command === 'continue' ? continueCmd(flags, _) : statusCmd(flags);
43
+ // harness.require_enforcement enterprise gate: refuse rules-based harnesses
44
+ // (only opencode is runtime-enforced). Covers run/savepoint/archive/status
45
+ // + other workflow commands; install/update/uninstall/list are setup and bypass.
46
+ {
47
+ const bypass = new Set(['install', 'update', 'uninstall', 'list']);
48
+ if (!bypass.has(command)) {
49
+ const projectDirForHarness = resolveProjectDir(str(flags.project));
50
+ enforceHarnessPolicy(projectDirForHarness);
51
+ }
36
52
  }
37
- // A command on a fresh project must be immediately usable bootstrap the
38
- // default .mugiwara/config when it is missing (not only at install time).
39
- // Skipped for install/update --dry-run: a dry run must not mutate the
40
- // project (the installer writes the config itself on a real install).
53
+ // Bootstrap default .mugiwara/config on any command when missingincluding
54
+ // `continue`/`status` so tier-3 agents that only mkdir .mugiwara still get
55
+ // a config. Also covered by readConfig() auto-create for non-CLI entry.
56
+ // Skipped for install/update --dry-run: a dry run must not mutate the project.
41
57
  const isDryRunInstall = (command === 'install' || command === 'update') && flag(flags.dryRun);
42
58
  if (!isDryRunInstall) {
43
- const projectDir = resolve(str(flags.project) ?? process.cwd());
59
+ const projectDir = resolveProjectDir(str(flags.project));
44
60
  if (ensureConfig(projectDir)) {
45
61
  console.log(`default .mugiwara/config written at ${join(projectDir, '.mugiwara', 'config')} (edit it to customise)`);
46
62
  }
47
63
  }
64
+ if (command === 'continue' || command === 'status') {
65
+ return command === 'continue' ? continueCmd(flags, _) : statusCmd(flags);
66
+ }
48
67
  switch (command) {
49
68
  case 'install': return install(flags);
50
69
  case 'update': return install({ ...flags, force: true });
@@ -61,12 +80,13 @@ export async function run(argv: string[]): Promise<void> {
61
80
  case 'blame': return blameCmd(flags, _);
62
81
  case 'handoff': return handoffCmd(flags, _);
63
82
  case 'sign': return signCmd(flags, _);
83
+ case 'migrate': return migrateCmd(flags);
64
84
  default: throw new Error(`Unknown command: ${command}`);
65
85
  }
66
86
  }
67
87
 
68
88
  function resetCmd(flags: Args['flags']): void {
69
- const projectDir = resolve(str(flags.project) ?? process.cwd());
89
+ const projectDir = resolveProjectDir(str(flags.project));
70
90
  const force = flag(flags.force);
71
91
  const result = resetMission(projectDir, flag(flags.keepLogs), force);
72
92
  if (result.blocked) {
@@ -79,7 +99,7 @@ function resetCmd(flags: Args['flags']): void {
79
99
  }
80
100
 
81
101
  function archive(flags: Args['flags'], positionals: string[]): void {
82
- const projectDir = resolve(str(flags.project) ?? process.cwd());
102
+ const projectDir = resolveProjectDir(str(flags.project));
83
103
  const mission = positionals[1];
84
104
  if (!mission) { console.error('usage: mugiwara archive <mission> [--project <dir>] [--dry-run]'); process.exit(1); }
85
105
  const result = archiveMission(projectDir, mission, { dryRun: flag(flags.dryRun) });
@@ -98,7 +118,7 @@ function archive(flags: Args['flags'], positionals: string[]): void {
98
118
  * touched before that date.
99
119
  */
100
120
  function cleanCmd(flags: Args['flags']): void {
101
- const projectDir = resolve(str(flags.project) ?? process.cwd());
121
+ const projectDir = resolveProjectDir(str(flags.project));
102
122
  const dryRun = flag(flags.dryRun);
103
123
  const root = join(projectDir, '.mugiwara', 'missions');
104
124
  if (!existsSync(root)) { console.log('nothing to clean (.mugiwara/missions/ does not exist).'); return; }
@@ -164,7 +184,7 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
164
184
  if (!interactive) { scope = 'project'; }
165
185
  else scope = (await choose(rl!, 'Install scope?', ['global (user-wide)', 'project (this repo)'])) === 0 ? 'global' : 'project';
166
186
  }
167
- const projectDir = resolve(str(flags.project) ?? process.cwd());
187
+ const projectDir = resolveProjectDir(str(flags.project));
168
188
  if (scope === 'project' && !existsSync(projectDir)) throw new Error(`Project dir not found: ${projectDir}`);
169
189
 
170
190
  let targetIds = str(flags.target)?.split(',').map(s => s.trim()) ?? null;
@@ -222,13 +242,15 @@ async function install(flags: Args['flags']): Promise<void> {
222
242
  });
223
243
  console.log(`\nOK mugiwara ${VERSION} installed (manifest: ${file})`);
224
244
  if (allNotes.length) console.log(`${allNotes.length} note(s) above may need attention.`);
245
+ console.log('CLI: run `npm i -g @ionivetech/mugiwara` so the crew can call `mugiwara savepoint/archive/continue`.');
246
+ console.log(' Without it the crew degrades to inline-only — no state, no resume, no closure gate.');
225
247
  // A fresh install writes a default .mugiwara/config — point at it directly.
226
248
  console.log('\nNext: edit .mugiwara/config to customise (mode, branch, coverage, depths).');
227
249
  }
228
250
 
229
251
  async function uninstall(flags: Args['flags']): Promise<void> {
230
252
  const scope: Scope = flag(flags.global) ? 'global' : 'project';
231
- const projectDir = resolve(str(flags.project) ?? process.cwd());
253
+ const projectDir = resolveProjectDir(str(flags.project));
232
254
  const home = homedir();
233
255
  const file = manifestPath({ scope, projectDir, home });
234
256
  const manifest = readManifest(file);
@@ -282,9 +304,27 @@ async function uninstall(flags: Args['flags']): Promise<void> {
282
304
  console.log(`OK removed ${removed.length} files`);
283
305
  }
284
306
 
307
+ function legacyWarning(projectDir: string): void {
308
+ if (hasLegacyLayout(projectDir)) {
309
+ console.error('⚠ legacy layout detected (v0.6 .mugiwara/state/ — run `mugiwara migrate` to move to missions/)');
310
+ }
311
+ }
312
+
313
+ function schemaWarnings(projectDir: string): void {
314
+ const states = readState(projectDir);
315
+ for (const s of states) {
316
+ const v = s.schema_version;
317
+ if (v !== CURRENT_SCHEMA_VERSION) {
318
+ const wrote = v === null || v === undefined || v === '' ? 'unknown' : String(v);
319
+ console.error(`⚠ state written by v${wrote} (mission ${s.mission}${s.member ? `/${s.member}` : ''}) — current expects v${CURRENT_SCHEMA_VERSION} — run \`mugiwara migrate\``);
320
+ }
321
+ }
322
+ }
323
+
285
324
  function list(flags: Args['flags']): void {
286
325
  const home = homedir();
287
- const projectDir = resolve(str(flags.project) ?? process.cwd());
326
+ const projectDir = resolveProjectDir(str(flags.project));
327
+ legacyWarning(projectDir);
288
328
  let found = false;
289
329
  for (const [label, file] of [
290
330
  ['project', manifestPath({ scope: 'project', projectDir, home })],
@@ -315,10 +355,28 @@ function list(flags: Args['flags']): void {
315
355
  * the caller must stop and let the user pick.
316
356
  */
317
357
  function continueCmd(flags: Args['flags'], positionals: string[]): void {
318
- const projectDir = resolve(str(flags.project) ?? process.cwd());
358
+ const projectDir = resolveProjectDir(str(flags.project));
359
+ legacyWarning(projectDir);
360
+ schemaWarnings(projectDir);
319
361
  const [mission, member] = positionals.slice(1);
320
362
  let entries = readContinue(projectDir);
321
363
 
364
+ // If the requested member's state file is unreadable, refuse rather than
365
+ // resuming from continue-<member>.json alone. A resume point without its
366
+ // state is a guess. (B6)
367
+ if (mission) {
368
+ // readState populates unreadableStateFiles for state files; entries already captured
369
+ readState(projectDir);
370
+ const badState = unreadableStateFiles();
371
+ const target = member ? `${mission}/${member}.json` : `${mission}/state.json`;
372
+ if (badState.includes(target)) {
373
+ console.error(`✗ mission "${mission}"${member ? ` member "${member}"` : ''} has unreadable state: ${target}`);
374
+ process.exit(1);
375
+ }
376
+ // re-read continue entries after the state scan cleared unreadable (preserve original entries)
377
+ // entries already holds the correct continue data, no need to re-read
378
+ }
379
+
322
380
  // default to this actor's work; --all crosses actors on a shared checkout
323
381
  if (!flag(flags.all)) {
324
382
  const actor = gitActor(projectDir);
@@ -357,9 +415,19 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
357
415
 
358
416
  /** `mugiwara status` — one screen of computed mission state, no model needed. */
359
417
  function statusCmd(flags: Args['flags']): void {
360
- const projectDir = resolve(str(flags.project) ?? process.cwd());
418
+ const projectDir = resolveProjectDir(str(flags.project));
419
+ legacyWarning(projectDir);
420
+ schemaWarnings(projectDir);
361
421
  const states = readState(projectDir);
362
- if (!states.length) { console.log('No mission state on disk.'); return; }
422
+ const bad = unreadableStateFiles();
423
+ if (bad.length) {
424
+ console.error(`⚠ ${bad.length} unreadable state file(s): ${bad.join(', ')}`);
425
+ console.error(' These are not "no mission" — they are corrupt. Inspect or delete them.');
426
+ }
427
+ if (!states.length) {
428
+ console.log(bad.length ? 'No readable mission state on disk.' : 'No mission state on disk.');
429
+ return;
430
+ }
363
431
  const actor = flag(flags.all) ? null : gitActor(projectDir);
364
432
  const rows = actor ? (states.filter((s) => s.actor === actor).length ? states.filter((s) => s.actor === actor) : states) : states;
365
433
  for (const s of rows) {
@@ -375,7 +443,7 @@ function statusCmd(flags: Args['flags']): void {
375
443
 
376
444
  /** `mugiwara cost [--mission <id>] [--json] [--ledger]` — show cost ledger, avoided work, efficiency, trail. */
377
445
  function costCmd(flags: Args['flags'], positionals: string[]): void {
378
- const projectDir = resolve(str(flags.project) ?? process.cwd());
446
+ const projectDir = resolveProjectDir(str(flags.project));
379
447
  const mission = str(flags.mission) ?? positionals[1] ?? (() => {
380
448
  const states = readState(projectDir);
381
449
  if (states.length === 1) return states[0].mission;
@@ -430,7 +498,7 @@ function costCmd(flags: Args['flags'], positionals: string[]): void {
430
498
 
431
499
  /** `mugiwara run <script.sh> [args]` — run a bundled harness script here. */
432
500
  function runCmd(flags: Args['flags'], positionals: string[]): void {
433
- const projectDir = resolve(str(flags.project) ?? process.cwd());
501
+ const projectDir = resolveProjectDir(str(flags.project));
434
502
  const name = positionals[1];
435
503
  if (!name) {
436
504
  console.error(`usage: mugiwara run <script> [args...]\n scripts: ${RUNNABLE.join(', ')}`);
@@ -442,7 +510,7 @@ function runCmd(flags: Args['flags'], positionals: string[]): void {
442
510
 
443
511
  /** `mugiwara blame <path>` — provenance note on the last commit touching path. */
444
512
  function blameCmd(flags: Args['flags'], positionals: string[]): void {
445
- const projectDir = resolve(str(flags.project) ?? process.cwd());
513
+ const projectDir = resolveProjectDir(str(flags.project));
446
514
  const path = positionals[1];
447
515
  if (!path) { console.error('usage: mugiwara blame <file-path>'); process.exit(1); }
448
516
  console.log(blamePath(projectDir, path));
@@ -475,10 +543,15 @@ export function stalenessLine(projectDir: string, baseSha: string): string | nul
475
543
 
476
544
  /** `mugiwara handoff <mission>` — a report the next engineer can act on. */
477
545
  function handoffCmd(flags: Args['flags'], positionals: string[]): void {
478
- const projectDir = resolve(str(flags.project) ?? process.cwd());
546
+ const projectDir = resolveProjectDir(str(flags.project));
479
547
  const mission = positionals[1];
480
548
  if (!mission) { console.error('usage: mugiwara handoff <mission> [--project <dir>]'); process.exit(1); }
481
549
  const states = readState(projectDir).filter((s) => s.mission === mission);
550
+ const bad = unreadableStateFiles().filter((p) => p.startsWith(`${mission}/`));
551
+ if (bad.length) {
552
+ console.error(`✗ mission "${mission}" has unreadable state: ${bad.join(', ')}`);
553
+ process.exit(1);
554
+ }
482
555
  if (!states.length) { console.error(`no in-flight mission "${mission}"`); process.exit(1); }
483
556
  const lines = [
484
557
  `# Handoff: ${mission}`,
@@ -508,9 +581,91 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
508
581
  console.log(`\nwritten: ${out}`);
509
582
  }
510
583
 
584
+ export function migrateCmd(flags: Args['flags']): void {
585
+ const projectDir = resolveProjectDir(str(flags.project));
586
+ const dryRun = flag(flags.dryRun);
587
+ const legacyState = join(projectDir, '.mugiwara', 'state');
588
+ const legacyContinue = join(projectDir, '.mugiwara', 'continue');
589
+ const missionsRoot = join(projectDir, '.mugiwara', 'missions');
590
+ const moves: Array<{ src: string; dest: string }> = [];
591
+
592
+ const collect = (srcRoot: string, isContinue: boolean) => {
593
+ if (!existsSync(srcRoot)) return;
594
+ const walk = (dir: string) => {
595
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
596
+ const full = join(dir, e.name);
597
+ if (e.isDirectory()) walk(full);
598
+ else if (e.isFile() && e.name.endsWith('.json')) {
599
+ const rel = full.slice(srcRoot.length + 1);
600
+ let destRel: string;
601
+ if (isContinue) {
602
+ const parts = rel.split('/');
603
+ const file = parts.pop()!;
604
+ const mission = parts.join('/');
605
+ const stem = file.slice(0, -'.json'.length);
606
+ let destFile: string;
607
+ if (stem === 'state') destFile = 'continue.json';
608
+ else destFile = `continue-${stem}.json`;
609
+ destRel = mission ? join(mission, destFile) : destFile;
610
+ } else {
611
+ destRel = rel;
612
+ }
613
+ moves.push({ src: full, dest: join(missionsRoot, destRel) });
614
+ }
615
+ }
616
+ };
617
+ walk(srcRoot);
618
+ };
619
+ collect(legacyState, false);
620
+ collect(legacyContinue, true);
621
+
622
+ // legacy flat: .mugiwara/state.json style? treat any top-level .mugiwara/state*.json as not legacy missions but still warn
623
+ // Already covered by state/ dir; nothing more to collect.
624
+
625
+ if (!moves.length) {
626
+ if (!existsSync(legacyState) && !existsSync(legacyContinue)) {
627
+ console.log('no legacy layout found (.mugiwara/state/ does not exist)');
628
+ } else {
629
+ console.log('no legacy state files to migrate');
630
+ }
631
+ return;
632
+ }
633
+
634
+ for (const m of moves) {
635
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
636
+ if (!dryRun) {
637
+ mkdirSync(dirname(m.dest), { recursive: true });
638
+ try {
639
+ const raw = JSON.parse(readFileSync(m.src, 'utf8')) as Record<string, unknown>;
640
+ raw.schema_version = CURRENT_SCHEMA_VERSION;
641
+ writeFileSync(m.dest, JSON.stringify(raw, null, 2) + '\n');
642
+ rmSync(m.src, { force: true });
643
+ } catch {
644
+ try { renameSync(m.src, m.dest); } catch { /* ignore */ }
645
+ }
646
+ }
647
+ }
648
+ if (!dryRun) {
649
+ const prune = (root: string) => {
650
+ if (!existsSync(root)) return;
651
+ const walkPrune = (dir: string) => {
652
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
653
+ if (e.isDirectory()) walkPrune(join(dir, e.name));
654
+ }
655
+ try { if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
656
+ };
657
+ walkPrune(root);
658
+ try { if (existsSync(root) && readdirSync(root).length === 0) rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
659
+ };
660
+ prune(legacyState);
661
+ prune(legacyContinue);
662
+ }
663
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${moves.length} file(s)${dryRun ? ' (dry run)' : ''}`);
664
+ }
665
+
511
666
  /** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
512
667
  function signCmd(flags: Args['flags'], _: string[]): void {
513
- const projectDir = resolve(str(flags.project) ?? process.cwd());
668
+ const projectDir = resolveProjectDir(str(flags.project));
514
669
  if (flag(flags.genKey)) {
515
670
  const backend = str(flags.backend) ?? 'auto';
516
671
  const home = homedir();
@@ -562,10 +717,12 @@ Usage:
562
717
  mugiwara sign <m> attestation: sign report.md (auto/minisign/pure/off; --verify to check)
563
718
  mugiwara sign --gen-key [--backend pure|minisign]
564
719
  create signing keys (pure ed25519 default)
720
+ mugiwara migrate [--dry-run] [--project <dir>]
721
+ move legacy .mugiwara/state/ layout to .mugiwara/missions/
565
722
  mugiwara run <script> [args...]
566
- run a bundled harness script here (${RUNNABLE.join(', ')})
723
+ run a bundled harness script here (${RUNNABLE.join(', ')})
567
724
  mugiwara savepoint <mission> [member] [flow] [mode]
568
- shorthand for: mugiwara run savepoint.sh ...
725
+ shorthand for: mugiwara run savepoint.sh ...
569
726
  mugiwara --help this help
570
727
  mugiwara --version print version
571
728
 
package/src/config.ts CHANGED
@@ -40,6 +40,12 @@ function configPaths(projectDir: string): string[] {
40
40
  * blank lines are skipped; values are trimmed.
41
41
  */
42
42
  export function readConfig(projectDir: string): Record<string, string> {
43
+ try {
44
+ const file = join(projectDir, '.mugiwara', 'config');
45
+ let exists = false;
46
+ try { exists = lstatSync(file).isFile() || lstatSync(file).isSymbolicLink(); } catch { exists = false; }
47
+ if (!exists) ensureConfig(projectDir);
48
+ } catch { /* best-effort: auto-create must not break reads */ }
43
49
  const out: Record<string, string> = {};
44
50
  for (const file of configPaths(projectDir)) {
45
51
  if (!existsSync(file)) continue;
package/src/continue.ts CHANGED
@@ -14,6 +14,32 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
16
 
17
+ export const CURRENT_SCHEMA_VERSION = 2;
18
+
19
+ /**
20
+ * v0.6 legacy layout: `.mugiwara/state/<mission>/<member>.json`
21
+ * (and `.mugiwara/continue/<mission>/...`) is invisible to v0.8
22
+ * `missions/` readers. Detect it so status/continue can warn.
23
+ */
24
+ export function hasLegacyLayout(projectDir: string): boolean {
25
+ for (const legacy of [join(projectDir, '.mugiwara', 'state'), join(projectDir, '.mugiwara', 'continue')]) {
26
+ if (!existsSync(legacy)) continue;
27
+ try {
28
+ const walk = (dir: string): boolean => {
29
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
30
+ if (e.isFile() && e.name.endsWith('.json')) return true;
31
+ if (e.isDirectory() && walk(join(dir, e.name))) return true;
32
+ }
33
+ return false;
34
+ };
35
+ if (walk(legacy)) return true;
36
+ } catch { /* ignore */ }
37
+ }
38
+ // also legacy flat files at .mugiwara/state.json style already handled by missions scan,
39
+ // but treat top-level .mugiwara/state/*.json absence as no legacy
40
+ return false;
41
+ }
42
+
17
43
  /** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
18
44
  const SAFE = /^[A-Za-z0-9._-]+$/;
19
45
  const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
@@ -51,6 +77,7 @@ export type StateEntry = ContinueEntry & {
51
77
  budget_status: string;
52
78
  files_touched: number;
53
79
  evidence: string[];
80
+ schema_version: number | string | null;
54
81
  };
55
82
 
56
83
  const num = (v: unknown): number => {
@@ -84,6 +111,11 @@ export function gitActor(cwd: string): string {
84
111
  return name || process.env.USER || process.env.USERNAME || '';
85
112
  }
86
113
 
114
+ const unreadable: string[] = [];
115
+
116
+ /** State files that exist but could not be parsed. Cleared by each scan. (B6) */
117
+ export function unreadableStateFiles(): string[] { return [...unreadable]; }
118
+
87
119
  /**
88
120
  * Read every mission dir under `.mugiwara/missions/<mission>/`, picking the
89
121
  * files this reader owns: state readers take `state.json` / `<member>.json`,
@@ -91,6 +123,7 @@ export function gitActor(cwd: string): string {
91
123
  * files are skipped.
92
124
  */
93
125
  function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
126
+ unreadable.length = 0;
94
127
  const base = join(projectDir, '.mugiwara', 'missions');
95
128
  if (!existsSync(base)) return [];
96
129
  const out: T[] = [];
@@ -121,7 +154,7 @@ function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Reco
121
154
  if (text(raw.mission) !== mission) continue;
122
155
  out.push(map(raw, member));
123
156
  } catch {
124
- // corrupt savepoint — skip, never crash the listing
157
+ unreadable.push(join(mission, f));
125
158
  }
126
159
  }
127
160
  }
@@ -148,6 +181,7 @@ export function readContinue(projectDir: string): ContinueEntry[] {
148
181
  export function readState(projectDir: string): StateEntry[] {
149
182
  return scan(projectDir, 'state', (r, member) => {
150
183
  const tasks = (r.tasks ?? {}) as Record<string, unknown>;
184
+ const sv = r.schema_version;
151
185
  return {
152
186
  mission: text(r.mission),
153
187
  member,
@@ -178,6 +212,7 @@ export function readState(projectDir: string): StateEntry[] {
178
212
  budget_status: text(r.budget_status) || 'ok',
179
213
  files_touched: num(r.files_touched),
180
214
  evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
215
+ schema_version: typeof sv === 'number' || typeof sv === 'string' ? sv : null,
181
216
  };
182
217
  });
183
218
  }
package/src/cost.ts CHANGED
@@ -33,7 +33,7 @@ export const LANE_BUDGET: Record<string, number> = {
33
33
  lean: 12000,
34
34
  standard: 25000,
35
35
  full: 50000,
36
- spike: 3000,
36
+ spike: 9000,
37
37
  };
38
38
 
39
39
  /** Token estimate for skills/agents loaded in this lane (0 for unknown/direct). */
@@ -149,6 +149,9 @@ export function appendCostEvent(missionDir: string, event: Omit<CostEvent, 'ts'>
149
149
  appendFileSync(join(missionDir, COST_EVENTS_FILE), JSON.stringify(line) + '\n', 'utf8');
150
150
  }
151
151
 
152
+ // ── Auto-compress (T4) — compressed event kind ───────────────────────────────
153
+ export const COMPRESSED_KIND = 'compressed';
154
+
152
155
  // ── Optimization decision records — structured rows in decisions.md ──
153
156
 
154
157
  export type OptDecision = {
package/src/installer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/installer.ts
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, lstatSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, lstatSync, chmodSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
@@ -57,6 +57,7 @@ export interface Target {
57
57
  }
58
58
 
59
59
  export const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'content');
60
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
60
61
  const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')) as { version: string };
61
62
  export const VERSION = pkg.version;
62
63
 
@@ -99,9 +100,13 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
99
100
  const backupRoot = join(scope === 'global' ? home : projectDir, '.mugiwara');
100
101
  const result: InstallResult = { written: [], skipped: [], backedUp: [], notes: [] };
101
102
 
102
- const writeOne = (absPath: string, text: string) => {
103
+ const writeOne = (absPath: string, text: string, mode?: number) => {
103
104
  if (existsSync(absPath)) {
104
- if (readFileSync(absPath, 'utf8') === text) { result.skipped.push(absPath); return; }
105
+ if (readFileSync(absPath, 'utf8') === text) {
106
+ // ensure mode even when content unchanged
107
+ if (!dryRun && mode !== undefined) { try { chmodSync(absPath, mode); } catch { /* ignore */ } }
108
+ result.skipped.push(absPath); return;
109
+ }
105
110
  if (!force) {
106
111
  result.skipped.push(absPath);
107
112
  result.notes.push(`conflict (not overwritten; run update to replace with backup): ${absPath}`);
@@ -114,7 +119,11 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
114
119
  if (!dryRun) { mkdirSync(backupDir, { recursive: true }); copyFileSync(absPath, backupFile); }
115
120
  result.backedUp.push(absPath);
116
121
  }
117
- if (!dryRun) { mkdirSync(dirname(absPath), { recursive: true }); writeFileSync(absPath, text); }
122
+ if (!dryRun) {
123
+ mkdirSync(dirname(absPath), { recursive: true });
124
+ writeFileSync(absPath, text);
125
+ if (mode !== undefined) { try { chmodSync(absPath, mode); } catch { /* ignore */ } }
126
+ }
118
127
  result.written.push(absPath);
119
128
  };
120
129
 
@@ -156,6 +165,20 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
156
165
  for (const r of sharedRefs) writeOne(join(sharedRoot, r.relPath), r.text);
157
166
  }
158
167
 
168
+ // Shell fallbacks: pure sh, no Node. The critical path (lane sizing + state)
169
+ // must survive on a harness where the CLI cannot run. See plan.md B1.
170
+ {
171
+ const SHELL_FALLBACKS = ['lane.sh', 'savepoint.sh', 'lib/patterns.sh', 'lib/lane-base.sh'];
172
+ const mugiwaraDir = join(scope === 'global' ? home : projectDir, '.mugiwara');
173
+ for (const rel of SHELL_FALLBACKS) {
174
+ const src = join(REPO_ROOT, 'scripts', rel);
175
+ if (!existsSync(src)) continue;
176
+ const text = readFileSync(src, 'utf8');
177
+ const dest = join(mugiwaraDir, 'bin', rel);
178
+ writeOne(dest, text, 0o755);
179
+ }
180
+ }
181
+
159
182
  if (target.postInstall) {
160
183
  const post = target.postInstall({ scope, projectDir, home, dryRun, files: result.written });
161
184
  result.written.push(...post.written);