@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/README.md +61 -56
- package/content/agents/luffy-orchestrator.md +1 -0
- package/content/skills/mugiwara-lessons/SKILL.md +2 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +11 -20
- package/content/skills/mugiwara-orchestration/references/solo-team.md +18 -0
- package/content/skills/mugiwara-planning/SKILL.md +7 -15
- package/content/skills/mugiwara-planning/references/sub-missions.md +14 -0
- package/content/skills/mugiwara-workflow/SKILL.md +20 -20
- package/dist/mugiwara.js +863 -133
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/references/multi-actor.md +17 -14
- package/scripts/gate-selftest.ts +294 -1
- package/scripts/lane-base.ts +16 -0
- package/scripts/lane.sh +5 -1
- package/scripts/lib/lane-base.sh +1 -1
- package/scripts/savepoint.sh +151 -15
- package/scripts/validate-content.ts +168 -0
- package/src/args.ts +1 -1
- package/src/cli.ts +203 -21
- package/src/config.ts +33 -11
- package/src/continue.ts +7 -1
- package/src/cost.ts +1 -1
- package/src/installer.ts +27 -4
- package/src/integrity.ts +41 -10
- package/src/mission.ts +137 -52
- package/src/policy.ts +17 -2
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
419
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
390
420
|
legacyWarning(projectDir);
|
|
391
421
|
schemaWarnings(projectDir);
|
|
392
422
|
const states = readState(projectDir);
|
|
393
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
543
|
-
const projectDir =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
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
|
|
29
|
-
'# enforce=block
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
unreadable.push(join(mission, f));
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
}
|
package/src/cost.ts
CHANGED
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) {
|
|
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) {
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|