@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.
- 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/mugiwara-helpers.mjs +2 -2
- package/.opencode/plugins/mugiwara.mjs +3 -18
- package/AGENTS.md +5 -4
- package/GEMINI.md +3 -3
- package/README.md +203 -331
- 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 +4 -4
- 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 +54 -43
- 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 +23 -11
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
- package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
- package/content/skills/mugiwara-execution/SKILL.md +37 -37
- package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
- package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
- package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
- package/content/skills/mugiwara-frontend/SKILL.md +44 -43
- package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
- package/content/skills/mugiwara-gates/SKILL.md +23 -14
- package/content/skills/mugiwara-healing/SKILL.md +26 -25
- package/content/skills/mugiwara-lessons/SKILL.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +11 -11
- 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/control-commands.md +14 -0
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
- package/content/skills/mugiwara-planning/SKILL.md +26 -29
- package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
- package/content/skills/mugiwara-planning/references/plan-template.md +24 -2
- package/content/skills/mugiwara-quality/SKILL.md +21 -12
- package/content/skills/mugiwara-resume/SKILL.md +8 -3
- package/content/skills/mugiwara-review/SKILL.md +19 -13
- package/content/skills/mugiwara-security/SKILL.md +47 -36
- 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 +11 -17
- package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
- package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
- package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +42 -38
- package/dist/mugiwara.js +1642 -528
- 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 +17 -13
- package/hooks/pipeline-guard.ts +24 -20
- 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 +20 -2
- package/references/posture-routing.md +31 -0
- package/references/prose-style.md +54 -0
- package/scripts/benchmark-governor.ts +516 -0
- package/scripts/benchmark-thresholds.json +47 -0
- package/scripts/check-doc-links.ts +46 -0
- package/scripts/conformance.ts +6 -16
- package/scripts/coverage-gate.ts +20 -6
- package/scripts/gate-selftest.ts +138 -7
- package/scripts/lane.sh +54 -8
- package/scripts/lib/lane-base.sh +4 -4
- package/scripts/lib/patterns.sh +5 -0
- package/scripts/policy-force.ts +22 -0
- package/scripts/retrieval-eval.ts +10 -4
- package/scripts/savepoint.sh +155 -58
- package/scripts/validate-content.ts +43 -11
- package/scripts/verify-install.ts +9 -7
- package/src/adaptive-budget.ts +178 -0
- package/src/args.ts +4 -3
- package/src/budget.ts +47 -0
- package/src/check-artifacts.ts +45 -0
- package/src/cli.ts +257 -108
- package/src/cognition.ts +234 -0
- package/src/config.ts +107 -0
- package/src/context.ts +72 -0
- package/src/continue.ts +29 -10
- package/src/cost.ts +186 -0
- package/src/evidence.ts +160 -0
- package/src/installer.ts +25 -34
- package/src/integrity.ts +158 -0
- package/src/investigation.ts +72 -0
- package/src/mission.ts +341 -94
- package/src/policy.ts +156 -0
- package/src/posture.ts +86 -0
- package/src/provenance.ts +116 -0
- package/src/reporting.ts +225 -0
- package/src/rollback.ts +95 -0
- package/src/routing.ts +69 -0
- package/src/run.ts +2 -2
- package/src/scope.ts +321 -0
- package/src/sign.ts +235 -0
- package/src/slop.ts +306 -0
- package/src/targets/claude.ts +2 -2
- package/src/work.ts +273 -0
- 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/mission.ts
CHANGED
|
@@ -1,18 +1,60 @@
|
|
|
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 { 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/
|
|
49
|
+
// state now lives at .mugiwara/missions/<mission>/[member].json — scan the
|
|
8
50
|
// latest state file for its actor
|
|
9
|
-
const
|
|
10
|
-
if (!existsSync(
|
|
11
|
-
const missions = readdirSync(
|
|
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(
|
|
15
|
-
for (const f of readdirSync(d).filter(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
120
|
+
const dir = join(root, 'missions', mission);
|
|
121
|
+
if (!existsSync(dir)) return { report: null, removed, kept };
|
|
98
122
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
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, '
|
|
164
|
-
const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}
|
|
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
|
-
|
|
412
|
+
const fd = openSync(indexFile, 'a');
|
|
413
|
+
try {
|
|
414
|
+
writeSync(fd, header + line);
|
|
415
|
+
} finally {
|
|
416
|
+
closeSync(fd);
|
|
417
|
+
}
|
|
171
418
|
}
|
|
172
|
-
index =
|
|
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
|
+
}
|