@ionivetech/mugiwara 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +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 +194 -328
- package/content/agents/franky-gates.md +1 -1
- package/content/agents/luffy-orchestrator.md +2 -2
- package/content/skills/mugiwara-backend/SKILL.md +52 -43
- package/content/skills/mugiwara-checkpoint/SKILL.md +19 -8
- package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
- package/content/skills/mugiwara-execution/SKILL.md +32 -32
- 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 +22 -16
- package/content/skills/mugiwara-healing/SKILL.md +26 -25
- package/content/skills/mugiwara-orchestration/SKILL.md +6 -6
- package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
- package/content/skills/mugiwara-planning/SKILL.md +26 -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 +19 -13
- package/content/skills/mugiwara-resume/SKILL.md +6 -1
- package/content/skills/mugiwara-review/SKILL.md +17 -12
- package/content/skills/mugiwara-security/SKILL.md +46 -35
- package/content/skills/mugiwara-workflow/SKILL.md +6 -9
- package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
- package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
- package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
- package/dist/mugiwara.js +925 -253
- package/gemini-extension.json +1 -1
- package/hooks/pipeline-guard.js +1 -1
- package/hooks/pipeline-guard.ts +2 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/references/multi-actor.md +21 -0
- package/references/posture-routing.md +31 -0
- 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 +20 -0
- package/scripts/lib/lane-base.sh +4 -4
- package/scripts/retrieval-eval.ts +9 -3
- package/scripts/savepoint.sh +20 -1
- package/scripts/validate-content.ts +22 -3
- package/src/adaptive-budget.ts +178 -0
- package/src/args.ts +3 -2
- package/src/budget.ts +7 -16
- package/src/check-artifacts.ts +45 -0
- package/src/cli.ts +102 -4
- package/src/cognition.ts +234 -0
- package/src/config.ts +107 -0
- package/src/context.ts +72 -0
- package/src/cost.ts +186 -0
- package/src/evidence.ts +160 -0
- package/src/installer.ts +2 -16
- package/src/integrity.ts +1 -1
- package/src/investigation.ts +72 -0
- package/src/mission.ts +124 -10
- package/src/posture.ts +86 -0
- package/src/reporting.ts +225 -0
- package/src/scope.ts +321 -0
- package/src/sign.ts +194 -20
- package/src/slop.ts +306 -0
- package/src/work.ts +273 -0
package/src/sign.ts
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
// Signed attestation: evidence that cannot be fabricated
|
|
3
3
|
// after the fact — optional, user-keyed, never a hard dependency.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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).
|
|
8
10
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
10
13
|
import { homedir } from 'node:os';
|
|
11
14
|
import { join } from 'node:path';
|
|
15
|
+
import { readConfig } from './config.ts';
|
|
12
16
|
|
|
13
17
|
export function signArgs(reportPath: string, secretKey: string): string[] {
|
|
14
18
|
return ['-Sm', reportPath, '-s', secretKey];
|
|
@@ -31,31 +35,201 @@ function defaultKey(flag: 'secret' | 'public'): string {
|
|
|
31
35
|
return join(homedir(), '.mugiwara', flag === 'secret' ? 'minisign.key' : 'minisign.pub');
|
|
32
36
|
}
|
|
33
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
|
+
|
|
34
164
|
export function signReport(projectDir: string, missionDir: string): { ok: boolean; message: string } {
|
|
35
165
|
const report = join(missionDir, 'report.md');
|
|
36
166
|
if (!existsSync(report)) return { ok: false, message: 'no report.md to sign — archive first' };
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
46
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')})` };
|
|
47
193
|
}
|
|
48
194
|
|
|
49
195
|
export function verifyReport(projectDir: string, missionDir: string): { ok: boolean; message: string } {
|
|
50
196
|
const report = join(missionDir, 'report.md');
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
55
218
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
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)' };
|
|
58
225
|
} catch {
|
|
59
|
-
return { ok: false, message: '
|
|
226
|
+
return { ok: false, message: 'invalid .mugisig file' };
|
|
60
227
|
}
|
|
61
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
|
+
}
|
package/src/slop.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/slop.ts
|
|
2
|
+
// Phase 6 Stop-Slop Governor — slop taxonomy, detection signals, progress
|
|
3
|
+
// measurement, work-to-cost anomaly, intervention rules + 6 category detectors
|
|
4
|
+
// (Native Cost Governor, plan §51 Phase 6, §20–§24, §21).
|
|
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
|
+
|
|
11
|
+
import { recordOptDecision } from './cost.ts';
|
|
12
|
+
|
|
13
|
+
// ── Slop taxonomy (§21) ──
|
|
14
|
+
|
|
15
|
+
export type SlopKind =
|
|
16
|
+
| 'investigation'
|
|
17
|
+
| 'context'
|
|
18
|
+
| 'reasoning'
|
|
19
|
+
| 'output'
|
|
20
|
+
| 'code'
|
|
21
|
+
| 'retry'
|
|
22
|
+
| 'healing'
|
|
23
|
+
| 'scope';
|
|
24
|
+
|
|
25
|
+
export const SLOP_TAXONOMY: Record<SlopKind, string> = {
|
|
26
|
+
investigation: 'reading unrelated files / searching without narrowing / repeated exploration',
|
|
27
|
+
context: 'repeated reads / duplicate content / irrelevant files',
|
|
28
|
+
reasoning: 'speculative architecture / repeated reconsideration / hypothetical requirements',
|
|
29
|
+
output: 'verbose output / duplicate explanations without compression',
|
|
30
|
+
code: 'unnecessary abstraction / dependency / boilerplate without justification',
|
|
31
|
+
retry: 'same action with same evidence repeatedly failing',
|
|
32
|
+
healing: 'healing cycle with no fixes',
|
|
33
|
+
scope: 'files outside declared scope without acceptance expansion',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classify a raw signal string into the §21 taxonomy via keyword match.
|
|
38
|
+
* Returns null when the signal does not map to any known kind.
|
|
39
|
+
*/
|
|
40
|
+
export function classifySlop(signal: string): SlopKind | null {
|
|
41
|
+
const s = signal.toLowerCase();
|
|
42
|
+
if (s.includes('same command') || s.includes('same action') || s.includes('repeated command') || s.includes('retry') || s.includes('same evidence'))
|
|
43
|
+
return 'retry';
|
|
44
|
+
if (s.includes('healing') || s.includes('heal') || s.includes('fixes_in_cycle') || s.includes('no fixes'))
|
|
45
|
+
return 'healing';
|
|
46
|
+
if (s.includes('repeated file') || s.includes('repeated read') || s.includes('duplicate') || s.includes('irrelevant file') || s.includes('re-read'))
|
|
47
|
+
return 'context';
|
|
48
|
+
if (s.includes('unrelated file') || s.includes('exploration') || s.includes('investigation') || s.includes('searching without'))
|
|
49
|
+
return 'investigation';
|
|
50
|
+
if (s.includes('scope') || s.includes('out-of-scope') || s.includes('out of scope') || s.includes('declared scope') || s.includes('unrelated refactor'))
|
|
51
|
+
return 'scope';
|
|
52
|
+
if (s.includes('loc') || s.includes('boilerplate') || s.includes('abstraction') || s.includes('dependency') || s.includes('code slop'))
|
|
53
|
+
return 'code';
|
|
54
|
+
if (s.includes('speculative') || s.includes('reconsideration') || s.includes('hypothetical') || s.includes('reasoning slop'))
|
|
55
|
+
return 'reasoning';
|
|
56
|
+
if (s.includes('verbose') || s.includes('duplicate explanation') || s.includes('output slop') || s.includes('compress'))
|
|
57
|
+
return 'output';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Detection signals (§22) ──
|
|
62
|
+
|
|
63
|
+
export type SlopSignal = { kind: SlopKind; signal: string; count: number; threshold: number };
|
|
64
|
+
|
|
65
|
+
export function detectSlopSignal(input: {
|
|
66
|
+
kind: SlopKind;
|
|
67
|
+
count: number;
|
|
68
|
+
threshold: number;
|
|
69
|
+
evidence_delta?: number;
|
|
70
|
+
}): { slop: boolean; reason: string } {
|
|
71
|
+
const { kind, count, threshold, evidence_delta } = input;
|
|
72
|
+
const hasGain = evidence_delta !== undefined && evidence_delta !== 0;
|
|
73
|
+
const slop = count >= threshold && !hasGain;
|
|
74
|
+
const reason = slop
|
|
75
|
+
? `slop: ${kind} — count ${count} ≥ threshold ${threshold} with no evidence gain`
|
|
76
|
+
: `no slop: ${kind} — ${count < threshold ? `count ${count} < threshold ${threshold}` : `evidence gained (${evidence_delta})`}`;
|
|
77
|
+
return { slop, reason };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Progress measurement (§23) ──
|
|
81
|
+
|
|
82
|
+
export type ProgressSnapshot = {
|
|
83
|
+
tokens_used: number;
|
|
84
|
+
evidence_items: number;
|
|
85
|
+
criteria_mapped: number;
|
|
86
|
+
files_understood: number;
|
|
87
|
+
tests_fixed: number;
|
|
88
|
+
code_chars: number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function measureProgress(
|
|
92
|
+
before: ProgressSnapshot,
|
|
93
|
+
after: ProgressSnapshot,
|
|
94
|
+
): { progress: number; cost_delta: number; progress_per_cost: number; slop_signal: boolean; reason: string } {
|
|
95
|
+
const evidenceDelta = after.evidence_items - before.evidence_items;
|
|
96
|
+
const criteriaDelta = after.criteria_mapped - before.criteria_mapped;
|
|
97
|
+
const testsDelta = after.tests_fixed - before.tests_fixed;
|
|
98
|
+
const codeDelta = after.code_chars - before.code_chars;
|
|
99
|
+
const codeProgress = codeDelta > 0 ? 1 : 0;
|
|
100
|
+
const progress = evidenceDelta + criteriaDelta + testsDelta + codeProgress;
|
|
101
|
+
const cost_delta = after.tokens_used - before.tokens_used;
|
|
102
|
+
const progress_per_cost = cost_delta > 0 ? progress / cost_delta : 0;
|
|
103
|
+
const slop_signal = cost_delta > 0 && progress === 0;
|
|
104
|
+
const reason = slop_signal ? `slop — ${cost_delta} tokens with no progress` : `progress ${progress} over ${cost_delta} tokens`;
|
|
105
|
+
return { progress, cost_delta, progress_per_cost, slop_signal, reason };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Work-to-cost anomaly (§24) ──
|
|
109
|
+
|
|
110
|
+
export type AnomalyInput = { progress_per_cost: number; baseline_per_cost: number; drop_threshold?: number };
|
|
111
|
+
|
|
112
|
+
export function detectAnomaly(input: AnomalyInput): { anomaly: boolean; reason: string } {
|
|
113
|
+
const threshold = input.drop_threshold ?? 0.5;
|
|
114
|
+
if (input.baseline_per_cost <= 0) {
|
|
115
|
+
return { anomaly: false, reason: 'no anomaly — baseline 0 or above threshold' };
|
|
116
|
+
}
|
|
117
|
+
const anomaly = input.progress_per_cost < input.baseline_per_cost * threshold;
|
|
118
|
+
if (anomaly) {
|
|
119
|
+
const pct = Math.round((1 - input.progress_per_cost / input.baseline_per_cost) * 100);
|
|
120
|
+
return { anomaly: true, reason: `anomaly — ${pct}% drop below baseline` };
|
|
121
|
+
}
|
|
122
|
+
return { anomaly: false, reason: 'no anomaly — baseline 0 or above threshold' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Intervention rules (§20) ──
|
|
126
|
+
|
|
127
|
+
export type Intervention = 'tolerate' | 'stop' | 'compress' | 'escalate';
|
|
128
|
+
export type InterventionInput = {
|
|
129
|
+
kind: SlopKind;
|
|
130
|
+
slop: boolean;
|
|
131
|
+
severity: 'harmless' | 'wasteful' | 'harmful';
|
|
132
|
+
progress_stalled: boolean;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function decideIntervention(input: InterventionInput): { intervention: Intervention; reason: string } {
|
|
136
|
+
if (!input.slop) return { intervention: 'tolerate', reason: `tolerate — no slop for ${input.kind}` };
|
|
137
|
+
if (input.severity === 'harmful') return { intervention: 'escalate', reason: `escalate — ${input.kind} harmful slop` };
|
|
138
|
+
if (input.severity === 'wasteful') return { intervention: 'stop', reason: `stop — ${input.kind} wasteful slop` };
|
|
139
|
+
// harmless
|
|
140
|
+
if (input.progress_stalled) return { intervention: 'compress', reason: `compress — ${input.kind} harmless but stalled` };
|
|
141
|
+
return { intervention: 'tolerate', reason: `tolerate — ${input.kind} harmless slop` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Retry slop (§21.6/§31) ──
|
|
145
|
+
|
|
146
|
+
export type RetryInput = {
|
|
147
|
+
action: string;
|
|
148
|
+
evidence_fingerprint: string;
|
|
149
|
+
outcome: 'fail' | 'pass';
|
|
150
|
+
history: { action: string; evidence_fingerprint: string; outcome: string }[];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export function detectRetrySlop(input: RetryInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
154
|
+
const kind: SlopKind = 'retry';
|
|
155
|
+
if (input.outcome !== 'fail') return { slop: false, reason: 'no slop — outcome is pass', kind };
|
|
156
|
+
const found = input.history.some((h) => h.action === input.action && h.evidence_fingerprint === input.evidence_fingerprint && h.outcome === 'fail');
|
|
157
|
+
if (found) return { slop: true, reason: `slop: retry — same action ${input.action} with same evidence ${input.evidence_fingerprint} repeatedly failing`, kind };
|
|
158
|
+
return { slop: false, reason: 'no slop — no matching failed history', kind };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Healing slop (§21.7/§32) ──
|
|
162
|
+
|
|
163
|
+
export type HealingInput = { cycle: number; fixes_in_cycle: number; history_fixes: number[]; max_cycles?: number };
|
|
164
|
+
|
|
165
|
+
export function detectHealingSlop(input: HealingInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
166
|
+
const kind: SlopKind = 'healing';
|
|
167
|
+
const max = input.max_cycles ?? 3;
|
|
168
|
+
const hasZeroHistory = input.history_fixes.some((n) => n === 0);
|
|
169
|
+
if (input.fixes_in_cycle === 0 && hasZeroHistory) {
|
|
170
|
+
return { slop: true, reason: `slop: healing — no fixes in cycle ${input.cycle} with previous zero-fix cycle`, kind };
|
|
171
|
+
}
|
|
172
|
+
if (input.cycle >= max && input.fixes_in_cycle === 0) {
|
|
173
|
+
return { slop: true, reason: `slop: healing — cycle ${input.cycle} ≥ max ${max} with no fixes`, kind };
|
|
174
|
+
}
|
|
175
|
+
return { slop: false, reason: 'no slop — healing making progress', kind };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Scope slop (§21.8) ──
|
|
179
|
+
|
|
180
|
+
export type ScopeSlopInput = {
|
|
181
|
+
files_changed: string[];
|
|
182
|
+
declared_scope: string[];
|
|
183
|
+
acceptance_expanded: boolean;
|
|
184
|
+
unrelated_refactors: string[];
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function detectScopeSlop(input: ScopeSlopInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
188
|
+
const kind: SlopKind = 'scope';
|
|
189
|
+
const outOfScope = input.files_changed.filter((f) => !input.declared_scope.includes(f));
|
|
190
|
+
const hasScopeDrift = outOfScope.length > 0 || input.unrelated_refactors.length > 0;
|
|
191
|
+
if (hasScopeDrift && !input.acceptance_expanded) {
|
|
192
|
+
const names = [...outOfScope, ...input.unrelated_refactors].join(', ');
|
|
193
|
+
return { slop: true, reason: `slop: scope — out-of-scope ${names} without acceptance expansion`, kind };
|
|
194
|
+
}
|
|
195
|
+
return { slop: false, reason: 'no scope slop — within declared scope or acceptance expanded', kind };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Context slop (§21.2/§12) ──
|
|
199
|
+
|
|
200
|
+
export type ContextSlopInput = {
|
|
201
|
+
repeated_reads: number;
|
|
202
|
+
repeated_read_threshold: number;
|
|
203
|
+
duplicate_chars: number;
|
|
204
|
+
irrelevant_files: string[];
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function detectContextSlop(input: ContextSlopInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
208
|
+
const kind: SlopKind = 'context';
|
|
209
|
+
const signals: string[] = [];
|
|
210
|
+
if (input.repeated_reads >= input.repeated_read_threshold) signals.push(`repeated reads ${input.repeated_reads} ≥ ${input.repeated_read_threshold}`);
|
|
211
|
+
if (input.duplicate_chars > 0) signals.push(`duplicate chars ${input.duplicate_chars}`);
|
|
212
|
+
if (input.irrelevant_files.length > 0) signals.push(`irrelevant files ${input.irrelevant_files.join(', ')}`);
|
|
213
|
+
if (signals.length > 0) return { slop: true, reason: `slop: context — ${signals.join('; ')}`, kind };
|
|
214
|
+
return { slop: false, reason: 'no context slop', kind };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Investigation slop (§21.1/§13) ──
|
|
218
|
+
|
|
219
|
+
export type InvestigationSlopInput = {
|
|
220
|
+
unrelated_files_opened: number;
|
|
221
|
+
max_unrelated_files: number;
|
|
222
|
+
repeated_reads: number;
|
|
223
|
+
repeated_read_threshold: number;
|
|
224
|
+
exploration_passes: number;
|
|
225
|
+
max_passes: number;
|
|
226
|
+
acceptance_mapped: boolean;
|
|
227
|
+
has_concrete_reason: boolean;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export function detectInvestigationSlop(input: InvestigationSlopInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
231
|
+
const kind: SlopKind = 'investigation';
|
|
232
|
+
if (input.has_concrete_reason) return { slop: false, reason: 'no investigation slop — concrete reason present', kind };
|
|
233
|
+
const breaches: string[] = [];
|
|
234
|
+
if (input.unrelated_files_opened > input.max_unrelated_files) breaches.push(`unrelated files ${input.unrelated_files_opened} > ${input.max_unrelated_files}`);
|
|
235
|
+
if (input.repeated_reads >= input.repeated_read_threshold) breaches.push(`repeated reads ${input.repeated_reads} ≥ ${input.repeated_read_threshold}`);
|
|
236
|
+
if (input.exploration_passes >= input.max_passes) breaches.push(`passes ${input.exploration_passes} ≥ ${input.max_passes}`);
|
|
237
|
+
if (breaches.length > 0) return { slop: true, reason: `slop: investigation — ${breaches.join('; ')}`, kind };
|
|
238
|
+
return { slop: false, reason: 'no investigation slop', kind };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Code slop (§21.5/§15) ──
|
|
242
|
+
|
|
243
|
+
export type CodeSlopInput = {
|
|
244
|
+
new_abstractions: number;
|
|
245
|
+
new_dependencies: number;
|
|
246
|
+
loc_added: number;
|
|
247
|
+
acceptance_expanded: boolean;
|
|
248
|
+
justification_provided: boolean;
|
|
249
|
+
boilerplate_chars: number;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export function detectCodeSlop(input: CodeSlopInput): { slop: boolean; reason: string; kind: SlopKind } {
|
|
253
|
+
const kind: SlopKind = 'code';
|
|
254
|
+
if (input.acceptance_expanded || input.justification_provided) {
|
|
255
|
+
return { slop: false, reason: 'no code slop — acceptance expanded or justified', kind };
|
|
256
|
+
}
|
|
257
|
+
const signals: string[] = [];
|
|
258
|
+
if (input.new_abstractions > 0) signals.push(`abstractions ${input.new_abstractions}`);
|
|
259
|
+
if (input.new_dependencies > 0) signals.push(`dependencies ${input.new_dependencies}`);
|
|
260
|
+
if (input.boilerplate_chars > 0) signals.push(`boilerplate ${input.boilerplate_chars} chars`);
|
|
261
|
+
if (input.loc_added > 100) signals.push(`loc ${input.loc_added}`);
|
|
262
|
+
if (signals.length > 0) return { slop: true, reason: `slop: code — ${signals.join('; ')} without acceptance or justification`, kind };
|
|
263
|
+
return { slop: false, reason: 'no code slop', kind };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Decision trail (§41) ──
|
|
267
|
+
|
|
268
|
+
export function recordSlopDecision(
|
|
269
|
+
missionDir: string,
|
|
270
|
+
d: { decision: string; reason: string; evidence?: string; kind?: SlopKind },
|
|
271
|
+
): void {
|
|
272
|
+
const prefix = d.kind ? `[${d.kind}] ` : '';
|
|
273
|
+
recordOptDecision(missionDir, {
|
|
274
|
+
actor: 'slop-governor',
|
|
275
|
+
decision: `${prefix}${d.decision}`,
|
|
276
|
+
reason: d.reason,
|
|
277
|
+
evidence: d.evidence,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Live wiring (§3.3) ──
|
|
282
|
+
// Runs the existing detectors over state already available at ledger-build time
|
|
283
|
+
// (state.json heal cycle, context-registry repeated reads). One call site feeds
|
|
284
|
+
// the ledger's slopMetrics.interventions so it stops reading 0. Per-crew
|
|
285
|
+
// attribution: healing → Brook (Flow 8), context → all.
|
|
286
|
+
export type LiveSlopResult = {
|
|
287
|
+
interventions: number;
|
|
288
|
+
perRole: Record<string, number>;
|
|
289
|
+
rows: { role: string; kind: SlopKind; reason: string }[];
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export function computeLiveSlop(input: {
|
|
293
|
+
heal_cycle: number;
|
|
294
|
+
repeated_reads: number;
|
|
295
|
+
repeated_read_threshold?: number;
|
|
296
|
+
max_heal_cycles?: number;
|
|
297
|
+
}): LiveSlopResult {
|
|
298
|
+
const rows: { role: string; kind: SlopKind; reason: string }[] = [];
|
|
299
|
+
const thr = input.repeated_read_threshold ?? 3;
|
|
300
|
+
const heal = detectHealingSlop({ cycle: input.heal_cycle, fixes_in_cycle: 0, history_fixes: [], max_cycles: input.max_heal_cycles ?? 3 });
|
|
301
|
+
if (heal.slop) rows.push({ role: 'Brook', kind: 'healing', reason: heal.reason });
|
|
302
|
+
if (input.repeated_reads >= thr) rows.push({ role: 'all', kind: 'context', reason: `repeated reads ${input.repeated_reads} ≥ ${thr}` });
|
|
303
|
+
const perRole: Record<string, number> = {};
|
|
304
|
+
for (const r of rows) perRole[r.role] = (perRole[r.role] ?? 0) + 1;
|
|
305
|
+
return { interventions: rows.length, perRole, rows };
|
|
306
|
+
}
|