@ionivetech/mugiwara 0.8.1 → 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.
@@ -649,7 +649,7 @@ console.log('\nT3 — lane-aware gates');
649
649
  return s.length === 12 && s.includes('run-evals') && s.includes('retrieval-eval') && s.includes('conformance');
650
650
  });
651
651
  assert('budget direct → 0, full → 50000', true, () => budgetForLane('direct') === 0 && budgetForLane('full') === 50000);
652
- assert('budget spike → 3000 (direct fixture 3k)', true, () => budgetForLane('spike') === 3000);
652
+ assert('budget spike → 9000 (direct fixture 9k)', true, () => budgetForLane('spike') === 9000);
653
653
  // mutation: break direct step count → should fail (file content shows not 3)
654
654
  const broken = originalPolicy.replace(
655
655
  "direct: ['build-hooks:check', 'typecheck', 'build']",
@@ -675,5 +675,160 @@ console.log('\nT3 — lane-aware gates');
675
675
  });
676
676
  }
677
677
 
678
+ // --- B1: CLI availability — remove section → content validation fails ---
679
+ console.log('\nB1 — CLI availability');
680
+ {
681
+ const wf = join(root, 'content', 'skills', 'mugiwara-workflow', 'SKILL.md');
682
+ const original = readFileSync(wf, 'utf8');
683
+ try {
684
+ const b1Pattern = /## CLI availability[\s\S]*?## Artifact trust/;
685
+ const b1Broken = original.replace(b1Pattern, '## Artifact trust');
686
+ if (!b1Pattern.test(original) || b1Broken === original) {
687
+ console.error('✗ B1: mutation target not found — the gate it guards may be dead.');
688
+ failed++;
689
+ } else {
690
+ writeFileSync(wf, b1Broken);
691
+ assert('missing CLI availability → grep fails', false, () => run('B1-grep', 'grep -q "CLI availability" content/skills/mugiwara-workflow/SKILL.md'));
692
+ }
693
+ } finally {
694
+ writeFileSync(wf, original);
695
+ assert('restored → content validation passes', true, () => run('B1-restore', 'grep -q "CLI availability" content/skills/mugiwara-workflow/SKILL.md'));
696
+ }
697
+ }
698
+
699
+ // --- B2: team evidence gate — revert to single state.json → integrity gate fails on team ---
700
+ console.log('\nB2 — team evidence gate');
701
+ {
702
+ const integ = join(root, 'src', 'integrity.ts');
703
+ const original = readFileSync(integ, 'utf8');
704
+ try {
705
+ const fixedPattern = /const stateFiles = existsSync\(missionDir\)/;
706
+ const broken = original.replace(fixedPattern, "const evidenceFile = join(missionDir, 'state.json'); // B2 revert");
707
+ const fullPattern = / \/\/ Solo layout writes state\.json; team layout writes <member>\.json per member\./;
708
+ let b2Broken = original;
709
+ if (fullPattern.test(original)) {
710
+ // remove the team-aware block header to make grep for stateFiles fail for the specific definition
711
+ b2Broken = original.replace(fixedPattern, "const evidenceFile = join(missionDir, 'state.json'); // B2 revert");
712
+ // also need to remove remaining stateFiles references to make grep fail - replace all stateFiles with evidenceFile
713
+ b2Broken = b2Broken.replace(/stateFiles/g, 'evidenceFile');
714
+ }
715
+ if (!fixedPattern.test(original) || broken === original) {
716
+ console.error('✗ B2: mutation target not found — the gate it guards may be dead.');
717
+ failed++;
718
+ } else {
719
+ writeFileSync(integ, b2Broken);
720
+ assert('single state.json → team evidence gate dead', false, () => run('B2', 'grep -q "const stateFiles = existsSync" src/integrity.ts'));
721
+ }
722
+ } finally {
723
+ writeFileSync(integ, original);
724
+ assert('restored → team gate present', true, () => run('B2-restore', 'grep -q "const stateFiles = existsSync" src/integrity.ts'));
725
+ }
726
+ }
727
+
728
+ // --- B3: task counter — restore unanchored grep → savepoint task test fails ---
729
+ console.log('\nB3 — task counter');
730
+ {
731
+ const sp = join(root, 'scripts', 'savepoint.sh');
732
+ const original = readFileSync(sp, 'utf8');
733
+ try {
734
+ const broken = original.replace('TASKS_TOTAL=$(count_boxes "$PLAN_FILE" \'[ xX]\')', 'TASKS_TOTAL=$(grep -cE \'^\\s*-\\s*\\[[ xX]\\]\' "$PLAN_FILE" 2>/dev/null || true)')
735
+ .replace('TASKS_DONE=$(count_boxes "$PLAN_FILE" \'[xX]\')', 'TASKS_DONE=$(grep -c \'\\[x\\]\' "$PLAN_FILE" 2>/dev/null || true)');
736
+ if (broken === original) {
737
+ console.error('✗ B3: mutation target not found — the gate it guards may be dead.');
738
+ failed++;
739
+ } else {
740
+ writeFileSync(sp, broken);
741
+ assert('unanchored grep → savepoint task test fails', false, () => run('B3', 'bun run test -- savepoint -t "B3: task counting"'));
742
+ }
743
+ } finally {
744
+ writeFileSync(sp, original);
745
+ assert('restored → savepoint task test passes', true, () => run('B3-restore', 'bun run test -- savepoint -t "B3: task counting"'));
746
+ }
747
+ }
748
+
749
+ // --- B4: repo root — restore [ -d .git ] → lane subdirectory test fails ---
750
+ console.log('\nB4 — repo root');
751
+ {
752
+ const lane = join(root, 'scripts', 'lane.sh');
753
+ const original = readFileSync(lane, 'utf8');
754
+ try {
755
+ const broken = original.replace(
756
+ /# Resolve the repo root: handles subdirectories and git worktrees[\s\S]*?cd "\$REPO_ROOT" \|\| \{ echo "lane: cannot enter repo root" >&2; exit 1; \}/,
757
+ '[ -d .git ] || { echo "lane: not a git repository" >&2; exit 1; }'
758
+ );
759
+ if (broken === original) {
760
+ console.error('✗ B4: mutation target not found — the gate it guards may be dead.');
761
+ failed++;
762
+ } else {
763
+ writeFileSync(lane, broken);
764
+ assert('[ -d .git ] → lane subdirectory gate dead', false, () => run('B4', 'grep -q "git rev-parse --show-toplevel" scripts/lane.sh'));
765
+ }
766
+ } finally {
767
+ writeFileSync(lane, original);
768
+ assert('restored → lane uses git rev-parse', true, () => run('B4-restore', 'grep -q "git rev-parse --show-toplevel" scripts/lane.sh'));
769
+ }
770
+ }
771
+
772
+ // --- B5: spike budget — set below base → lane-base fails ---
773
+ console.log('\nB5 — spike budget');
774
+ {
775
+ const baseFile = join(root, 'scripts', 'lib', 'lane-base.sh');
776
+ const original = readFileSync(baseFile, 'utf8');
777
+ try {
778
+ const broken = original.replace('BUDGET_spike=9000', 'BUDGET_spike=3000');
779
+ if (broken === original) {
780
+ console.error('✗ B5: mutation target not found — the gate it guards may be dead.');
781
+ failed++;
782
+ } else {
783
+ writeFileSync(baseFile, broken);
784
+ assert('BUDGET_spike 3000 < LANE_BASE 5411 → lane-base fails', false, () => run('B5', 'bun scripts/lane-base.ts'));
785
+ }
786
+ } finally {
787
+ writeFileSync(baseFile, original);
788
+ assert('restored → lane-base passes', true, () => run('B5-restore', 'bun scripts/lane-base.ts'));
789
+ }
790
+ }
791
+
792
+ // --- B6: corrupt state — swallow parse errors again → status test fails ---
793
+ console.log('\nB6 — corrupt state');
794
+ {
795
+ const cont = join(root, 'src', 'continue.ts');
796
+ const original = readFileSync(cont, 'utf8');
797
+ try {
798
+ const broken = original.replace('unreadable.push(join(mission, f));', '// corrupt savepoint — skip, never crash the listing');
799
+ if (broken === original) {
800
+ console.error('✗ B6: mutation target not found — the gate it guards may be dead.');
801
+ failed++;
802
+ } else {
803
+ writeFileSync(cont, broken);
804
+ assert('swallow parse errors → unreadable gate dead', false, () => run('B6', 'grep -q "unreadableStateFiles" src/continue.ts && grep -q "unreadable.push" src/continue.ts'));
805
+ }
806
+ } finally {
807
+ writeFileSync(cont, original);
808
+ assert('restored → corrupt state surfaced', true, () => run('B6-restore', 'grep -q "unreadable.push" src/continue.ts'));
809
+ }
810
+ }
811
+
812
+ // --- B7: zero evidence — remove check → integrity team test fails ---
813
+ console.log('\nB7 — zero evidence');
814
+ {
815
+ const integ = join(root, 'src', 'integrity.ts');
816
+ const original = readFileSync(integ, 'utf8');
817
+ try {
818
+ const b7Pattern = /mission declares no evidence/;
819
+ const broken = original.replace(b7Pattern, 'ZERO_EVIDENCE_REMOVED');
820
+ if (!b7Pattern.test(original) || broken === original) {
821
+ console.error('✗ B7: mutation target not found — the gate it guards may be dead.');
822
+ failed++;
823
+ } else {
824
+ writeFileSync(integ, broken);
825
+ assert('no zero-evidence check → integrity gate dead', false, () => run('B7', 'grep -q "mission declares no evidence" src/integrity.ts'));
826
+ }
827
+ } finally {
828
+ writeFileSync(integ, original);
829
+ assert('restored → zero-evidence warned', true, () => run('B7-restore', 'grep -q "mission declares no evidence" src/integrity.ts'));
830
+ }
831
+ }
832
+
678
833
  console.log(`\n${passed} passed, ${failed} failed`);
679
834
  process.exit(failed > 0 ? 1 : 0);
@@ -107,6 +107,22 @@ for (const lane of lanes) {
107
107
  }
108
108
  }
109
109
 
110
+ // A lane whose base exceeds its budget is born in `warn` — the budget is then
111
+ // noise rather than a signal. (B5)
112
+ for (const lane of ['lean', 'standard', 'full', 'spike']) {
113
+ const base = constants[lane]?.base ?? 0;
114
+ const budget = constants[lane]?.budget ?? 0;
115
+ if (base >= budget) {
116
+ console.log(` ✗ LANE_BASE_${lane} (${base}) >= BUDGET_${lane} (${budget}) — every mission starts over budget`);
117
+ failures++;
118
+ }
119
+ const pct = budget ? Math.round((base / budget) * 100) : 100;
120
+ if (pct > 80) {
121
+ console.log(` ✗ LANE_BASE_${lane} is ${pct}% of BUDGET_${lane} — leaves no headroom (target ≤70%)`);
122
+ failures++;
123
+ }
124
+ }
125
+
110
126
  if (failures > 0) {
111
127
  console.log(`\nlane-base: ${failures} constant(s) drifted from content load`);
112
128
  process.exit(1);
package/scripts/lane.sh CHANGED
@@ -11,7 +11,11 @@ BASE="${1:-main}"
11
11
  JSON_OUT=0
12
12
  [ "${2:-}" = "--json" ] && JSON_OUT=1
13
13
 
14
- [ -d .git ] || { echo "lane: not a git repository" >&2; exit 1; }
14
+ # Resolve the repo root: handles subdirectories and git worktrees, where .git
15
+ # is a file rather than a directory. (B4)
16
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
17
+ echo "lane: not a git repository" >&2; exit 1; }
18
+ cd "$REPO_ROOT" || { echo "lane: cannot enter repo root" >&2; exit 1; }
15
19
 
16
20
  # resolve base
17
21
  if ! git rev-parse "$BASE" >/dev/null 2>&1; then
@@ -16,4 +16,4 @@ LANE_BASE_spike=5411
16
16
  BUDGET_lean=12000
17
17
  BUDGET_standard=25000
18
18
  BUDGET_full=50000
19
- BUDGET_spike=3000
19
+ BUDGET_spike=9000
@@ -8,6 +8,18 @@ set -u
8
8
 
9
9
  die() { echo "savepoint: $*" >&2; exit 1; }
10
10
 
11
+ # count_boxes <file> <char-class> — count markdown checkboxes.
12
+ # Anchored so prose mentioning "- [x]" is not counted; skips fenced code blocks
13
+ # so documentation examples are not counted; matches [x] and [X] alike. (B3)
14
+ count_boxes() {
15
+ [ -f "$1" ] || { echo 0; return; }
16
+ awk -v pat="$2" '
17
+ /^[[:space:]]*```/ { inblock = !inblock; next }
18
+ !inblock && $0 ~ ("^[[:space:]]*-[[:space:]]*\\[" pat "\\]") { n++ }
19
+ END { print n+0 }
20
+ ' "$1"
21
+ }
22
+
11
23
  MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
12
24
 
13
25
  # optional provider-reported tokens file (T4): --tokens-file <path> JSON {input_tokens, output_tokens}
@@ -154,6 +166,11 @@ esac
154
166
  # (BSD/macOS-safe: no \+ BRE).
155
167
  BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-' | tr -cd 'A-Za-z0-9._-' | sed 's/^\.\{1,\}$//' )
156
168
 
169
+ # Resolve the repo root: handles subdirectories and git worktrees, where .git
170
+ # is a file rather than a directory. (B4)
171
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not a git repository"
172
+ cd "$REPO_ROOT" || die "cannot enter repo root"
173
+
157
174
  # state + continue live in the mission dir. Solo (member empty) → state.json
158
175
  # + continue.json; team writes <member>.json + continue-<member>.json so
159
176
  # parallel members never clobber each other.
@@ -167,7 +184,6 @@ else
167
184
  fi
168
185
 
169
186
  [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode] [lane]"
170
- [ -d .git ] || die "not a git repository"
171
187
 
172
188
  # --- computed fields ---
173
189
  BASE_SHA=$(git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git merge-base HEAD "$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')" 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "unknown")
@@ -328,16 +344,23 @@ if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
328
344
  # total counts ALL task lines (checked + unchecked); done counts checked only.
329
345
  # A fully-completed plan must read total=N done=N, never total=0 (the old
330
346
  # unchecked-only grep degenerated a done plan to tasks.total=0).
331
- TASKS_TOTAL=$(grep -cE '^\s*-\s*\[[ xX]\]' "$PLAN_FILE" 2>/dev/null || true)
332
- TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || true)
347
+ TASKS_TOTAL=$(count_boxes "$PLAN_FILE" '[ xX]')
348
+ TASKS_DONE=$(count_boxes "$PLAN_FILE" '[xX]')
333
349
  fi
334
350
  # Fallback for large campaigns (>3 phases, >1500 lines) where master plan.md is an index
335
351
  # and tasks live in sub-plan/*.md — only when plan.md has zero checkbox tasks to
336
352
  # keep simple missions unchanged.
337
353
  if [ "${TASKS_TOTAL:-0}" -eq 0 ] 2>/dev/null && [ -d "$MISSION_DIR/sub-plan" ]; then
338
- TASKS_TOTAL=$(grep -rcE '^\s*-\s*\[[ xX]\]' "$MISSION_DIR/sub-plan" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}' || true)
339
- TASKS_DONE=$(grep -rc '\[x\]' "$MISSION_DIR/sub-plan" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}' || true)
354
+ TASKS_TOTAL=0; TASKS_DONE=0
355
+ for _sp in "$MISSION_DIR"/sub-plan/*.md; do
356
+ [ -f "$_sp" ] || continue
357
+ TASKS_TOTAL=$(( TASKS_TOTAL + $(count_boxes "$_sp" '[ xX]') ))
358
+ TASKS_DONE=$(( TASKS_DONE + $(count_boxes "$_sp" '[xX]') ))
359
+ done
340
360
  fi
361
+ # done ≤ total is an invariant of the audit trail — never let a report show
362
+ # progress above 100%, whatever the plan file contains. (B3)
363
+ [ "${TASKS_DONE:-0}" -gt "${TASKS_TOTAL:-0}" ] 2>/dev/null && TASKS_DONE="$TASKS_TOTAL"
341
364
 
342
365
  # blocker count
343
366
  BLOCKERS_FILE="$MISSION_DIR/blockers.md"
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
  }
@@ -75,7 +86,7 @@ export async function run(argv: string[]): Promise<void> {
75
86
  }
76
87
 
77
88
  function resetCmd(flags: Args['flags']): void {
78
- const projectDir = resolve(str(flags.project) ?? process.cwd());
89
+ const projectDir = resolveProjectDir(str(flags.project));
79
90
  const force = flag(flags.force);
80
91
  const result = resetMission(projectDir, flag(flags.keepLogs), force);
81
92
  if (result.blocked) {
@@ -88,7 +99,7 @@ function resetCmd(flags: Args['flags']): void {
88
99
  }
89
100
 
90
101
  function archive(flags: Args['flags'], positionals: string[]): void {
91
- const projectDir = resolve(str(flags.project) ?? process.cwd());
102
+ const projectDir = resolveProjectDir(str(flags.project));
92
103
  const mission = positionals[1];
93
104
  if (!mission) { console.error('usage: mugiwara archive <mission> [--project <dir>] [--dry-run]'); process.exit(1); }
94
105
  const result = archiveMission(projectDir, mission, { dryRun: flag(flags.dryRun) });
@@ -107,7 +118,7 @@ function archive(flags: Args['flags'], positionals: string[]): void {
107
118
  * touched before that date.
108
119
  */
109
120
  function cleanCmd(flags: Args['flags']): void {
110
- const projectDir = resolve(str(flags.project) ?? process.cwd());
121
+ const projectDir = resolveProjectDir(str(flags.project));
111
122
  const dryRun = flag(flags.dryRun);
112
123
  const root = join(projectDir, '.mugiwara', 'missions');
113
124
  if (!existsSync(root)) { console.log('nothing to clean (.mugiwara/missions/ does not exist).'); return; }
@@ -173,7 +184,7 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
173
184
  if (!interactive) { scope = 'project'; }
174
185
  else scope = (await choose(rl!, 'Install scope?', ['global (user-wide)', 'project (this repo)'])) === 0 ? 'global' : 'project';
175
186
  }
176
- const projectDir = resolve(str(flags.project) ?? process.cwd());
187
+ const projectDir = resolveProjectDir(str(flags.project));
177
188
  if (scope === 'project' && !existsSync(projectDir)) throw new Error(`Project dir not found: ${projectDir}`);
178
189
 
179
190
  let targetIds = str(flags.target)?.split(',').map(s => s.trim()) ?? null;
@@ -231,13 +242,15 @@ async function install(flags: Args['flags']): Promise<void> {
231
242
  });
232
243
  console.log(`\nOK mugiwara ${VERSION} installed (manifest: ${file})`);
233
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.');
234
247
  // A fresh install writes a default .mugiwara/config — point at it directly.
235
248
  console.log('\nNext: edit .mugiwara/config to customise (mode, branch, coverage, depths).');
236
249
  }
237
250
 
238
251
  async function uninstall(flags: Args['flags']): Promise<void> {
239
252
  const scope: Scope = flag(flags.global) ? 'global' : 'project';
240
- const projectDir = resolve(str(flags.project) ?? process.cwd());
253
+ const projectDir = resolveProjectDir(str(flags.project));
241
254
  const home = homedir();
242
255
  const file = manifestPath({ scope, projectDir, home });
243
256
  const manifest = readManifest(file);
@@ -310,7 +323,7 @@ function schemaWarnings(projectDir: string): void {
310
323
 
311
324
  function list(flags: Args['flags']): void {
312
325
  const home = homedir();
313
- const projectDir = resolve(str(flags.project) ?? process.cwd());
326
+ const projectDir = resolveProjectDir(str(flags.project));
314
327
  legacyWarning(projectDir);
315
328
  let found = false;
316
329
  for (const [label, file] of [
@@ -342,12 +355,28 @@ function list(flags: Args['flags']): void {
342
355
  * the caller must stop and let the user pick.
343
356
  */
344
357
  function continueCmd(flags: Args['flags'], positionals: string[]): void {
345
- const projectDir = resolve(str(flags.project) ?? process.cwd());
358
+ const projectDir = resolveProjectDir(str(flags.project));
346
359
  legacyWarning(projectDir);
347
360
  schemaWarnings(projectDir);
348
361
  const [mission, member] = positionals.slice(1);
349
362
  let entries = readContinue(projectDir);
350
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
+
351
380
  // default to this actor's work; --all crosses actors on a shared checkout
352
381
  if (!flag(flags.all)) {
353
382
  const actor = gitActor(projectDir);
@@ -386,11 +415,19 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
386
415
 
387
416
  /** `mugiwara status` — one screen of computed mission state, no model needed. */
388
417
  function statusCmd(flags: Args['flags']): void {
389
- const projectDir = resolve(str(flags.project) ?? process.cwd());
418
+ const projectDir = resolveProjectDir(str(flags.project));
390
419
  legacyWarning(projectDir);
391
420
  schemaWarnings(projectDir);
392
421
  const states = readState(projectDir);
393
- 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
+ }
394
431
  const actor = flag(flags.all) ? null : gitActor(projectDir);
395
432
  const rows = actor ? (states.filter((s) => s.actor === actor).length ? states.filter((s) => s.actor === actor) : states) : states;
396
433
  for (const s of rows) {
@@ -406,7 +443,7 @@ function statusCmd(flags: Args['flags']): void {
406
443
 
407
444
  /** `mugiwara cost [--mission <id>] [--json] [--ledger]` — show cost ledger, avoided work, efficiency, trail. */
408
445
  function costCmd(flags: Args['flags'], positionals: string[]): void {
409
- const projectDir = resolve(str(flags.project) ?? process.cwd());
446
+ const projectDir = resolveProjectDir(str(flags.project));
410
447
  const mission = str(flags.mission) ?? positionals[1] ?? (() => {
411
448
  const states = readState(projectDir);
412
449
  if (states.length === 1) return states[0].mission;
@@ -461,7 +498,7 @@ function costCmd(flags: Args['flags'], positionals: string[]): void {
461
498
 
462
499
  /** `mugiwara run <script.sh> [args]` — run a bundled harness script here. */
463
500
  function runCmd(flags: Args['flags'], positionals: string[]): void {
464
- const projectDir = resolve(str(flags.project) ?? process.cwd());
501
+ const projectDir = resolveProjectDir(str(flags.project));
465
502
  const name = positionals[1];
466
503
  if (!name) {
467
504
  console.error(`usage: mugiwara run <script> [args...]\n scripts: ${RUNNABLE.join(', ')}`);
@@ -473,7 +510,7 @@ function runCmd(flags: Args['flags'], positionals: string[]): void {
473
510
 
474
511
  /** `mugiwara blame <path>` — provenance note on the last commit touching path. */
475
512
  function blameCmd(flags: Args['flags'], positionals: string[]): void {
476
- const projectDir = resolve(str(flags.project) ?? process.cwd());
513
+ const projectDir = resolveProjectDir(str(flags.project));
477
514
  const path = positionals[1];
478
515
  if (!path) { console.error('usage: mugiwara blame <file-path>'); process.exit(1); }
479
516
  console.log(blamePath(projectDir, path));
@@ -506,10 +543,15 @@ export function stalenessLine(projectDir: string, baseSha: string): string | nul
506
543
 
507
544
  /** `mugiwara handoff <mission>` — a report the next engineer can act on. */
508
545
  function handoffCmd(flags: Args['flags'], positionals: string[]): void {
509
- const projectDir = resolve(str(flags.project) ?? process.cwd());
546
+ const projectDir = resolveProjectDir(str(flags.project));
510
547
  const mission = positionals[1];
511
548
  if (!mission) { console.error('usage: mugiwara handoff <mission> [--project <dir>]'); process.exit(1); }
512
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
+ }
513
555
  if (!states.length) { console.error(`no in-flight mission "${mission}"`); process.exit(1); }
514
556
  const lines = [
515
557
  `# Handoff: ${mission}`,
@@ -540,7 +582,7 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
540
582
  }
541
583
 
542
584
  export function migrateCmd(flags: Args['flags']): void {
543
- const projectDir = resolve(str(flags.project) ?? process.cwd());
585
+ const projectDir = resolveProjectDir(str(flags.project));
544
586
  const dryRun = flag(flags.dryRun);
545
587
  const legacyState = join(projectDir, '.mugiwara', 'state');
546
588
  const legacyContinue = join(projectDir, '.mugiwara', 'continue');
@@ -623,7 +665,7 @@ export function migrateCmd(flags: Args['flags']): void {
623
665
 
624
666
  /** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
625
667
  function signCmd(flags: Args['flags'], _: string[]): void {
626
- const projectDir = resolve(str(flags.project) ?? process.cwd());
668
+ const projectDir = resolveProjectDir(str(flags.project));
627
669
  if (flag(flags.genKey)) {
628
670
  const backend = str(flags.backend) ?? 'auto';
629
671
  const home = homedir();
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);