@ionivetech/mugiwara 0.8.1 → 0.9.0

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.
package/src/cli.ts CHANGED
@@ -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, hasLegacyLayout, CURRENT_SCHEMA_VERSION } 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';
@@ -25,6 +25,17 @@ import { enforceHarnessPolicy } from './policy.ts';
25
25
  const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
26
26
  const flag = (v: FlagValue): boolean => v === true;
27
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
+
28
39
  export async function run(argv: string[]): Promise<void> {
29
40
  const { command, flags, _ } = parseArgs(argv);
30
41
  if (flag(flags.help) || command === 'help') return help();
@@ -35,7 +46,7 @@ export async function run(argv: string[]): Promise<void> {
35
46
  {
36
47
  const bypass = new Set(['install', 'update', 'uninstall', 'list']);
37
48
  if (!bypass.has(command)) {
38
- const projectDirForHarness = resolve(str(flags.project) ?? process.cwd());
49
+ const projectDirForHarness = resolveProjectDir(str(flags.project));
39
50
  enforceHarnessPolicy(projectDirForHarness);
40
51
  }
41
52
  }
@@ -45,7 +56,7 @@ export async function run(argv: string[]): Promise<void> {
45
56
  // Skipped for install/update --dry-run: a dry run must not mutate the project.
46
57
  const isDryRunInstall = (command === 'install' || command === 'update') && flag(flags.dryRun);
47
58
  if (!isDryRunInstall) {
48
- const projectDir = resolve(str(flags.project) ?? process.cwd());
59
+ const projectDir = resolveProjectDir(str(flags.project));
49
60
  if (ensureConfig(projectDir)) {
50
61
  console.log(`default .mugiwara/config written at ${join(projectDir, '.mugiwara', 'config')} (edit it to customise)`);
51
62
  }
@@ -69,13 +80,14 @@ export async function run(argv: string[]): Promise<void> {
69
80
  case 'blame': return blameCmd(flags, _);
70
81
  case 'handoff': return handoffCmd(flags, _);
71
82
  case 'sign': return signCmd(flags, _);
72
- case 'migrate': return migrateCmd(flags);
83
+ case 'migrate': return migrateCmd(flags, _);
84
+ case 'lesson': return lessonCmd(flags, _);
73
85
  default: throw new Error(`Unknown command: ${command}`);
74
86
  }
75
87
  }
76
88
 
77
89
  function resetCmd(flags: Args['flags']): void {
78
- const projectDir = resolve(str(flags.project) ?? process.cwd());
90
+ const projectDir = resolveProjectDir(str(flags.project));
79
91
  const force = flag(flags.force);
80
92
  const result = resetMission(projectDir, flag(flags.keepLogs), force);
81
93
  if (result.blocked) {
@@ -88,7 +100,7 @@ function resetCmd(flags: Args['flags']): void {
88
100
  }
89
101
 
90
102
  function archive(flags: Args['flags'], positionals: string[]): void {
91
- const projectDir = resolve(str(flags.project) ?? process.cwd());
103
+ const projectDir = resolveProjectDir(str(flags.project));
92
104
  const mission = positionals[1];
93
105
  if (!mission) { console.error('usage: mugiwara archive <mission> [--project <dir>] [--dry-run]'); process.exit(1); }
94
106
  const result = archiveMission(projectDir, mission, { dryRun: flag(flags.dryRun) });
@@ -107,7 +119,7 @@ function archive(flags: Args['flags'], positionals: string[]): void {
107
119
  * touched before that date.
108
120
  */
109
121
  function cleanCmd(flags: Args['flags']): void {
110
- const projectDir = resolve(str(flags.project) ?? process.cwd());
122
+ const projectDir = resolveProjectDir(str(flags.project));
111
123
  const dryRun = flag(flags.dryRun);
112
124
  const root = join(projectDir, '.mugiwara', 'missions');
113
125
  if (!existsSync(root)) { console.log('nothing to clean (.mugiwara/missions/ does not exist).'); return; }
@@ -173,7 +185,7 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
173
185
  if (!interactive) { scope = 'project'; }
174
186
  else scope = (await choose(rl!, 'Install scope?', ['global (user-wide)', 'project (this repo)'])) === 0 ? 'global' : 'project';
175
187
  }
176
- const projectDir = resolve(str(flags.project) ?? process.cwd());
188
+ const projectDir = resolveProjectDir(str(flags.project));
177
189
  if (scope === 'project' && !existsSync(projectDir)) throw new Error(`Project dir not found: ${projectDir}`);
178
190
 
179
191
  let targetIds = str(flags.target)?.split(',').map(s => s.trim()) ?? null;
@@ -231,13 +243,15 @@ async function install(flags: Args['flags']): Promise<void> {
231
243
  });
232
244
  console.log(`\nOK mugiwara ${VERSION} installed (manifest: ${file})`);
233
245
  if (allNotes.length) console.log(`${allNotes.length} note(s) above may need attention.`);
246
+ console.log('CLI: run `npm i -g @ionivetech/mugiwara` so the crew can call `mugiwara savepoint/archive/continue`.');
247
+ console.log(' Without it the crew degrades to inline-only — no state, no resume, no closure gate.');
234
248
  // A fresh install writes a default .mugiwara/config — point at it directly.
235
249
  console.log('\nNext: edit .mugiwara/config to customise (mode, branch, coverage, depths).');
236
250
  }
237
251
 
238
252
  async function uninstall(flags: Args['flags']): Promise<void> {
239
253
  const scope: Scope = flag(flags.global) ? 'global' : 'project';
240
- const projectDir = resolve(str(flags.project) ?? process.cwd());
254
+ const projectDir = resolveProjectDir(str(flags.project));
241
255
  const home = homedir();
242
256
  const file = manifestPath({ scope, projectDir, home });
243
257
  const manifest = readManifest(file);
@@ -310,7 +324,7 @@ function schemaWarnings(projectDir: string): void {
310
324
 
311
325
  function list(flags: Args['flags']): void {
312
326
  const home = homedir();
313
- const projectDir = resolve(str(flags.project) ?? process.cwd());
327
+ const projectDir = resolveProjectDir(str(flags.project));
314
328
  legacyWarning(projectDir);
315
329
  let found = false;
316
330
  for (const [label, file] of [
@@ -342,12 +356,28 @@ function list(flags: Args['flags']): void {
342
356
  * the caller must stop and let the user pick.
343
357
  */
344
358
  function continueCmd(flags: Args['flags'], positionals: string[]): void {
345
- const projectDir = resolve(str(flags.project) ?? process.cwd());
359
+ const projectDir = resolveProjectDir(str(flags.project));
346
360
  legacyWarning(projectDir);
347
361
  schemaWarnings(projectDir);
348
362
  const [mission, member] = positionals.slice(1);
349
363
  let entries = readContinue(projectDir);
350
364
 
365
+ // If the requested member's state file is unreadable, refuse rather than
366
+ // resuming from continue-<member>.json alone. A resume point without its
367
+ // state is a guess. (B6)
368
+ if (mission) {
369
+ // readState populates unreadableStateFiles for state files; entries already captured
370
+ readState(projectDir);
371
+ const badState = unreadableStateFiles();
372
+ const target = member ? `${mission}/${member}.json` : `${mission}/state.json`;
373
+ if (badState.includes(target)) {
374
+ console.error(`✗ mission "${mission}"${member ? ` member "${member}"` : ''} has unreadable state: ${target}`);
375
+ process.exit(1);
376
+ }
377
+ // re-read continue entries after the state scan cleared unreadable (preserve original entries)
378
+ // entries already holds the correct continue data, no need to re-read
379
+ }
380
+
351
381
  // default to this actor's work; --all crosses actors on a shared checkout
352
382
  if (!flag(flags.all)) {
353
383
  const actor = gitActor(projectDir);
@@ -386,11 +416,19 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
386
416
 
387
417
  /** `mugiwara status` — one screen of computed mission state, no model needed. */
388
418
  function statusCmd(flags: Args['flags']): void {
389
- const projectDir = resolve(str(flags.project) ?? process.cwd());
419
+ const projectDir = resolveProjectDir(str(flags.project));
390
420
  legacyWarning(projectDir);
391
421
  schemaWarnings(projectDir);
392
422
  const states = readState(projectDir);
393
- if (!states.length) { console.log('No mission state on disk.'); return; }
423
+ const bad = unreadableStateFiles();
424
+ if (bad.length) {
425
+ console.error(`⚠ ${bad.length} unreadable state file(s): ${bad.join(', ')}`);
426
+ console.error(' These are not "no mission" — they are corrupt. Inspect or delete them.');
427
+ }
428
+ if (!states.length) {
429
+ console.log(bad.length ? 'No readable mission state on disk.' : 'No mission state on disk.');
430
+ return;
431
+ }
394
432
  const actor = flag(flags.all) ? null : gitActor(projectDir);
395
433
  const rows = actor ? (states.filter((s) => s.actor === actor).length ? states.filter((s) => s.actor === actor) : states) : states;
396
434
  for (const s of rows) {
@@ -406,7 +444,7 @@ function statusCmd(flags: Args['flags']): void {
406
444
 
407
445
  /** `mugiwara cost [--mission <id>] [--json] [--ledger]` — show cost ledger, avoided work, efficiency, trail. */
408
446
  function costCmd(flags: Args['flags'], positionals: string[]): void {
409
- const projectDir = resolve(str(flags.project) ?? process.cwd());
447
+ const projectDir = resolveProjectDir(str(flags.project));
410
448
  const mission = str(flags.mission) ?? positionals[1] ?? (() => {
411
449
  const states = readState(projectDir);
412
450
  if (states.length === 1) return states[0].mission;
@@ -461,7 +499,7 @@ function costCmd(flags: Args['flags'], positionals: string[]): void {
461
499
 
462
500
  /** `mugiwara run <script.sh> [args]` — run a bundled harness script here. */
463
501
  function runCmd(flags: Args['flags'], positionals: string[]): void {
464
- const projectDir = resolve(str(flags.project) ?? process.cwd());
502
+ const projectDir = resolveProjectDir(str(flags.project));
465
503
  const name = positionals[1];
466
504
  if (!name) {
467
505
  console.error(`usage: mugiwara run <script> [args...]\n scripts: ${RUNNABLE.join(', ')}`);
@@ -473,7 +511,7 @@ function runCmd(flags: Args['flags'], positionals: string[]): void {
473
511
 
474
512
  /** `mugiwara blame <path>` — provenance note on the last commit touching path. */
475
513
  function blameCmd(flags: Args['flags'], positionals: string[]): void {
476
- const projectDir = resolve(str(flags.project) ?? process.cwd());
514
+ const projectDir = resolveProjectDir(str(flags.project));
477
515
  const path = positionals[1];
478
516
  if (!path) { console.error('usage: mugiwara blame <file-path>'); process.exit(1); }
479
517
  console.log(blamePath(projectDir, path));
@@ -506,10 +544,15 @@ export function stalenessLine(projectDir: string, baseSha: string): string | nul
506
544
 
507
545
  /** `mugiwara handoff <mission>` — a report the next engineer can act on. */
508
546
  function handoffCmd(flags: Args['flags'], positionals: string[]): void {
509
- const projectDir = resolve(str(flags.project) ?? process.cwd());
547
+ const projectDir = resolveProjectDir(str(flags.project));
510
548
  const mission = positionals[1];
511
549
  if (!mission) { console.error('usage: mugiwara handoff <mission> [--project <dir>]'); process.exit(1); }
512
550
  const states = readState(projectDir).filter((s) => s.mission === mission);
551
+ const bad = unreadableStateFiles().filter((p) => p.startsWith(`${mission}/`));
552
+ if (bad.length) {
553
+ console.error(`✗ mission "${mission}" has unreadable state: ${bad.join(', ')}`);
554
+ process.exit(1);
555
+ }
513
556
  if (!states.length) { console.error(`no in-flight mission "${mission}"`); process.exit(1); }
514
557
  const lines = [
515
558
  `# Handoff: ${mission}`,
@@ -539,9 +582,143 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
539
582
  console.log(`\nwritten: ${out}`);
540
583
  }
541
584
 
542
- export function migrateCmd(flags: Args['flags']): void {
543
- const projectDir = resolve(str(flags.project) ?? process.cwd());
585
+ function lessonCmd(flags: Args['flags'], positionals: string[]): void {
586
+ const projectDir = resolveProjectDir(str(flags.project));
587
+ const text = positionals.slice(1).join(' ').trim();
588
+ if (!text) { console.error('usage: mugiwara lesson "<text>" [--project <dir>]'); process.exit(1); }
589
+ const file = join(projectDir, '.mugiwara', 'lessons.md');
590
+ const date = new Date().toISOString().slice(0, 10);
591
+ const sanitized = text.replace(/\|/g, '/').replace(/\r?\n/g, ' ').trim();
592
+ const line = `| ${date} | manual | general | ${sanitized} |`;
593
+ const header = '| Date | Mission | Area | Lesson |\n|---|---|---|---|';
594
+ let existing = '';
595
+ try { existing = readFileSync(file, 'utf8'); } catch {}
596
+ if (!existing) {
597
+ mkdirSync(join(projectDir, '.mugiwara'), { recursive: true });
598
+ writeFileSync(file, header + '\n' + line + '\n');
599
+ } else {
600
+ // ensure file ends with newline
601
+ const needsNewline = !existing.endsWith('\n');
602
+ writeFileSync(file, existing + (needsNewline ? '\n' : '') + line + '\n');
603
+ }
604
+ console.log(`lesson appended: ${line}`);
605
+ }
606
+
607
+ export function migrateCmd(flags: Args['flags'], positionals: string[] = []): void {
608
+ const projectDir = resolveProjectDir(str(flags.project));
544
609
  const dryRun = flag(flags.dryRun);
610
+ // --to-team / --to-solo: solo<->team layout switch (W4). Moves, not copies.
611
+ const toTeam = str(flags.toTeam);
612
+ const toSolo = str(flags.toSolo);
613
+ if (toTeam || toSolo) {
614
+ const member = (toTeam ?? toSolo) as string;
615
+ if (!/^[A-Za-z0-9._-]+$/.test(member) || /^\.+$/.test(member) || member === 'state' || member === 'continue') {
616
+ console.error(`invalid member name "${member}" (allowlist: [a-zA-Z0-9._-], not a dot-path, not state/continue)`);
617
+ process.exit(1);
618
+ }
619
+ if (toTeam && toSolo) {
620
+ console.error('use either --to-team or --to-solo, not both');
621
+ process.exit(1);
622
+ }
623
+ const missionsRootInner = join(projectDir, '.mugiwara', 'missions');
624
+ let mission = str(flags.mission) ?? (positionals[1] ? String(positionals[1]) : null);
625
+ const inferMission = (): string | null => {
626
+ if (!existsSync(missionsRootInner)) return null;
627
+ const all = readdirSync(missionsRootInner, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
628
+ if (mission && all.includes(mission)) return mission;
629
+ if (mission) return mission;
630
+ // try to find candidate missions for the requested operation
631
+ if (toTeam) {
632
+ const candidates = all.filter(m => existsSync(join(missionsRootInner, m, 'state.json')));
633
+ if (candidates.length === 1) return candidates[0];
634
+ if (candidates.length === 0) {
635
+ console.error('no solo mission with state.json found for --to-team');
636
+ process.exit(1);
637
+ }
638
+ console.error(`multiple solo missions: ${candidates.join(', ')} — specify --mission <id>`);
639
+ process.exit(1);
640
+ } else {
641
+ const candidates = all.filter(m => existsSync(join(missionsRootInner, m, `${member}.json`)));
642
+ if (candidates.length === 1) return candidates[0];
643
+ if (candidates.length === 0) {
644
+ console.error(`no mission with ${member}.json found for --to-solo`);
645
+ process.exit(1);
646
+ }
647
+ console.error(`multiple missions with ${member}.json: ${candidates.join(', ')} — specify --mission <id>`);
648
+ process.exit(1);
649
+ }
650
+ return null;
651
+ };
652
+ const targetMission = inferMission();
653
+ if (!targetMission) {
654
+ console.error('could not infer mission — specify --mission <id>');
655
+ process.exit(1);
656
+ }
657
+ const dir = join(missionsRootInner, targetMission);
658
+ if (toTeam) {
659
+ const srcState = join(dir, 'state.json');
660
+ const srcContinue = join(dir, 'continue.json');
661
+ const destState = join(dir, `${member}.json`);
662
+ const destContinue = join(dir, `continue-${member}.json`);
663
+ if (!existsSync(srcState)) {
664
+ console.error(`mission "${targetMission}" has no state.json — already team or not found`);
665
+ process.exit(1);
666
+ }
667
+ if (existsSync(destState)) {
668
+ console.error(`destination ${destState} already exists`);
669
+ process.exit(1);
670
+ }
671
+ const toMove: Array<{ src: string; dest: string }> = [{ src: srcState, dest: destState }];
672
+ if (existsSync(srcContinue)) toMove.push({ src: srcContinue, dest: destContinue });
673
+ for (const m of toMove) {
674
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
675
+ if (!dryRun) {
676
+ mkdirSync(dirname(m.dest), { recursive: true });
677
+ try { renameSync(m.src, m.dest); } catch { /* fallback copy */
678
+ try { writeFileSync(m.dest, readFileSync(m.src)); rmSync(m.src, { force: true }); } catch {}
679
+ }
680
+ }
681
+ }
682
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${toMove.length} file(s)${dryRun ? ' (dry run)' : ''}`);
683
+ return;
684
+ } else {
685
+ // --to-solo
686
+ const srcState = join(dir, `${member}.json`);
687
+ const srcContinue = join(dir, `continue-${member}.json`);
688
+ const destState = join(dir, 'state.json');
689
+ const destContinue = join(dir, 'continue.json');
690
+ if (!existsSync(srcState)) {
691
+ console.error(`mission "${targetMission}" has no ${member}.json`);
692
+ process.exit(1);
693
+ }
694
+ const files = readdirSync(dir).filter(f => {
695
+ const stem = f.replace(/\.json$/, '');
696
+ return f.endsWith('.json') && stem !== 'continue' && !stem.startsWith('continue-');
697
+ });
698
+ const members = files.filter(f => f !== 'state.json');
699
+ if (members.length > 1) {
700
+ console.error(`mission "${targetMission}" has ${members.length} members (${members.join(', ')}) — refusing --to-solo (would orphan)`);
701
+ process.exit(1);
702
+ }
703
+ if (existsSync(destState)) {
704
+ console.error(`destination ${destState} already exists`);
705
+ process.exit(1);
706
+ }
707
+ const toMove: Array<{ src: string; dest: string }> = [{ src: srcState, dest: destState }];
708
+ if (existsSync(srcContinue)) toMove.push({ src: srcContinue, dest: destContinue });
709
+ for (const m of toMove) {
710
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
711
+ if (!dryRun) {
712
+ mkdirSync(dirname(m.dest), { recursive: true });
713
+ try { renameSync(m.src, m.dest); } catch {
714
+ try { writeFileSync(m.dest, readFileSync(m.src)); rmSync(m.src, { force: true }); } catch {}
715
+ }
716
+ }
717
+ }
718
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${toMove.length} file(s)${dryRun ? ' (dry run)' : ''}`);
719
+ return;
720
+ }
721
+ }
545
722
  const legacyState = join(projectDir, '.mugiwara', 'state');
546
723
  const legacyContinue = join(projectDir, '.mugiwara', 'continue');
547
724
  const missionsRoot = join(projectDir, '.mugiwara', 'missions');
@@ -623,7 +800,7 @@ export function migrateCmd(flags: Args['flags']): void {
623
800
 
624
801
  /** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
625
802
  function signCmd(flags: Args['flags'], _: string[]): void {
626
- const projectDir = resolve(str(flags.project) ?? process.cwd());
803
+ const projectDir = resolveProjectDir(str(flags.project));
627
804
  if (flag(flags.genKey)) {
628
805
  const backend = str(flags.backend) ?? 'auto';
629
806
  const home = homedir();
@@ -676,7 +853,12 @@ Usage:
676
853
  mugiwara sign --gen-key [--backend pure|minisign]
677
854
  create signing keys (pure ed25519 default)
678
855
  mugiwara migrate [--dry-run] [--project <dir>]
679
- move legacy .mugiwara/state/ layout to .mugiwara/missions/
856
+ move legacy .mugiwara/state/ layout to .mugiwara/missions/
857
+ mugiwara migrate --to-team <member> [--mission <id>] [--dry-run]
858
+ move state.json -> <member>.json (solo -> team)
859
+ mugiwara migrate --to-solo <member> [--mission <id>] [--dry-run]
860
+ move <member>.json -> state.json (team -> solo; refuses if >1 member)
861
+ mugiwara lesson "<text>" append a dated row to .mugiwara/lessons.md
680
862
  mugiwara run <script> [args...]
681
863
  run a bundled harness script here (${RUNNABLE.join(', ')})
682
864
  mugiwara savepoint <mission> [member] [flow] [mode]
package/src/config.ts CHANGED
@@ -9,24 +9,43 @@ import { join } from 'node:path';
9
9
 
10
10
  /** The default config body, identical to what the installer has always written. */
11
11
  export const DEFAULT_CONFIG = [
12
- 'mode=guided',
12
+ '# Mugiwara config. Project overrides ~/.mugiwara/config.',
13
+ '# Every key here is read by code. Delete a line to take its default.',
14
+ '',
15
+ '# -- Autonomy ---------------------------------------------',
16
+ 'mode=guided # guided | semi | auto — how much the crew does without asking',
17
+ 'verbosity=normal # normal | full — how much the crew echoes',
18
+ '',
19
+ '# -- Team -------------------------------------------------',
20
+ '# team_member= # your member id; set it and state isolates per person',
21
+ '# team_members=1 # how many people on this mission; >1 enables team-scoped posture',
22
+ '',
23
+ '# -- Git --------------------------------------------------',
13
24
  'branch=feature/{type}-{issue}-{slug}',
14
25
  'commit=conventional',
15
- 'auto_commit=on',
26
+ 'auto_commit=on # on | off — off hands you an uncommitted tree in guided/semi',
27
+ '',
28
+ '# -- Gates ------------------------------------------------',
16
29
  'coverage_new=85',
17
30
  'coverage_modified=90',
18
- 'review_depth=full',
31
+ 'review_depth=full # full | standard | quick',
19
32
  'quality_depth=full',
20
33
  'verify_merged=off',
21
- 'delegate_threshold=60',
22
- 'heal_max_cycles=3',
23
- 'verbosity=normal',
24
- '# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
25
- '# investigation_max_passes=2 # optional: cap investigation passes (spec §13)',
34
+ '',
35
+ '# -- Limits -----------------------------------------------',
36
+ 'delegate_threshold=60 # % of budget before delegation is advised',
37
+ 'heal_max_cycles=3 # heal loop halts here and escalates',
38
+ '',
39
+ '# -- Monorepo ---------------------------------------------',
40
+ '# lane_scope_glob=packages/api/** # count only matching files when sizing the lane',
41
+ '',
42
+ '# -- Optional ---------------------------------------------',
43
+ '# context_budget_chars=150000 # fail archive if the trail exceeds this',
44
+ '# investigation_max_passes=2',
26
45
  '# investigation_max_unrelated_files=5',
27
46
  '# investigation_repeated_read_threshold=2',
28
- '# sign=auto # optional: auto | minisign | pure | off — report attestation',
29
- '# enforce=block # optional: off | warn | block — pipeline-guard policy',
47
+ '# sign=auto # auto | minisign | pure | off',
48
+ '# enforce=block # off | warn | block — pipeline-guard policy',
30
49
  ].join('\n') + '\n';
31
50
 
32
51
  /** Config file path candidates: project first, then user home. */
@@ -57,7 +76,10 @@ export function readConfig(projectDir: string): Record<string, string> {
57
76
  const key = t.slice(0, eq).trim();
58
77
  if (!key) continue;
59
78
  if (key in out) continue; // project value already set — keep it
60
- out[key] = t.slice(eq + 1).trim();
79
+ let rawVal = t.slice(eq + 1).trim();
80
+ const hash = rawVal.indexOf('#');
81
+ if (hash !== -1) rawVal = rawVal.slice(0, hash).trim();
82
+ out[key] = rawVal;
61
83
  }
62
84
  }
63
85
  return out;
package/src/continue.ts CHANGED
@@ -111,6 +111,11 @@ export function gitActor(cwd: string): string {
111
111
  return name || process.env.USER || process.env.USERNAME || '';
112
112
  }
113
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
+
114
119
  /**
115
120
  * Read every mission dir under `.mugiwara/missions/<mission>/`, picking the
116
121
  * files this reader owns: state readers take `state.json` / `<member>.json`,
@@ -118,6 +123,7 @@ export function gitActor(cwd: string): string {
118
123
  * files are skipped.
119
124
  */
120
125
  function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
126
+ unreadable.length = 0;
121
127
  const base = join(projectDir, '.mugiwara', 'missions');
122
128
  if (!existsSync(base)) return [];
123
129
  const out: T[] = [];
@@ -148,7 +154,7 @@ function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Reco
148
154
  if (text(raw.mission) !== mission) continue;
149
155
  out.push(map(raw, member));
150
156
  } catch {
151
- // corrupt savepoint — skip, never crash the listing
157
+ unreadable.push(join(mission, f));
152
158
  }
153
159
  }
154
160
  }
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). */
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);
package/src/integrity.ts CHANGED
@@ -162,23 +162,54 @@ export function checkTrail(missionDir: string, projectRoot: string): IntegrityIs
162
162
 
163
163
  // 3: evidence entries recorded as repo paths must exist
164
164
  const evidencePaths: string[] = [];
165
- const evidenceFile = join(missionDir, 'state.json');
166
- if (existsSync(evidenceFile)) {
165
+ // Solo layout writes state.json; team layout writes <member>.json per member.
166
+ // Reading only state.json left the evidence gate dead on the team path (B2).
167
+ const stateFiles = existsSync(missionDir)
168
+ ? readdirSync(missionDir)
169
+ .filter((n) => n.endsWith('.json') && n !== 'continue.json' && !n.startsWith('continue-'))
170
+ .sort()
171
+ : [];
172
+ for (const name of stateFiles) {
173
+ const evidenceFile = join(missionDir, name);
167
174
  try {
168
175
  const s = JSON.parse(readFileSync(evidenceFile, 'utf8')) as { evidence?: unknown };
169
- if (Array.isArray(s.evidence)) {
170
- for (const e of s.evidence) {
171
- if (typeof e !== 'string' || !e.trim()) continue;
172
- evidencePaths.push(e);
173
- const cand = join(projectRoot, e);
174
- if (!isAbsolute(e) && !existsSync(cand) && !existsSync(join(missionDir, e))) {
175
- issues.push({ kind: 'evidence', detail: `state.json evidence "${e}" does not exist` });
176
- }
176
+ if (!Array.isArray(s.evidence)) continue;
177
+ for (const e of s.evidence) {
178
+ if (typeof e !== 'string' || !e.trim()) continue;
179
+ evidencePaths.push(e);
180
+ const cand = join(projectRoot, e);
181
+ if (!isAbsolute(e) && !existsSync(cand) && !existsSync(join(missionDir, e))) {
182
+ issues.push({ kind: 'evidence', detail: `${name} evidence "${e}" does not exist` });
177
183
  }
178
184
  }
179
185
  } catch { /* corrupt state — the state reader owns that error */ }
180
186
  }
181
187
 
188
+ // Iron Law: no evidence = not complete. Absent evidence is a different failure
189
+ // from thin evidence, and previously went unreported entirely. (B7)
190
+ if (evidencePaths.length === 0) {
191
+ let severity: 'warn' | 'block' = 'warn';
192
+ try {
193
+ const policy = loadPolicy(projectRoot);
194
+ const lanes = (policy as unknown as { evidence?: { require_nonempty_for_lanes?: string[] } })?.evidence?.require_nonempty_for_lanes;
195
+ if (Array.isArray(lanes) && lanes.length) {
196
+ const stateLanes = new Set<string>();
197
+ for (const name of stateFiles) {
198
+ try {
199
+ const s = JSON.parse(readFileSync(join(missionDir, name), 'utf8')) as { lane?: unknown };
200
+ if (typeof s.lane === 'string') stateLanes.add(s.lane);
201
+ } catch { /* ignore */ }
202
+ }
203
+ if ([...stateLanes].some((l) => lanes.includes(l))) severity = 'block';
204
+ }
205
+ } catch { /* policy read failure -> keep warn */ }
206
+ issues.push({
207
+ kind: 'evidence',
208
+ severity,
209
+ detail: 'mission declares no evidence — closing with zero recorded checks',
210
+ });
211
+ }
212
+
182
213
  // 4: evidence-content spot check (T7): a PASS verdict that cites an evidence
183
214
  // path must point at a file that exists AND contains command-output shape
184
215
  // (backticked command or exit-status token). Fake-but-consistent trails