@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.opencode/mugiwara-helpers.mjs +2 -2
- package/README.md +196 -330
- package/content/agents/brook-healing.md +1 -1
- package/content/agents/franky-gates.md +1 -1
- package/content/agents/luffy-orchestrator.md +2 -2
- package/content/agents/memory-keeper.md +5 -0
- package/content/agents/usopp-brainstorm.md +3 -2
- package/content/agents/zoro-execution.md +4 -3
- package/content/skills/mugiwara-backend/SKILL.md +52 -43
- package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
- package/content/skills/mugiwara-checkpoint/SKILL.md +21 -8
- package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
- package/content/skills/mugiwara-execution/SKILL.md +34 -33
- 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-frontend/SKILL.md +44 -44
- package/content/skills/mugiwara-gates/SKILL.md +28 -16
- package/content/skills/mugiwara-healing/SKILL.md +30 -25
- package/content/skills/mugiwara-lessons/SKILL.md +3 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +10 -9
- package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
- package/content/skills/mugiwara-planning/SKILL.md +28 -14
- package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
- package/content/skills/mugiwara-planning/references/plan-template.md +22 -0
- package/content/skills/mugiwara-quality/SKILL.md +8 -13
- package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
- package/content/skills/mugiwara-resume/SKILL.md +3 -9
- package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
- package/content/skills/mugiwara-review/SKILL.md +17 -24
- package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
- package/content/skills/mugiwara-security/SKILL.md +47 -35
- package/content/skills/mugiwara-ship/SKILL.md +2 -0
- package/content/skills/mugiwara-workflow/SKILL.md +13 -13
- package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
- package/dist/mugiwara.js +1802 -316
- package/gemini-extension.json +1 -1
- package/hooks/mugiwara-mode-tracker.js +24 -4
- package/hooks/mugiwara-mode-tracker.ts +36 -7
- package/hooks/pipeline-guard.js +1 -1
- package/hooks/pipeline-guard.ts +2 -1
- package/hooks/session-start.js +6 -1
- package/hooks/session-start.ts +8 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/references/cost-governor.md +104 -0
- package/references/multi-actor.md +21 -0
- package/references/posture-routing.md +31 -0
- package/references/wave-banners.md +1 -2
- package/scripts/benchmark-governor.ts +516 -0
- package/scripts/benchmark-thresholds.json +47 -0
- package/scripts/check-doc-links.ts +8 -2
- package/scripts/gate-selftest.ts +104 -21
- package/scripts/lib/lane-base.sh +4 -4
- package/scripts/retrieval-eval.ts +9 -3
- package/scripts/savepoint.sh +41 -2
- package/scripts/validate-content.ts +82 -3
- package/scripts/verify-install.ts +20 -0
- package/scripts/write-metrics.ts +73 -0
- package/src/adaptive-budget.ts +178 -0
- package/src/args.ts +3 -2
- package/src/budget.ts +18 -16
- package/src/check-artifacts.ts +45 -0
- package/src/cli.ts +221 -8
- package/src/cognition.ts +234 -0
- package/src/config.ts +113 -0
- package/src/context.ts +72 -0
- package/src/continue.ts +29 -0
- package/src/cost.ts +189 -0
- package/src/evidence.ts +160 -0
- package/src/installer.ts +2 -16
- package/src/integrity.ts +65 -16
- package/src/investigation.ts +72 -0
- package/src/mission.ts +246 -16
- package/src/policy.ts +355 -2
- package/src/posture.ts +86 -0
- package/src/provenance.ts +29 -9
- package/src/reporting.ts +225 -0
- package/src/scope.ts +321 -0
- package/src/sign.ts +234 -18
- package/src/slop.ts +306 -0
- package/src/work.ts +273 -0
package/src/cognition.ts
ADDED
|
@@ -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
|
+
}
|