@ionivetech/mugiwara 0.6.6 → 0.8.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 (138) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -7
  3. package/.codex-plugin/plugin.json +2 -7
  4. package/.cursor-plugin/plugin.json +2 -7
  5. package/.kimi-plugin/plugin.json +2 -7
  6. package/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/.opencode/plugins/mugiwara.mjs +3 -18
  8. package/AGENTS.md +5 -4
  9. package/GEMINI.md +3 -3
  10. package/README.md +203 -331
  11. package/content/agents/brook-healing.md +8 -4
  12. package/content/agents/chopper-checkpoint.md +7 -3
  13. package/content/agents/eval-runner.md +4 -4
  14. package/content/agents/franky-gates.md +4 -4
  15. package/content/agents/jinbe-security.md +6 -7
  16. package/content/agents/luffy-orchestrator.md +11 -13
  17. package/content/agents/memory-keeper.md +4 -4
  18. package/content/agents/nami-planner.md +4 -4
  19. package/content/agents/resume-coordinator.md +7 -7
  20. package/content/agents/robin-reviewer.md +7 -3
  21. package/content/agents/sanji-quality.md +2 -2
  22. package/content/agents/skeptic-verifier.md +2 -2
  23. package/content/agents/usopp-brainstorm.md +3 -3
  24. package/content/agents/zoro-execution.md +6 -6
  25. package/content/skills/mugiwara-backend/SKILL.md +54 -43
  26. package/content/skills/mugiwara-backend/references/database.md +61 -0
  27. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  28. package/content/skills/mugiwara-checkpoint/SKILL.md +23 -11
  29. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
  30. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  31. package/content/skills/mugiwara-execution/SKILL.md +37 -37
  32. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  33. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  34. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  35. package/content/skills/mugiwara-frontend/SKILL.md +44 -43
  36. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  37. package/content/skills/mugiwara-gates/SKILL.md +23 -14
  38. package/content/skills/mugiwara-healing/SKILL.md +26 -25
  39. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  40. package/content/skills/mugiwara-orchestration/SKILL.md +11 -11
  41. package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
  42. package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
  43. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  44. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
  45. package/content/skills/mugiwara-planning/SKILL.md +26 -29
  46. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  47. package/content/skills/mugiwara-planning/references/plan-template.md +24 -2
  48. package/content/skills/mugiwara-quality/SKILL.md +21 -12
  49. package/content/skills/mugiwara-resume/SKILL.md +8 -3
  50. package/content/skills/mugiwara-review/SKILL.md +19 -13
  51. package/content/skills/mugiwara-security/SKILL.md +47 -36
  52. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  53. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  54. package/content/skills/mugiwara-workflow/SKILL.md +11 -17
  55. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
  56. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
  57. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
  58. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  59. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
  60. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +42 -38
  62. package/dist/mugiwara.js +1642 -528
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +18 -10
  65. package/hooks/auto-savepoint.ts +23 -23
  66. package/hooks/engagement-marker.js +1 -1
  67. package/hooks/engagement-marker.ts +1 -1
  68. package/hooks/pipeline-guard.js +17 -13
  69. package/hooks/pipeline-guard.ts +24 -20
  70. package/hooks/session-start.js +13 -10
  71. package/hooks/session-start.ts +24 -19
  72. package/package.json +2 -2
  73. package/plugin.json +1 -1
  74. package/references/complexity.md +27 -4
  75. package/references/definition-of-done.md +1 -1
  76. package/references/multi-actor.md +20 -2
  77. package/references/posture-routing.md +31 -0
  78. package/references/prose-style.md +54 -0
  79. package/scripts/benchmark-governor.ts +516 -0
  80. package/scripts/benchmark-thresholds.json +47 -0
  81. package/scripts/check-doc-links.ts +46 -0
  82. package/scripts/conformance.ts +6 -16
  83. package/scripts/coverage-gate.ts +20 -6
  84. package/scripts/gate-selftest.ts +138 -7
  85. package/scripts/lane.sh +54 -8
  86. package/scripts/lib/lane-base.sh +4 -4
  87. package/scripts/lib/patterns.sh +5 -0
  88. package/scripts/policy-force.ts +22 -0
  89. package/scripts/retrieval-eval.ts +10 -4
  90. package/scripts/savepoint.sh +155 -58
  91. package/scripts/validate-content.ts +43 -11
  92. package/scripts/verify-install.ts +9 -7
  93. package/src/adaptive-budget.ts +178 -0
  94. package/src/args.ts +4 -3
  95. package/src/budget.ts +47 -0
  96. package/src/check-artifacts.ts +45 -0
  97. package/src/cli.ts +257 -108
  98. package/src/cognition.ts +234 -0
  99. package/src/config.ts +107 -0
  100. package/src/context.ts +72 -0
  101. package/src/continue.ts +29 -10
  102. package/src/cost.ts +186 -0
  103. package/src/evidence.ts +160 -0
  104. package/src/installer.ts +25 -34
  105. package/src/integrity.ts +158 -0
  106. package/src/investigation.ts +72 -0
  107. package/src/mission.ts +341 -94
  108. package/src/policy.ts +156 -0
  109. package/src/posture.ts +86 -0
  110. package/src/provenance.ts +116 -0
  111. package/src/reporting.ts +225 -0
  112. package/src/rollback.ts +95 -0
  113. package/src/routing.ts +69 -0
  114. package/src/run.ts +2 -2
  115. package/src/scope.ts +321 -0
  116. package/src/sign.ts +235 -0
  117. package/src/slop.ts +306 -0
  118. package/src/targets/claude.ts +2 -2
  119. package/src/work.ts +273 -0
  120. package/.opencode/commands/mugiwara-execute.md +0 -13
  121. package/.opencode/commands/mugiwara-heal.md +0 -13
  122. package/.opencode/commands/mugiwara-onboard.md +0 -14
  123. package/.opencode/commands/mugiwara-plan.md +0 -17
  124. package/.opencode/commands/mugiwara-ship.md +0 -13
  125. package/.opencode/commands/using-mugiwara.md +0 -20
  126. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  127. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  128. package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
  129. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  130. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  131. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  132. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  133. package/content/skills/using-mugiwara/SKILL.md +0 -51
  134. package/references/token-budget.md +0 -56
  135. package/scripts/evidence.sh +0 -81
  136. package/scripts/initiative.ts +0 -296
  137. package/scripts/mission-report.sh +0 -293
  138. package/src/onboard.ts +0 -207
package/src/mission.ts CHANGED
@@ -1,18 +1,60 @@
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 { checkMissionArtifacts } from './check-artifacts.ts';
8
+ import { generateRollback } from './rollback.ts';
9
+ import { writeProvenance } from './provenance.ts';
10
+ import { rankFiles, renderRouting } from './routing.ts';
11
+ import { formatFootprint, measureContextChars, readBudgetConfig } from './budget.ts';
12
+ import { budgetForLane, costEnvelope, appendCostEvent } from './cost.ts';
13
+ import { loadRegistry } from './evidence.ts';
14
+ import { computeContextMetrics, contextStatus } from './context.ts';
15
+ import { buildCostLedger, renderAdaptationSection } from './reporting.ts';
16
+
17
+ function isStateFile(f: string): boolean {
18
+ // state.json (solo) or <member>.json (team) — never continue*.json
19
+ const stem = f.replace(/\.json$/, '');
20
+ return f.endsWith('.json') && stem !== 'continue' && !stem.startsWith('continue-');
21
+ }
22
+
23
+ /** Primary state for closure artifacts: solo state.json wins over members. */
24
+ function primaryState(dir: string, files: string[]): Record<string, unknown> | null {
25
+ const name = files.includes('state.json') ? 'state.json' : files.find((f) => f.endsWith('.json') && f !== 'continue.json' && !f.startsWith('continue-'));
26
+ if (!name) return null;
27
+ try {
28
+ return JSON.parse(readFileSync(join(dir, name), 'utf8')) as Record<string, unknown>;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Files the mission changed, base..branch. Empty on any git failure — routing is best-effort. */
35
+ function changedFiles(projectDir: string, state: Record<string, unknown> | null): string[] {
36
+ const base = typeof state?.base_sha === 'string' ? state.base_sha : '';
37
+ const branch = typeof state?.branch === 'string' ? state.branch : '';
38
+ if (!base || base === 'unknown' || !branch) return [];
39
+ try {
40
+ return execFileSync('git', ['diff', '--name-only', base, branch], {
41
+ cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
42
+ }).split(/\r?\n/).filter(Boolean);
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
5
47
 
6
48
  function activeActor(projectDir: string): string | null {
7
- // state now lives at .mugiwara/state/<mission>/[member].json — scan the
49
+ // state now lives at .mugiwara/missions/<mission>/[member].json — scan the
8
50
  // 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);
51
+ const missionsDir = join(projectDir, '.mugiwara', 'missions');
52
+ if (!existsSync(missionsDir)) return null;
53
+ const missions = readdirSync(missionsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
12
54
  let latest: { actor: string; updated: number } | null = null;
13
55
  for (const mission of missions) {
14
- const d = join(stateDir, mission);
15
- for (const f of readdirSync(d).filter(f => f.endsWith('.json'))) {
56
+ const d = join(missionsDir, mission);
57
+ for (const f of readdirSync(d).filter(isStateFile)) {
16
58
  try {
17
59
  const s = JSON.parse(readFileSync(join(d, f), 'utf8'));
18
60
  const t = Date.parse(s.updated_at || '') || 0;
@@ -37,12 +79,9 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
37
79
 
38
80
  const removed: string[] = [];
39
81
  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']) {
82
+ // current layout: everything lives in missions/. Legacy pre-0.7 dirs are
83
+ // removed too so an upgraded project ends up with one layout, not two.
84
+ for (const dir of ['missions', 'spec', 'plans', 'results', 'review', 'issues', 'reports', 'state', 'continue']) {
46
85
  const p = join(root, dir);
47
86
  if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
48
87
  }
@@ -53,11 +92,14 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
53
92
  if (existsSync(p)) { rmSync(p); removed.push(f); }
54
93
  }
55
94
  }
95
+ // lessons.md moved to the .mugiwara root; legacy home was logs/
56
96
  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');
97
+ for (const p of [join(root, 'lessons.md'), join(root, 'logs')]) {
98
+ if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(p.startsWith(join(root, 'logs')) ? 'logs' : 'lessons.md'); }
99
+ }
100
+ } else {
101
+ if (existsSync(join(root, 'lessons.md'))) kept.push('lessons.md');
102
+ else if (existsSync(join(root, join('logs', 'lessons.md')))) kept.push(join('logs', 'lessons.md'));
61
103
  }
62
104
  for (const f of ['config', 'manifest.json', 'backup']) {
63
105
  if (existsSync(join(root, f))) kept.push(f);
@@ -68,108 +110,313 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
68
110
  export function archiveMission(projectDir: string, mission: string, opts: { dryRun?: boolean } = {}): { report: string | null; removed: string[]; kept: string[]; index?: string } {
69
111
  const { dryRun = false } = opts;
70
112
  const root = join(projectDir, '.mugiwara');
71
- // mission allowlist — same as savepoint.sh / mission-report.sh. Dot-only
113
+ // mission allowlist — same as savepoint.sh. Dot-only
72
114
  // names (".", "..") would resolve upward through join(...,"..") and let
73
115
  // rmSync reach state.json/config outside the mission dir.
74
116
  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
117
  const removed: string[] = [];
76
118
  const kept: string[] = [];
77
119
 
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
- }
120
+ const dir = join(root, 'missions', mission);
121
+ if (!existsSync(dir)) return { report: null, removed, kept };
98
122
 
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));
123
+ // Closure integrity gate: the trail validates itself before it
124
+ // folds. Dangling links, secrets, or missing evidence fail the archive.
125
+ if (!dryRun) {
126
+ const issues = checkTrail(dir, projectDir);
127
+ if (issues.length) {
128
+ throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(issues)}`);
106
129
  }
107
- }
108
-
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));
130
+ // Artifact gate (roadmap v0.8 item 4): Lane 2+ missions must carry
131
+ // plan.md + flows/* evidence — a mission without its trail does not fold.
132
+ const artifacts = checkMissionArtifacts(dir);
133
+ if (!artifacts.ok) {
134
+ throw new Error(`archive artifact gate failed — missing: ${artifacts.missing.join(', ')} (lane ${artifacts.lane}). Write the evidence trail before archiving.`);
117
135
  }
118
136
  }
119
137
 
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));
138
+ const files = readdirSync(dir);
139
+ const state = primaryState(dir, files);
140
+ // unique models across every stage's state file (A4) — collected HERE,
141
+ // before the fold deletes the .json files; team members and solo
142
+ // re-savepoints each record the model that ran their stage.
143
+ const stageModels = [...new Set(files.filter(isStateFile).map((f) => {
144
+ try {
145
+ const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
146
+ return typeof s.model === 'string' ? s.model : '';
147
+ } catch { return ''; }
148
+ }).filter(Boolean))];
149
+
150
+ // Cost surface — always readable section for the report (T8)
151
+ let costSection = '';
152
+ if (!dryRun && state) {
153
+ const chars = measureContextChars(dir);
154
+ const budget = readBudgetConfig(projectDir);
155
+ const footprintLine = formatFootprint(chars, budget);
156
+ const est = typeof state.tokens_est === 'number' ? state.tokens_est : 0;
157
+ const src = typeof state.tokens_source === 'string' ? state.tokens_source : 'computed';
158
+ const lane = typeof state.lane === 'string' ? state.lane : 'unknown';
159
+ // C2: `status` gates on the LANE token budget (what savepoint.sh enforces),
160
+ // never on the context char budget. `contextStatus` below is its own gate.
161
+ const laneBudget = budgetForLane(lane);
162
+ // Q1/Q2: the normalized envelope computes planned/used/remaining/pct/status
163
+ // on the lane token budget — ONE computation (Q2), reused at render and for
164
+ // the closure event. `effBudget` stays only for the readable delta display.
165
+ const env = costEnvelope({ lane, budget: laneBudget, tokens_est: est });
166
+ const effBudget = budget || laneBudget; // display-only delta basis — never for status (C2)
167
+ const delta = effBudget ? (est <= effBudget ? `${(effBudget - est).toLocaleString()} under` : `${(est - effBudget).toLocaleString()} over`) : 'no budget configured';
168
+ const srcLabel = src === 'reported' ? 'provider-reported' : 'estimator';
169
+ const statusLabel = env.status.toUpperCase(); // derived from the single computation, not recomputed
170
+ // context status on context_budget_chars — separate gate, never token `est` (C2)
171
+ const ctxStatus = contextStatus(budget, chars);
172
+ // context-efficiency metrics from the evidence registry (reads), else all-zero
173
+ // with a note — a zero row must not be misread as "efficient" (risk row).
174
+ const registry = loadRegistry(dir);
175
+ const reads_total = registry.reduce((s, e) => s + e.reads, 0);
176
+ const repeated_reads = registry.reduce((s, e) => s + Math.max(e.reads - 1, 0), 0);
177
+ // M1: honest char accounting — each registered entry carries the content
178
+ // length it holds (chars). total_chars = chars actually loaded (each entry
179
+ // × its reads); unique_chars = distinct payload bytes. computeContextMetrics
180
+ // derives duplicate_chars = total − unique (bytes re-read) and
181
+ // read_avoidance_chars = same (bytes not reloaded by reuse). When a
182
+ // registry exists but carries no char payloads (legacy/absent field), the
183
+ // char fields render as n/a — never fabricated 0 — so reuse_rate > 0 never
184
+ // sits beside a false "read_avoidance_chars: 0".
185
+ const unique_chars = registry.reduce((s, e) => s + (e.chars ?? 0), 0);
186
+ const total_chars = registry.reduce((s, e) => s + (e.chars ?? 0) * e.reads, 0);
187
+ const charTracked = registry.length > 0 && total_chars > 0;
188
+ const metrics = registry.length
189
+ ? computeContextMetrics({
190
+ files_loaded: registry.length,
191
+ reads_total,
192
+ reads_reused: repeated_reads,
193
+ unique_chars,
194
+ total_chars,
195
+ repeated_reads,
196
+ })
197
+ : { files_loaded: 0, repeated_reads: 0, duplicate_chars: 0, reuse_rate: 0, read_avoidance_chars: 0 };
198
+ const ctxNote = registry.length
199
+ ? (charTracked ? '' : ' (char data not tracked)')
200
+ : ' (no registry — reads not tracked)';
201
+ // provider-reported rollup when any stage reported
202
+ let reportedTotal = 0;
203
+ let hasReported = false;
204
+ for (const f of files.filter(isStateFile)) {
205
+ try {
206
+ const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
207
+ if (s.tokens_source === 'reported' && typeof s.tokens_est === 'number') {
208
+ reportedTotal += s.tokens_est;
209
+ hasReported = true;
210
+ }
211
+ } catch { /* corrupt — skip */ }
128
212
  }
129
- }
213
+ if (!hasReported && src === 'reported' && est > 0) {
214
+ reportedTotal = est;
215
+ hasReported = true;
216
+ }
217
+ costSection = [
218
+ '## Cost',
219
+ '',
220
+ '| Metric | Value |',
221
+ '|--------|-------|',
222
+ `| **Tokens used** | ${est.toLocaleString()} (${srcLabel}) |`,
223
+ `| **Lane** | ${lane} (budget ${effBudget ? effBudget.toLocaleString() : '—'} · warn ${effBudget ? env.warn_at.toLocaleString() : '—'} · stop ${effBudget ? env.stop_at.toLocaleString() : '—'}) |`,
224
+ `| **Budget status** | ${effBudget ? `${env.pct}% of budget · ${delta} · ${statusLabel}` : 'no lane budget'} |`,
225
+ `| **Context footprint** | ${chars.toLocaleString()} chars${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
226
+ `| **Context budget status** | ${ctxStatus.toUpperCase()}${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
227
+ `| **Context efficiency** | files_loaded: ${metrics.files_loaded} · repeated_reads: ${metrics.repeated_reads} · duplicate_chars: ${charTracked ? metrics.duplicate_chars : 'n/a'} · reuse_rate: ${metrics.reuse_rate} · read_avoidance_chars: ${charTracked ? metrics.read_avoidance_chars : 'n/a'}${ctxNote} |`,
228
+ ].join('\n');
229
+ if (hasReported) {
230
+ costSection += `\n| **Provider total** | ${reportedTotal.toLocaleString()} (provider-reported — sum of reported stages) |`;
231
+ }
232
+ // Phase 8 Reporting — ledger/avoided/efficiency/trail rows (§39/§43)
233
+ try {
234
+ const ledger = buildCostLedger({ missionDir: dir, envelope: env });
235
+ costSection += `\n| Budget | ${ledger.envelope.status} ${ledger.envelope.pct}% (${ledger.envelope.used}/${ledger.envelope.planned}) |`;
236
+ costSection += `\n| Context | ${chars.toLocaleString()} chars, reuse ${ledger.efficiency.reuse_rate} |`;
237
+ costSection += `\n| Avoided | ${ledger.avoided.stages_avoided} stages, ${ledger.avoided.contexts_avoided} contexts, ${ledger.avoided.tokens_avoided_est} tokens est |`;
238
+ costSection += `\n| Efficiency | reuse ${ledger.efficiency.reuse_rate}, dup ${ledger.efficiency.duplicate_avoidance_chars} chars, budget ${ledger.efficiency.budget_efficiency_pct}% |`;
239
+ costSection += `\n| Trail | ${ledger.trail.length} decisions |`;
240
+ if (ledger.trail.length) {
241
+ const show = ledger.trail.slice(0, 5);
242
+ for (const t of show) costSection += `\n- ${t.ts} — ${t.actor}: ${t.decision} — reason: ${t.reason}${t.evidence ? ` — evidence: ${t.evidence}` : ''}`;
243
+ if (ledger.trail.length > 5) costSection += `\n… ${ledger.trail.length - 5} more`;
244
+ }
245
+ } catch { /* ledger best-effort — trail parse failure never blocks archive */ }
246
+ costSection += '\n';
247
+ // Phase E — adaptation summary from the posture decision trail
248
+ try {
249
+ costSection += renderAdaptationSection(dir);
250
+ } catch { /* best-effort */ }
130
251
 
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));
252
+ // Cost Governor: record the closure cost event — the mission's final
253
+ // cost snapshot, folded into report.md with the rest of the trail.
254
+ // (Phase 1 native cost governor; pure append, never rewrites state.)
255
+ appendCostEvent(dir, {
256
+ kind: 'closure',
257
+ mission,
258
+ tokens_est: est,
259
+ budget: laneBudget,
260
+ status: env.status, // Q2: the single lane-token-budget computation
261
+ context_chars: chars,
262
+ context_status: ctxStatus,
263
+ context_metrics: metrics,
264
+ });
265
+ // M2: the closure event (with context_status possibly 'over') is recorded
266
+ // BEFORE the hard gate throws — an over-budget closure still leaves a
267
+ // ledger row so the over-budget condition is observable, never erased.
268
+ if (budget && chars > budget) {
269
+ throw new Error(`closure context budget failed — ${footprintLine}. Trim the trail or raise context_budget_chars.`);
138
270
  }
139
271
  }
140
272
 
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));
273
+ // Fold order: narrative artifacts first, wave evidence last (chronological).
274
+ const FOLD_TOP = ['decisions.md', 'blockers.md', 'review.md', 'security.md', 'spec.md'];
275
+ const fold: string[] = [];
276
+ for (const f of FOLD_TOP) {
277
+ if (files.includes(f)) fold.push(f);
146
278
  }
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));
279
+ // Flow artifacts: flows/ is the current layout; a legacy mission that still
280
+ // keeps waves/ folds from there so an upgrade never strands a trail.
281
+ // 07-pr-verdict.md SURVIVES archive as a standalone `pr-verdict.md` at the
282
+ // mission root it is the PR material handed to the user, so it must not
283
+ // fold away into report.md.
284
+ const PR_VERDICT = 'pr-verdict.md';
285
+ const PR_VERDICT_SRC = join('flows', '07-pr-verdict.md');
286
+ const flowsDir = join(dir, 'flows');
287
+ const legacyWavesDir = join(dir, 'waves');
288
+ const artDir = existsSync(flowsDir) ? flowsDir : existsSync(legacyWavesDir) ? legacyWavesDir : flowsDir;
289
+ const artRel = artDir === legacyWavesDir ? 'waves' : 'flows';
290
+ if (existsSync(artDir)) {
291
+ for (const f of readdirSync(artDir).sort()) {
292
+ if (artRel === 'flows' && f === '07-pr-verdict.md') continue; // survives as pr-verdict.md
293
+ fold.push(join(artRel, f));
294
+ }
152
295
  }
296
+ // Cost events ledger — appended by the closure event above (or a prior
297
+ // savepoint in a later phase); folds like any other trail artifact so
298
+ // nothing survives loose after archive.
299
+ if (existsSync(join(dir, 'cost-events.jsonl'))) fold.push('cost-events.jsonl');
300
+ // H1: the context registry is the same class of artifact as cost-events.jsonl
301
+ // (append-only JSONL ledger) — fold it into report.md and remove it so it does
302
+ // NOT survive loose after archive (survival parity; the fold loop below also
303
+ // removes every folded file).
304
+ if (existsSync(join(dir, 'context-registry.jsonl'))) fold.push('context-registry.jsonl');
153
305
 
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);
306
+ // The report survives: an existing report.md wins; otherwise the closure
307
+ // wave seeds it; otherwise it starts empty.
308
+ let report = '';
309
+ const reportPath = join(dir, 'report.md');
310
+ if (files.includes('report.md')) report = readFileSync(reportPath, 'utf8');
311
+ else if (existsSync(join(artDir, '06-closure.md'))) report = readFileSync(join(artDir, '06-closure.md'), 'utf8');
312
+
313
+ if (!dryRun) {
314
+ mkdirSync(dir, { recursive: true });
315
+ // PR verdict survives archive as a standalone file at the mission root —
316
+ // it is the PR material handed to the user and must not fold away.
317
+ const prVerdictSrc = join(dir, PR_VERDICT_SRC);
318
+ if (existsSync(prVerdictSrc)) {
319
+ const prVerdictPath = join(dir, PR_VERDICT);
320
+ writeFileSync(prVerdictPath, readFileSync(prVerdictSrc, 'utf8'));
321
+ kept.push(join('missions', mission, PR_VERDICT));
322
+ }
323
+ if (fold.length) {
324
+ const sections = fold.map((f) => {
325
+ const body = readFileSync(join(dir, f), 'utf8').trim();
326
+ const name = f.includes('/') ? (f.split('/').pop() ?? f) : f;
327
+ return `\n\n## Archived: ${name}\n\n${body}`;
328
+ }).join('');
329
+ // atomic: write the folded report to a temp file, then rename over the
330
+ // target. A crash mid-write must never leave a truncated report — the
331
+ // fold deletes the wave files right after, so a partial write loses them.
332
+ const tmp = `${reportPath}.tmp`;
333
+ const routingSection = state
334
+ ? renderRouting(rankFiles(changedFiles(projectDir, state), {
335
+ mission,
336
+ evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
337
+ sensitive_paths: Array.isArray(state.sensitive_paths) ? (state.sensitive_paths as string[]) : [],
338
+ } as never), mission)
339
+ : '';
340
+ writeFileSync(tmp, report.trimEnd() + sections + (routingSection || '') + (costSection ? `\n${costSection}\n` : '') + '\n');
341
+ renameSync(tmp, reportPath);
342
+ }
343
+ for (const f of fold) {
344
+ rmSync(join(dir, f), { force: true, recursive: true });
345
+ removed.push(join('missions', mission, f));
346
+ }
347
+ // the pr-verdict source was copied to the root — remove the flows/ copy
348
+ if (existsSync(prVerdictSrc)) {
349
+ rmSync(join(dir, PR_VERDICT_SRC), { force: true });
350
+ removed.push(join('missions', mission, PR_VERDICT_SRC));
351
+ }
352
+ // session state dies with the mission
353
+ for (const f of files.filter((f) => f.endsWith('.json'))) rmSync(join(dir, f), { force: true });
354
+ // flows/ may now be empty — remove the folder
355
+ if (existsSync(artDir) && readdirSync(artDir).length === 0) rmSync(artDir, { recursive: true, force: true });
356
+ // report.md must exist after archive — the closed marker `mugiwara clean`
357
+ // filters on. A stale in-flight mission folds nothing, so seed a stub.
358
+ if (!existsSync(reportPath)) {
359
+ writeFileSync(reportPath, `# Mission: ${mission}\n\nArchived before closure — no wave artifacts were present.\n`);
360
+ }
361
+ // Closure artifacts: executable rollback map and the
362
+ // two-layer provenance record. Best-effort — absent git/base degrades.
363
+ if (state && typeof state.branch === 'string') {
364
+ const rb = generateRollback(projectDir, dir, {
365
+ mission,
366
+ branch: state.branch,
367
+ baseSha: typeof state.base_sha === 'string' ? state.base_sha : 'unknown',
368
+ });
369
+ if (rb) kept.push(join('missions', mission, rb.file));
370
+ try {
371
+ writeProvenance(projectDir, dir, {
372
+ mission,
373
+ actor: typeof state.actor === 'string' ? state.actor : '',
374
+ lane: typeof state.lane === 'string' ? state.lane : '',
375
+ mode: typeof state.mode === 'string' ? state.mode : '',
376
+ branch: state.branch,
377
+ tasks_done: Number(state.tasks_done) || 0,
378
+ tasks_total: Number(state.tasks_total) || 0,
379
+ evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
380
+ models: stageModels,
381
+ });
382
+ kept.push(join('missions', mission, 'provenance.md'));
383
+ } catch { /* provenance is additive; archive proceeds */ }
384
+ }
385
+ } else {
386
+ for (const f of fold) removed.push(join('missions', mission, f));
158
387
  }
388
+ removed.push(join('missions', mission, '<session state>'));
389
+ if (files.includes('plan.md')) kept.push(join('missions', mission, 'plan.md'));
390
+ if (files.includes('handoff.md')) kept.push(join('missions', mission, 'handoff.md'));
391
+ kept.push(join('missions', mission, 'report.md'));
159
392
 
160
- // summary index: append one line per archived mission (retention aid),
161
- // idempotently never duplicate a line for an already-indexed mission.
393
+ // summary index: append one line per archived mission (retention aid).
394
+ // Atomic-append contract (finding A3): the line is written as ONE write()
395
+ // on an O_APPEND fd — POSIX positions O_APPEND writes atomically, so
396
+ // concurrent archivers never overwrite each other's bytes, and a small
397
+ // (<4k) single write does not interleave in practice. The pre-read
398
+ // idempotency check below still has a benign race window under true
399
+ // concurrency: two racers may both see the line missing and both append.
400
+ // That is a duplicate line, not a lost one — duplicates are the preferred
401
+ // failure mode; any future consumer of index.md must dedupe lines on read.
402
+ // Header creation stays racy-but-safe: two first-appends may each prepend
403
+ // "# Mission index\n\n", and header-only-plus-lines remains valid markdown
404
+ // either way.
162
405
  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`;
406
+ const indexFile = join(root, 'index.md');
407
+ const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}\n`;
165
408
  if (!dryRun) {
166
- mkdirSync(join(root, 'reports'), { recursive: true });
167
409
  const existing = existsSync(indexFile) ? readFileSync(indexFile, 'utf8') : '';
168
410
  if (!existing.split(/\r?\n/).some(l => l.startsWith(`- ${mission} —`))) {
169
411
  const header = existing ? '' : '# Mission index\n\n';
170
- appendFileSync(indexFile, header + line);
412
+ const fd = openSync(indexFile, 'a');
413
+ try {
414
+ writeSync(fd, header + line);
415
+ } finally {
416
+ closeSync(fd);
417
+ }
171
418
  }
172
- index = join('reports', 'index.md');
419
+ index = 'index.md';
173
420
  }
174
- return { report, removed, kept, index };
421
+ return { report: join('missions', mission, 'report.md'), removed, kept, index };
175
422
  }
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
+ }