@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/scope.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// src/scope.ts
|
|
2
|
+
// Phase 4 Scope & Code Governor — verdict engine + decision trail
|
|
3
|
+
// (Native Cost Governor initiative, plan §51 Phase 4, spec §5.4/§14/§15/§16/§38/§41).
|
|
4
|
+
//
|
|
5
|
+
// Turns the shipped primitives into auditable scope/code verdicts: scope drift
|
|
6
|
+
// detection, existing-code reuse checks, abstraction justification, dependency
|
|
7
|
+
// justification, minimum sufficient implementation policy, code waste detection,
|
|
8
|
+
// and change-surface measurement. Honest boundary (same as Phase 3 work.ts):
|
|
9
|
+
// this module PRODUCES and RECORDS verdicts; the LLM crew (workflow skill, T2)
|
|
10
|
+
// is the only thing that acts on them. It makes the decision structured,
|
|
11
|
+
// auditable, and instructed — it does not pretend a TS function can force the
|
|
12
|
+
// model.
|
|
13
|
+
//
|
|
14
|
+
// Every verdict is a pure function of explicit inputs (unit-testable, parity
|
|
15
|
+
// locked by fixtures) and every drift/reuse/abstraction/dependency/sufficient/
|
|
16
|
+
// waste/surface decision lands in the trail via recordScopeDecision →
|
|
17
|
+
// recordOptDecision (§41, S2 sanitizer).
|
|
18
|
+
import { recordOptDecision } from './cost.ts';
|
|
19
|
+
|
|
20
|
+
// ── Scope drift detection (§14/§51-1) ───────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type ScopeDriftInput = {
|
|
23
|
+
change: string;
|
|
24
|
+
declared_scope: string[];
|
|
25
|
+
touched_files: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ScopeDriftVerdict = {
|
|
29
|
+
change: string;
|
|
30
|
+
drift: boolean;
|
|
31
|
+
reason: string;
|
|
32
|
+
scope_score: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Detect scope drift: a touched file is in scope when it includes any
|
|
37
|
+
* `declared_scope` token (substring match). `scope_score` is the fraction of
|
|
38
|
+
* touched files outside scope (0 when none); drift holds when any file is
|
|
39
|
+
* outside. Reason names the outside files or 'within declared scope'.
|
|
40
|
+
*/
|
|
41
|
+
export function detectScopeDrift(input: ScopeDriftInput): ScopeDriftVerdict {
|
|
42
|
+
const outside = input.touched_files.filter(
|
|
43
|
+
(f) => !input.declared_scope.some((tok) => f.includes(tok)),
|
|
44
|
+
);
|
|
45
|
+
const scope_score = input.touched_files.length === 0 ? 0 : outside.length / input.touched_files.length;
|
|
46
|
+
if (outside.length === 0) {
|
|
47
|
+
return { change: input.change, drift: false, reason: 'within declared scope', scope_score };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
change: input.change,
|
|
51
|
+
drift: true,
|
|
52
|
+
reason: `outside declared scope: ${outside.join(', ')}`,
|
|
53
|
+
scope_score,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Existing-code reuse checks (§14/§51-2) ──────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export type ReuseInput = {
|
|
60
|
+
change: string;
|
|
61
|
+
existing_symbol: boolean;
|
|
62
|
+
existing_component: boolean;
|
|
63
|
+
existing_utility: boolean;
|
|
64
|
+
existing_module: boolean;
|
|
65
|
+
local_modification_viable: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ReuseVerdict = { change: string; reuse: boolean; reason: string };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reuse holds only when some existing code is present AND local modification
|
|
72
|
+
* is viable — the §14 default: prefer reuse + local modification over new
|
|
73
|
+
* architecture. Never returns reuse:true just because code exists; the reason
|
|
74
|
+
* names whether the gap is "no existing code" vs "not viable" otherwise.
|
|
75
|
+
*/
|
|
76
|
+
export function checkExistingCodeReuse(input: ReuseInput): ReuseVerdict {
|
|
77
|
+
const anyExisting =
|
|
78
|
+
input.existing_symbol ||
|
|
79
|
+
input.existing_component ||
|
|
80
|
+
input.existing_utility ||
|
|
81
|
+
input.existing_module;
|
|
82
|
+
if (anyExisting && input.local_modification_viable) {
|
|
83
|
+
return { change: input.change, reuse: true, reason: 'existing code reusable via local modification' };
|
|
84
|
+
}
|
|
85
|
+
if (anyExisting) {
|
|
86
|
+
return { change: input.change, reuse: false, reason: 'existing code present but local modification not viable' };
|
|
87
|
+
}
|
|
88
|
+
return { change: input.change, reuse: false, reason: 'no existing code solves this' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Abstraction justification (§15/§51-3) ───────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export type AbstractionInput = {
|
|
94
|
+
abstraction: string;
|
|
95
|
+
used_in_places: number;
|
|
96
|
+
reduces_duplication: boolean;
|
|
97
|
+
required_by_contract: boolean;
|
|
98
|
+
speculative: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type AbstractionVerdict = {
|
|
102
|
+
abstraction: string;
|
|
103
|
+
justified: boolean;
|
|
104
|
+
reason: string;
|
|
105
|
+
use_count: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An abstraction is justified only when it is not speculative AND it is either
|
|
110
|
+
* required by contract or used in ≥2 places with a duplication benefit. Rejects
|
|
111
|
+
* speculative abstractions for hypothetical requirements; single-use
|
|
112
|
+
* abstractions with no contract and no duplication benefit are refused.
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateAbstraction(input: AbstractionInput): AbstractionVerdict {
|
|
115
|
+
if (input.speculative) {
|
|
116
|
+
return { abstraction: input.abstraction, justified: false, reason: 'speculative — no concrete requirement', use_count: input.used_in_places };
|
|
117
|
+
}
|
|
118
|
+
if (input.required_by_contract) {
|
|
119
|
+
return { abstraction: input.abstraction, justified: true, reason: 'required by contract', use_count: input.used_in_places };
|
|
120
|
+
}
|
|
121
|
+
if (input.used_in_places >= 2 && input.reduces_duplication) {
|
|
122
|
+
return { abstraction: input.abstraction, justified: true, reason: 'used in >= 2 places and reduces duplication', use_count: input.used_in_places };
|
|
123
|
+
}
|
|
124
|
+
return { abstraction: input.abstraction, justified: false, reason: 'single use, no contract, no duplication benefit', use_count: input.used_in_places };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Dependency justification (§16/§51-4) ────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export type DependencyInput = {
|
|
130
|
+
dependency: string;
|
|
131
|
+
equivalent_available: boolean;
|
|
132
|
+
solvable_with_existing: boolean;
|
|
133
|
+
long_term_value: boolean;
|
|
134
|
+
maintenance_cost: number;
|
|
135
|
+
removed_cost: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type DependencyVerdict = { dependency: string; justified: boolean; reason: string };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A dependency is justified only when no equivalent is available, it is not
|
|
142
|
+
* solvable with existing code, it carries long-term value, and its maintenance
|
|
143
|
+
* cost does not exceed the cost of removing it (§16). Never justified merely
|
|
144
|
+
* because it is convenient — the reason names the first failing clause.
|
|
145
|
+
*/
|
|
146
|
+
export function evaluateDependency(input: DependencyInput): DependencyVerdict {
|
|
147
|
+
if (input.equivalent_available) {
|
|
148
|
+
return { dependency: input.dependency, justified: false, reason: 'equivalent available' };
|
|
149
|
+
}
|
|
150
|
+
if (input.solvable_with_existing) {
|
|
151
|
+
return { dependency: input.dependency, justified: false, reason: 'solvable with existing code' };
|
|
152
|
+
}
|
|
153
|
+
if (!input.long_term_value) {
|
|
154
|
+
return { dependency: input.dependency, justified: false, reason: 'no long-term value' };
|
|
155
|
+
}
|
|
156
|
+
if (input.maintenance_cost > input.removed_cost) {
|
|
157
|
+
return { dependency: input.dependency, justified: false, reason: 'maintenance cost exceeds removal cost' };
|
|
158
|
+
}
|
|
159
|
+
return { dependency: input.dependency, justified: true, reason: 'no equivalent, not solvable with existing, long-term value, maintenance within removal cost' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Minimum sufficient implementation policy (§15/§38/§51-5) ────────────────
|
|
163
|
+
|
|
164
|
+
export type SufficientInput = {
|
|
165
|
+
change: string;
|
|
166
|
+
necessary_complexity: number;
|
|
167
|
+
incidental_complexity: number;
|
|
168
|
+
verifiable: boolean;
|
|
169
|
+
coverage_satisfied: boolean;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type SufficientVerdict = {
|
|
173
|
+
change: string;
|
|
174
|
+
status: 'under' | 'over' | 'sufficient';
|
|
175
|
+
sufficient: boolean;
|
|
176
|
+
reason: string;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Minimum sufficient implementation: `under` when required verification or
|
|
181
|
+
* coverage is missing (never sacrifice quality — §38); `over` when incidental
|
|
182
|
+
* complexity is added without need (§15 waste); else `sufficient`. Necessary
|
|
183
|
+
* complexity is not penalized — this never optimizes for minimum LOC at the
|
|
184
|
+
* expense of maintainability.
|
|
185
|
+
*/
|
|
186
|
+
export function minimumSufficientCheck(input: SufficientInput): SufficientVerdict {
|
|
187
|
+
if (!input.verifiable) {
|
|
188
|
+
return { change: input.change, status: 'under', sufficient: false, reason: 'not verifiable — under minimum' };
|
|
189
|
+
}
|
|
190
|
+
if (!input.coverage_satisfied) {
|
|
191
|
+
return { change: input.change, status: 'under', sufficient: false, reason: 'coverage not satisfied — under minimum' };
|
|
192
|
+
}
|
|
193
|
+
if (input.incidental_complexity > 0) {
|
|
194
|
+
return { change: input.change, status: 'over', sufficient: false, reason: 'incidental complexity added without need — over minimum' };
|
|
195
|
+
}
|
|
196
|
+
return { change: input.change, status: 'sufficient', sufficient: true, reason: 'minimum sufficient — verification and coverage satisfied, no incidental complexity' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Code waste detection (§15/§51-6) ────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export type WasteInput = {
|
|
202
|
+
change: string;
|
|
203
|
+
unnecessary_helper: boolean;
|
|
204
|
+
unnecessary_abstraction: boolean;
|
|
205
|
+
unnecessary_wrapper: boolean;
|
|
206
|
+
unnecessary_interface: boolean;
|
|
207
|
+
unnecessary_config: boolean;
|
|
208
|
+
unnecessary_dependency: boolean;
|
|
209
|
+
unnecessary_generated_code: boolean;
|
|
210
|
+
unnecessary_refactor: boolean;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export type WasteVerdict = { change: string; waste: boolean; reason: string; waste_types: string[] };
|
|
214
|
+
|
|
215
|
+
const WASTE_TYPES: Array<{ flag: keyof WasteInput; name: string }> = [
|
|
216
|
+
{ flag: 'unnecessary_helper', name: 'helper' },
|
|
217
|
+
{ flag: 'unnecessary_abstraction', name: 'abstraction' },
|
|
218
|
+
{ flag: 'unnecessary_wrapper', name: 'wrapper' },
|
|
219
|
+
{ flag: 'unnecessary_interface', name: 'interface' },
|
|
220
|
+
{ flag: 'unnecessary_config', name: 'config' },
|
|
221
|
+
{ flag: 'unnecessary_dependency', name: 'dependency' },
|
|
222
|
+
{ flag: 'unnecessary_generated_code', name: 'generated code' },
|
|
223
|
+
{ flag: 'unnecessary_refactor', name: 'refactor' },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Detect §15 code waste: every true flag is named in `waste_types` (helper,
|
|
228
|
+
* abstraction, wrapper, interface, config, dependency, generated code,
|
|
229
|
+
* refactor). `waste` holds when any type is present; the reason joins them.
|
|
230
|
+
*/
|
|
231
|
+
export function detectCodeWaste(input: WasteInput): WasteVerdict {
|
|
232
|
+
const waste_types = WASTE_TYPES.filter((t) => input[t.flag]).map((t) => t.name);
|
|
233
|
+
if (waste_types.length === 0) {
|
|
234
|
+
return { change: input.change, waste: false, reason: 'no code waste', waste_types: [] };
|
|
235
|
+
}
|
|
236
|
+
return { change: input.change, waste: true, reason: `unnecessary: ${waste_types.join(', ')}`, waste_types };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Change-surface measurement (§5.4/§51-7) ─────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export type SurfaceInput = {
|
|
242
|
+
change: string;
|
|
243
|
+
files_changed: number;
|
|
244
|
+
loc_added: number;
|
|
245
|
+
loc_removed: number;
|
|
246
|
+
new_abstractions: number;
|
|
247
|
+
new_dependencies: number;
|
|
248
|
+
new_files: number;
|
|
249
|
+
generated_boilerplate: number;
|
|
250
|
+
within_declared_scope: boolean;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type ChangeSurface = {
|
|
254
|
+
files_changed: number;
|
|
255
|
+
loc_added: number;
|
|
256
|
+
loc_removed: number;
|
|
257
|
+
loc_changed: number;
|
|
258
|
+
new_abstractions: number;
|
|
259
|
+
new_dependencies: number;
|
|
260
|
+
new_files: number;
|
|
261
|
+
generated_boilerplate: number;
|
|
262
|
+
within_declared_scope: boolean;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export type SurfaceVerdict = {
|
|
266
|
+
change: string;
|
|
267
|
+
surface: ChangeSurface;
|
|
268
|
+
justified: boolean;
|
|
269
|
+
reason: string;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Measure the change surface: `loc_changed = loc_added + loc_removed`. The
|
|
274
|
+
* surface is justified only when the change stays inside the declared scope
|
|
275
|
+
* and introduces no new abstractions or dependencies. This produces the §5.4
|
|
276
|
+
* metric block the Phase-8 ledger consumes — this task measures, Phase 8
|
|
277
|
+
* renders.
|
|
278
|
+
*/
|
|
279
|
+
export function measureChangeSurface(input: SurfaceInput): SurfaceVerdict {
|
|
280
|
+
const surface: ChangeSurface = {
|
|
281
|
+
files_changed: input.files_changed,
|
|
282
|
+
loc_added: input.loc_added,
|
|
283
|
+
loc_removed: input.loc_removed,
|
|
284
|
+
loc_changed: input.loc_added + input.loc_removed,
|
|
285
|
+
new_abstractions: input.new_abstractions,
|
|
286
|
+
new_dependencies: input.new_dependencies,
|
|
287
|
+
new_files: input.new_files,
|
|
288
|
+
generated_boilerplate: input.generated_boilerplate,
|
|
289
|
+
within_declared_scope: input.within_declared_scope,
|
|
290
|
+
};
|
|
291
|
+
if (!input.within_declared_scope) {
|
|
292
|
+
return { change: input.change, surface, justified: false, reason: 'outside declared scope' };
|
|
293
|
+
}
|
|
294
|
+
if (input.new_abstractions > 0) {
|
|
295
|
+
return { change: input.change, surface, justified: false, reason: 'introduces new abstraction' };
|
|
296
|
+
}
|
|
297
|
+
if (input.new_dependencies > 0) {
|
|
298
|
+
return { change: input.change, surface, justified: false, reason: 'introduces new dependency' };
|
|
299
|
+
}
|
|
300
|
+
return { change: input.change, surface, justified: true, reason: 'proportional to declared scope — no new abstraction or dependency' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Decision trail (§41) ────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Record any drift/reuse/abstraction/dependency/sufficient/waste/surface
|
|
307
|
+
* verdict as an optimization decision with the `scope-governor` actor. Thin
|
|
308
|
+
* wrapper over the sanitized recordOptDecision (S2 — newline/CR stripped, no
|
|
309
|
+
* markdown injection).
|
|
310
|
+
*/
|
|
311
|
+
export function recordScopeDecision(
|
|
312
|
+
missionDir: string,
|
|
313
|
+
d: { decision: string; reason: string; evidence?: string },
|
|
314
|
+
): void {
|
|
315
|
+
recordOptDecision(missionDir, {
|
|
316
|
+
actor: 'scope-governor',
|
|
317
|
+
decision: d.decision,
|
|
318
|
+
reason: d.reason,
|
|
319
|
+
...(d.evidence ? { evidence: d.evidence } : {}),
|
|
320
|
+
});
|
|
321
|
+
}
|
package/src/sign.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// src/sign.ts
|
|
2
|
+
// Signed attestation: evidence that cannot be fabricated
|
|
3
|
+
// after the fact — optional, user-keyed, never a hard dependency.
|
|
4
|
+
//
|
|
5
|
+
// Dual backend (roadmap v0.8 item 1):
|
|
6
|
+
// - minisign: external binary when installed + user supplies keys (legacy)
|
|
7
|
+
// - pure: internal node:crypto ed25519, zero binary, zero deps
|
|
8
|
+
// Backend chosen via sign in .mugiwara/config (auto|minisign|pure|off).
|
|
9
|
+
// Detached signature lives beside the report (report.md.minisig | .mugisig).
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { readConfig } from './config.ts';
|
|
16
|
+
|
|
17
|
+
export function signArgs(reportPath: string, secretKey: string): string[] {
|
|
18
|
+
return ['-Sm', reportPath, '-s', secretKey];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function verifyArgs(reportPath: string, pubKey: string | null): string[] {
|
|
22
|
+
return pubKey ? ['-Vm', reportPath, '-p', pubKey] : ['-Vm', reportPath];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasMinisign(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
execFileSync('minisign', ['-v'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultKey(flag: 'secret' | 'public'): string {
|
|
35
|
+
return join(homedir(), '.mugiwara', flag === 'secret' ? 'minisign.key' : 'minisign.pub');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- pure ed25519 backend ------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface PureSig {
|
|
41
|
+
algo: 'ed25519-pure';
|
|
42
|
+
sig: string; // 64B base64
|
|
43
|
+
pub: string; // 32B base64
|
|
44
|
+
mission: string;
|
|
45
|
+
commit: string;
|
|
46
|
+
ts: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Generate a 32-byte ed25519 seed + public key, both base64. */
|
|
50
|
+
export function generatePureKey(): { key: string; pub: string } {
|
|
51
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
52
|
+
const privJwk = privateKey.export({ format: 'jwk' });
|
|
53
|
+
const pubJwk = publicKey.export({ format: 'jwk' });
|
|
54
|
+
return {
|
|
55
|
+
key: Buffer.from(privJwk.d!, 'base64url').toString('base64'),
|
|
56
|
+
pub: Buffer.from(pubJwk.x!, 'base64url').toString('base64'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensure ~/.mugiwara/mugiwara.key + .pub exist (idempotent — never
|
|
62
|
+
* overwrite, never follow a symlink). Returns the .mugiwara dir.
|
|
63
|
+
*/
|
|
64
|
+
export function ensurePureKey(homeDir: string): string {
|
|
65
|
+
const dir = join(homeDir, '.mugiwara');
|
|
66
|
+
const keyPath = join(dir, 'mugiwara.key');
|
|
67
|
+
const pubPath = join(dir, 'mugiwara.pub');
|
|
68
|
+
if (!existsSync(keyPath) || !existsSync(pubPath)) {
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
const { key, pub } = generatePureKey();
|
|
71
|
+
// atomic-ish: write key first, then pub; a partial pair re-keys on next run
|
|
72
|
+
if (!existsSync(keyPath)) {
|
|
73
|
+
writeFileSync(keyPath, key + '\n', { mode: 0o600 });
|
|
74
|
+
// existing file (non-secret pub) never gets broadened — only secure the key
|
|
75
|
+
}
|
|
76
|
+
if (!existsSync(pubPath)) writeFileSync(pubPath, pub + '\n');
|
|
77
|
+
}
|
|
78
|
+
// defense-in-depth: seed material must never be world/group readable,
|
|
79
|
+
// even if created earlier with a loose umask or by an older version.
|
|
80
|
+
try { chmodSync(keyPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sign content with the pure backend. Returns either the parsed signature
|
|
86
|
+
* object or an error result. When outputPath is given, writes the .mugisig
|
|
87
|
+
* JSON file beside the report.
|
|
88
|
+
*/
|
|
89
|
+
export interface PureSignOk extends PureSig { ok: true; }
|
|
90
|
+
export type PureSignResult = PureSignOk | { ok: false; message: string };
|
|
91
|
+
|
|
92
|
+
export function pureSign(
|
|
93
|
+
content: string,
|
|
94
|
+
seedBase64: string,
|
|
95
|
+
opts: { mission: string; commit: string; ts: string; pub: string; outputPath?: string },
|
|
96
|
+
): PureSignResult {
|
|
97
|
+
const seed = Buffer.from(seedBase64.trim(), 'base64');
|
|
98
|
+
if (seed.length !== 32) return { ok: false, message: 'invalid seed (want 32B base64)' };
|
|
99
|
+
const pubBuf = Buffer.from(opts.pub.trim(), 'base64');
|
|
100
|
+
if (pubBuf.length !== 32) return { ok: false, message: 'invalid pub (want 32B base64)' };
|
|
101
|
+
const privateKey = createPrivateKey({
|
|
102
|
+
key: {
|
|
103
|
+
kty: 'OKP',
|
|
104
|
+
crv: 'Ed25519',
|
|
105
|
+
d: seed.toString('base64url'),
|
|
106
|
+
x: pubBuf.toString('base64url'),
|
|
107
|
+
},
|
|
108
|
+
format: 'jwk',
|
|
109
|
+
});
|
|
110
|
+
const sig = sign(null, Buffer.from(content, 'utf8'), privateKey).toString('base64');
|
|
111
|
+
const out: PureSig = { algo: 'ed25519-pure', sig, pub: opts.pub, mission: opts.mission, commit: opts.commit, ts: opts.ts };
|
|
112
|
+
if (opts.outputPath) writeFileSync(opts.outputPath, JSON.stringify(out, null, 2) + '\n');
|
|
113
|
+
return { ...out, ok: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Verify a pure signature against content. */
|
|
117
|
+
export function pureVerify(content: string, sig: PureSig): boolean {
|
|
118
|
+
try {
|
|
119
|
+
const pub = Buffer.from(sig.pub, 'base64');
|
|
120
|
+
if (pub.length !== 32) return false;
|
|
121
|
+
const publicKey = createPublicKey({
|
|
122
|
+
key: { kty: 'OKP', crv: 'Ed25519', x: pub.toString('base64url') },
|
|
123
|
+
format: 'jwk',
|
|
124
|
+
});
|
|
125
|
+
return verify(null, Buffer.from(content, 'utf8'), publicKey, Buffer.from(sig.sig, 'base64'));
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- backend resolution ---------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export type BackendChoice = 'off' | 'minisign' | 'minisign-fail' | 'pure';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the effective signing backend from config sign + runtime
|
|
137
|
+
* facts. Unknown values fall back to pure — never a silent off.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveBackend(
|
|
140
|
+
configured: string | undefined,
|
|
141
|
+
env: { hasMinisign: boolean; hasKey: boolean },
|
|
142
|
+
): BackendChoice {
|
|
143
|
+
switch (configured) {
|
|
144
|
+
case 'off': return 'off';
|
|
145
|
+
case 'minisign': return env.hasMinisign ? 'minisign' : 'minisign-fail';
|
|
146
|
+
case 'pure': return 'pure';
|
|
147
|
+
case 'auto':
|
|
148
|
+
default:
|
|
149
|
+
return env.hasMinisign && env.hasKey ? 'minisign' : 'pure';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Read sign from config (project then home). */
|
|
154
|
+
export function configuredBackend(projectDir: string): string | undefined {
|
|
155
|
+
return readConfig(projectDir).sign;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function missionMeta(projectDir: string, mission: string): { commit: string; ts: string } {
|
|
159
|
+
let commit = 'unknown';
|
|
160
|
+
try { commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* best-effort */ }
|
|
161
|
+
return { commit, ts: new Date().toISOString() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function signReport(projectDir: string, missionDir: string): { ok: boolean; message: string } {
|
|
165
|
+
const report = join(missionDir, 'report.md');
|
|
166
|
+
if (!existsSync(report)) return { ok: false, message: 'no report.md to sign — archive first' };
|
|
167
|
+
const mission = missionDir.split(join('.mugiwara', 'missions', '')).pop() ?? 'unknown';
|
|
168
|
+
const backend = resolveBackend(configuredBackend(projectDir), { hasMinisign: hasMinisign(), hasKey: existsSync(defaultKey('secret')) });
|
|
169
|
+
|
|
170
|
+
if (backend === 'off') return { ok: false, message: 'signing disabled (sign=off)' };
|
|
171
|
+
if (backend === 'minisign-fail') return { ok: false, message: 'sign=minisign but minisign not installed — install it or set sign=pure' };
|
|
172
|
+
if (backend === 'minisign') {
|
|
173
|
+
const secretKey = process.env.MUGIWARA_SIGN_KEY?.trim() || defaultKey('secret');
|
|
174
|
+
try {
|
|
175
|
+
execFileSync('minisign', signArgs(report, secretKey), { cwd: projectDir, stdio: 'pipe', input: process.env.MUGIWARA_SIGN_PASSWORD ?? '' });
|
|
176
|
+
return { ok: true, message: `signed ${report}.minisig (minisign, key: ${secretKey})` };
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return { ok: false, message: `signing failed: ${(e as Error).message}` };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// pure backend
|
|
183
|
+
const dir = ensurePureKey(homedir());
|
|
184
|
+
const seed = process.env.MUGIWARA_SIGN_KEY?.trim() || readFileSyncSafe(join(dir, 'mugiwara.key'));
|
|
185
|
+
const pub = process.env.MUGIWARA_SIGN_PUB?.trim() || readFileSyncSafe(join(dir, 'mugiwara.pub'));
|
|
186
|
+
if (!seed || !pub) return { ok: false, message: 'pure keys missing — run `mugiwara sign --gen-key --backend pure`' };
|
|
187
|
+
const content = readFileSafe(report);
|
|
188
|
+
if (content === null) return { ok: false, message: `cannot read ${report}` };
|
|
189
|
+
const { commit, ts } = missionMeta(projectDir, mission);
|
|
190
|
+
const sig = pureSign(content, seed, { mission, commit, ts, pub, outputPath: `${report}.mugisig` });
|
|
191
|
+
if (!sig.ok) return { ok: false, message: `signing failed: ${sig.message}` };
|
|
192
|
+
return { ok: true, message: `signed ${report}.mugisig (pure ed25519, key: ${join(dir, 'mugiwara.key')})` };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function verifyReport(projectDir: string, missionDir: string): { ok: boolean; message: string } {
|
|
196
|
+
const report = join(missionDir, 'report.md');
|
|
197
|
+
const minisig = `${report}.minisig`;
|
|
198
|
+
const mugisig = `${report}.mugisig`;
|
|
199
|
+
|
|
200
|
+
// pure first? No — verify what exists; try both, minisig then mugisig.
|
|
201
|
+
if (!existsSync(minisig) && !existsSync(mugisig)) {
|
|
202
|
+
return { ok: false, message: 'not signed (no .minisig or .mugisig beside report.md)' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (existsSync(minisig)) {
|
|
206
|
+
// minisig wins when both signatures exist — deterministic, documented.
|
|
207
|
+
if (!hasMinisign()) return { ok: false, message: 'minisig present but minisign not installed — cannot verify that signature' };
|
|
208
|
+
const pubKey = existsSync(defaultKey('public')) ? defaultKey('public') : null;
|
|
209
|
+
try {
|
|
210
|
+
execFileSync('minisign', verifyArgs(report, pubKey), { cwd: projectDir, stdio: 'pipe' });
|
|
211
|
+
return { ok: true, message: 'signature verifies against report.md (minisig)' };
|
|
212
|
+
} catch {
|
|
213
|
+
return { ok: false, message: 'SIGNATURE INVALID — report.md changed after signing (minisig)' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// mugisig — pure verify
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(readFileSafe(mugisig) ?? '{}') as PureSig;
|
|
220
|
+
const content = readFileSafe(report);
|
|
221
|
+
if (content === null || parsed.algo !== 'ed25519-pure') return { ok: false, message: 'invalid .mugisig file' };
|
|
222
|
+
return pureVerify(content, parsed)
|
|
223
|
+
? { ok: true, message: 'signature verifies against report.md (mugisig, ed25519-pure)' }
|
|
224
|
+
: { ok: false, message: 'SIGNATURE INVALID — report.md changed after signing (mugisig)' };
|
|
225
|
+
} catch {
|
|
226
|
+
return { ok: false, message: 'invalid .mugisig file' };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readFileSafe(p: string): string | null {
|
|
231
|
+
try { return readFileSync(p, 'utf8'); } catch { return null; }
|
|
232
|
+
}
|
|
233
|
+
function readFileSyncSafe(p: string): string {
|
|
234
|
+
try { return readFileSync(p, 'utf8').trim(); } catch { return ''; }
|
|
235
|
+
}
|