@ionivetech/mugiwara 0.6.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -7
  3. package/.codex-plugin/plugin.json +2 -7
  4. package/.cursor-plugin/plugin.json +2 -7
  5. package/.kimi-plugin/plugin.json +2 -7
  6. package/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/.opencode/plugins/mugiwara.mjs +3 -18
  8. package/AGENTS.md +5 -4
  9. package/GEMINI.md +3 -3
  10. package/README.md +203 -331
  11. package/content/agents/brook-healing.md +8 -4
  12. package/content/agents/chopper-checkpoint.md +7 -3
  13. package/content/agents/eval-runner.md +4 -4
  14. package/content/agents/franky-gates.md +4 -4
  15. package/content/agents/jinbe-security.md +6 -7
  16. package/content/agents/luffy-orchestrator.md +11 -13
  17. package/content/agents/memory-keeper.md +4 -4
  18. package/content/agents/nami-planner.md +4 -4
  19. package/content/agents/resume-coordinator.md +7 -7
  20. package/content/agents/robin-reviewer.md +7 -3
  21. package/content/agents/sanji-quality.md +2 -2
  22. package/content/agents/skeptic-verifier.md +2 -2
  23. package/content/agents/usopp-brainstorm.md +3 -3
  24. package/content/agents/zoro-execution.md +6 -6
  25. package/content/skills/mugiwara-backend/SKILL.md +54 -43
  26. package/content/skills/mugiwara-backend/references/database.md +61 -0
  27. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  28. package/content/skills/mugiwara-checkpoint/SKILL.md +23 -11
  29. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
  30. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  31. package/content/skills/mugiwara-execution/SKILL.md +37 -37
  32. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  33. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  34. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  35. package/content/skills/mugiwara-frontend/SKILL.md +44 -43
  36. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  37. package/content/skills/mugiwara-gates/SKILL.md +23 -14
  38. package/content/skills/mugiwara-healing/SKILL.md +26 -25
  39. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  40. package/content/skills/mugiwara-orchestration/SKILL.md +11 -11
  41. package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
  42. package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
  43. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  44. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
  45. package/content/skills/mugiwara-planning/SKILL.md +26 -29
  46. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  47. package/content/skills/mugiwara-planning/references/plan-template.md +24 -2
  48. package/content/skills/mugiwara-quality/SKILL.md +21 -12
  49. package/content/skills/mugiwara-resume/SKILL.md +8 -3
  50. package/content/skills/mugiwara-review/SKILL.md +19 -13
  51. package/content/skills/mugiwara-security/SKILL.md +47 -36
  52. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  53. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  54. package/content/skills/mugiwara-workflow/SKILL.md +11 -17
  55. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
  56. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
  57. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
  58. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  59. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
  60. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +42 -38
  62. package/dist/mugiwara.js +1642 -528
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +18 -10
  65. package/hooks/auto-savepoint.ts +23 -23
  66. package/hooks/engagement-marker.js +1 -1
  67. package/hooks/engagement-marker.ts +1 -1
  68. package/hooks/pipeline-guard.js +17 -13
  69. package/hooks/pipeline-guard.ts +24 -20
  70. package/hooks/session-start.js +13 -10
  71. package/hooks/session-start.ts +24 -19
  72. package/package.json +2 -2
  73. package/plugin.json +1 -1
  74. package/references/complexity.md +27 -4
  75. package/references/definition-of-done.md +1 -1
  76. package/references/multi-actor.md +20 -2
  77. package/references/posture-routing.md +31 -0
  78. package/references/prose-style.md +54 -0
  79. package/scripts/benchmark-governor.ts +516 -0
  80. package/scripts/benchmark-thresholds.json +47 -0
  81. package/scripts/check-doc-links.ts +46 -0
  82. package/scripts/conformance.ts +6 -16
  83. package/scripts/coverage-gate.ts +20 -6
  84. package/scripts/gate-selftest.ts +138 -7
  85. package/scripts/lane.sh +54 -8
  86. package/scripts/lib/lane-base.sh +4 -4
  87. package/scripts/lib/patterns.sh +5 -0
  88. package/scripts/policy-force.ts +22 -0
  89. package/scripts/retrieval-eval.ts +10 -4
  90. package/scripts/savepoint.sh +155 -58
  91. package/scripts/validate-content.ts +43 -11
  92. package/scripts/verify-install.ts +9 -7
  93. package/src/adaptive-budget.ts +178 -0
  94. package/src/args.ts +4 -3
  95. package/src/budget.ts +47 -0
  96. package/src/check-artifacts.ts +45 -0
  97. package/src/cli.ts +257 -108
  98. package/src/cognition.ts +234 -0
  99. package/src/config.ts +107 -0
  100. package/src/context.ts +72 -0
  101. package/src/continue.ts +29 -10
  102. package/src/cost.ts +186 -0
  103. package/src/evidence.ts +160 -0
  104. package/src/installer.ts +25 -34
  105. package/src/integrity.ts +158 -0
  106. package/src/investigation.ts +72 -0
  107. package/src/mission.ts +341 -94
  108. package/src/policy.ts +156 -0
  109. package/src/posture.ts +86 -0
  110. package/src/provenance.ts +116 -0
  111. package/src/reporting.ts +225 -0
  112. package/src/rollback.ts +95 -0
  113. package/src/routing.ts +69 -0
  114. package/src/run.ts +2 -2
  115. package/src/scope.ts +321 -0
  116. package/src/sign.ts +235 -0
  117. package/src/slop.ts +306 -0
  118. package/src/targets/claude.ts +2 -2
  119. package/src/work.ts +273 -0
  120. package/.opencode/commands/mugiwara-execute.md +0 -13
  121. package/.opencode/commands/mugiwara-heal.md +0 -13
  122. package/.opencode/commands/mugiwara-onboard.md +0 -14
  123. package/.opencode/commands/mugiwara-plan.md +0 -17
  124. package/.opencode/commands/mugiwara-ship.md +0 -13
  125. package/.opencode/commands/using-mugiwara.md +0 -20
  126. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  127. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  128. package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
  129. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  130. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  131. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  132. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  133. package/content/skills/using-mugiwara/SKILL.md +0 -51
  134. package/references/token-budget.md +0 -56
  135. package/scripts/evidence.sh +0 -81
  136. package/scripts/initiative.ts +0 -296
  137. package/scripts/mission-report.sh +0 -293
  138. package/src/onboard.ts +0 -207
package/src/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
+ }