@ionivetech/mugiwara 0.6.6 → 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 (110) 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/plugins/mugiwara.mjs +3 -18
  7. package/AGENTS.md +5 -4
  8. package/GEMINI.md +3 -3
  9. package/README.md +69 -63
  10. package/content/agents/brook-healing.md +8 -4
  11. package/content/agents/chopper-checkpoint.md +7 -3
  12. package/content/agents/eval-runner.md +4 -4
  13. package/content/agents/franky-gates.md +3 -3
  14. package/content/agents/jinbe-security.md +6 -7
  15. package/content/agents/luffy-orchestrator.md +11 -13
  16. package/content/agents/memory-keeper.md +4 -4
  17. package/content/agents/nami-planner.md +4 -4
  18. package/content/agents/resume-coordinator.md +7 -7
  19. package/content/agents/robin-reviewer.md +7 -3
  20. package/content/agents/sanji-quality.md +2 -2
  21. package/content/agents/skeptic-verifier.md +2 -2
  22. package/content/agents/usopp-brainstorm.md +3 -3
  23. package/content/agents/zoro-execution.md +6 -6
  24. package/content/skills/mugiwara-backend/SKILL.md +2 -0
  25. package/content/skills/mugiwara-backend/references/database.md +61 -0
  26. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  27. package/content/skills/mugiwara-checkpoint/SKILL.md +6 -5
  28. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
  29. package/content/skills/mugiwara-execution/SKILL.md +8 -8
  30. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  31. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  32. package/content/skills/mugiwara-frontend/SKILL.md +3 -2
  33. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  34. package/content/skills/mugiwara-gates/SKILL.md +8 -5
  35. package/content/skills/mugiwara-healing/SKILL.md +2 -2
  36. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  37. package/content/skills/mugiwara-orchestration/SKILL.md +7 -7
  38. package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
  39. package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
  40. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
  41. package/content/skills/mugiwara-planning/SKILL.md +3 -18
  42. package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
  43. package/content/skills/mugiwara-quality/SKILL.md +5 -2
  44. package/content/skills/mugiwara-resume/SKILL.md +2 -2
  45. package/content/skills/mugiwara-review/SKILL.md +3 -2
  46. package/content/skills/mugiwara-security/SKILL.md +1 -1
  47. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  48. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  49. package/content/skills/mugiwara-workflow/SKILL.md +9 -12
  50. package/content/skills/mugiwara-workflow/references/workspace-layout.md +39 -38
  51. package/dist/mugiwara.js +904 -462
  52. package/gemini-extension.json +1 -1
  53. package/hooks/auto-savepoint.js +18 -10
  54. package/hooks/auto-savepoint.ts +23 -23
  55. package/hooks/engagement-marker.js +1 -1
  56. package/hooks/engagement-marker.ts +1 -1
  57. package/hooks/pipeline-guard.js +16 -12
  58. package/hooks/pipeline-guard.ts +22 -19
  59. package/hooks/session-start.js +13 -10
  60. package/hooks/session-start.ts +24 -19
  61. package/package.json +2 -2
  62. package/plugin.json +1 -1
  63. package/references/complexity.md +27 -4
  64. package/references/definition-of-done.md +1 -1
  65. package/references/multi-actor.md +0 -3
  66. package/references/prose-style.md +54 -0
  67. package/scripts/check-doc-links.ts +40 -0
  68. package/scripts/conformance.ts +6 -16
  69. package/scripts/coverage-gate.ts +20 -6
  70. package/scripts/gate-selftest.ts +118 -7
  71. package/scripts/lane.sh +54 -8
  72. package/scripts/lib/patterns.sh +5 -0
  73. package/scripts/policy-force.ts +22 -0
  74. package/scripts/retrieval-eval.ts +1 -1
  75. package/scripts/savepoint.sh +136 -58
  76. package/scripts/validate-content.ts +22 -9
  77. package/scripts/verify-install.ts +9 -7
  78. package/src/args.ts +2 -2
  79. package/src/budget.ts +56 -0
  80. package/src/cli.ts +161 -110
  81. package/src/continue.ts +29 -10
  82. package/src/installer.ts +25 -20
  83. package/src/integrity.ts +158 -0
  84. package/src/mission.ts +228 -95
  85. package/src/policy.ts +156 -0
  86. package/src/provenance.ts +116 -0
  87. package/src/rollback.ts +95 -0
  88. package/src/routing.ts +69 -0
  89. package/src/run.ts +2 -2
  90. package/src/sign.ts +61 -0
  91. package/src/targets/claude.ts +2 -2
  92. package/.opencode/commands/mugiwara-execute.md +0 -13
  93. package/.opencode/commands/mugiwara-heal.md +0 -13
  94. package/.opencode/commands/mugiwara-onboard.md +0 -14
  95. package/.opencode/commands/mugiwara-plan.md +0 -17
  96. package/.opencode/commands/mugiwara-ship.md +0 -13
  97. package/.opencode/commands/using-mugiwara.md +0 -20
  98. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  99. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  100. package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
  101. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  102. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  103. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  104. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  105. package/content/skills/using-mugiwara/SKILL.md +0 -51
  106. package/references/token-budget.md +0 -56
  107. package/scripts/evidence.sh +0 -81
  108. package/scripts/initiative.ts +0 -296
  109. package/scripts/mission-report.sh +0 -293
  110. package/src/onboard.ts +0 -207
@@ -0,0 +1,158 @@
1
+ // src/integrity.ts
2
+ // Closure integrity gate: the audit trail validates itself
3
+ // at archive time. Deterministic checks only — a violation fails the archive
4
+ // with an actionable message instead of shipping a broken or leaking artifact.
5
+ //
6
+ // Three checks:
7
+ // 1. Paths — every relative markdown link in the trail resolves to a file
8
+ // (mission-relative or repo-root).
9
+ // 2. Secrets — no trail file matches known secret shapes.
10
+ // 3. Evidence — cited wave/evidence paths exist.
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { isAbsolute, join, relative } from 'node:path';
13
+
14
+ export type IntegrityIssue = { kind: 'dangling-path' | 'secret' | 'evidence' | 'evidence-thin'; detail: string };
15
+
16
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
17
+ [/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
18
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key block'],
19
+ [/gh[pousr]_[A-Za-z0-9]{20,}/, 'GitHub token'],
20
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
21
+ [/sk-[A-Za-z0-9]{32,}/, 'API key (sk-…)'],
22
+ [/eyJhbGciOi[A-Za-z0-9_.-]{20,}/, 'JWT pasted verbatim'],
23
+ [/(api[_-]?key|secret|passwd|password)\s*[=:]\s*["'][^"'\s]{8,}["']/i, 'credential assignment'],
24
+ ];
25
+
26
+ const ALLOW_SECRET = 'mugiwara:allow-secret';
27
+
28
+ /** Secret shapes per line; a line carrying the allow marker is skipped — deliberate examples stay possible. */
29
+ function findSecrets(body: string): Array<{ label: string; hit: string }> {
30
+ const out: Array<{ label: string; hit: string }> = [];
31
+ for (const line of body.split(/\r?\n/)) {
32
+ if (line.includes(ALLOW_SECRET)) continue;
33
+ for (const [re, label] of SECRET_PATTERNS) {
34
+ const hit = line.match(re);
35
+ if (hit) out.push({ label, hit: hit[0] });
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const TRAIL_EXTS = new Set(['.md', '.json', '.sh']);
42
+
43
+ function trailFiles(dir: string): string[] {
44
+ const out: string[] = [];
45
+ const walk = (d: string): void => {
46
+ for (const e of readdirSync(d, { withFileTypes: true })) {
47
+ const p = join(d, e.name);
48
+ if (e.isDirectory()) walk(p);
49
+ else if (TRAIL_EXTS.has(e.name.slice(e.name.lastIndexOf('.')) || '')) out.push(p);
50
+ }
51
+ };
52
+ walk(dir);
53
+ return out;
54
+ }
55
+
56
+ /** Relative markdown-link targets like `](../src/x.ts)` — skip URLs and anchors. */
57
+ function linkedPaths(md: string): string[] {
58
+ const out: string[] = [];
59
+ for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
60
+ const t = m[1];
61
+ if (/^(https?:|mailto:|#|\/\/)/.test(t)) continue;
62
+ out.push(t.split('#')[0]);
63
+ }
64
+ return out.filter(Boolean);
65
+ }
66
+
67
+ function hasCommandOutputShape(body: string): boolean {
68
+ return /`[^`]+`/.test(body) || /\b(exit\s+[01]|✓|✗|\bPASS\b|\bFAIL\b|passed|failed|\d+\s+(passed|failed))\b/i.test(body);
69
+ }
70
+
71
+ function collectPassCitedPaths(missionDir: string): string[] {
72
+ const out: string[] = [];
73
+ for (const f of trailFiles(missionDir)) {
74
+ let body: string;
75
+ try { body = readFileSync(f, 'utf8'); } catch { continue; }
76
+ for (const line of body.split(/\r?\n/)) {
77
+ if (!/\bPASS\b/.test(line)) continue;
78
+ for (const p of linkedPaths(line)) out.push(p);
79
+ // also catch bare repo-path mentions like flows/04-gates.md or evidence/foo.md
80
+ for (const m of line.matchAll(/(?:^|[\s"'(])([a-zA-Z0-9._\/-]+\.(?:md|txt|log|json))\b/g)) {
81
+ const cand = m[1];
82
+ if (cand.includes('/')) out.push(cand);
83
+ }
84
+ }
85
+ }
86
+ return [...new Set(out)];
87
+ }
88
+
89
+ export function checkTrail(missionDir: string, projectRoot: string): IntegrityIssue[] {
90
+ const issues: IntegrityIssue[] = [];
91
+ const files = trailFiles(missionDir);
92
+
93
+ // 1 + 2: per-file link resolution and secret scan
94
+ for (const f of files) {
95
+ let body: string;
96
+ try { body = readFileSync(f, 'utf8'); } catch { continue; }
97
+ for (const target of linkedPaths(body)) {
98
+ if (isAbsolute(target)) continue;
99
+ const fromMission = join(missionDir, target);
100
+ const fromRoot = join(projectRoot, target);
101
+ if (!existsSync(fromMission) && !existsSync(fromRoot)) {
102
+ issues.push({
103
+ kind: 'dangling-path',
104
+ detail: `${relative(projectRoot, f)} links "${target}" — no such file (mission dir or repo root)`,
105
+ });
106
+ }
107
+ }
108
+ for (const { label, hit } of findSecrets(body)) {
109
+ issues.push({
110
+ kind: 'secret',
111
+ detail: `${relative(projectRoot, f)} matches ${label}: ${hit.slice(0, 12)}…`,
112
+ });
113
+ }
114
+ }
115
+
116
+ // 3: evidence entries recorded as repo paths must exist
117
+ const evidencePaths: string[] = [];
118
+ const evidenceFile = join(missionDir, 'state.json');
119
+ if (existsSync(evidenceFile)) {
120
+ try {
121
+ const s = JSON.parse(readFileSync(evidenceFile, 'utf8')) as { evidence?: unknown };
122
+ if (Array.isArray(s.evidence)) {
123
+ for (const e of s.evidence) {
124
+ if (typeof e !== 'string' || !e.trim()) continue;
125
+ evidencePaths.push(e);
126
+ const cand = join(projectRoot, e);
127
+ if (!isAbsolute(e) && !existsSync(cand) && !existsSync(join(missionDir, e))) {
128
+ issues.push({ kind: 'evidence', detail: `state.json evidence "${e}" does not exist` });
129
+ }
130
+ }
131
+ }
132
+ } catch { /* corrupt state — the state reader owns that error */ }
133
+ }
134
+
135
+ // 4: evidence-content spot check (T7): a PASS verdict that cites an evidence
136
+ // path must point at a file that exists AND contains command-output shape
137
+ // (backticked command or exit-status token). Fake-but-consistent trails
138
+ // defeat existence-only checks — this raises the bar cheaply.
139
+ const passCited = collectPassCitedPaths(missionDir);
140
+ for (const e of passCited) {
141
+ if (!e.trim() || isAbsolute(e)) continue;
142
+ const candMission = join(missionDir, e);
143
+ const candRoot = join(projectRoot, e);
144
+ const resolved = existsSync(candMission) ? candMission : existsSync(candRoot) ? candRoot : null;
145
+ if (!resolved) continue; // already reported as evidence/dangling elsewhere
146
+ let body: string;
147
+ try { body = readFileSync(resolved, 'utf8'); } catch { continue; }
148
+ if (!hasCommandOutputShape(body)) {
149
+ issues.push({ kind: 'evidence-thin', detail: `evidence "${e}" exists but lacks command output (no backticked command or exit-status token)` });
150
+ }
151
+ }
152
+
153
+ return issues;
154
+ }
155
+
156
+ export function formatIssues(issues: IntegrityIssue[]): string {
157
+ return issues.map((i) => ` ✗ [${i.kind}] ${i.detail}`).join('\n');
158
+ }
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
  }