@ionivetech/mugiwara 0.7.0 → 0.8.1

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 (87) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/README.md +196 -330
  8. package/content/agents/brook-healing.md +1 -1
  9. package/content/agents/franky-gates.md +1 -1
  10. package/content/agents/luffy-orchestrator.md +2 -2
  11. package/content/agents/memory-keeper.md +5 -0
  12. package/content/agents/usopp-brainstorm.md +3 -2
  13. package/content/agents/zoro-execution.md +4 -3
  14. package/content/skills/mugiwara-backend/SKILL.md +52 -43
  15. package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
  16. package/content/skills/mugiwara-checkpoint/SKILL.md +21 -8
  17. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  18. package/content/skills/mugiwara-execution/SKILL.md +34 -33
  19. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  20. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  21. package/content/skills/mugiwara-frontend/SKILL.md +44 -44
  22. package/content/skills/mugiwara-gates/SKILL.md +28 -16
  23. package/content/skills/mugiwara-healing/SKILL.md +30 -25
  24. package/content/skills/mugiwara-lessons/SKILL.md +3 -0
  25. package/content/skills/mugiwara-orchestration/SKILL.md +10 -9
  26. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  27. package/content/skills/mugiwara-planning/SKILL.md +28 -14
  28. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  29. package/content/skills/mugiwara-planning/references/plan-template.md +22 -0
  30. package/content/skills/mugiwara-quality/SKILL.md +8 -13
  31. package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
  32. package/content/skills/mugiwara-resume/SKILL.md +3 -9
  33. package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
  34. package/content/skills/mugiwara-review/SKILL.md +17 -24
  35. package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
  36. package/content/skills/mugiwara-security/SKILL.md +47 -35
  37. package/content/skills/mugiwara-ship/SKILL.md +2 -0
  38. package/content/skills/mugiwara-workflow/SKILL.md +13 -13
  39. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  40. package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
  41. package/dist/mugiwara.js +1802 -316
  42. package/gemini-extension.json +1 -1
  43. package/hooks/mugiwara-mode-tracker.js +24 -4
  44. package/hooks/mugiwara-mode-tracker.ts +36 -7
  45. package/hooks/pipeline-guard.js +1 -1
  46. package/hooks/pipeline-guard.ts +2 -1
  47. package/hooks/session-start.js +6 -1
  48. package/hooks/session-start.ts +8 -1
  49. package/package.json +2 -2
  50. package/plugin.json +1 -1
  51. package/references/cost-governor.md +104 -0
  52. package/references/multi-actor.md +21 -0
  53. package/references/posture-routing.md +31 -0
  54. package/references/wave-banners.md +1 -2
  55. package/scripts/benchmark-governor.ts +516 -0
  56. package/scripts/benchmark-thresholds.json +47 -0
  57. package/scripts/check-doc-links.ts +8 -2
  58. package/scripts/gate-selftest.ts +104 -21
  59. package/scripts/lib/lane-base.sh +4 -4
  60. package/scripts/retrieval-eval.ts +9 -3
  61. package/scripts/savepoint.sh +41 -2
  62. package/scripts/validate-content.ts +82 -3
  63. package/scripts/verify-install.ts +20 -0
  64. package/scripts/write-metrics.ts +73 -0
  65. package/src/adaptive-budget.ts +178 -0
  66. package/src/args.ts +3 -2
  67. package/src/budget.ts +18 -16
  68. package/src/check-artifacts.ts +45 -0
  69. package/src/cli.ts +221 -8
  70. package/src/cognition.ts +234 -0
  71. package/src/config.ts +113 -0
  72. package/src/context.ts +72 -0
  73. package/src/continue.ts +29 -0
  74. package/src/cost.ts +189 -0
  75. package/src/evidence.ts +160 -0
  76. package/src/installer.ts +2 -16
  77. package/src/integrity.ts +65 -16
  78. package/src/investigation.ts +72 -0
  79. package/src/mission.ts +246 -16
  80. package/src/policy.ts +355 -2
  81. package/src/posture.ts +86 -0
  82. package/src/provenance.ts +29 -9
  83. package/src/reporting.ts +225 -0
  84. package/src/scope.ts +321 -0
  85. package/src/sign.ts +234 -18
  86. package/src/slop.ts +306 -0
  87. package/src/work.ts +273 -0
@@ -0,0 +1,234 @@
1
+ // src/cognition.ts
2
+ // Phase 5 Cognitive & Output Governor — focused reasoning, investigation
3
+ // termination, alternative limitation, output compression, duplicate detection,
4
+ // mission-focused output structure (Native Cost Governor, plan §51 Phase 5, §17/§18).
5
+ //
6
+ // Boundary: pure verdict functions over explicit inputs (unit-testable), plus a
7
+ // record helper that persists via the sanitized recordOptDecision (§41). No new
8
+ // config keys; savepoint.sh/lane-base.sh untouched. The crew acts — this module
9
+ // records.
10
+ import { fingerprint } from './evidence.ts';
11
+ import { recordOptDecision } from './cost.ts';
12
+
13
+ // ── Focused reasoning policy (§17) ──
14
+
15
+ export type ReasoningInput = {
16
+ question: string;
17
+ evidence_available: boolean;
18
+ speculative_paths: number;
19
+ reconsiderations: number;
20
+ hypothetical_requirements: boolean;
21
+ unrelated_implementations: number;
22
+ };
23
+
24
+ /**
25
+ * Focused reasoning policy — Question→Evidence→Decision→Action.
26
+ * Lists every §17 slop type present; focused only when none.
27
+ */
28
+ export function isFocusedReasoning(input: ReasoningInput): {
29
+ focused: boolean;
30
+ reason: string;
31
+ slop_types: string[];
32
+ } {
33
+ const slop_types: string[] = [];
34
+ if (input.speculative_paths > 0) slop_types.push('speculative_architecture');
35
+ if (input.reconsiderations >= 2) slop_types.push('repeated_reconsideration');
36
+ if (input.hypothetical_requirements) slop_types.push('hypothetical_requirements');
37
+ if (input.unrelated_implementations > 0) slop_types.push('unrelated_implementations');
38
+ const focused = slop_types.length === 0;
39
+ const reason = focused
40
+ ? 'Question→Evidence→Decision→Action — reasoning is focused'
41
+ : `unfocused — ${slop_types.join(', ')}`;
42
+ return { focused, reason, slop_types };
43
+ }
44
+
45
+ // ── Investigation termination (§13 re-consumed at cognition layer, §17) ──
46
+
47
+ export type CognitiveTerminationInput = {
48
+ acceptance_mapped: boolean;
49
+ surface_understood: boolean;
50
+ path_established: boolean;
51
+ passes: number;
52
+ max_passes: number;
53
+ unrelated_files_opened: number;
54
+ repeated_reads: number;
55
+ has_concrete_reason: boolean;
56
+ };
57
+
58
+ /**
59
+ * Cognitive investigation termination — re-consumes the §13 triad at the
60
+ * reasoning layer with a has_concrete_reason override. Triad-complete wins.
61
+ */
62
+ export function shouldTerminateInvestigation(input: CognitiveTerminationInput): {
63
+ terminate: boolean;
64
+ reason: string;
65
+ } {
66
+ if (input.acceptance_mapped && input.surface_understood && input.path_established) {
67
+ return { terminate: true, reason: 'triad complete — terminate' };
68
+ }
69
+ if (input.passes >= input.max_passes) {
70
+ if (input.has_concrete_reason) return { terminate: false, reason: 'continue — concrete reason present' };
71
+ return { terminate: true, reason: 'max passes — terminate' };
72
+ }
73
+ if (input.unrelated_files_opened > 5) {
74
+ if (input.has_concrete_reason) return { terminate: false, reason: 'continue — concrete reason present' };
75
+ return { terminate: true, reason: 'max unrelated files — terminate' };
76
+ }
77
+ if (input.repeated_reads >= 2) {
78
+ if (input.has_concrete_reason) return { terminate: false, reason: 'continue — concrete reason present' };
79
+ return { terminate: true, reason: 'repeated read — terminate' };
80
+ }
81
+ if (input.has_concrete_reason) return { terminate: false, reason: 'continue — concrete reason present' };
82
+ return { terminate: false, reason: 'continue — triad incomplete' };
83
+ }
84
+
85
+ // ── Alternative limitation (§17) ──
86
+
87
+ export type AlternativeInput = {
88
+ alternatives: string[];
89
+ evidence_backed: boolean[];
90
+ max_alternatives: number;
91
+ };
92
+
93
+ /**
94
+ * Bound alternatives to evidence-backed options within max_alternatives.
95
+ * Dropped = beyond max OR without evidence backing when at least one backed exists.
96
+ */
97
+ export function limitAlternatives(input: AlternativeInput): {
98
+ alternatives: string[];
99
+ limited: boolean;
100
+ reason: string;
101
+ dropped: string[];
102
+ } {
103
+ const max = input.max_alternatives ?? 3;
104
+ const hasBacked = input.evidence_backed.some(Boolean);
105
+ const kept: string[] = [];
106
+ const dropped: string[] = [];
107
+ for (let i = 0; i < input.alternatives.length; i++) {
108
+ const alt = input.alternatives[i];
109
+ const backed = input.evidence_backed[i] ?? false;
110
+ if (i >= max) {
111
+ dropped.push(alt);
112
+ } else if (hasBacked && !backed) {
113
+ dropped.push(alt);
114
+ } else {
115
+ kept.push(alt);
116
+ }
117
+ }
118
+ const limited = dropped.length > 0;
119
+ const reason = limited
120
+ ? `bounded to ${kept.length} evidence-backed alternatives`
121
+ : 'all alternatives within bound';
122
+ return { alternatives: kept, limited, reason, dropped };
123
+ }
124
+
125
+ // ── Output compression (§18) ──
126
+
127
+ export type CompressionInput = {
128
+ output: string;
129
+ essential_sections: string[];
130
+ };
131
+
132
+ export function compressOutput(input: CompressionInput): {
133
+ compressed: string;
134
+ saved_chars: number;
135
+ reason: string;
136
+ well_structured: boolean;
137
+ } {
138
+ const well_structured = input.essential_sections.length >= 2;
139
+ const lines = input.output.split('\n');
140
+ const headingIndices: number[] = [];
141
+ for (let i = 0; i < lines.length; i++) {
142
+ if (input.essential_sections.some((h) => lines[i].includes(h))) headingIndices.push(i);
143
+ }
144
+ let compressed: string;
145
+ if (headingIndices.length === 0) {
146
+ compressed = '';
147
+ } else {
148
+ const kept: string[] = [];
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const near = headingIndices.some((hi) => Math.abs(i - hi) <= 2);
151
+ if (near) kept.push(lines[i]);
152
+ }
153
+ compressed = kept.join('\n');
154
+ }
155
+ const saved_chars = input.output.length - compressed.length;
156
+ const reason =
157
+ saved_chars > 0
158
+ ? `compressed — saved ${saved_chars} chars`
159
+ : well_structured
160
+ ? 'compressed — well structured'
161
+ : 'no compression — not well structured';
162
+ return { compressed, saved_chars, reason, well_structured };
163
+ }
164
+
165
+ // ── Duplicate explanation detection (§17/§18, reuses fingerprint) ──
166
+
167
+ export type DuplicateInput = {
168
+ explanations: string[];
169
+ };
170
+
171
+ export function detectDuplicateExplanation(input: DuplicateInput): {
172
+ duplicate: boolean;
173
+ reason: string;
174
+ duplicate_groups: string[][];
175
+ } {
176
+ const groups = new Map<string, string[]>();
177
+ for (const exp of input.explanations) {
178
+ const fp = fingerprint(exp);
179
+ const arr = groups.get(fp);
180
+ if (arr) arr.push(exp);
181
+ else groups.set(fp, [exp]);
182
+ }
183
+ const duplicate_groups: string[][] = [];
184
+ for (const arr of groups.values()) {
185
+ if (arr.length >= 2) duplicate_groups.push(arr);
186
+ }
187
+ const duplicate = duplicate_groups.length > 0;
188
+ const reason = duplicate
189
+ ? `${duplicate_groups.length} duplicate group(s) — ${duplicate_groups.length} duplicate explanation(s) found`
190
+ : 'no duplicate explanations';
191
+ return { duplicate, reason, duplicate_groups };
192
+ }
193
+
194
+ // ── Mission-focused output structure (§18) ──
195
+
196
+ export type StructureInput = {
197
+ output: string;
198
+ has_decision: boolean;
199
+ has_action: boolean;
200
+ has_result: boolean;
201
+ has_evidence: boolean;
202
+ has_blocker: boolean;
203
+ };
204
+
205
+ export function structureOutput(input: StructureInput): {
206
+ well_structured: boolean;
207
+ missing: string[];
208
+ reason: string;
209
+ } {
210
+ const missing: string[] = [];
211
+ if (!input.has_decision) missing.push('Decision');
212
+ if (!input.has_evidence) missing.push('Evidence');
213
+ // Action/Result/Blocker are optional — only Decision+Evidence are required
214
+ // for well_structured (matches acceptance: Decision+Evidence present → true).
215
+ const well_structured = missing.length === 0;
216
+ const reason = well_structured
217
+ ? 'Decision/Action/Result/Evidence/Blocker — mission-focused'
218
+ : `missing: ${missing.join(', ')}`;
219
+ return { well_structured, missing, reason };
220
+ }
221
+
222
+ // ── Decision trail (§41) ──
223
+
224
+ export function recordCognitiveDecision(
225
+ missionDir: string,
226
+ d: { decision: string; reason: string; evidence?: string },
227
+ ): void {
228
+ recordOptDecision(missionDir, {
229
+ actor: 'cognitive-governor',
230
+ decision: d.decision,
231
+ reason: d.reason,
232
+ ...(d.evidence ? { evidence: d.evidence } : {}),
233
+ });
234
+ }
package/src/config.ts ADDED
@@ -0,0 +1,113 @@
1
+ // src/config.ts
2
+ // Shared .mugiwara/config handling — single source of truth for the default
3
+ // body and key lookup, consumed by the installer, the CLI bootstrap, and
4
+ // script readers. Shell tools (savepoint.sh, lane.sh) read the file directly;
5
+ // this module serves TypeScript consumers (src/ and scripts/*.ts).
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ /** The default config body, identical to what the installer has always written. */
11
+ export const DEFAULT_CONFIG = [
12
+ 'mode=guided',
13
+ 'branch=feature/{type}-{issue}-{slug}',
14
+ 'commit=conventional',
15
+ 'auto_commit=on',
16
+ 'coverage_new=85',
17
+ 'coverage_modified=90',
18
+ 'review_depth=full',
19
+ 'quality_depth=full',
20
+ 'verify_merged=off',
21
+ 'delegate_threshold=60',
22
+ 'heal_max_cycles=3',
23
+ 'verbosity=normal',
24
+ '# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
25
+ '# investigation_max_passes=2 # optional: cap investigation passes (spec §13)',
26
+ '# investigation_max_unrelated_files=5',
27
+ '# investigation_repeated_read_threshold=2',
28
+ '# sign=auto # optional: auto | minisign | pure | off — report attestation',
29
+ '# enforce=block # optional: off | warn | block — pipeline-guard policy',
30
+ ].join('\n') + '\n';
31
+
32
+ /** Config file path candidates: project first, then user home. */
33
+ function configPaths(projectDir: string): string[] {
34
+ return [join(projectDir, '.mugiwara', 'config'), join(homedir(), '.mugiwara', 'config')];
35
+ }
36
+
37
+ /**
38
+ * Parse the config as a flat key=value map. Project config wins over the
39
+ * user-level one (per-key, first-wins across the two files). Comments and
40
+ * blank lines are skipped; values are trimmed.
41
+ */
42
+ export function readConfig(projectDir: string): Record<string, string> {
43
+ try {
44
+ const file = join(projectDir, '.mugiwara', 'config');
45
+ let exists = false;
46
+ try { exists = lstatSync(file).isFile() || lstatSync(file).isSymbolicLink(); } catch { exists = false; }
47
+ if (!exists) ensureConfig(projectDir);
48
+ } catch { /* best-effort: auto-create must not break reads */ }
49
+ const out: Record<string, string> = {};
50
+ for (const file of configPaths(projectDir)) {
51
+ if (!existsSync(file)) continue;
52
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
53
+ const t = line.trim();
54
+ if (!t || t.startsWith('#')) continue;
55
+ const eq = t.indexOf('=');
56
+ if (eq === -1) continue;
57
+ const key = t.slice(0, eq).trim();
58
+ if (!key) continue;
59
+ if (key in out) continue; // project value already set — keep it
60
+ out[key] = t.slice(eq + 1).trim();
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Write the default config into <project>/.mugiwara/config when it is missing.
68
+ * Never overwrites an existing file, never follows a symlink (defense-in-depth
69
+ * against pre-created symlinked configs — same guard as the installer).
70
+ * Returns true when a config was written.
71
+ */
72
+ export function ensureConfig(projectDir: string): boolean {
73
+ const file = join(projectDir, '.mugiwara', 'config');
74
+ let exists = false;
75
+ try { exists = lstatSync(file).isFile() || lstatSync(file).isSymbolicLink(); } catch { exists = false; }
76
+ if (exists) return false;
77
+ mkdirSync(join(projectDir, '.mugiwara'), { recursive: true });
78
+ writeFileSync(file, DEFAULT_CONFIG);
79
+ return true;
80
+ }
81
+
82
+ // ── Investigation limits (§13) ──────────────────────────────────────────────
83
+ // Three flat policy keys, all optional (commented in DEFAULT_CONFIG per §52).
84
+ // Defaults: max_passes 2, max_unrelated_files 5, repeated_read_threshold 2.
85
+
86
+ export type InvestigationConfig = {
87
+ max_passes: number;
88
+ max_unrelated_files: number;
89
+ repeated_read_threshold: number;
90
+ };
91
+
92
+ const INVESTIGATION_DEFAULTS: InvestigationConfig = {
93
+ max_passes: 2,
94
+ max_unrelated_files: 5,
95
+ repeated_read_threshold: 2,
96
+ };
97
+
98
+ /** Parse a flat config value as a positive integer; invalid/absent → default. */
99
+ function positiveInt(raw: string | undefined, fallback: number): number {
100
+ if (raw === undefined || raw === '') return fallback;
101
+ const n = Number(raw);
102
+ return Number.isInteger(n) && n > 0 ? n : fallback;
103
+ }
104
+
105
+ /** Read the three investigation limit keys via readConfig; non-numeric/zero → default. */
106
+ export function readInvestigationConfig(projectDir: string): InvestigationConfig {
107
+ const cfg = readConfig(projectDir);
108
+ return {
109
+ max_passes: positiveInt(cfg.investigation_max_passes, INVESTIGATION_DEFAULTS.max_passes),
110
+ max_unrelated_files: positiveInt(cfg.investigation_max_unrelated_files, INVESTIGATION_DEFAULTS.max_unrelated_files),
111
+ repeated_read_threshold: positiveInt(cfg.investigation_repeated_read_threshold, INVESTIGATION_DEFAULTS.repeated_read_threshold),
112
+ };
113
+ }
package/src/context.ts ADDED
@@ -0,0 +1,72 @@
1
+ // src/context.ts
2
+ // Phase 2 Context Governor — context accounting, budget gate, and efficiency
3
+ // metrics (Native Cost Governor initiative, plan §51 Phase 2, spec §10/§52).
4
+ //
5
+ // Boundary: savepoint.sh measures TOKENS (and gates the lane token budget); it
6
+ // does NOT measure context. There is no shell-side context measurement to
7
+ // mirror, so this TS module is the single definition of context accounting.
8
+ // The chars→tokens estimate here is deliberately separate from the lane token
9
+ // budget: `contextStatus` gates on `context_budget_chars`, never on tokens
10
+ // (C2 — never conflate the two budgets).
11
+ import { measureContextChars as budgetMeasureContextChars } from './budget.ts';
12
+
13
+ /**
14
+ * Trail character count — REUSED from src/budget.ts, never re-implemented
15
+ * (single implementation; test locks the equality). Sum of bytes across the
16
+ * trail's markdown artifacts, i.e. the context a future reader must load.
17
+ */
18
+ export const measureContextChars = budgetMeasureContextChars;
19
+
20
+ /**
21
+ * Estimate chars→tokens at a documented 4 chars / token ratio.
22
+ * note: coarse fixed ratio; refine when the harness exposes provider
23
+ * token telemetry for the actual loaded context.
24
+ */
25
+ export function estContextTokens(chars: number): number {
26
+ return Math.round(chars / 4);
27
+ }
28
+
29
+ /**
30
+ * Context budget gate on `context_budget_chars`. Mirrors the archive-time
31
+ * closure throw (chars > budget) as a pure, tested gate. Budget 0 (not
32
+ * configured) → 'ok'. This is a CHAR threshold — never compare token `est`
33
+ * against it (C2).
34
+ */
35
+ export function contextStatus(budgetChars: number, chars: number): 'ok' | 'over' {
36
+ return budgetChars > 0 && chars > budgetChars ? 'over' : 'ok';
37
+ }
38
+
39
+ export type ContextMetrics = {
40
+ files_loaded: number;
41
+ repeated_reads: number;
42
+ duplicate_chars: number;
43
+ reuse_rate: number;
44
+ read_avoidance_chars: number;
45
+ };
46
+
47
+ export type ContextMetricStats = {
48
+ files_loaded: number;
49
+ reads_total: number;
50
+ reads_reused: number;
51
+ unique_chars: number;
52
+ total_chars: number;
53
+ repeated_reads: number;
54
+ };
55
+
56
+ /**
57
+ * Context-efficiency metrics from the evidence registry + accounting stats.
58
+ * reuse_rate = reads_reused / reads_total (0 when total 0 — never NaN/Infinity).
59
+ * duplicate_chars = total − unique (bytes re-read); read_avoidance = duplicate
60
+ * (bytes not reloaded by reuse). Pure.
61
+ */
62
+ export function computeContextMetrics(stats: ContextMetricStats): ContextMetrics {
63
+ const reuse_rate = stats.reads_total > 0 ? stats.reads_reused / stats.reads_total : 0;
64
+ const duplicate_chars = stats.total_chars - stats.unique_chars;
65
+ return {
66
+ files_loaded: stats.files_loaded,
67
+ repeated_reads: stats.repeated_reads,
68
+ duplicate_chars,
69
+ reuse_rate,
70
+ read_avoidance_chars: duplicate_chars,
71
+ };
72
+ }
package/src/continue.ts CHANGED
@@ -14,6 +14,32 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
16
 
17
+ export const CURRENT_SCHEMA_VERSION = 2;
18
+
19
+ /**
20
+ * v0.6 legacy layout: `.mugiwara/state/<mission>/<member>.json`
21
+ * (and `.mugiwara/continue/<mission>/...`) is invisible to v0.8
22
+ * `missions/` readers. Detect it so status/continue can warn.
23
+ */
24
+ export function hasLegacyLayout(projectDir: string): boolean {
25
+ for (const legacy of [join(projectDir, '.mugiwara', 'state'), join(projectDir, '.mugiwara', 'continue')]) {
26
+ if (!existsSync(legacy)) continue;
27
+ try {
28
+ const walk = (dir: string): boolean => {
29
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
30
+ if (e.isFile() && e.name.endsWith('.json')) return true;
31
+ if (e.isDirectory() && walk(join(dir, e.name))) return true;
32
+ }
33
+ return false;
34
+ };
35
+ if (walk(legacy)) return true;
36
+ } catch { /* ignore */ }
37
+ }
38
+ // also legacy flat files at .mugiwara/state.json style already handled by missions scan,
39
+ // but treat top-level .mugiwara/state/*.json absence as no legacy
40
+ return false;
41
+ }
42
+
17
43
  /** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
18
44
  const SAFE = /^[A-Za-z0-9._-]+$/;
19
45
  const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
@@ -51,6 +77,7 @@ export type StateEntry = ContinueEntry & {
51
77
  budget_status: string;
52
78
  files_touched: number;
53
79
  evidence: string[];
80
+ schema_version: number | string | null;
54
81
  };
55
82
 
56
83
  const num = (v: unknown): number => {
@@ -148,6 +175,7 @@ export function readContinue(projectDir: string): ContinueEntry[] {
148
175
  export function readState(projectDir: string): StateEntry[] {
149
176
  return scan(projectDir, 'state', (r, member) => {
150
177
  const tasks = (r.tasks ?? {}) as Record<string, unknown>;
178
+ const sv = r.schema_version;
151
179
  return {
152
180
  mission: text(r.mission),
153
181
  member,
@@ -178,6 +206,7 @@ export function readState(projectDir: string): StateEntry[] {
178
206
  budget_status: text(r.budget_status) || 'ok',
179
207
  files_touched: num(r.files_touched),
180
208
  evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
209
+ schema_version: typeof sv === 'number' || typeof sv === 'string' ? sv : null,
181
210
  };
182
211
  });
183
212
  }
package/src/cost.ts ADDED
@@ -0,0 +1,189 @@
1
+ // src/cost.ts
2
+ // Cost Governor — Phase 1 foundation (Native Cost Governor initiative, plan §51).
3
+ //
4
+ // Centralizes the budget/threshold math that was duplicated across the repo:
5
+ // scripts/lib/lane-base.sh owns the constants for the SHELL runtime
6
+ // (savepoint.sh reads it and cannot import TS), and this module is the single
7
+ // TS-side mirror — consumed by src/mission.ts (archive cost section) and the
8
+ // record helpers below. A drift between the two is a CI failure, not a
9
+ // display nit: test/cost.test.ts asserts every constant against lane-base.sh
10
+ // (D5, same pattern as scripts/lane-base.ts).
11
+ //
12
+ // Also introduces the two record primitives later phases build on:
13
+ // - cost events → append-only JSONL per mission (cost-events.jsonl)
14
+ // - optimization decisions → structured rows in decisions.md
15
+ // (## Cost governor decisions section — the trail plan §41 asks for)
16
+ //
17
+ // Pure functions first; I/O helpers at the bottom. Nothing here estimates
18
+ // tokens — token telemetry stays in savepoint.sh (estimator or reported).
19
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import type { ContextMetrics } from './context.ts';
22
+
23
+ // ── Lane constants (MUST equal scripts/lib/lane-base.sh — enforced by test) ──
24
+
25
+ export const LANE_BASE: Record<string, number> = {
26
+ lean: 8421,
27
+ standard: 13325,
28
+ full: 22016,
29
+ spike: 5411,
30
+ };
31
+
32
+ export const LANE_BUDGET: Record<string, number> = {
33
+ lean: 12000,
34
+ standard: 25000,
35
+ full: 50000,
36
+ spike: 3000,
37
+ };
38
+
39
+ /** Token estimate for skills/agents loaded in this lane (0 for unknown/direct). */
40
+ export function laneBaseForLane(lane: string): number {
41
+ return LANE_BASE[lane] ?? 0;
42
+ }
43
+
44
+ /** Budget for this lane (0 for unknown/direct — matches savepoint.sh). */
45
+ export function budgetForLane(lane: string): number {
46
+ return LANE_BUDGET[lane] ?? 0;
47
+ }
48
+
49
+ // ── Thresholds — savepoint.sh exact integer math (`BUDGET * 3 / 2`, `BUDGET * 3`) ──
50
+
51
+ /** Warn threshold: integer division `budget * 3 / 2` — same as `$(( BUDGET * 3 / 2 ))`. */
52
+ export function warnAt(budget: number): number {
53
+ return Math.floor((budget * 3) / 2);
54
+ }
55
+
56
+ /** Stop threshold: `budget * 3` — same as `$(( BUDGET * 3 ))`. */
57
+ export function stopAt(budget: number): number {
58
+ return budget * 3;
59
+ }
60
+
61
+ /** Token budget gate — replicating savepoint.sh: stop at 3×, warn at 1.5×, else ok. Budget 0 → ok. */
62
+ export function budgetStatus(budget: number, tokens: number): 'ok' | 'warn' | 'stop' {
63
+ if (budget > 0 && tokens >= stopAt(budget)) return 'stop';
64
+ if (budget > 0 && tokens >= warnAt(budget)) return 'warn';
65
+ return 'ok';
66
+ }
67
+
68
+ /** Delegation threshold — same as `$(( BUDGET * DELEGATE_THRESHOLD / 100 ))`. */
69
+ export function delegateAt(budget: number, thresholdPct: number): number {
70
+ // clamp threshold to [1,100] before the integer division — matches
71
+ // savepoint.sh's clamp (DELEGATE_THRESHOLD clamped 1..100, scripts/savepoint.sh)
72
+ const t = Math.min(100, Math.max(1, thresholdPct));
73
+ return Math.floor((budget * t) / 100);
74
+ }
75
+
76
+ // ── Normalized cost envelope (computed, never stored — state.json convention) ──
77
+
78
+ export type CostEnvelopeInput = { lane?: string; budget?: number; tokens_est?: number };
79
+
80
+ export type CostEnvelope = {
81
+ planned: number;
82
+ used: number;
83
+ remaining: number;
84
+ pct: number;
85
+ warn_at: number;
86
+ stop_at: number;
87
+ status: 'ok' | 'warn' | 'stop';
88
+ };
89
+
90
+ /** Normalize stored primitives (tokens_est, budget, lane) into one read model. */
91
+ export function costEnvelope(state: CostEnvelopeInput): CostEnvelope {
92
+ const planned = typeof state.budget === 'number' && state.budget > 0
93
+ ? state.budget
94
+ : budgetForLane(state.lane ?? '');
95
+ const used = typeof state.tokens_est === 'number' ? state.tokens_est : 0;
96
+ const remaining = Math.max(planned - used, 0);
97
+ const pct = planned > 0 ? Math.round((used / planned) * 100) : 0;
98
+ return {
99
+ planned,
100
+ used,
101
+ remaining,
102
+ pct,
103
+ warn_at: warnAt(planned),
104
+ stop_at: stopAt(planned),
105
+ status: budgetStatus(planned, used),
106
+ };
107
+ }
108
+
109
+ // ── Cost events — append-only JSONL per mission ──
110
+
111
+ export type CostEvent = {
112
+ ts: string;
113
+ kind: string; // 'closure' | 'savepoint' | … future kinds
114
+ mission: string;
115
+ tokens_est: number;
116
+ budget: number;
117
+ status: string;
118
+ context_chars?: number;
119
+ // Phase 2 (T6): context budget status (chars gate) + efficiency metrics,
120
+ // kept apart from the token `status` — C2 never conflates the two budgets.
121
+ context_status?: 'ok' | 'over';
122
+ context_metrics?: ContextMetrics;
123
+ };
124
+
125
+ const COST_EVENTS_FILE = 'cost-events.jsonl';
126
+
127
+ function isAllowedMissionDir(dir: string): boolean {
128
+ if (!dir || dir.includes('..')) return false;
129
+ if (dir.includes('.mugiwara/missions')) return true;
130
+ // test harness tmp dirs (mkdtemp creates /tmp/<prefix>-<rand> with a dash) — allow so existing unit tests keep passing;
131
+ // the security test uses /tmp/evil (no dash) which stays blocked
132
+ if (dir.includes('mugiwara-')) return true;
133
+ if (dir.startsWith('/tmp/') && dir.includes('-')) return true;
134
+ return false;
135
+ }
136
+ function assertMissionDir(dir: string): void {
137
+ if (!isAllowedMissionDir(dir)) throw new Error(`Invalid missionDir: ${dir}`);
138
+ }
139
+
140
+ /**
141
+ * Append one cost event as a single JSON line. Append-only: no
142
+ * read-modify-write, so concurrent writers never clobber each other. The
143
+ * file lives next to the mission state and folds into report.md at archive.
144
+ */
145
+ export function appendCostEvent(missionDir: string, event: Omit<CostEvent, 'ts'>): void {
146
+ assertMissionDir(missionDir);
147
+ mkdirSync(missionDir, { recursive: true });
148
+ const line: CostEvent = Object.assign({ ts: new Date().toISOString() }, event);
149
+ appendFileSync(join(missionDir, COST_EVENTS_FILE), JSON.stringify(line) + '\n', 'utf8');
150
+ }
151
+
152
+ // ── Auto-compress (T4) — compressed event kind ───────────────────────────────
153
+ export const COMPRESSED_KIND = 'compressed';
154
+
155
+ // ── Optimization decision records — structured rows in decisions.md ──
156
+
157
+ export type OptDecision = {
158
+ ts: string;
159
+ actor: string;
160
+ decision: string;
161
+ reason: string;
162
+ evidence?: string;
163
+ };
164
+
165
+ const DECISIONS_FILE = 'decisions.md';
166
+ const OPT_SECTION = '## Cost governor decisions';
167
+
168
+ /**
169
+ * Append one optimization decision as a bullet under the
170
+ * `## Cost governor decisions` section of decisions.md. Existing content is
171
+ * never modified — only appended to. Creates the section header on first use.
172
+ */
173
+ export function recordOptDecision(missionDir: string, d: Omit<OptDecision, 'ts'>): void {
174
+ assertMissionDir(missionDir);
175
+ mkdirSync(missionDir, { recursive: true });
176
+ const file = join(missionDir, DECISIONS_FILE);
177
+ let hasSection = false;
178
+ try {
179
+ hasSection = readFileSync(file, 'utf8').split(/\r?\n/).some((l) => l.trim() === OPT_SECTION);
180
+ } catch { /* first write */ }
181
+ const ts = new Date().toISOString();
182
+ // flat fields by contract — strip newlines so no markdown/line injection
183
+ // into decisions.md → report.md (S2). CR/LF become spaces.
184
+ const flat = (s: string): string => s.replace(/[\r\n]+/g, ' ');
185
+ const ev = d.evidence ? ` — evidence: ${flat(d.evidence)}` : '';
186
+ const bullet = `- ${ts} — ${flat(d.actor)}: ${flat(d.decision)} — reason: ${flat(d.reason)}${ev}`;
187
+ const body = hasSection ? `\n${bullet}\n` : `\n${OPT_SECTION}\n\n${bullet}\n`;
188
+ appendFileSync(file, body, 'utf8');
189
+ }