@ionivetech/mugiwara 0.6.5 → 0.7.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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +3 -9
  3. package/.codex-plugin/plugin.json +2 -8
  4. package/.cursor-plugin/plugin.json +2 -8
  5. package/.kimi-plugin/plugin.json +2 -8
  6. package/.opencode/commands/mugiwara-continue.md +44 -31
  7. package/.opencode/commands/mugiwara-review.md +1 -1
  8. package/.opencode/commands/mugiwara-security.md +1 -1
  9. package/.opencode/commands/mugiwara.md +2 -2
  10. package/.opencode/plugins/mugiwara.mjs +3 -19
  11. package/AGENTS.md +6 -6
  12. package/GEMINI.md +3 -3
  13. package/README.md +82 -74
  14. package/content/agents/brook-healing.md +12 -11
  15. package/content/agents/chopper-checkpoint.md +14 -13
  16. package/content/agents/eval-runner.md +6 -9
  17. package/content/agents/franky-gates.md +7 -10
  18. package/content/agents/jinbe-security.md +10 -14
  19. package/content/agents/luffy-orchestrator.md +20 -22
  20. package/content/agents/memory-keeper.md +9 -12
  21. package/content/agents/nami-planner.md +7 -10
  22. package/content/agents/resume-coordinator.md +12 -15
  23. package/content/agents/robin-reviewer.md +13 -12
  24. package/content/agents/sanji-quality.md +9 -12
  25. package/content/agents/skeptic-verifier.md +7 -10
  26. package/content/agents/usopp-brainstorm.md +6 -9
  27. package/content/agents/zoro-execution.md +9 -12
  28. package/content/skills/mugiwara-backend/SKILL.md +4 -0
  29. package/content/skills/mugiwara-backend/references/database.md +61 -0
  30. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  31. package/content/skills/mugiwara-checkpoint/SKILL.md +17 -14
  32. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +5 -5
  33. package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
  34. package/content/skills/mugiwara-execution/SKILL.md +24 -24
  35. package/content/skills/mugiwara-execution/references/dispatch.md +3 -3
  36. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  37. package/content/skills/mugiwara-frontend/SKILL.md +5 -6
  38. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  39. package/content/skills/mugiwara-gates/SKILL.md +14 -10
  40. package/content/skills/mugiwara-git/SKILL.md +7 -2
  41. package/content/skills/mugiwara-healing/SKILL.md +7 -5
  42. package/content/skills/mugiwara-healing/references/workers.md +2 -2
  43. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  44. package/content/skills/mugiwara-orchestration/SKILL.md +28 -26
  45. package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
  46. package/content/skills/mugiwara-orchestration/references/closure.md +18 -13
  47. package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
  48. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +32 -22
  49. package/content/skills/mugiwara-planning/SKILL.md +5 -18
  50. package/content/skills/mugiwara-planning/references/plan-template.md +3 -3
  51. package/content/skills/mugiwara-quality/SKILL.md +10 -7
  52. package/content/skills/mugiwara-resume/SKILL.md +20 -22
  53. package/content/skills/mugiwara-review/SKILL.md +7 -2
  54. package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
  55. package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
  56. package/content/skills/mugiwara-security/SKILL.md +6 -2
  57. package/content/skills/mugiwara-ship/SKILL.md +2 -2
  58. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  59. package/content/skills/mugiwara-testcases/SKILL.md +5 -3
  60. package/content/skills/mugiwara-workflow/SKILL.md +32 -35
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +41 -40
  62. package/dist/mugiwara.js +1370 -153
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +114 -0
  65. package/hooks/auto-savepoint.ts +102 -0
  66. package/hooks/engagement-marker.js +59 -0
  67. package/hooks/engagement-marker.ts +97 -0
  68. package/hooks/hooks.json +41 -2
  69. package/hooks/mugiwara-mode-tracker.js +79 -0
  70. package/hooks/pipeline-guard.js +186 -0
  71. package/hooks/pipeline-guard.ts +270 -0
  72. package/hooks/session-start.js +109 -0
  73. package/hooks/session-start.ts +62 -45
  74. package/package.json +7 -3
  75. package/plugin.json +1 -1
  76. package/references/agent-protocol.md +15 -0
  77. package/references/complexity.md +77 -0
  78. package/references/definition-of-done.md +1 -1
  79. package/references/multi-actor.md +4 -7
  80. package/references/prose-style.md +54 -0
  81. package/references/skill-versioning.md +1 -1
  82. package/references/wave-banners.md +16 -16
  83. package/scripts/build-hooks.ts +56 -0
  84. package/scripts/check-doc-links.ts +40 -0
  85. package/scripts/conformance.ts +23 -16
  86. package/scripts/coverage-gate.ts +193 -0
  87. package/scripts/gate-selftest.ts +118 -7
  88. package/scripts/install.ps1 +5 -4
  89. package/scripts/install.sh +4 -4
  90. package/scripts/lane.sh +56 -6
  91. package/scripts/lib/patterns.sh +89 -0
  92. package/scripts/policy-force.ts +22 -0
  93. package/scripts/retrieval-eval.ts +1 -1
  94. package/scripts/savepoint.sh +249 -67
  95. package/scripts/validate-content.ts +22 -9
  96. package/scripts/verify-install.ts +156 -44
  97. package/src/args.ts +2 -2
  98. package/src/budget.ts +56 -0
  99. package/src/cli.ts +272 -4
  100. package/src/continue.ts +262 -0
  101. package/src/installer.ts +59 -15
  102. package/src/integrity.ts +158 -0
  103. package/src/mission.ts +228 -95
  104. package/src/policy.ts +156 -0
  105. package/src/provenance.ts +116 -0
  106. package/src/rollback.ts +95 -0
  107. package/src/routing.ts +69 -0
  108. package/src/run.ts +82 -0
  109. package/src/sign.ts +61 -0
  110. package/src/targets/claude.ts +140 -2
  111. package/src/targets/copilot.ts +20 -1
  112. package/src/targets/generic.ts +9 -4
  113. package/src/targets/opencode.ts +0 -1
  114. package/.opencode/commands/mugiwara-execute.md +0 -13
  115. package/.opencode/commands/mugiwara-heal.md +0 -13
  116. package/.opencode/commands/mugiwara-onboard.md +0 -15
  117. package/.opencode/commands/mugiwara-plan.md +0 -17
  118. package/.opencode/commands/mugiwara-ship.md +0 -13
  119. package/.opencode/commands/using-mugiwara.md +0 -20
  120. package/content/agents/onboarding-guide.md +0 -124
  121. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  122. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  123. package/content/skills/mugiwara-context-budget/SKILL.md +0 -64
  124. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  125. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  126. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  127. package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
  128. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  129. package/content/skills/using-mugiwara/SKILL.md +0 -51
  130. package/references/token-budget.md +0 -34
  131. package/scripts/evidence.sh +0 -81
  132. package/scripts/initiative.ts +0 -296
  133. package/scripts/mission-report.sh +0 -292
  134. package/scripts/onboard.ts +0 -266
  135. package/scripts/probe.ts +0 -40
package/src/mission.ts CHANGED
@@ -1,18 +1,55 @@
1
1
  // src/mission.ts
2
- // Mission-state helpers for the mugiwara CLI (installer + reset only).
3
- import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
2
+ // Mission-state helpers for the mugiwara CLI.
3
+ import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync, openSync, writeSync, closeSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { join } from 'node:path';
6
+ import { checkTrail, formatIssues } from './integrity.ts';
7
+ import { generateRollback } from './rollback.ts';
8
+ import { writeProvenance } from './provenance.ts';
9
+ import { rankFiles, renderRouting } from './routing.ts';
10
+ import { formatFootprint, measureContextChars, readBudgetConfig } from './budget.ts';
11
+
12
+ function isStateFile(f: string): boolean {
13
+ // state.json (solo) or <member>.json (team) — never continue*.json
14
+ const stem = f.replace(/\.json$/, '');
15
+ return f.endsWith('.json') && stem !== 'continue' && !stem.startsWith('continue-');
16
+ }
17
+
18
+ /** Primary state for closure artifacts: solo state.json wins over members. */
19
+ function primaryState(dir: string, files: string[]): Record<string, unknown> | null {
20
+ const name = files.includes('state.json') ? 'state.json' : files.find((f) => f.endsWith('.json') && f !== 'continue.json' && !f.startsWith('continue-'));
21
+ if (!name) return null;
22
+ try {
23
+ return JSON.parse(readFileSync(join(dir, name), 'utf8')) as Record<string, unknown>;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Files the mission changed, base..branch. Empty on any git failure — routing is best-effort. */
30
+ function changedFiles(projectDir: string, state: Record<string, unknown> | null): string[] {
31
+ const base = typeof state?.base_sha === 'string' ? state.base_sha : '';
32
+ const branch = typeof state?.branch === 'string' ? state.branch : '';
33
+ if (!base || base === 'unknown' || !branch) return [];
34
+ try {
35
+ return execFileSync('git', ['diff', '--name-only', base, branch], {
36
+ cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).split(/\r?\n/).filter(Boolean);
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
5
42
 
6
43
  function activeActor(projectDir: string): string | null {
7
- // state now lives at .mugiwara/state/<mission>/[member].json — scan the
44
+ // state now lives at .mugiwara/missions/<mission>/[member].json — scan the
8
45
  // latest state file for its actor
9
- const stateDir = join(projectDir, '.mugiwara', 'state');
10
- if (!existsSync(stateDir)) return null;
11
- const missions = readdirSync(stateDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
46
+ const missionsDir = join(projectDir, '.mugiwara', 'missions');
47
+ if (!existsSync(missionsDir)) return null;
48
+ const missions = readdirSync(missionsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
12
49
  let latest: { actor: string; updated: number } | null = null;
13
50
  for (const mission of missions) {
14
- const d = join(stateDir, mission);
15
- for (const f of readdirSync(d).filter(f => f.endsWith('.json'))) {
51
+ const d = join(missionsDir, mission);
52
+ for (const f of readdirSync(d).filter(isStateFile)) {
16
53
  try {
17
54
  const s = JSON.parse(readFileSync(join(d, f), 'utf8'));
18
55
  const t = Date.parse(s.updated_at || '') || 0;
@@ -37,12 +74,9 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
37
74
 
38
75
  const removed: string[] = [];
39
76
  const kept: string[] = [];
40
- for (const dir of ['spec', 'plans', 'results', 'review', 'issues', 'reports']) {
41
- const p = join(root, dir);
42
- if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
43
- }
44
- // mission state + continue folders — state/<mission>/, continue/<mission>/
45
- for (const dir of ['state', 'continue']) {
77
+ // current layout: everything lives in missions/. Legacy pre-0.7 dirs are
78
+ // removed too so an upgraded project ends up with one layout, not two.
79
+ for (const dir of ['missions', 'spec', 'plans', 'results', 'review', 'issues', 'reports', 'state', 'continue']) {
46
80
  const p = join(root, dir);
47
81
  if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
48
82
  }
@@ -53,11 +87,14 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
53
87
  if (existsSync(p)) { rmSync(p); removed.push(f); }
54
88
  }
55
89
  }
90
+ // lessons.md moved to the .mugiwara root; legacy home was logs/
56
91
  if (!keepLogs) {
57
- const p = join(root, 'logs');
58
- if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push('logs'); }
59
- } else if (existsSync(join(root, 'logs'))) {
60
- kept.push('logs');
92
+ for (const p of [join(root, 'lessons.md'), join(root, 'logs')]) {
93
+ if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(p.startsWith(join(root, 'logs')) ? 'logs' : 'lessons.md'); }
94
+ }
95
+ } else {
96
+ if (existsSync(join(root, 'lessons.md'))) kept.push('lessons.md');
97
+ else if (existsSync(join(root, join('logs', 'lessons.md')))) kept.push(join('logs', 'lessons.md'));
61
98
  }
62
99
  for (const f of ['config', 'manifest.json', 'backup']) {
63
100
  if (existsSync(join(root, f))) kept.push(f);
@@ -68,108 +105,204 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
68
105
  export function archiveMission(projectDir: string, mission: string, opts: { dryRun?: boolean } = {}): { report: string | null; removed: string[]; kept: string[]; index?: string } {
69
106
  const { dryRun = false } = opts;
70
107
  const root = join(projectDir, '.mugiwara');
71
- // mission allowlist — same as savepoint.sh / mission-report.sh. Dot-only
108
+ // mission allowlist — same as savepoint.sh. Dot-only
72
109
  // names (".", "..") would resolve upward through join(...,"..") and let
73
110
  // rmSync reach state.json/config outside the mission dir.
74
111
  if (!mission || /[^a-zA-Z0-9._-]/.test(mission) || /^\.+$/.test(mission)) throw new Error(`invalid mission name "${mission}" (allowlist: [a-zA-Z0-9._-], not a dot-path)`);
75
112
  const removed: string[] = [];
76
113
  const kept: string[] = [];
77
114
 
78
- // A file belongs to this mission when stripping the optional YYYY-MM-DD-
79
- // prefix leaves `<mission>.md` or `<mission>-<suffix>.md`. Covers both the
80
- // bare names and the date-prefixed names the prose writes (audit-trail.md).
81
- const belongs = (f: string): boolean => {
82
- const base = f.replace(/^\d{4}-\d{2}-\d{2}-/, '');
83
- return base === `${mission}.md` || base.startsWith(`${mission}-`);
84
- };
85
-
86
- // locate the report (the archive target that must survive). Reports are
87
- // date-prefixed (`reports/YYYY-MM-DD-<mission>.md`); compare the stripped
88
- // mission name so `bar-foo.md` is not mistaken for mission `foo`.
89
- let report: string | null = null;
90
- const reportsDir = join(root, 'reports');
91
- if (existsSync(reportsDir)) {
92
- const f = readdirSync(reportsDir).find(n => {
93
- const m = n.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
94
- return !!m && m[2] === mission;
95
- });
96
- if (f) report = join('reports', f);
97
- }
115
+ const dir = join(root, 'missions', mission);
116
+ if (!existsSync(dir)) return { report: null, removed, kept };
98
117
 
99
- // step results 01..05 + todos.md are evidence kept; archive removes
100
- // step results 01..05 + todos.md are evidence kept; archive removes
101
- // only spec/review/issues/logs + continue/<mission>/ + state/<mission>/
102
- const resultsDir = join(root, 'results', mission);
103
- if (existsSync(resultsDir)) {
104
- for (const f of readdirSync(resultsDir)) {
105
- kept.push(join('results', mission, f));
118
+ // Closure integrity gate: the trail validates itself before it
119
+ // folds. Dangling links, secrets, or missing evidence fail the archive.
120
+ if (!dryRun) {
121
+ const issues = checkTrail(dir, projectDir);
122
+ if (issues.length) {
123
+ throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(issues)}`);
106
124
  }
107
125
  }
108
126
 
109
- // spec, review, issues, per-mission decision log — bare + date-prefixed
110
- const specDir = join(root, 'spec');
111
- if (existsSync(specDir)) {
112
- for (const f of readdirSync(specDir)) {
113
- if (!belongs(f)) continue;
114
- const p = join(specDir, f);
115
- if (!dryRun) rmSync(p);
116
- removed.push(join('spec', f));
117
- }
118
- }
127
+ const files = readdirSync(dir);
128
+ const state = primaryState(dir, files);
129
+ // unique models across every stage's state file (A4) — collected HERE,
130
+ // before the fold deletes the .json files; team members and solo
131
+ // re-savepoints each record the model that ran their stage.
132
+ const stageModels = [...new Set(files.filter(isStateFile).map((f) => {
133
+ try {
134
+ const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
135
+ return typeof s.model === 'string' ? s.model : '';
136
+ } catch { return ''; }
137
+ }).filter(Boolean))];
119
138
 
120
- for (const dir of ['review', 'issues']) {
121
- const d = join(root, dir);
122
- if (!existsSync(d)) continue;
123
- for (const f of readdirSync(d)) {
124
- if (!belongs(f)) continue;
125
- const p = join(d, f);
126
- if (!dryRun) rmSync(p, { force: true });
127
- removed.push(join(dir, f));
139
+ // Cost surface always readable section for the report (T8)
140
+ let costSection = '';
141
+ if (!dryRun && state) {
142
+ const chars = measureContextChars(dir);
143
+ const budget = readBudgetConfig(projectDir);
144
+ const footprintLine = formatFootprint(chars, budget);
145
+ if (budget && chars > budget) {
146
+ throw new Error(`closure context budget failed — ${footprintLine}. Trim the trail or raise context_budget_chars.`);
128
147
  }
129
- }
130
-
131
- const logsDir = join(root, 'logs');
132
- if (existsSync(logsDir)) {
133
- for (const f of readdirSync(logsDir)) {
134
- if (!belongs(f)) continue;
135
- const p = join(logsDir, f);
136
- if (!dryRun) rmSync(p);
137
- removed.push(join('logs', f));
148
+ const est = typeof state.tokens_est === 'number' ? state.tokens_est : 0;
149
+ const src = typeof state.tokens_source === 'string' ? state.tokens_source : 'computed';
150
+ const lane = typeof state.lane === 'string' ? state.lane : 'unknown';
151
+ // lane budgets for readable delta (from lane-base.sh, mirrored here for display only)
152
+ const laneBudget = lane === 'lean' ? 12000 : lane === 'standard' ? 25000 : lane === 'full' ? 50000 : lane === 'spike' ? 3000 : 0;
153
+ const effBudget = budget || laneBudget;
154
+ const pct = effBudget ? Math.round((est / effBudget) * 100) : 0;
155
+ const delta = effBudget ? (est <= effBudget ? `${(effBudget - est).toLocaleString()} under` : `${(est - effBudget).toLocaleString()} over`) : 'no budget configured';
156
+ const srcLabel = src === 'reported' ? 'provider-reported' : 'estimator';
157
+ // provider-reported rollup when any stage reported
158
+ let reportedTotal = 0;
159
+ let hasReported = false;
160
+ for (const f of files.filter(isStateFile)) {
161
+ try {
162
+ const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
163
+ if (s.tokens_source === 'reported' && typeof s.tokens_est === 'number') {
164
+ reportedTotal += s.tokens_est;
165
+ hasReported = true;
166
+ }
167
+ } catch { /* corrupt — skip */ }
168
+ }
169
+ if (!hasReported && src === 'reported' && est > 0) {
170
+ reportedTotal = est;
171
+ hasReported = true;
138
172
  }
173
+ costSection = [
174
+ '## Cost',
175
+ '',
176
+ '| Metric | Value |',
177
+ '|--------|-------|',
178
+ `| **Tokens used** | ${est.toLocaleString()} (${srcLabel}) |`,
179
+ `| **Lane** | ${lane} (budget ${effBudget ? effBudget.toLocaleString() : '—'} · warn ${effBudget ? Math.round(effBudget * 1.5).toLocaleString() : '—'} · stop ${effBudget ? (effBudget * 3).toLocaleString() : '—'}) |`,
180
+ `| **Budget status** | ${effBudget ? `${pct}% of budget · ${delta} · ${est >= effBudget * 3 ? 'STOP' : est >= effBudget * 1.5 ? 'WARN' : 'OK'}` : 'no lane budget'} |`,
181
+ `| **Context footprint** | ${chars.toLocaleString()} chars${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
182
+ ].join('\n');
183
+ if (hasReported) {
184
+ costSection += `\n| **Provider total** | ${reportedTotal.toLocaleString()} (provider-reported — sum of reported stages) |`;
185
+ }
186
+ costSection += '\n';
139
187
  }
140
188
 
141
- // continue/<mission>/ is a session handoff remove this mission's folder
142
- const contDir = join(root, 'continue', mission);
143
- if (existsSync(contDir)) {
144
- if (!dryRun) rmSync(contDir, { recursive: true, force: true });
145
- removed.push(join('continue', mission));
189
+ // Fold order: narrative artifacts first, wave evidence last (chronological).
190
+ const FOLD_TOP = ['decisions.md', 'blockers.md', 'review.md', 'security.md', 'spec.md'];
191
+ const fold: string[] = [];
192
+ for (const f of FOLD_TOP) {
193
+ if (files.includes(f)) fold.push(f);
146
194
  }
147
- // state/<mission>/ remove this mission's computed state (archived)
148
- const stateDir = join(root, 'state', mission);
149
- if (existsSync(stateDir)) {
150
- if (!dryRun) rmSync(stateDir, { recursive: true, force: true });
151
- removed.push(join('state', mission));
195
+ // Flow artifacts: flows/ is the current layout; a legacy mission that still
196
+ // keeps waves/ folds from there so an upgrade never strands a trail.
197
+ const flowsDir = join(dir, 'flows');
198
+ const legacyWavesDir = join(dir, 'waves');
199
+ const artDir = existsSync(flowsDir) ? flowsDir : existsSync(legacyWavesDir) ? legacyWavesDir : flowsDir;
200
+ const artRel = artDir === legacyWavesDir ? 'waves' : 'flows';
201
+ if (existsSync(artDir)) {
202
+ for (const f of readdirSync(artDir).sort()) fold.push(join(artRel, f));
152
203
  }
153
204
 
154
- // kept: report + the audit-trail survivors
155
- if (report) kept.push(report);
156
- for (const k of ['plans', 'config', join('logs', 'lessons.md')]) {
157
- if (existsSync(join(root, k))) kept.push(k);
205
+ // The report survives: an existing report.md wins; otherwise the closure
206
+ // wave seeds it; otherwise it starts empty.
207
+ let report = '';
208
+ const reportPath = join(dir, 'report.md');
209
+ if (files.includes('report.md')) report = readFileSync(reportPath, 'utf8');
210
+ else if (existsSync(join(artDir, '06-closure.md'))) report = readFileSync(join(artDir, '06-closure.md'), 'utf8');
211
+
212
+ if (!dryRun) {
213
+ mkdirSync(dir, { recursive: true });
214
+ if (fold.length) {
215
+ const sections = fold.map((f) => {
216
+ const body = readFileSync(join(dir, f), 'utf8').trim();
217
+ const name = f.includes('/') ? (f.split('/').pop() ?? f) : f;
218
+ return `\n\n## Archived: ${name}\n\n${body}`;
219
+ }).join('');
220
+ // atomic: write the folded report to a temp file, then rename over the
221
+ // target. A crash mid-write must never leave a truncated report — the
222
+ // fold deletes the wave files right after, so a partial write loses them.
223
+ const tmp = `${reportPath}.tmp`;
224
+ const routingSection = state
225
+ ? renderRouting(rankFiles(changedFiles(projectDir, state), {
226
+ mission,
227
+ evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
228
+ sensitive_paths: Array.isArray(state.sensitive_paths) ? (state.sensitive_paths as string[]) : [],
229
+ } as never), mission)
230
+ : '';
231
+ writeFileSync(tmp, report.trimEnd() + sections + (routingSection || '') + (costSection ? `\n${costSection}\n` : '') + '\n');
232
+ renameSync(tmp, reportPath);
233
+ }
234
+ for (const f of fold) {
235
+ rmSync(join(dir, f), { force: true, recursive: true });
236
+ removed.push(join('missions', mission, f));
237
+ }
238
+ // session state dies with the mission
239
+ for (const f of files.filter((f) => f.endsWith('.json'))) rmSync(join(dir, f), { force: true });
240
+ // flows/ may now be empty — remove the folder
241
+ if (existsSync(artDir) && readdirSync(artDir).length === 0) rmSync(artDir, { recursive: true, force: true });
242
+ // report.md must exist after archive — the closed marker `mugiwara clean`
243
+ // filters on. A stale in-flight mission folds nothing, so seed a stub.
244
+ if (!existsSync(reportPath)) {
245
+ writeFileSync(reportPath, `# Mission: ${mission}\n\nArchived before closure — no wave artifacts were present.\n`);
246
+ }
247
+ // Closure artifacts: executable rollback map and the
248
+ // two-layer provenance record. Best-effort — absent git/base degrades.
249
+ if (state && typeof state.branch === 'string') {
250
+ const rb = generateRollback(projectDir, dir, {
251
+ mission,
252
+ branch: state.branch,
253
+ baseSha: typeof state.base_sha === 'string' ? state.base_sha : 'unknown',
254
+ });
255
+ if (rb) kept.push(join('missions', mission, rb.file));
256
+ try {
257
+ writeProvenance(projectDir, dir, {
258
+ mission,
259
+ actor: typeof state.actor === 'string' ? state.actor : '',
260
+ lane: typeof state.lane === 'string' ? state.lane : '',
261
+ mode: typeof state.mode === 'string' ? state.mode : '',
262
+ branch: state.branch,
263
+ tasks_done: Number(state.tasks_done) || 0,
264
+ tasks_total: Number(state.tasks_total) || 0,
265
+ evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
266
+ models: stageModels,
267
+ });
268
+ kept.push(join('missions', mission, 'provenance.md'));
269
+ } catch { /* provenance is additive; archive proceeds */ }
270
+ }
271
+ } else {
272
+ for (const f of fold) removed.push(join('missions', mission, f));
158
273
  }
274
+ removed.push(join('missions', mission, '<session state>'));
275
+ if (files.includes('plan.md')) kept.push(join('missions', mission, 'plan.md'));
276
+ if (files.includes('handoff.md')) kept.push(join('missions', mission, 'handoff.md'));
277
+ kept.push(join('missions', mission, 'report.md'));
159
278
 
160
- // summary index: append one line per archived mission (retention aid),
161
- // idempotently never duplicate a line for an already-indexed mission.
279
+ // summary index: append one line per archived mission (retention aid).
280
+ // Atomic-append contract (finding A3): the line is written as ONE write()
281
+ // on an O_APPEND fd — POSIX positions O_APPEND writes atomically, so
282
+ // concurrent archivers never overwrite each other's bytes, and a small
283
+ // (<4k) single write does not interleave in practice. The pre-read
284
+ // idempotency check below still has a benign race window under true
285
+ // concurrency: two racers may both see the line missing and both append.
286
+ // That is a duplicate line, not a lost one — duplicates are the preferred
287
+ // failure mode; any future consumer of index.md must dedupe lines on read.
288
+ // Header creation stays racy-but-safe: two first-appends may each prepend
289
+ // "# Mission index\n\n", and header-only-plus-lines remains valid markdown
290
+ // either way.
162
291
  let index: string | undefined;
163
- const indexFile = join(root, 'reports', 'index.md');
164
- const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}${report ? ` → ${report}` : ''}\n`;
292
+ const indexFile = join(root, 'index.md');
293
+ const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}\n`;
165
294
  if (!dryRun) {
166
- mkdirSync(join(root, 'reports'), { recursive: true });
167
295
  const existing = existsSync(indexFile) ? readFileSync(indexFile, 'utf8') : '';
168
296
  if (!existing.split(/\r?\n/).some(l => l.startsWith(`- ${mission} —`))) {
169
297
  const header = existing ? '' : '# Mission index\n\n';
170
- appendFileSync(indexFile, header + line);
298
+ const fd = openSync(indexFile, 'a');
299
+ try {
300
+ writeSync(fd, header + line);
301
+ } finally {
302
+ closeSync(fd);
303
+ }
171
304
  }
172
- index = join('reports', 'index.md');
305
+ index = 'index.md';
173
306
  }
174
- return { report, removed, kept, index };
307
+ return { report: join('missions', mission, 'report.md'), removed, kept, index };
175
308
  }
package/src/policy.ts ADDED
@@ -0,0 +1,156 @@
1
+ // src/policy.ts
2
+ // mugiwara.policy.yml — org rules that override crew judgement.
3
+ //
4
+ // One optional file at the repo root; absent means today's
5
+ // behavior everywhere. Policy only ever pushes UP (more scrutiny), never down:
6
+ // lanes forced to full, coverage thresholds raised, paths flagged for human
7
+ // approval.
8
+ //
9
+ // The parser is a deliberate YAML subset — nested maps, string arrays,
10
+ // scalars — enough for the documented schema and nothing more. No dependency:
11
+ // a governance file that needs an npm install to read would not be read.
12
+
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ export type MugiwaraPolicy = {
17
+ lanes?: { force_full?: string[] };
18
+ gates?: {
19
+ coverage?: { new?: number; modified?: number };
20
+ require_human_approval?: string[];
21
+ };
22
+ evidence?: { required?: string[] };
23
+ };
24
+
25
+ const POLICY_FILES = ['mugiwara.policy.yml', 'mugiwara.policy.yaml'];
26
+ const KNOWN_ROOTS = ['lanes', 'gates', 'evidence'];
27
+
28
+ /**
29
+ * Minimal YAML subset: maps, `- item` string lists, scalars.
30
+ *
31
+ * A `key:` with no value stays pending on its scope until a deeper line turns
32
+ * it into a map (first mapping child) or a list (first `- ` child). Scopes are
33
+ * keyed by the indent of their own key line; any line at indent <= a scope's
34
+ * key indent leaves that scope. Enough for the documented schema, nothing more.
35
+ */
36
+ export function parsePolicyYaml(text: string): Record<string, unknown> {
37
+ const root: Record<string, unknown> = {};
38
+ type Scope = { keyIndent: number; obj: Record<string, unknown>; pending?: { key: string; indent: number } };
39
+ const stack: Scope[] = [{ keyIndent: -1, obj: root }];
40
+ const lines = text.split(/\r?\n/);
41
+
42
+ const process = (i: number): void => {
43
+ if (i >= lines.length) return;
44
+ const noComment = lines[i].replace(/(^|\s)#.*$/, '');
45
+ if (!noComment.trim()) return process(i + 1);
46
+ const indent = noComment.length - noComment.trimStart().length;
47
+ const line = noComment.trim();
48
+
49
+ while (stack.length > 1 && indent <= stack[stack.length - 1].keyIndent) stack.pop();
50
+ const top = stack[stack.length - 1];
51
+
52
+ // A pending `parent:` whose first child this line is → become a map scope,
53
+ // then dispatch this same line into the new scope. A `- ` child instead
54
+ // turns the pending key into a LIST below — it must not materialize a map.
55
+ if (top.pending && !line.startsWith('- ') && indent > top.pending.indent && !Array.isArray(top.obj[top.pending.key])) {
56
+ const { key, indent: pIndent } = top.pending;
57
+ delete top.pending;
58
+ const created: Record<string, unknown> = {};
59
+ top.obj[key] = created;
60
+ stack.push({ keyIndent: pIndent, obj: created });
61
+ return process(i);
62
+ }
63
+
64
+ if (line.startsWith('- ')) {
65
+ if (top.pending) {
66
+ const existing = top.obj[top.pending.key];
67
+ const arr: unknown[] = Array.isArray(existing) ? existing : [];
68
+ arr.push(scalar(line.slice(2)));
69
+ top.obj[top.pending.key] = arr;
70
+ }
71
+ // stray items outside a pending key are ignored in this subset
72
+ return process(i + 1);
73
+ }
74
+
75
+ const colon = line.indexOf(':');
76
+ if (colon === -1) return process(i + 1); // not a mapping line in this subset
77
+ const key = line.slice(0, colon).trim();
78
+ const rest = line.slice(colon + 1).trim();
79
+ if (rest === '') {
80
+ top.pending = { key, indent };
81
+ } else {
82
+ top.obj[key] = scalar(rest);
83
+ delete top.pending;
84
+ }
85
+ process(i + 1);
86
+ };
87
+
88
+ process(0);
89
+ return root;
90
+ }
91
+
92
+ function scalar(v: string): unknown {
93
+ const t = v.trim().replace(/^["']|["']$/g, '');
94
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
95
+ if (t === 'true') return true;
96
+ if (t === 'false') return false;
97
+ return t;
98
+ }
99
+
100
+ export function loadPolicy(projectDir: string): MugiwaraPolicy | null {
101
+ for (const name of POLICY_FILES) {
102
+ const file = join(projectDir, name);
103
+ if (!existsSync(file)) continue;
104
+ return normalize(parsePolicyYaml(readFileSync(file, 'utf8')));
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function normalize(raw: Record<string, unknown>): MugiwaraPolicy {
110
+ // A typo'd root key would otherwise silently disable the rule it carried.
111
+ for (const k of Object.keys(raw)) {
112
+ if (!KNOWN_ROOTS.includes(k)) throw new Error(`unknown policy key "${k}" (known: ${KNOWN_ROOTS.join(', ')})`);
113
+ }
114
+ const out: MugiwaraPolicy = {};
115
+ const lanes = raw.lanes as Record<string, unknown> | undefined;
116
+ if (lanes && Array.isArray(lanes.force_full)) out.lanes = { force_full: strings(lanes.force_full) };
117
+ const gates = raw.gates as Record<string, unknown> | undefined;
118
+ if (gates) {
119
+ out.gates = {};
120
+ const cov = gates.coverage as Record<string, unknown> | undefined;
121
+ if (cov) {
122
+ out.gates.coverage = {};
123
+ if (typeof cov.new === 'number') out.gates.coverage.new = cov.new;
124
+ if (typeof cov.modified === 'number') out.gates.coverage.modified = cov.modified;
125
+ }
126
+ if (Array.isArray(gates.require_human_approval))
127
+ out.gates.require_human_approval = strings(gates.require_human_approval);
128
+ }
129
+ const evidence = raw.evidence as Record<string, unknown> | undefined;
130
+ if (evidence && Array.isArray(evidence.required)) out.evidence = { required: strings(evidence.required) };
131
+ return out;
132
+ }
133
+
134
+ function strings(a: unknown[]): string[] {
135
+ return a.filter((x): x is string => typeof x === 'string' && x.length > 0);
136
+ }
137
+
138
+ /** Glob → RegExp: `**` crosses separators, `*` stays within one. */
139
+ export function globToRegExp(glob: string): RegExp {
140
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
141
+ const src = esc.replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
142
+ return new RegExp(`^${src}$`);
143
+ }
144
+
145
+ /** Any changed path matching any policy glob? Returns the matching globs. */
146
+ export function matchedGlobs(paths: string[], globs: string[]): string[] {
147
+ return globs.filter((g) => paths.some((p) => globToRegExp(g).test(p)));
148
+ }
149
+
150
+ /**
151
+ * Coverage thresholds: the max of the .mugiwara/config value (or its
152
+ * fallback) and any policy raise. Policy pushes up, never down.
153
+ */
154
+ export function effectiveThreshold(configured: number, policyValue: number | undefined): number {
155
+ return Math.max(configured, policyValue ?? 0);
156
+ }
@@ -0,0 +1,116 @@
1
+ // src/provenance.ts
2
+ // Provenance ledger: line-of-sight from a commit to the
3
+ // mission that produced it — which agent persona, which lane, verified by
4
+ // what evidence. Distributed in two layers:
5
+ // 1. a git note on refs/notes/mugiwara attached to the branch head
6
+ // (local precision archive; survives rebase via notes.rewriteRef)
7
+ // 2. provenance.md in the mission dir, ready to paste as a PR comment
8
+ // (the layer every hosting UI can show)
9
+ import { execFileSync } from 'node:child_process';
10
+ import { writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const NOTES_REF = 'refs/notes/mugiwara';
14
+
15
+ /** Structural subset any mission state satisfies — keeps this import-free. */
16
+ export type NoteSource = {
17
+ mission: string;
18
+ actor: string;
19
+ lane: string;
20
+ mode: string;
21
+ branch: string;
22
+ tasks_done: number;
23
+ tasks_total: number;
24
+ evidence: string[];
25
+ models?: string[];
26
+ };
27
+
28
+ function git(cwd: string, args: string[]): string {
29
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
30
+ }
31
+
32
+ export function modelLabel(): string {
33
+ return process.env.MUGIWARA_MODEL?.trim()
34
+ || process.env.ANTHROPIC_MODEL?.trim()
35
+ || 'model-unrecorded (set MUGIWARA_MODEL to attribute)';
36
+ }
37
+
38
+ /** The provenance block — identical wording in the note and the md file. */
39
+ export function buildNote(s: {
40
+ mission: string; actor: string; lane: string; mode: string; branch: string;
41
+ tasks_done: number; tasks_total: number; evidence: string[]; model?: string; models?: string[];
42
+ }): string {
43
+ const gates = s.evidence.length ? s.evidence.join(' · ') : 'no evidence recorded';
44
+ // Per-stage attribution (A4): when flow history recorded models, render the
45
+ // unique set — a mid-mission switch must not collapse to the last env value.
46
+ // With nothing recorded, keep the env-fallback label wording.
47
+ const uniqModels = [...new Set((s.models ?? []).filter(Boolean))];
48
+ const modelPart = uniqModels.length ? `model(s): ${uniqModels.join(', ')}` : (s.model ?? modelLabel());
49
+ return [
50
+ `mission: ${s.mission}`,
51
+ `agent: ${s.actor || 'unknown'} · ${modelPart} · lane ${s.lane} · mode ${s.mode}`,
52
+ `tasks: ${s.tasks_done}/${s.tasks_total}`,
53
+ `gates/evidence: ${gates}`,
54
+ `branch: ${s.branch}`,
55
+ 'human review: pending (PR review is the terminal gate)',
56
+ ].join('\n');
57
+ }
58
+
59
+ /** PR-paste-ready markdown wrapper around the same facts. */
60
+ export function renderProvenanceMd(note: string, sha: string | null): string {
61
+ const lines = [
62
+ '# Provenance',
63
+ '',
64
+ '<!-- paste below into the PR description or a PR comment -->',
65
+ '',
66
+ '```',
67
+ note,
68
+ '```',
69
+ '',
70
+ sha ? `Commit: ${sha}` : 'Commit: not recorded (no git head resolved at closure)',
71
+ '',
72
+ 'Query locally after pushing notes:',
73
+ '`git fetch origin refs/notes/mugiwara:refs/notes/mugiwara` then `mugiwara blame <path>`.',
74
+ ];
75
+ return lines.join('\n') + '\n';
76
+ }
77
+
78
+ export function attachGitNote(projectDir: string, branch: string, note: string): { sha: string } | null {
79
+ try {
80
+ let sha: string;
81
+ try {
82
+ sha = git(projectDir, ['rev-parse', '--verify', branch]);
83
+ } catch {
84
+ sha = git(projectDir, ['rev-parse', 'HEAD']);
85
+ }
86
+ git(projectDir, ['notes', '--ref=mugiwara', 'add', '-f', '-m', note, sha]);
87
+ return { sha };
88
+ } catch {
89
+ // not a repo, detached oddities, or notes disabled — degrade honestly
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** `mugiwara blame <path>` — last commit that touched the path + its note. */
95
+ export function blamePath(projectDir: string, path: string): string {
96
+ let sha: string;
97
+ try {
98
+ sha = git(projectDir, ['log', '-1', '--format=%H', '--', path]);
99
+ } catch {
100
+ return `blame: not a git repository (${projectDir})`;
101
+ }
102
+ if (!sha) return `blame: no commit touches "${path}"`;
103
+ try {
104
+ const note = git(projectDir, ['notes', '--ref=mugiwara', 'show', sha]);
105
+ return `${path} @ ${sha.slice(0, 7)}\n${note}`;
106
+ } catch {
107
+ return `${path} @ ${sha.slice(0, 7)}\n(no mugiwara provenance note on this commit)`;
108
+ }
109
+ }
110
+
111
+ /** Closure hook: write provenance.md + attach the git note. */
112
+ export function writeProvenance(projectDir: string, missionDir: string, state: NoteSource): void {
113
+ const note = buildNote(state);
114
+ const attached = attachGitNote(projectDir, state.branch, note);
115
+ writeFileSync(join(missionDir, 'provenance.md'), renderProvenanceMd(note, attached ? attached.sha : null));
116
+ }