@ionivetech/mugiwara 0.8.0 → 0.8.2
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/README.md +2 -2
- package/content/agents/brook-healing.md +1 -1
- 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-brainstorm/SKILL.md +5 -3
- package/content/skills/mugiwara-checkpoint/SKILL.md +2 -0
- package/content/skills/mugiwara-execution/SKILL.md +4 -3
- package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
- package/content/skills/mugiwara-gates/SKILL.md +6 -0
- package/content/skills/mugiwara-healing/SKILL.md +5 -1
- package/content/skills/mugiwara-lessons/SKILL.md +3 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +7 -6
- package/content/skills/mugiwara-planning/SKILL.md +2 -0
- package/content/skills/mugiwara-quality/SKILL.md +3 -14
- package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
- package/content/skills/mugiwara-resume/SKILL.md +3 -14
- package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
- package/content/skills/mugiwara-review/SKILL.md +3 -15
- package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
- package/content/skills/mugiwara-security/SKILL.md +1 -0
- package/content/skills/mugiwara-ship/SKILL.md +2 -0
- package/content/skills/mugiwara-workflow/SKILL.md +28 -25
- package/dist/mugiwara.js +1323 -402
- package/gemini-extension.json +1 -1
- package/hooks/mugiwara-mode-tracker.js +24 -4
- package/hooks/mugiwara-mode-tracker.ts +36 -7
- 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/wave-banners.md +1 -2
- package/scripts/gate-selftest.ts +239 -21
- package/scripts/lane-base.ts +16 -0
- package/scripts/lane.sh +5 -1
- package/scripts/lib/lane-base.sh +1 -1
- package/scripts/savepoint.sh +48 -5
- package/scripts/validate-content.ts +60 -0
- package/scripts/verify-install.ts +20 -0
- package/scripts/write-metrics.ts +73 -0
- package/src/budget.ts +11 -0
- package/src/cli.ts +185 -28
- package/src/config.ts +6 -0
- package/src/continue.ts +36 -1
- package/src/cost.ts +4 -1
- package/src/installer.ts +27 -4
- package/src/integrity.ts +105 -25
- package/src/mission.ts +123 -7
- package/src/policy.ts +372 -4
- package/src/provenance.ts +29 -9
- package/src/sign.ts +45 -3
- package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +0 -5
- package/content/skills/mugiwara-workflow/references/benchmark-governor.md +0 -53
- package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +0 -5
- package/content/skills/mugiwara-workflow/references/scope-code-governor.md +0 -14
- package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +0 -14
package/src/policy.ts
CHANGED
|
@@ -19,11 +19,22 @@ export type MugiwaraPolicy = {
|
|
|
19
19
|
coverage?: { new?: number; modified?: number };
|
|
20
20
|
require_human_approval?: string[];
|
|
21
21
|
};
|
|
22
|
-
evidence?: {
|
|
22
|
+
evidence?: {
|
|
23
|
+
required?: string[];
|
|
24
|
+
/** Lanes where an empty evidence set blocks archive instead of warning. (B7) */
|
|
25
|
+
require_nonempty_for_lanes?: string[];
|
|
26
|
+
};
|
|
27
|
+
integrity?: { extra_secret_patterns?: Array<{ pattern: string; label: string; severity?: 'block' | 'warn' }> };
|
|
28
|
+
attestation?: {
|
|
29
|
+
required?: boolean;
|
|
30
|
+
trusted_keys?: Array<{ id: string; pubkey: string; added?: string }>;
|
|
31
|
+
revoked?: Array<{ id: string; revoked?: string; reason?: string; pubkey?: string }>;
|
|
32
|
+
};
|
|
33
|
+
harness?: { require_enforcement?: boolean };
|
|
23
34
|
};
|
|
24
35
|
|
|
25
36
|
const POLICY_FILES = ['mugiwara.policy.yml', 'mugiwara.policy.yaml'];
|
|
26
|
-
const KNOWN_ROOTS = ['lanes', 'gates', 'evidence'];
|
|
37
|
+
const KNOWN_ROOTS = ['lanes', 'gates', 'evidence', 'integrity', 'attestation', 'harness'];
|
|
27
38
|
|
|
28
39
|
/**
|
|
29
40
|
* Minimal YAML subset: maps, `- item` string lists, scalars.
|
|
@@ -97,11 +108,238 @@ function scalar(v: string): unknown {
|
|
|
97
108
|
return t;
|
|
98
109
|
}
|
|
99
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Dedicated extractor for integrity.extra_secret_patterns list-of-maps.
|
|
113
|
+
* The minimal YAML subset parser only handles scalar lists; this scans the raw
|
|
114
|
+
* text line-by-line for map items so both forms work:
|
|
115
|
+
* - { pattern: "\\b...\\b", label: "NIK" }
|
|
116
|
+
* - - pattern: "\\b...\\b"
|
|
117
|
+
* label: "NIK"
|
|
118
|
+
* severity: warn
|
|
119
|
+
*/
|
|
120
|
+
export function extractExtraSecretPatterns(text: string): Array<{ pattern: string; label: string; severity?: string }> {
|
|
121
|
+
const lines = text.split(/\r?\n/);
|
|
122
|
+
let inBlock = false;
|
|
123
|
+
let baseIndent = -1;
|
|
124
|
+
const out: Array<Record<string, string>> = [];
|
|
125
|
+
let current: Record<string, string> | null = null;
|
|
126
|
+
let currentIndent = -1;
|
|
127
|
+
for (const rawLine of lines) {
|
|
128
|
+
const noComment = rawLine.replace(/(^|\s)#.*$/, '');
|
|
129
|
+
if (!noComment.trim()) continue;
|
|
130
|
+
const indent = noComment.length - noComment.trimStart().length;
|
|
131
|
+
const trimmed = noComment.trim();
|
|
132
|
+
if (!inBlock) {
|
|
133
|
+
if (trimmed.startsWith('extra_secret_patterns:')) {
|
|
134
|
+
inBlock = true;
|
|
135
|
+
baseIndent = indent;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// exit if a sibling key at same or shallower indent (not a list item) appears
|
|
140
|
+
if (indent <= baseIndent && !trimmed.startsWith('-') && trimmed.includes(':')) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
if (trimmed.startsWith('-')) {
|
|
144
|
+
if (current) out.push(current);
|
|
145
|
+
current = {};
|
|
146
|
+
currentIndent = indent;
|
|
147
|
+
const afterDash = trimmed.slice(1).trim();
|
|
148
|
+
if (!afterDash) continue;
|
|
149
|
+
if (afterDash.startsWith('{') && afterDash.endsWith('}')) {
|
|
150
|
+
const inner = afterDash.slice(1, -1);
|
|
151
|
+
for (const part of inner.split(',')) {
|
|
152
|
+
const colon = part.indexOf(':');
|
|
153
|
+
if (colon === -1) continue;
|
|
154
|
+
const k = part.slice(0, colon).trim();
|
|
155
|
+
const v = part.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
156
|
+
if (k && v) current[k] = v;
|
|
157
|
+
}
|
|
158
|
+
} else if (afterDash.includes(':')) {
|
|
159
|
+
const colon = afterDash.indexOf(':');
|
|
160
|
+
const k = afterDash.slice(0, colon).trim();
|
|
161
|
+
const v = afterDash.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
162
|
+
if (k && v) current[k] = v;
|
|
163
|
+
} else {
|
|
164
|
+
const v = afterDash.replace(/^["']|["']$/g, '');
|
|
165
|
+
if (v) current['pattern'] = v;
|
|
166
|
+
}
|
|
167
|
+
} else if (current && trimmed.includes(':')) {
|
|
168
|
+
if (indent > currentIndent) {
|
|
169
|
+
const colon = trimmed.indexOf(':');
|
|
170
|
+
const k = trimmed.slice(0, colon).trim();
|
|
171
|
+
const v = trimmed.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
172
|
+
if (k && v) current[k] = v;
|
|
173
|
+
} else if (indent <= baseIndent) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (current) out.push(current);
|
|
180
|
+
return out.filter((o) => typeof o.pattern === 'string' && typeof o.label === 'string' && o.pattern.length > 0) as Array<{ pattern: string; label: string; severity?: string }>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract attestation block: handles both inline `{ id: "...", pubkey: "ed25519:..." }`
|
|
185
|
+
* and multiline lists. Scans inside `attestation:` indented block so
|
|
186
|
+
* `evidence: required:` is not confused.
|
|
187
|
+
*/
|
|
188
|
+
export function extractAttestation(text: string): {
|
|
189
|
+
required?: boolean;
|
|
190
|
+
trusted_keys?: Array<Record<string, string>>;
|
|
191
|
+
revoked?: Array<Record<string, string>>;
|
|
192
|
+
} | null {
|
|
193
|
+
const lines = text.split(/\r?\n/);
|
|
194
|
+
let attBase = -1;
|
|
195
|
+
let inAtt = false;
|
|
196
|
+
let required: boolean | undefined;
|
|
197
|
+
const trusted: Array<Record<string, string>> = [];
|
|
198
|
+
const revoked: Array<Record<string, string>> = [];
|
|
199
|
+
// collection state
|
|
200
|
+
let collecting: 'trusted' | 'revoked' | null = null;
|
|
201
|
+
let collectBase = -1;
|
|
202
|
+
let current: Record<string, string> | null = null;
|
|
203
|
+
let curIndent = -1;
|
|
204
|
+
|
|
205
|
+
const flush = () => {
|
|
206
|
+
if (!current) return;
|
|
207
|
+
if (collecting === 'trusted') trusted.push(current);
|
|
208
|
+
else if (collecting === 'revoked') revoked.push(current);
|
|
209
|
+
current = null;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
213
|
+
const rawLine = lines[idx];
|
|
214
|
+
const noComment = rawLine.replace(/(^|\s)#.*$/, '');
|
|
215
|
+
if (!noComment.trim()) continue;
|
|
216
|
+
const indent = noComment.length - noComment.trimStart().length;
|
|
217
|
+
const trimmed = noComment.trim();
|
|
218
|
+
|
|
219
|
+
if (!inAtt) {
|
|
220
|
+
if (trimmed === 'attestation:' || trimmed.startsWith('attestation:')) {
|
|
221
|
+
// handle `attestation: { ... }` inline — not used, but parse required if present
|
|
222
|
+
const after = trimmed.slice('attestation:'.length).trim();
|
|
223
|
+
if (after.startsWith('{')) {
|
|
224
|
+
// inline map form not needed for MVP
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
inAtt = true;
|
|
228
|
+
attBase = indent;
|
|
229
|
+
// if there is a value after colon on same line (e.g., attestation: foo) ignore
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// inside attestation block
|
|
236
|
+
// exit attestation when sibling root key at same/shallower indent
|
|
237
|
+
if (indent <= attBase && !trimmed.startsWith('-') && trimmed.includes(':')) {
|
|
238
|
+
flush();
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// handle collecting state first
|
|
243
|
+
if (collecting) {
|
|
244
|
+
// exiting collection to sibling key inside attestation (e.g., revoked: after trusted_keys:)
|
|
245
|
+
if (indent <= collectBase && !trimmed.startsWith('-') && trimmed.includes(':')) {
|
|
246
|
+
flush();
|
|
247
|
+
collecting = null;
|
|
248
|
+
// fall through to process this line as att child
|
|
249
|
+
} else if (trimmed.startsWith('-')) {
|
|
250
|
+
flush();
|
|
251
|
+
current = {};
|
|
252
|
+
curIndent = indent;
|
|
253
|
+
const afterDash = trimmed.slice(1).trim();
|
|
254
|
+
if (!afterDash) continue;
|
|
255
|
+
if (afterDash.startsWith('{') && afterDash.endsWith('}')) {
|
|
256
|
+
const inner = afterDash.slice(1, -1);
|
|
257
|
+
for (const part of inner.split(',')) {
|
|
258
|
+
const colon = part.indexOf(':');
|
|
259
|
+
if (colon === -1) continue;
|
|
260
|
+
const k = part.slice(0, colon).trim();
|
|
261
|
+
const v = part.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
262
|
+
if (k && v) current[k] = v;
|
|
263
|
+
}
|
|
264
|
+
} else if (afterDash.includes(':')) {
|
|
265
|
+
const colon = afterDash.indexOf(':');
|
|
266
|
+
const k = afterDash.slice(0, colon).trim();
|
|
267
|
+
const v = afterDash.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
268
|
+
if (k && v) current[k] = v;
|
|
269
|
+
} else {
|
|
270
|
+
const v = afterDash.replace(/^["']|["']$/g, '');
|
|
271
|
+
if (v) current['id'] = v;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
} else if (current && trimmed.includes(':')) {
|
|
275
|
+
if (indent > curIndent) {
|
|
276
|
+
const colon = trimmed.indexOf(':');
|
|
277
|
+
const k = trimmed.slice(0, colon).trim();
|
|
278
|
+
const v = trimmed.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
|
279
|
+
if (k && v) current[k] = v;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// if still collecting but line is not part of current item, skip?
|
|
284
|
+
if (collecting) continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// not collecting (or just exited) — look for att children
|
|
288
|
+
if (trimmed.startsWith('required:')) {
|
|
289
|
+
const v = trimmed.slice('required:'.length).trim().replace(/^["']|["']$/g, '');
|
|
290
|
+
if (v === 'true') required = true;
|
|
291
|
+
else if (v === 'false') required = false;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (trimmed.startsWith('trusted_keys:')) {
|
|
295
|
+
const after = trimmed.slice('trusted_keys:'.length).trim();
|
|
296
|
+
// handle inline empty `[]`
|
|
297
|
+
if (after === '[]') continue;
|
|
298
|
+
collecting = 'trusted';
|
|
299
|
+
collectBase = indent;
|
|
300
|
+
current = null;
|
|
301
|
+
// if inline list with one map on same line? e.g., trusted_keys: [{ id: "a", pubkey: "x" }]
|
|
302
|
+
// MVP not needed; empty case already handled
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (trimmed.startsWith('revoked:')) {
|
|
306
|
+
const after = trimmed.slice('revoked:'.length).trim();
|
|
307
|
+
if (after === '[]') continue;
|
|
308
|
+
collecting = 'revoked';
|
|
309
|
+
collectBase = indent;
|
|
310
|
+
current = null;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
flush();
|
|
315
|
+
if (required === undefined && trusted.length === 0 && revoked.length === 0) return null;
|
|
316
|
+
const out: { required?: boolean; trusted_keys?: Array<Record<string, string>>; revoked?: Array<Record<string, string>> } = {};
|
|
317
|
+
if (required !== undefined) out.required = required;
|
|
318
|
+
if (trusted.length) out.trusted_keys = trusted;
|
|
319
|
+
if (revoked.length) out.revoked = revoked;
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
100
323
|
export function loadPolicy(projectDir: string): MugiwaraPolicy | null {
|
|
101
324
|
for (const name of POLICY_FILES) {
|
|
102
325
|
const file = join(projectDir, name);
|
|
103
326
|
if (!existsSync(file)) continue;
|
|
104
|
-
|
|
327
|
+
const text = readFileSync(file, 'utf8');
|
|
328
|
+
const raw = parsePolicyYaml(text);
|
|
329
|
+
const extra = extractExtraSecretPatterns(text);
|
|
330
|
+
if (extra.length) {
|
|
331
|
+
if (!raw.integrity || typeof raw.integrity !== 'object' || Array.isArray(raw.integrity)) raw.integrity = {};
|
|
332
|
+
(raw.integrity as Record<string, unknown>).extra_secret_patterns = extra;
|
|
333
|
+
}
|
|
334
|
+
const att = extractAttestation(text);
|
|
335
|
+
if (att) {
|
|
336
|
+
if (!raw.attestation || typeof raw.attestation !== 'object' || Array.isArray(raw.attestation)) raw.attestation = {};
|
|
337
|
+
const a = raw.attestation as Record<string, unknown>;
|
|
338
|
+
if (att.required !== undefined) a.required = att.required;
|
|
339
|
+
if (att.trusted_keys) a.trusted_keys = att.trusted_keys;
|
|
340
|
+
if (att.revoked) a.revoked = att.revoked;
|
|
341
|
+
}
|
|
342
|
+
return normalize(raw);
|
|
105
343
|
}
|
|
106
344
|
return null;
|
|
107
345
|
}
|
|
@@ -127,7 +365,70 @@ function normalize(raw: Record<string, unknown>): MugiwaraPolicy {
|
|
|
127
365
|
out.gates.require_human_approval = strings(gates.require_human_approval);
|
|
128
366
|
}
|
|
129
367
|
const evidence = raw.evidence as Record<string, unknown> | undefined;
|
|
130
|
-
if (evidence
|
|
368
|
+
if (evidence) {
|
|
369
|
+
const ev: NonNullable<MugiwaraPolicy['evidence']> = {};
|
|
370
|
+
if (Array.isArray(evidence.required)) ev.required = strings(evidence.required);
|
|
371
|
+
else if (typeof evidence.required === 'string' && (evidence.required as string).trim().startsWith('[')) {
|
|
372
|
+
try { const p = JSON.parse(evidence.required as string); if (Array.isArray(p)) ev.required = strings(p); } catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
if (Array.isArray(evidence.require_nonempty_for_lanes)) ev.require_nonempty_for_lanes = strings(evidence.require_nonempty_for_lanes);
|
|
375
|
+
else if (typeof evidence.require_nonempty_for_lanes === 'string' && (evidence.require_nonempty_for_lanes as string).trim().startsWith('[')) {
|
|
376
|
+
try { const p = JSON.parse(evidence.require_nonempty_for_lanes as string); if (Array.isArray(p)) ev.require_nonempty_for_lanes = strings(p); } catch { /* ignore */ }
|
|
377
|
+
}
|
|
378
|
+
if (ev.required || ev.require_nonempty_for_lanes) out.evidence = ev;
|
|
379
|
+
}
|
|
380
|
+
const integrity = raw.integrity as Record<string, unknown> | undefined;
|
|
381
|
+
if (integrity && Array.isArray(integrity.extra_secret_patterns)) {
|
|
382
|
+
const arr = integrity.extra_secret_patterns as unknown[];
|
|
383
|
+
const cleaned: Array<{ pattern: string; label: string; severity?: 'block' | 'warn' }> = [];
|
|
384
|
+
for (const e of arr) {
|
|
385
|
+
if (!e || typeof e !== 'object') continue;
|
|
386
|
+
const rec = e as Record<string, unknown>;
|
|
387
|
+
if (typeof rec.pattern !== 'string' || typeof rec.label !== 'string') continue;
|
|
388
|
+
const sev = rec.severity === 'warn' ? 'warn' as const : rec.severity === 'block' ? 'block' as const : undefined;
|
|
389
|
+
const entry: { pattern: string; label: string; severity?: 'block' | 'warn' } = { pattern: rec.pattern, label: rec.label };
|
|
390
|
+
if (sev) entry.severity = sev;
|
|
391
|
+
cleaned.push(entry);
|
|
392
|
+
}
|
|
393
|
+
if (cleaned.length) out.integrity = { extra_secret_patterns: cleaned };
|
|
394
|
+
}
|
|
395
|
+
const att = raw.attestation as Record<string, unknown> | undefined;
|
|
396
|
+
if (att) {
|
|
397
|
+
const a: NonNullable<MugiwaraPolicy['attestation']> = {};
|
|
398
|
+
if (typeof att.required === 'boolean') a.required = att.required;
|
|
399
|
+
if (Array.isArray(att.trusted_keys)) {
|
|
400
|
+
const cleanedTk: Array<{ id: string; pubkey: string; added?: string }> = [];
|
|
401
|
+
for (const e of att.trusted_keys as unknown[]) {
|
|
402
|
+
if (!e || typeof e !== 'object') continue;
|
|
403
|
+
const rec = e as Record<string, unknown>;
|
|
404
|
+
if (typeof rec.id !== 'string' || typeof rec.pubkey !== 'string') continue;
|
|
405
|
+
if (!rec.id.trim() || !rec.pubkey.trim()) continue;
|
|
406
|
+
const entry: { id: string; pubkey: string; added?: string } = { id: rec.id.trim(), pubkey: rec.pubkey.trim() };
|
|
407
|
+
if (typeof rec.added === 'string' && rec.added.trim()) entry.added = rec.added.trim();
|
|
408
|
+
cleanedTk.push(entry);
|
|
409
|
+
}
|
|
410
|
+
if (cleanedTk.length) a.trusted_keys = cleanedTk;
|
|
411
|
+
}
|
|
412
|
+
if (Array.isArray(att.revoked)) {
|
|
413
|
+
const cleanedRv: Array<{ id: string; revoked?: string; reason?: string; pubkey?: string }> = [];
|
|
414
|
+
for (const e of att.revoked as unknown[]) {
|
|
415
|
+
if (!e || typeof e !== 'object') continue;
|
|
416
|
+
const rec = e as Record<string, unknown>;
|
|
417
|
+
if (typeof rec.id !== 'string' || !rec.id.trim()) continue;
|
|
418
|
+
const entry: { id: string; revoked?: string; reason?: string; pubkey?: string } = { id: rec.id.trim() };
|
|
419
|
+
if (typeof rec.revoked === 'string' && rec.revoked.trim()) entry.revoked = rec.revoked.trim();
|
|
420
|
+
if (typeof rec.reason === 'string' && rec.reason.trim()) entry.reason = rec.reason.trim();
|
|
421
|
+
if (typeof rec.pubkey === 'string' && rec.pubkey.trim()) entry.pubkey = rec.pubkey.trim();
|
|
422
|
+
cleanedRv.push(entry);
|
|
423
|
+
}
|
|
424
|
+
if (cleanedRv.length) a.revoked = cleanedRv;
|
|
425
|
+
}
|
|
426
|
+
if (a.required !== undefined || a.trusted_keys || a.revoked) out.attestation = a;
|
|
427
|
+
}
|
|
428
|
+
const harness = raw.harness as Record<string, unknown> | undefined;
|
|
429
|
+
if (harness && typeof harness.require_enforcement === 'boolean') {
|
|
430
|
+
out.harness = { require_enforcement: harness.require_enforcement };
|
|
431
|
+
}
|
|
131
432
|
return out;
|
|
132
433
|
}
|
|
133
434
|
|
|
@@ -154,3 +455,70 @@ export function matchedGlobs(paths: string[], globs: string[]): string[] {
|
|
|
154
455
|
export function effectiveThreshold(configured: number, policyValue: number | undefined): number {
|
|
155
456
|
return Math.max(configured, policyValue ?? 0);
|
|
156
457
|
}
|
|
458
|
+
|
|
459
|
+
// ── Harness enforcement (D8) ────────────────────────────────────────────────
|
|
460
|
+
// Only opencode has runtime write-scope enforcement; the other 11 harnesses
|
|
461
|
+
// are rules-based. `harness.require_enforcement: true` refuses to run where
|
|
462
|
+
// the harness is not enforced.
|
|
463
|
+
|
|
464
|
+
/** Detect current harness. Mirrors savepoint.sh logic. */
|
|
465
|
+
export function detectHarness(projectDir?: string): string {
|
|
466
|
+
const e = process.env;
|
|
467
|
+
const has = (v: string | undefined) => v !== undefined && v !== '';
|
|
468
|
+
if (has(e.CLAUDECODE) || has(e.CLAUDE_CODE_ENTRYPOINT) || (typeof e.ANTHROPIC_MODEL === 'string' && /claude/i.test(e.ANTHROPIC_MODEL))) return 'claude';
|
|
469
|
+
if (has(e.OPENCODE) || has(e.OPENCODE_TOKENS_FILE)) return 'opencode';
|
|
470
|
+
const candidates = [
|
|
471
|
+
projectDir ? join(projectDir, '.opencode', 'config.json') : null,
|
|
472
|
+
join(process.cwd(), '.opencode', 'config.json'),
|
|
473
|
+
].filter(Boolean) as string[];
|
|
474
|
+
for (const p of candidates) {
|
|
475
|
+
try { if (existsSync(p)) return 'opencode'; } catch { /* ignore */ }
|
|
476
|
+
}
|
|
477
|
+
if (has(e.CURSOR) || has(e.VSCODE_GIT_ASKPASS_NODE)) return 'cursor';
|
|
478
|
+
return 'unknown';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Only opencode is runtime-enforced. */
|
|
482
|
+
export function isEnforcedHarness(projectDir?: string): boolean {
|
|
483
|
+
return detectHarness(projectDir) === 'opencode';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Pure check: returns error message when policy requires enforcement but harness is rules-based, else null. */
|
|
487
|
+
export function getHarnessEnforcementError(projectDir: string): string | null {
|
|
488
|
+
let policy: MugiwaraPolicy | null;
|
|
489
|
+
try { policy = loadPolicy(projectDir); } catch (e) { throw e; }
|
|
490
|
+
if (!policy?.harness?.require_enforcement) return null;
|
|
491
|
+
if (isEnforcedHarness(projectDir)) return null;
|
|
492
|
+
const h = detectHarness(projectDir);
|
|
493
|
+
return `harness enforcement required but current harness is rules-based only \u2014 use opencode or set harness.require_enforcement:false (detected: ${h})`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Fail closed when policy requires enforcement but harness is rules-based. */
|
|
497
|
+
export function enforceHarnessPolicy(projectDir: string): void {
|
|
498
|
+
const err = getHarnessEnforcementError(projectDir);
|
|
499
|
+
if (!err) return;
|
|
500
|
+
console.error(`\u2717 ${err}`);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Alias for CLI import convenience. */
|
|
505
|
+
export const checkHarnessEnforcement = enforceHarnessPolicy;
|
|
506
|
+
|
|
507
|
+
// ── Lane-aware gates (T3) ───────────────────────────────────────────────────
|
|
508
|
+
// Direct → minimal (typecheck+build only), lean → +validate-content, standard+
|
|
509
|
+
// → +evals/retrieval/conformance/benchmark. Single source for gate-selftest
|
|
510
|
+
// and the franky-gates skill doc. Full = 12 steps, direct = 3 steps.
|
|
511
|
+
export const GATE_STEPS_BY_LANE: Record<string, string[]> = {
|
|
512
|
+
direct: ['build-hooks:check', 'typecheck', 'build'],
|
|
513
|
+
lean: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links'],
|
|
514
|
+
standard: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links', 'test:coverage', 'coverage-gate', 'verify-install'],
|
|
515
|
+
full: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links', 'test:coverage', 'coverage-gate', 'verify-install', 'run-evals', 'retrieval-eval', 'conformance'],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
export function gatesForLane(lane: string): string[] {
|
|
519
|
+
return GATE_STEPS_BY_LANE[lane] ?? GATE_STEPS_BY_LANE.full;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function isLaneAwareGateStep(step: string, lane: string): boolean {
|
|
523
|
+
return gatesForLane(lane).includes(step);
|
|
524
|
+
}
|
package/src/provenance.ts
CHANGED
|
@@ -75,16 +75,34 @@ export function renderProvenanceMd(note: string, sha: string | null): string {
|
|
|
75
75
|
return lines.join('\n') + '\n';
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function attachGitNote(projectDir: string, branch: string, note: string): { sha: string } | null {
|
|
78
|
+
export function attachGitNote(projectDir: string, branch: string, note: string, baseSha?: string): { sha: string; count: number } | null {
|
|
79
79
|
try {
|
|
80
|
-
|
|
80
|
+
const range = baseSha ? `${baseSha}..${branch}` : branch;
|
|
81
|
+
let shas: string[] = [];
|
|
81
82
|
try {
|
|
82
|
-
|
|
83
|
+
const raw = git(projectDir, ['rev-list', range]);
|
|
84
|
+
shas = raw.split('\n').filter(Boolean);
|
|
83
85
|
} catch {
|
|
84
|
-
|
|
86
|
+
// rev-list failed (unknown baseSha/branch) — fall back to single head
|
|
87
|
+
shas = [];
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
// ponytail: cap at 200 commits, fallback to head-only beyond
|
|
90
|
+
if (shas.length > 200) {
|
|
91
|
+
console.warn(`attachGitNote: range ${shas.length} >200, falling back to head-only`);
|
|
92
|
+
shas = [];
|
|
93
|
+
}
|
|
94
|
+
let targets = shas;
|
|
95
|
+
if (!targets.length) {
|
|
96
|
+
try {
|
|
97
|
+
targets = [git(projectDir, ['rev-parse', '--verify', branch])];
|
|
98
|
+
} catch {
|
|
99
|
+
targets = [git(projectDir, ['rev-parse', 'HEAD'])];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const sha of targets) {
|
|
103
|
+
git(projectDir, ['notes', '--ref=mugiwara', 'add', '-f', '-m', note, sha]);
|
|
104
|
+
}
|
|
105
|
+
return { sha: targets[0], count: targets.length };
|
|
88
106
|
} catch {
|
|
89
107
|
// not a repo, detached oddities, or notes disabled — degrade honestly
|
|
90
108
|
return null;
|
|
@@ -104,13 +122,15 @@ export function blamePath(projectDir: string, path: string): string {
|
|
|
104
122
|
const note = git(projectDir, ['notes', '--ref=mugiwara', 'show', sha]);
|
|
105
123
|
return `${path} @ ${sha.slice(0, 7)}\n${note}`;
|
|
106
124
|
} catch {
|
|
107
|
-
return `${path} @ ${sha.slice(0, 7)}\
|
|
125
|
+
return `${path} @ ${sha.slice(0, 7)}\nno per-commit note — see .mugiwara/missions/<m>/provenance.md`;
|
|
108
126
|
}
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
/** Closure hook: write provenance.md + attach the git note. */
|
|
112
|
-
export function writeProvenance(projectDir: string, missionDir: string, state: NoteSource): void {
|
|
130
|
+
export function writeProvenance(projectDir: string, missionDir: string, state: NoteSource & { base_sha?: string }, baseSha?: string): void {
|
|
113
131
|
const note = buildNote(state);
|
|
114
|
-
const
|
|
132
|
+
const resolvedBase = baseSha ?? (typeof (state as Record<string, unknown>).base_sha === 'string' ? (state as Record<string, unknown>).base_sha as string : undefined);
|
|
133
|
+
const cleanBase = resolvedBase && resolvedBase !== 'unknown' ? resolvedBase : undefined;
|
|
134
|
+
const attached = attachGitNote(projectDir, state.branch, note, cleanBase);
|
|
115
135
|
writeFileSync(join(missionDir, 'provenance.md'), renderProvenanceMd(note, attached ? attached.sha : null));
|
|
116
136
|
}
|
package/src/sign.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify }
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { readConfig } from './config.ts';
|
|
16
|
+
import { loadPolicy } from './policy.ts';
|
|
16
17
|
|
|
17
18
|
export function signArgs(reportPath: string, secretKey: string): string[] {
|
|
18
19
|
return ['-Sm', reportPath, '-s', secretKey];
|
|
@@ -192,6 +193,42 @@ export function signReport(projectDir: string, missionDir: string): { ok: boolea
|
|
|
192
193
|
return { ok: true, message: `signed ${report}.mugisig (pure ed25519, key: ${join(dir, 'mugiwara.key')})` };
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
function normalizePubkey(pk: string): string {
|
|
197
|
+
const t = pk.trim();
|
|
198
|
+
return t.startsWith('ed25519:') ? t.slice('ed25519:'.length).trim() : t;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function checkTrust(projectDir: string, signerPubB64: string): { ok: boolean; message?: string } {
|
|
202
|
+
try {
|
|
203
|
+
const policy = loadPolicy(projectDir);
|
|
204
|
+
const att = policy?.attestation;
|
|
205
|
+
if (!att) return { ok: true };
|
|
206
|
+
const trusted = att.trusted_keys ?? [];
|
|
207
|
+
const revoked = att.revoked ?? [];
|
|
208
|
+
const signer = signerPubB64.trim();
|
|
209
|
+
// revoked by pubkey direct match (before trusted check, so revoked is authoritative)
|
|
210
|
+
const directlyRevoked = revoked.some((r) => {
|
|
211
|
+
const rpk = (r as { pubkey?: string }).pubkey;
|
|
212
|
+
if (!rpk) return false;
|
|
213
|
+
return normalizePubkey(rpk) === signer;
|
|
214
|
+
});
|
|
215
|
+
if (directlyRevoked) return { ok: false, message: 'signature valid but signer revoked (pubkey in revoked list)' };
|
|
216
|
+
|
|
217
|
+
if (trusted.length === 0) {
|
|
218
|
+
// No trust list → only signature validity matters; revoked already checked
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
const match = trusted.find((e) => normalizePubkey(e.pubkey) === signer);
|
|
222
|
+
if (!match) return { ok: false, message: 'signature valid but signer untrusted (pub not in trusted_keys)' };
|
|
223
|
+
// check revoked by id (revoked id means that trusted id is revoked)
|
|
224
|
+
const revokedById = revoked.some((r) => r.id === match.id);
|
|
225
|
+
if (revokedById) return { ok: false, message: `signature valid but signer revoked (id: ${match.id})` };
|
|
226
|
+
return { ok: true };
|
|
227
|
+
} catch {
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
195
232
|
export function verifyReport(projectDir: string, missionDir: string): { ok: boolean; message: string } {
|
|
196
233
|
const report = join(missionDir, 'report.md');
|
|
197
234
|
const minisig = `${report}.minisig`;
|
|
@@ -208,6 +245,10 @@ export function verifyReport(projectDir: string, missionDir: string): { ok: bool
|
|
|
208
245
|
const pubKey = existsSync(defaultKey('public')) ? defaultKey('public') : null;
|
|
209
246
|
try {
|
|
210
247
|
execFileSync('minisign', verifyArgs(report, pubKey), { cwd: projectDir, stdio: 'pipe' });
|
|
248
|
+
// trust check for pure is not applied to minisig (MVP: pure-only trust).
|
|
249
|
+
// If a .mugisig also exists alongside minisig, still trust-check the pure pub for completeness,
|
|
250
|
+
// but minisig verification already succeeded — treat as ok.
|
|
251
|
+
// For strict attestation, operator should use pure backend when trusted_keys is configured.
|
|
211
252
|
return { ok: true, message: 'signature verifies against report.md (minisig)' };
|
|
212
253
|
} catch {
|
|
213
254
|
return { ok: false, message: 'SIGNATURE INVALID — report.md changed after signing (minisig)' };
|
|
@@ -219,9 +260,10 @@ export function verifyReport(projectDir: string, missionDir: string): { ok: bool
|
|
|
219
260
|
const parsed = JSON.parse(readFileSafe(mugisig) ?? '{}') as PureSig;
|
|
220
261
|
const content = readFileSafe(report);
|
|
221
262
|
if (content === null || parsed.algo !== 'ed25519-pure') return { ok: false, message: 'invalid .mugisig file' };
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
263
|
+
if (!pureVerify(content, parsed)) return { ok: false, message: 'SIGNATURE INVALID — report.md changed after signing (mugisig)' };
|
|
264
|
+
const trust = checkTrust(projectDir, parsed.pub);
|
|
265
|
+
if (!trust.ok) return { ok: false, message: trust.message! };
|
|
266
|
+
return { ok: true, message: 'signature verifies against report.md (mugisig, ed25519-pure)' };
|
|
225
267
|
} catch {
|
|
226
268
|
return { ok: false, message: 'invalid .mugisig file' };
|
|
227
269
|
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Adaptive Budget & Circuit Breaker (Phase 7)
|
|
2
|
-
|
|
3
|
-
Reserve expected max before expensive stages (Review/Security/Healing); continuously project `current + remaining required + expected conditional + possible healing` (§26); expand budget only with evidence (§27 valid: scope legitimately expanded, security-sensitive path, test surface larger, architecture dependency, legitimate healing; invalid: verbosity/reread/repeat/unnecessary code); respect progressive thresholds (§28: 60%→optimize, 75%→aggressive, 90%→protect, 100%→pause, 150%→warning, 300%→stop); trip breaker when `actual ≥ 2× expected` without progress/scope/evidence (§29, note: double-threshold); flag 5k-zero-progress anomaly (§24, re-consumes slop signal); record every non-ok verdict via `recordBudgetDecision` (§41).
|
|
4
|
-
|
|
5
|
-
Honest boundary: verdicts-not-enforcement; crew acts. No new config; savepoint/lane-base untouched. Report/CLI budget ledger → Phase 8.
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# Benchmark & Hardening — Full Checklist (Phase 9)
|
|
2
|
-
|
|
3
|
-
Tracks `scripts/benchmark-governor.ts` harness (deterministic, no network).
|
|
4
|
-
|
|
5
|
-
## Cost suite (§48) — 4 workloads
|
|
6
|
-
- [ ] lean-trivial: projected 8000 + overhead 1000, context ≤20000, evidence ≥1, surface files 2 loc 50
|
|
7
|
-
- [ ] standard-feature: projected 15000 + overhead 1500, context ≤40000, evidence ≥3
|
|
8
|
-
- [ ] large-repo: projected 22000 + overhead 2200, context ≤80000, evidence ≥5, surface 50 files
|
|
9
|
-
- [ ] long-mission: projected 23000 + overhead 2300, context ≤90000, 9 stages projection ≤ budget
|
|
10
|
-
- Check: `measured.tokens ≤ projected + overhead` else fail; `measured.context ≤ max` else fail
|
|
11
|
-
|
|
12
|
-
## Stop-Slop suite (§45) — 12 scenarios detect→classify→intervene
|
|
13
|
-
- [ ] endless-exploration → investigation slop → stop
|
|
14
|
-
- [ ] repeated-reads (3× no evidence) → context slop → stop; with concrete reason → tolerate
|
|
15
|
-
- [ ] repeated-commands (same cmd+evidence fail) → retry slop → stop
|
|
16
|
-
- [ ] repeated-failed-test → retry slop → stop
|
|
17
|
-
- [ ] repeated-reasoning → reasoning slop → stop
|
|
18
|
-
- [ ] unnecessary-abstraction → code slop → stop
|
|
19
|
-
- [ ] unnecessary-dependency → code slop → stop
|
|
20
|
-
- [ ] unrelated-refactor → scope slop → stop
|
|
21
|
-
- [ ] verbose-output → output slop → stop
|
|
22
|
-
- [ ] no-progress-healing (cycle ≥3, 0 fixes) → healing slop → stop
|
|
23
|
-
- [ ] premature-completion → scope slop → escalate
|
|
24
|
-
- [ ] excessive-context (repeated reads + duplicate chars) → context slop → stop
|
|
25
|
-
|
|
26
|
-
## Stress (bench-only, no runtime)
|
|
27
|
-
- [ ] large repository: 50 files within declared scope → pass (scope drift negative)
|
|
28
|
-
- [ ] long mission: 9 stages, projectBudget max ≤ full budget 50000 → pass
|
|
29
|
-
- [ ] runaway: actual 2× expected with no progress/scope/evidence → breaker tripped → fail (measures, not enforces)
|
|
30
|
-
|
|
31
|
-
## Thresholds (ratchet, like retrieval-eval)
|
|
32
|
-
- Thresholds live in `scripts/benchmark-thresholds.json` (or in-script THRESHOLDS const)
|
|
33
|
-
- `tokens > projected + overhead` → harness fails workload
|
|
34
|
-
- `context_chars > context_max` → fail
|
|
35
|
-
- Thresholds only move on explicit fixture update (reviewed diff), never silently
|
|
36
|
-
- `note: thresholds are fixture constants, not config — ratchet like retrieval-eval`
|
|
37
|
-
|
|
38
|
-
## Regression (§49)
|
|
39
|
-
- `checkRegression`: cost down but correctness/evidence/security/quality/scope down → fail
|
|
40
|
-
- Baseline from thresholds `baselines` + workload `expected_*`; measured vs baseline pure comparison
|
|
41
|
-
|
|
42
|
-
## Cross-platform & Determinism
|
|
43
|
-
- Harness pure over explicit fixture inputs, no Date.now/Math.random/network, deterministic on all platforms
|
|
44
|
-
- `scripts/conformance.ts` 12-platform parity proves cross-platform
|
|
45
|
-
|
|
46
|
-
## CI Enforcement
|
|
47
|
-
- `package.json:gate` includes `bun scripts/benchmark-governor.ts` (extend existing gate)
|
|
48
|
-
- `scripts/gate-selftest.ts` tampers thresholds → harness must exit 1 (G3 — gate that cannot fail is not a gate)
|
|
49
|
-
- note: harness measures, does not enforce — no runtime gate
|
|
50
|
-
|
|
51
|
-
## Docs
|
|
52
|
-
- `docs/concepts/cost.md` ## Benchmark & Hardening documents harness/threshold/stress contracts
|
|
53
|
-
- `docs/cost-governor.md` hub links to cost.md for deep contracts
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Cognitive & Output Governor
|
|
2
|
-
|
|
3
|
-
Reasoning stays focused — Question→Evidence→Decision→Action, no speculative architecture/hypothetical requirements/repeated reconsideration/unrelated implementations (§17); investigation terminates when acceptance_mapped+surface_understood+path_established or limits hit without concrete reason (§13); alternatives bounded to evidence-backed options (default 3).
|
|
4
|
-
|
|
5
|
-
Output compressed to mission-focused structure (Decision/Action/Result/Evidence/Blocker, §18), duplicate explanations fingerprinted; every cognitive verdict lands as a `cognitive-governor` trail row in `.mugiwara/missions/<mission>/decisions.md` → `## Cost governor decisions`. savepoint/lane-base/config untouched.
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# Scope & Code Governor
|
|
2
|
-
|
|
3
|
-
Prefer the smallest correct scope — reuse existing code + local modification
|
|
4
|
-
over new architecture (§14). An abstraction is justified only when used in ≥2
|
|
5
|
-
places or required by contract, never speculatively (§15); a dependency is
|
|
6
|
-
added only with explicit justification (§16); implementations are minimum
|
|
7
|
-
sufficient, never minimum LOC at the expense of verification/quality
|
|
8
|
-
(§15/§38).
|
|
9
|
-
|
|
10
|
-
Code waste (unnecessary helper/abstraction/wrapper/interface/config/
|
|
11
|
-
dependency/generated code/refactor) is named; the change surface is measured;
|
|
12
|
-
every scope verdict lands as a `scope-governor` trail row in
|
|
13
|
-
`.mugiwara/missions/<mission>/decisions.md` → `## Cost governor decisions`.
|
|
14
|
-
savepoint/lane-base/config untouched.
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# Stop-Slop Governor
|
|
2
|
-
|
|
3
|
-
Slop taxonomy (§21 eight kinds); detection signals (§22: repeated reads/commands,
|
|
4
|
-
token-without-evidence, LOC-without-acceptance, abstraction-without-justification);
|
|
5
|
-
progress measurement (§23: evidence/criteria/tests/code vs cost delta, slop when
|
|
6
|
-
cost grows without progress); work-to-cost anomaly (§24 drop signal); intervention
|
|
7
|
-
rules (§20 tolerate/stop/compress/escalate by severity); six category detectors
|
|
8
|
-
(retry §21.6/§31 same-action-same-evidence-same-failure→STOP, healing §21.7/§32
|
|
9
|
-
no-progress→stop, scope §21.8 out-of-scope-without-acceptance→reject, context
|
|
10
|
-
§21.2 duplicate/irrelevant→discard/compress, investigation §21.1 unbounded-
|
|
11
|
-
exploration→stop, code §21.5 unnecessary abstraction/dependency/boilerplate→
|
|
12
|
-
remove/simplify). Every slop verdict lands as a `slop-governor` trail row in
|
|
13
|
-
`.mugiwara/missions/<mission>/decisions.md` → `## Cost governor decisions`.
|
|
14
|
-
savepoint/lane-base/config untouched.
|