@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -7
- package/.codex-plugin/plugin.json +2 -7
- package/.cursor-plugin/plugin.json +2 -7
- package/.kimi-plugin/plugin.json +2 -7
- package/.opencode/plugins/mugiwara.mjs +3 -18
- package/AGENTS.md +5 -4
- package/GEMINI.md +3 -3
- package/README.md +69 -63
- package/content/agents/brook-healing.md +8 -4
- package/content/agents/chopper-checkpoint.md +7 -3
- package/content/agents/eval-runner.md +4 -4
- package/content/agents/franky-gates.md +3 -3
- package/content/agents/jinbe-security.md +6 -7
- package/content/agents/luffy-orchestrator.md +11 -13
- package/content/agents/memory-keeper.md +4 -4
- package/content/agents/nami-planner.md +4 -4
- package/content/agents/resume-coordinator.md +7 -7
- package/content/agents/robin-reviewer.md +7 -3
- package/content/agents/sanji-quality.md +2 -2
- package/content/agents/skeptic-verifier.md +2 -2
- package/content/agents/usopp-brainstorm.md +3 -3
- package/content/agents/zoro-execution.md +6 -6
- package/content/skills/mugiwara-backend/SKILL.md +2 -0
- package/content/skills/mugiwara-backend/references/database.md +61 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +6 -5
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
- package/content/skills/mugiwara-execution/SKILL.md +8 -8
- package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
- package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
- package/content/skills/mugiwara-frontend/SKILL.md +3 -2
- package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
- package/content/skills/mugiwara-gates/SKILL.md +8 -5
- package/content/skills/mugiwara-healing/SKILL.md +2 -2
- package/content/skills/mugiwara-lessons/SKILL.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +7 -7
- package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
- package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
- package/content/skills/mugiwara-planning/SKILL.md +3 -18
- package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
- package/content/skills/mugiwara-quality/SKILL.md +5 -2
- package/content/skills/mugiwara-resume/SKILL.md +2 -2
- package/content/skills/mugiwara-review/SKILL.md +3 -2
- package/content/skills/mugiwara-security/SKILL.md +1 -1
- package/content/skills/mugiwara-ship/SKILL.md +1 -1
- package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
- package/content/skills/mugiwara-workflow/SKILL.md +9 -12
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +39 -38
- package/dist/mugiwara.js +904 -462
- package/gemini-extension.json +1 -1
- package/hooks/auto-savepoint.js +18 -10
- package/hooks/auto-savepoint.ts +23 -23
- package/hooks/engagement-marker.js +1 -1
- package/hooks/engagement-marker.ts +1 -1
- package/hooks/pipeline-guard.js +16 -12
- package/hooks/pipeline-guard.ts +22 -19
- package/hooks/session-start.js +13 -10
- package/hooks/session-start.ts +24 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/references/complexity.md +27 -4
- package/references/definition-of-done.md +1 -1
- package/references/multi-actor.md +0 -3
- package/references/prose-style.md +54 -0
- package/scripts/check-doc-links.ts +40 -0
- package/scripts/conformance.ts +6 -16
- package/scripts/coverage-gate.ts +20 -6
- package/scripts/gate-selftest.ts +118 -7
- package/scripts/lane.sh +54 -8
- package/scripts/lib/patterns.sh +5 -0
- package/scripts/policy-force.ts +22 -0
- package/scripts/retrieval-eval.ts +1 -1
- package/scripts/savepoint.sh +136 -58
- package/scripts/validate-content.ts +22 -9
- package/scripts/verify-install.ts +9 -7
- package/src/args.ts +2 -2
- package/src/budget.ts +56 -0
- package/src/cli.ts +161 -110
- package/src/continue.ts +29 -10
- package/src/installer.ts +25 -20
- package/src/integrity.ts +158 -0
- package/src/mission.ts +228 -95
- package/src/policy.ts +156 -0
- package/src/provenance.ts +116 -0
- package/src/rollback.ts +95 -0
- package/src/routing.ts +69 -0
- package/src/run.ts +2 -2
- package/src/sign.ts +61 -0
- package/src/targets/claude.ts +2 -2
- package/.opencode/commands/mugiwara-execute.md +0 -13
- package/.opencode/commands/mugiwara-heal.md +0 -13
- package/.opencode/commands/mugiwara-onboard.md +0 -14
- package/.opencode/commands/mugiwara-plan.md +0 -17
- package/.opencode/commands/mugiwara-ship.md +0 -13
- package/.opencode/commands/using-mugiwara.md +0 -20
- package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
- package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
- package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
- package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
- package/content/skills/mugiwara-pr/SKILL.md +0 -69
- package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
- package/content/skills/mugiwara-sunset/SKILL.md +0 -82
- package/content/skills/using-mugiwara/SKILL.md +0 -51
- package/references/token-budget.md +0 -56
- package/scripts/evidence.sh +0 -81
- package/scripts/initiative.ts +0 -296
- package/scripts/mission-report.sh +0 -293
- package/src/onboard.ts +0 -207
package/src/integrity.ts
ADDED
|
@@ -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
|
|
3
|
-
import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync,
|
|
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/
|
|
44
|
+
// state now lives at .mugiwara/missions/<mission>/[member].json — scan the
|
|
8
45
|
// latest state file for its actor
|
|
9
|
-
const
|
|
10
|
-
if (!existsSync(
|
|
11
|
-
const missions = readdirSync(
|
|
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(
|
|
15
|
-
for (const f of readdirSync(d).filter(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
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, '
|
|
164
|
-
const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}
|
|
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
|
-
|
|
298
|
+
const fd = openSync(indexFile, 'a');
|
|
299
|
+
try {
|
|
300
|
+
writeSync(fd, header + line);
|
|
301
|
+
} finally {
|
|
302
|
+
closeSync(fd);
|
|
303
|
+
}
|
|
171
304
|
}
|
|
172
|
-
index =
|
|
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
|
}
|