@ionivetech/mugiwara 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.codex-plugin/plugin.json +1 -2
  4. package/.cursor-plugin/plugin.json +1 -2
  5. package/.kimi-plugin/plugin.json +1 -2
  6. package/.opencode/commands/mugiwara-continue.md +44 -31
  7. package/.opencode/commands/mugiwara-execute.md +1 -1
  8. package/.opencode/commands/mugiwara-heal.md +1 -1
  9. package/.opencode/commands/mugiwara-onboard.md +8 -9
  10. package/.opencode/commands/mugiwara-plan.md +1 -1
  11. package/.opencode/commands/mugiwara-review.md +1 -1
  12. package/.opencode/commands/mugiwara-security.md +1 -1
  13. package/.opencode/commands/mugiwara-ship.md +1 -1
  14. package/.opencode/commands/mugiwara.md +2 -2
  15. package/.opencode/plugins/mugiwara.mjs +1 -2
  16. package/AGENTS.md +1 -2
  17. package/README.md +33 -31
  18. package/content/agents/brook-healing.md +5 -8
  19. package/content/agents/chopper-checkpoint.md +8 -11
  20. package/content/agents/eval-runner.md +2 -5
  21. package/content/agents/franky-gates.md +4 -7
  22. package/content/agents/jinbe-security.md +4 -7
  23. package/content/agents/luffy-orchestrator.md +13 -13
  24. package/content/agents/memory-keeper.md +5 -8
  25. package/content/agents/nami-planner.md +4 -7
  26. package/content/agents/resume-coordinator.md +7 -10
  27. package/content/agents/robin-reviewer.md +6 -9
  28. package/content/agents/sanji-quality.md +7 -10
  29. package/content/agents/skeptic-verifier.md +5 -8
  30. package/content/agents/usopp-brainstorm.md +3 -6
  31. package/content/agents/zoro-execution.md +7 -10
  32. package/content/skills/mugiwara-backend/SKILL.md +2 -0
  33. package/content/skills/mugiwara-checkpoint/SKILL.md +13 -11
  34. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +4 -4
  35. package/content/skills/mugiwara-context-budget/SKILL.md +5 -3
  36. package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
  37. package/content/skills/mugiwara-execution/SKILL.md +20 -20
  38. package/content/skills/mugiwara-execution/references/dispatch.md +2 -2
  39. package/content/skills/mugiwara-execution/references/resume-batching.md +1 -1
  40. package/content/skills/mugiwara-frontend/SKILL.md +2 -4
  41. package/content/skills/mugiwara-gates/SKILL.md +8 -7
  42. package/content/skills/mugiwara-git/SKILL.md +7 -2
  43. package/content/skills/mugiwara-healing/SKILL.md +6 -4
  44. package/content/skills/mugiwara-healing/references/workers.md +2 -2
  45. package/content/skills/mugiwara-orchestration/SKILL.md +26 -24
  46. package/content/skills/mugiwara-orchestration/references/check-ins.md +16 -15
  47. package/content/skills/mugiwara-orchestration/references/closure.md +5 -5
  48. package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
  49. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +22 -22
  50. package/content/skills/mugiwara-planning/SKILL.md +3 -1
  51. package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
  52. package/content/skills/mugiwara-pr/SKILL.md +6 -6
  53. package/content/skills/mugiwara-pr/references/verdict-format.md +3 -3
  54. package/content/skills/mugiwara-quality/SKILL.md +7 -7
  55. package/content/skills/mugiwara-resume/SKILL.md +18 -20
  56. package/content/skills/mugiwara-review/SKILL.md +5 -1
  57. package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
  58. package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
  59. package/content/skills/mugiwara-security/SKILL.md +5 -1
  60. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  61. package/content/skills/mugiwara-ship/references/cleanup.md +1 -1
  62. package/content/skills/mugiwara-testcases/SKILL.md +5 -3
  63. package/content/skills/mugiwara-workflow/SKILL.md +27 -27
  64. package/content/skills/mugiwara-workflow/references/workspace-layout.md +15 -15
  65. package/content/skills/using-mugiwara/SKILL.md +5 -5
  66. package/dist/mugiwara.js +808 -33
  67. package/gemini-extension.json +1 -1
  68. package/hooks/auto-savepoint.js +106 -0
  69. package/hooks/auto-savepoint.ts +102 -0
  70. package/hooks/engagement-marker.js +59 -0
  71. package/hooks/engagement-marker.ts +97 -0
  72. package/hooks/hooks.json +41 -2
  73. package/hooks/mugiwara-mode-tracker.js +79 -0
  74. package/hooks/pipeline-guard.js +182 -0
  75. package/hooks/pipeline-guard.ts +267 -0
  76. package/hooks/session-start.js +106 -0
  77. package/hooks/session-start.ts +44 -32
  78. package/package.json +7 -3
  79. package/plugin.json +1 -1
  80. package/references/agent-protocol.md +15 -0
  81. package/references/complexity.md +54 -0
  82. package/references/definition-of-done.md +1 -1
  83. package/references/multi-actor.md +5 -5
  84. package/references/skill-versioning.md +1 -1
  85. package/references/token-budget.md +30 -8
  86. package/references/wave-banners.md +16 -16
  87. package/scripts/build-hooks.ts +56 -0
  88. package/scripts/conformance.ts +17 -0
  89. package/scripts/coverage-gate.ts +179 -0
  90. package/scripts/install.ps1 +5 -4
  91. package/scripts/install.sh +4 -4
  92. package/scripts/lane.sh +6 -2
  93. package/scripts/lib/patterns.sh +84 -0
  94. package/scripts/mission-report.sh +12 -11
  95. package/scripts/savepoint.sh +141 -37
  96. package/scripts/verify-install.ts +154 -44
  97. package/src/args.ts +1 -1
  98. package/src/cli.ts +217 -0
  99. package/src/continue.ts +243 -0
  100. package/src/installer.ts +39 -0
  101. package/src/onboard.ts +207 -0
  102. package/src/run.ts +82 -0
  103. package/src/targets/claude.ts +140 -2
  104. package/src/targets/copilot.ts +20 -1
  105. package/src/targets/generic.ts +9 -4
  106. package/src/targets/opencode.ts +0 -1
  107. package/content/agents/onboarding-guide.md +0 -124
  108. package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
  109. package/scripts/onboard.ts +0 -266
  110. package/scripts/probe.ts +0 -40
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // src/cli.ts
3
3
  import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { homedir } from 'node:os';
5
6
  import { dirname, join, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
@@ -10,6 +11,9 @@ import { targets, TARGET_IDS } from './targets/index.ts';
10
11
  import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProjectGitignore } from './installer.ts';
11
12
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
12
13
  import { resetMission, archiveMission } from './mission.ts';
14
+ import { runOnboard } from './onboard.ts';
15
+ import { runScript, RUNNABLE } from './run.ts';
16
+ import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor } from './continue.ts';
13
17
 
14
18
  const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
15
19
  const flag = (v: FlagValue): boolean => v === true;
@@ -25,6 +29,13 @@ export async function run(argv: string[]): Promise<void> {
25
29
  case 'list': return list(flags);
26
30
  case 'reset': return resetCmd(flags);
27
31
  case 'archive': return archive(flags, _);
32
+ case 'onboard': return onboardCmd(flags);
33
+ case 'continue': return continueCmd(flags, _);
34
+ case 'status': return statusCmd(flags);
35
+ case 'run': return runCmd(flags, _);
36
+ case 'savepoint': return runCmd(flags, ['run', 'savepoint.sh', ..._.slice(1)]);
37
+ case 'initiative': return initiativeCmd(flags, _);
38
+ case 'start': return startCmd(flags, _);
28
39
  default: throw new Error(`Unknown command: ${command}`);
29
40
  }
30
41
  }
@@ -53,6 +64,12 @@ function archive(flags: Args['flags'], positionals: string[]): void {
53
64
  if (result.index) console.log(`index updated: ${result.index}`);
54
65
  }
55
66
 
67
+ async function onboardCmd(flags: Args['flags']): Promise<void> {
68
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
69
+ if (!existsSync(projectDir)) throw new Error(`Project dir not found: ${projectDir}`);
70
+ await runOnboard(projectDir);
71
+ }
72
+
56
73
  async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; projectDir: string; targetIds: string[] }> {
57
74
  const interactive = !flag(flags.yes);
58
75
  if (interactive && !process.stdin.isTTY) {
@@ -123,6 +140,22 @@ async function install(flags: Args['flags']): Promise<void> {
123
140
  });
124
141
  console.log(`\nOK mugiwara ${VERSION} installed (manifest: ${file})`);
125
142
  if (allNotes.length) console.log(`${allNotes.length} note(s) above may need attention.`);
143
+ // TASK 8: name exactly one next step, and state the active mode + enforce
144
+ // value since both change behaviour.
145
+ if (scope === 'project') {
146
+ const cfg = join(projectDir, '.mugiwara', 'config');
147
+ let mode = 'guided', enforce = 'block';
148
+ if (existsSync(cfg)) {
149
+ for (const line of readFileSync(cfg, 'utf8').split(/\r?\n/)) {
150
+ const [k, v] = line.split('=').map((s) => s.trim());
151
+ if (k === 'mode') mode = v;
152
+ if (k === 'enforce') enforce = v;
153
+ }
154
+ }
155
+ console.log(`\nNext: run \`mugiwara onboard\` to customise (mode=${mode}, enforce=${enforce} now).`);
156
+ } else {
157
+ console.log('\nNext: run `mugiwara onboard` in a project to write its .mugiwara/config.');
158
+ }
126
159
  }
127
160
 
128
161
  async function uninstall(flags: Args['flags']): Promise<void> {
@@ -150,6 +183,16 @@ async function uninstall(flags: Args['flags']): Promise<void> {
150
183
  if (!ok) { console.log('Aborted.'); return; }
151
184
  }
152
185
  const removed = removeInstalled(manifest, { dryRun: flag(flags.dryRun) });
186
+ // Un-merge anything we injected into files the user owns. These are
187
+ // deliberately absent from the manifest — deleting them would destroy the
188
+ // user's own configuration alongside ours.
189
+ for (const id of manifest.targets) {
190
+ const t = targets[id];
191
+ if (!t?.postUninstall) continue;
192
+ const post = t.postUninstall({ scope, projectDir, home, dryRun: flag(flags.dryRun) });
193
+ for (const f of post.changed) console.log(` unwired mugiwara hooks from ${f}`);
194
+ for (const n of post.notes) console.log(` note: ${n}`);
195
+ }
153
196
  if (!flag(flags.dryRun)) {
154
197
  if (scope === 'project') {
155
198
  const gi = removeProjectGitignore(projectDir);
@@ -192,6 +235,166 @@ function list(flags: Args['flags']): void {
192
235
  if (!found) console.log('No mugiwara installation found.');
193
236
  }
194
237
 
238
+ /**
239
+ * `mugiwara continue [mission] [member]` — the deterministic half of resume.
240
+ *
241
+ * Selecting which mission/member to resume is a directory scan, not a judgement
242
+ * call, so it runs here instead of costing the host model a reasoning turn.
243
+ * Only the last step (verifying next_action against the plan) needs a model,
244
+ * and that happens after this prints.
245
+ *
246
+ * Exit codes: 0 = a single resume point was printed; 2 = ambiguous or absent,
247
+ * the caller must stop and let the user pick.
248
+ */
249
+ function continueCmd(flags: Args['flags'], positionals: string[]): void {
250
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
251
+ const [mission, member] = positionals.slice(1);
252
+ let entries = readContinue(projectDir);
253
+
254
+ // default to this actor's work; --all crosses actors on a shared checkout
255
+ if (!flag(flags.all)) {
256
+ const actor = gitActor(projectDir);
257
+ const mine = entries.filter((e) => e.actor === actor);
258
+ // an actor-less savepoint (older file, or git identity unset) is still the
259
+ // only thing on disk — showing nothing would look like "no missions"
260
+ if (mine.length) entries = mine;
261
+ }
262
+
263
+ const r = resolveContinue(entries, mission, member);
264
+ if (r.kind === 'resume') { console.log(formatResume(r.entry)); return; }
265
+
266
+ if (r.kind === 'none') {
267
+ console.log('No mission in flight. Start one with Flow 0 triage (mugiwara-orchestration).');
268
+ } else if (r.kind === 'missions') {
269
+ console.log(`${new Set(r.entries.map((e) => e.mission)).size} missions in flight:\n`);
270
+ console.log(formatTable(r.entries));
271
+ console.log('\nPick one: mugiwara continue <mission> [member]');
272
+ } else if (r.kind === 'members') {
273
+ console.log(`Mission "${r.mission}" has ${r.entries.length} members in flight:\n`);
274
+ console.log(formatTable(r.entries));
275
+ console.log(`\nPick one: mugiwara continue ${r.mission} <member>`);
276
+ } else if (r.kind === 'unknown-mission') {
277
+ console.error(`No in-flight mission "${r.mission}". Known: ${r.known.join(', ') || '(none)'}`);
278
+ } else {
279
+ console.error(`Mission "${r.mission}" has no member "${r.member}". Known: ${r.known.join(', ')}`);
280
+ }
281
+ process.exit(2);
282
+ }
283
+
284
+ /** `mugiwara status` — one screen of computed mission state, no model needed. */
285
+ function statusCmd(flags: Args['flags']): void {
286
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
287
+ const states = readState(projectDir);
288
+ if (!states.length) { console.log('No mission state on disk.'); return; }
289
+ const actor = flag(flags.all) ? null : gitActor(projectDir);
290
+ const rows = actor ? (states.filter((s) => s.actor === actor).length ? states.filter((s) => s.actor === actor) : states) : states;
291
+ for (const s of rows) {
292
+ const scope = s.member ? ` [${s.member}]` : '';
293
+ console.log(`${s.mission}${scope}`);
294
+ console.log(` flow ${s.flow} · ${s.tasks_done}/${s.tasks_total} tasks · lane ${s.lane}${s.lane_rose ? ' ⬆ ROSE' : ''}${s.lane_reason ? ` (${s.lane_reason})` : ''} · mode ${s.mode}`);
295
+ console.log(` blockers ${s.blockers_open} · heal cycle ${s.heal_cycle}/${s.heal_max_cycles}${s.heal_halt ? ' — HALT' : ''} · files touched ${s.files_touched}`);
296
+ if (s.budget) console.log(` tokens ${s.tokens_est}/${s.budget} (${s.budget_status})${s.delegate_due ? ' · delegate due' : ''}`);
297
+ console.log(` branch ${s.branch} · updated ${s.updated_at}`);
298
+ if (s.evidence.length) console.log(` evidence: ${s.evidence.join(', ')}`);
299
+ }
300
+ }
301
+
302
+ /** `mugiwara run <script.sh> [args]` — run a bundled harness script here. */
303
+ function runCmd(flags: Args['flags'], positionals: string[]): void {
304
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
305
+ const name = positionals[1];
306
+ if (!name) {
307
+ console.error(`usage: mugiwara run <script> [args...]\n scripts: ${RUNNABLE.join(', ')}`);
308
+ process.exit(1);
309
+ }
310
+ const code = runScript(name, positionals.slice(2), projectDir);
311
+ if (code !== 0) process.exit(code);
312
+ }
313
+
314
+ /** `mugiwara initiative <status|conflict-check|set-status> <plan> [args]` — team sub-mission coordination. */
315
+ function initiativeCmd(flags: Args['flags'], positionals: string[]): void {
316
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
317
+ const sub = positionals[1];
318
+ if (!sub) {
319
+ console.error('usage: mugiwara initiative <status|conflict-check|set-status> <plan-file> [--id <id> --status <s>]');
320
+ process.exit(1);
321
+ }
322
+ const script = join(import.meta.dirname, '..', 'scripts', 'initiative.ts');
323
+ // initiative.ts is TypeScript — spawn `bun` (which strips types), not node,
324
+ // which only strips types on >=22.18. engines floor is >=20.11.
325
+ const bun = process.platform === 'win32' ? 'bun.exe' : 'bun';
326
+ const r = spawnSync(bun, [script, ...positionals.slice(1)], {
327
+ cwd: projectDir,
328
+ stdio: 'inherit',
329
+ env: process.env,
330
+ });
331
+ if (r.error) throw r.error;
332
+ if (r.status) process.exit(r.status);
333
+ }
334
+
335
+ /** `mugiwara start <mission> [member]` — begin a mission from its plan. */
336
+ function startCmd(flags: Args['flags'], positionals: string[]): void {
337
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
338
+ const mission = positionals[1];
339
+ if (!mission) {
340
+ console.error('usage: mugiwara start <mission> [member]');
341
+ process.exit(1);
342
+ }
343
+ // find the plan doc: plans/<mission>.md or plans/YYYY-MM-DD-<mission>.md
344
+ const plansDir = join(projectDir, '.mugiwara', 'plans');
345
+ let planFile = '';
346
+ if (existsSync(plansDir)) {
347
+ const bare = join(plansDir, `${mission}.md`);
348
+ if (existsSync(bare)) planFile = bare;
349
+ else {
350
+ const dated = readdirSync(plansDir).filter((f) => f.endsWith(`-${mission}.md`)).sort().pop();
351
+ if (dated) planFile = join(plansDir, dated);
352
+ }
353
+ }
354
+ if (!planFile) {
355
+ console.error(`mugiwara start: no plan found for "${mission}" in ${plansDir}`);
356
+ process.exit(1);
357
+ }
358
+ const plan = readFileSync(planFile, 'utf8');
359
+
360
+ // team plan = has a ## Sub-missions table; solo = none.
361
+ const subMatch = plan.match(/##\s*Sub-missions/i);
362
+ let member = positionals[2] ?? '';
363
+ if (subMatch && !member) {
364
+ // team: find the row whose Assignee matches this git actor
365
+ const actor = gitActor(projectDir);
366
+ const name = actor.split('<')[0].trim();
367
+ const rowRe = /^\|\s*([\w.-]+)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/gm;
368
+ let m: RegExpExecArray | null;
369
+ let found = '';
370
+ while ((m = rowRe.exec(plan))) {
371
+ const assignee = m[3].trim();
372
+ if (assignee === name || assignee === actor) { found = m[1].trim(); break; }
373
+ }
374
+ if (!found) {
375
+ console.error(`mugiwara start: no sub-mission assigned to "${name}" in ${planFile}. Pass a member explicitly: mugiwara start ${mission} <member>`);
376
+ process.exit(1);
377
+ }
378
+ member = found;
379
+ }
380
+
381
+ // write the savepoint: mission [member] flow=0 mode=auto lane=full (start)
382
+ const code = runScript('savepoint.sh', [mission, member, '0', 'auto', 'full'], projectDir);
383
+ if (code !== 0) process.exit(code);
384
+
385
+ // team plan: mark the sub-mission in-progress in the plan (source of truth)
386
+ if (subMatch && member) {
387
+ const init = join(import.meta.dirname, '..', 'scripts', 'initiative.ts');
388
+ const bun = process.platform === 'win32' ? 'bun.exe' : 'bun';
389
+ const r = spawnSync(bun, [init, 'set-status', planFile, '--id', member, '--status', 'in-progress'], {
390
+ cwd: projectDir, stdio: 'inherit', env: process.env,
391
+ });
392
+ if (r.status) process.exit(r.status);
393
+ }
394
+
395
+ console.log(`mugiwara start: ${mission}${member ? ` [${member}]` : ''} begun at Flow 0. Resume anytime with: mugiwara continue ${mission}${member ? ` ${member}` : ''}`);
396
+ }
397
+
195
398
  function help(): void {
196
399
  console.log(`mugiwara ${VERSION} — the Straw Hat crew for AI agents
197
400
 
@@ -203,6 +406,19 @@ Usage:
203
406
  mugiwara list --check health check: show installations + missing files
204
407
  mugiwara reset wipe mission state (spec/plans/results/review/issues[/logs])
205
408
  mugiwara archive <m> fold a closed mission's evidence into its report, then remove loose files
409
+ mugiwara onboard run the zero-LLM onboarding wizard (writes .mugiwara/config)
410
+ mugiwara continue list in-flight missions (exit 2 = pick one, nothing resumed)
411
+ mugiwara continue <m> [member]
412
+ print the exact resume point for that mission/member
413
+ mugiwara status computed mission state: wave, tasks, lane, blockers, budget
414
+ mugiwara run <script> [args...]
415
+ run a bundled harness script here (${RUNNABLE.join(', ')})
416
+ mugiwara savepoint <mission> [member] [wave] [mode]
417
+ shorthand for: mugiwara run savepoint.sh ...
418
+ mugiwara initiative <status|conflict-check|set-status> <plan> [args]
419
+ team sub-mission coordination (status / conflict-check / set-status)
420
+ mugiwara start <mission> [member]
421
+ begin a mission from its plan (solo, or team sub-mission by git actor)
206
422
  mugiwara --help this help
207
423
  mugiwara --version print version
208
424
 
@@ -214,6 +430,7 @@ Flags:
214
430
  --force overwrite differing files (with backup)
215
431
  --dry-run print actions without writing
216
432
  --check with list: report missing files (health check)
433
+ --all with continue/status: every actor, not just yours
217
434
  --keep-logs with reset: keep .mugiwara/logs (lessons ledger survives)`);
218
435
  }
219
436
 
@@ -0,0 +1,243 @@
1
+ // src/continue.ts
2
+ // Deterministic resume-point reader. Powers `mugiwara continue` and
3
+ // `mugiwara status` — pure fs + JSON, zero model judgement. The prose skills
4
+ // used to describe this scan so the host model would perform it; that cost a
5
+ // full reasoning turn to answer "what is in flight?". This is the same answer
6
+ // in one process spawn.
7
+ //
8
+ // Reads what scripts/savepoint.sh writes:
9
+ // .mugiwara/continue/<mission>/state.json (solo)
10
+ // .mugiwara/continue/<mission>/<member>.json (team)
11
+ // .mugiwara/state/<mission>/... (same shape, more fields)
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { join } from 'node:path';
15
+
16
+ /** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
17
+ const SAFE = /^[A-Za-z0-9._-]+$/;
18
+ const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
19
+
20
+ export type ContinueEntry = {
21
+ mission: string;
22
+ member: string | null;
23
+ actor: string;
24
+ branch: string;
25
+ flow: number;
26
+ mode: string;
27
+ tasks_done: number;
28
+ tasks_total: number;
29
+ lane: string;
30
+ next_action: string;
31
+ next_session_prompt: string;
32
+ updated_at: string;
33
+ };
34
+
35
+ export type StateEntry = ContinueEntry & {
36
+ lane_reason: string;
37
+ lane_rose: boolean;
38
+ lane_prev: string;
39
+ lane_peak: string;
40
+ blockers_open: number;
41
+ heal_cycle: number;
42
+ heal_max_cycles: number;
43
+ heal_halt: boolean;
44
+ delegate_threshold: number;
45
+ delegate_due: boolean;
46
+ tokens_est: number;
47
+ budget: number;
48
+ budget_status: string;
49
+ files_touched: number;
50
+ evidence: string[];
51
+ };
52
+
53
+ const num = (v: unknown): number => {
54
+ const n = Number(v);
55
+ return Number.isFinite(n) ? n : 0;
56
+ };
57
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
58
+
59
+ /**
60
+ * Resolve the git actor exactly as scripts/savepoint.sh does:
61
+ * STATE_ACTOR → GIT_AUTHOR_NAME → "name <email>" → USER. A byte difference
62
+ * here silently filters every mission out of the listing.
63
+ */
64
+ export function gitActor(cwd: string): string {
65
+ const env = (process.env.STATE_ACTOR ?? '').trim();
66
+ if (env) return env;
67
+ const author = (process.env.GIT_AUTHOR_NAME ?? '').trim();
68
+ if (author) return author;
69
+ const git = (args: string[]): string => {
70
+ try {
71
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
72
+ } catch {
73
+ return '';
74
+ }
75
+ };
76
+ const name = git(['config', 'user.name']);
77
+ const email = git(['config', 'user.email']);
78
+ if (name && email) return `${name} <${email}>`;
79
+ // USERNAME is the Windows spelling of USER; savepoint.sh resolves the same
80
+ // chain, and the two must stay byte-identical or the actor filter drops rows.
81
+ return name || process.env.USER || process.env.USERNAME || '';
82
+ }
83
+
84
+ /** Read every JSON under `.mugiwara/<root>/<mission>/*.json`. Corrupt files are skipped. */
85
+ function scan<T>(projectDir: string, root: string, map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
86
+ const base = join(projectDir, '.mugiwara', root);
87
+ if (!existsSync(base)) return [];
88
+ const out: T[] = [];
89
+ const missions = readdirSync(base, { withFileTypes: true })
90
+ .filter((e) => e.isDirectory() && isSafeKey(e.name))
91
+ .map((e) => e.name)
92
+ .sort();
93
+ for (const mission of missions) {
94
+ const dir = join(base, mission);
95
+ for (const f of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
96
+ // state.json is the solo (member-less) file; <member>.json is a team member
97
+ const stem = f.slice(0, -'.json'.length);
98
+ const member = stem === 'state' ? null : stem;
99
+ if (member !== null && !isSafeKey(member)) continue;
100
+ try {
101
+ const raw = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
102
+ // trust the path over the file body: a mission field that disagrees
103
+ // with its own directory is a corrupt or hand-edited file
104
+ if (text(raw.mission) !== mission) continue;
105
+ out.push(map(raw, member));
106
+ } catch {
107
+ // corrupt savepoint — skip, never crash the listing
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ export function readContinue(projectDir: string): ContinueEntry[] {
115
+ return scan(projectDir, 'continue', (r, member) => ({
116
+ mission: text(r.mission),
117
+ member,
118
+ actor: text(r.actor),
119
+ branch: text(r.branch),
120
+ flow: num(r.flow ?? r.wave),
121
+ mode: text(r.mode) || 'guided',
122
+ tasks_done: num(r.tasks_done),
123
+ tasks_total: num(r.tasks_total),
124
+ lane: text(r.lane) || 'direct',
125
+ next_action: text(r.next_action),
126
+ next_session_prompt: text(r.next_session_prompt),
127
+ updated_at: text(r.updated_at),
128
+ }));
129
+ }
130
+
131
+ export function readState(projectDir: string): StateEntry[] {
132
+ return scan(projectDir, 'state', (r, member) => {
133
+ const tasks = (r.tasks ?? {}) as Record<string, unknown>;
134
+ return {
135
+ mission: text(r.mission),
136
+ member,
137
+ actor: text(r.actor),
138
+ branch: text(r.branch),
139
+ flow: num(r.flow ?? r.wave),
140
+ mode: text(r.mode) || 'guided',
141
+ tasks_done: num(tasks.done),
142
+ tasks_total: num(tasks.total),
143
+ lane: text(r.lane) || 'direct',
144
+ lane_reason: text(r.lane_reason),
145
+ lane_rose: r.lane_rose === true,
146
+ lane_prev: text(r.lane_prev),
147
+ lane_peak: text(r.lane_peak),
148
+ next_action: '',
149
+ next_session_prompt: '',
150
+ updated_at: text(r.updated_at),
151
+ blockers_open: num(r.blockers_open),
152
+ heal_cycle: num(r.heal_cycle),
153
+ heal_max_cycles: num(r.heal_max_cycles) || 3,
154
+ heal_halt: r.heal_halt === true,
155
+ delegate_threshold: num(r.delegate_threshold) || 60,
156
+ delegate_due: r.delegate_due === true,
157
+ tokens_est: num(r.tokens_est),
158
+ budget: num(r.budget),
159
+ budget_status: text(r.budget_status) || 'ok',
160
+ files_touched: num(r.files_touched),
161
+ evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
162
+ };
163
+ });
164
+ }
165
+
166
+ export type Resolution =
167
+ | { kind: 'none' }
168
+ | { kind: 'missions'; entries: ContinueEntry[] }
169
+ | { kind: 'members'; mission: string; entries: ContinueEntry[] }
170
+ | { kind: 'unknown-mission'; mission: string; known: string[] }
171
+ | { kind: 'unknown-member'; mission: string; member: string; known: string[] }
172
+ | { kind: 'resume'; entry: ContinueEntry };
173
+
174
+ /**
175
+ * The three command forms, decided from disk alone:
176
+ * (none) → list every in-flight mission
177
+ * <mission> → solo resumes; team lists its members
178
+ * <mission> <member>→ resume that member
179
+ *
180
+ * Solo-vs-team needs no plan-doc parse: savepoint writes `state.json` with a
181
+ * null member for solo and `<member>.json` per team member.
182
+ */
183
+ export function resolveContinue(entries: ContinueEntry[], mission?: string, member?: string): Resolution {
184
+ if (!entries.length) return { kind: 'none' };
185
+
186
+ if (!mission) {
187
+ const missions = [...new Set(entries.map((e) => e.mission))];
188
+ if (missions.length === 1) return resolveContinue(entries, missions[0], member);
189
+ return { kind: 'missions', entries };
190
+ }
191
+
192
+ const inMission = entries.filter((e) => e.mission === mission);
193
+ if (!inMission.length) {
194
+ return { kind: 'unknown-mission', mission, known: [...new Set(entries.map((e) => e.mission))] };
195
+ }
196
+
197
+ if (member) {
198
+ const hit = inMission.find((e) => e.member === member);
199
+ if (!hit) {
200
+ return {
201
+ kind: 'unknown-member',
202
+ mission,
203
+ member,
204
+ known: inMission.map((e) => e.member ?? '(solo)'),
205
+ };
206
+ }
207
+ return { kind: 'resume', entry: hit };
208
+ }
209
+
210
+ const solo = inMission.find((e) => e.member === null);
211
+ // a solo file resumes directly; anything else needs the member picked, and
212
+ // is never guessed — resuming the wrong member's work is unrecoverable
213
+ if (solo && inMission.length === 1) return { kind: 'resume', entry: solo };
214
+ return { kind: 'members', mission, entries: inMission };
215
+ }
216
+
217
+ function pad(s: string, n: number): string {
218
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
219
+ }
220
+
221
+ /** Fixed-width table — the whole point is that the host prints this verbatim. */
222
+ export function formatTable(entries: ContinueEntry[]): string {
223
+ const rows = entries.map((e) => [
224
+ e.mission,
225
+ e.member ?? '—',
226
+ String(e.flow),
227
+ `${e.tasks_done}/${e.tasks_total}`,
228
+ e.lane,
229
+ e.mode,
230
+ ]);
231
+ const head = ['MISSION', 'MEMBER', 'FLOW', 'TASKS', 'LANE', 'MODE'];
232
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
233
+ const line = (cells: string[]): string =>
234
+ ' ' + cells.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
235
+ return [line(head), ...rows.map(line)].join('\n');
236
+ }
237
+
238
+ /** The single resume line the mugiwara-resume skill contracts for. */
239
+ export function formatResume(e: ContinueEntry): string {
240
+ const scope = e.member ? ` [${e.member}]` : '';
241
+ const next = e.next_session_prompt || '(no next_session_prompt recorded)';
242
+ return `Resumed: ${e.mission}${scope}, Flow ${e.flow}, ${e.tasks_done}/${e.tasks_total} tasks — next_action: ${e.next_action} — run: ${next}`;
243
+ }
package/src/installer.ts CHANGED
@@ -44,6 +44,15 @@ export interface Target {
44
44
  transformSkillFull?(data: FrontmatterData, body: string): TransformOut | null;
45
45
  transformAgentFull?(data: FrontmatterData, body: string): TransformOut | null;
46
46
  postInstall?(opts: { scope: Scope; projectDir: string; home: string; dryRun: boolean; files: string[] }): { written: string[]; notes: string[] };
47
+ /**
48
+ * Undo anything postInstall did to a file mugiwara does not own.
49
+ *
50
+ * Files listed in the manifest are deleted wholesale on uninstall, so a
51
+ * shared file we merely EDITED (settings.json) must never be reported as
52
+ * written — deleting it destroys the user's own configuration. Such files are
53
+ * un-merged here instead.
54
+ */
55
+ postUninstall?(opts: { scope: Scope; projectDir: string; home: string; dryRun: boolean }): { changed: string[]; notes: string[] };
47
56
  }
48
57
 
49
58
  export const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'content');
@@ -151,6 +160,36 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
151
160
  result.written.push(...post.written);
152
161
  result.notes.push(...post.notes);
153
162
  }
163
+
164
+ // TASK 8: a fresh install must be immediately usable — write a default
165
+ // .mugiwara/config (the same defaults `mugiwara onboard` would write) so no
166
+ // key silently falls back. Only for project scope; global installs don't own
167
+ // a project config. Never overwrite an existing config.
168
+ if (scope === 'project') {
169
+ const configPath = join(projectDir, '.mugiwara', 'config');
170
+ // lstat, not existsSync: a pre-created symlinked config must not be
171
+ // followed and overwritten (TOCTOU / symlink defense-in-depth).
172
+ let configExists = false;
173
+ try { configExists = lstatSync(configPath).isFile() || lstatSync(configPath).isSymbolicLink(); } catch { configExists = false; }
174
+ if (!configExists) {
175
+ const body = [
176
+ 'mode=guided',
177
+ 'branch=feature/{type}-{issue}-{slug}',
178
+ 'commit=conventional',
179
+ 'auto_commit=on',
180
+ 'coverage_new=90',
181
+ 'coverage_modified=80',
182
+ 'review_depth=full',
183
+ 'quality_depth=full',
184
+ 'delegate_threshold=60',
185
+ 'heal_max_cycles=3',
186
+ 'verbosity=normal',
187
+ ].join('\n') + '\n';
188
+ if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
189
+ result.written.push(configPath);
190
+ result.notes.push(`default config written: ${configPath} (run \`mugiwara onboard\` to customise)`);
191
+ }
192
+ }
154
193
  return result;
155
194
  }
156
195