@ionivetech/mugiwara 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/README.md +2 -2
  7. package/content/agents/brook-healing.md +1 -1
  8. package/content/agents/memory-keeper.md +5 -0
  9. package/content/agents/usopp-brainstorm.md +3 -2
  10. package/content/agents/zoro-execution.md +4 -3
  11. package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
  12. package/content/skills/mugiwara-checkpoint/SKILL.md +2 -0
  13. package/content/skills/mugiwara-execution/SKILL.md +4 -3
  14. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  15. package/content/skills/mugiwara-gates/SKILL.md +6 -0
  16. package/content/skills/mugiwara-healing/SKILL.md +5 -1
  17. package/content/skills/mugiwara-lessons/SKILL.md +3 -0
  18. package/content/skills/mugiwara-orchestration/SKILL.md +5 -4
  19. package/content/skills/mugiwara-planning/SKILL.md +2 -0
  20. package/content/skills/mugiwara-quality/SKILL.md +3 -14
  21. package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
  22. package/content/skills/mugiwara-resume/SKILL.md +3 -14
  23. package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
  24. package/content/skills/mugiwara-review/SKILL.md +3 -15
  25. package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
  26. package/content/skills/mugiwara-security/SKILL.md +1 -0
  27. package/content/skills/mugiwara-ship/SKILL.md +2 -0
  28. package/content/skills/mugiwara-workflow/SKILL.md +10 -7
  29. package/dist/mugiwara.js +1190 -376
  30. package/gemini-extension.json +1 -1
  31. package/hooks/mugiwara-mode-tracker.js +24 -4
  32. package/hooks/mugiwara-mode-tracker.ts +36 -7
  33. package/hooks/session-start.js +6 -1
  34. package/hooks/session-start.ts +8 -1
  35. package/package.json +2 -2
  36. package/plugin.json +1 -1
  37. package/references/cost-governor.md +104 -0
  38. package/references/wave-banners.md +1 -2
  39. package/scripts/gate-selftest.ts +84 -21
  40. package/scripts/savepoint.sh +22 -2
  41. package/scripts/validate-content.ts +60 -0
  42. package/scripts/verify-install.ts +20 -0
  43. package/scripts/write-metrics.ts +73 -0
  44. package/src/budget.ts +11 -0
  45. package/src/cli.ts +128 -13
  46. package/src/config.ts +6 -0
  47. package/src/continue.ts +29 -0
  48. package/src/cost.ts +3 -0
  49. package/src/integrity.ts +64 -15
  50. package/src/mission.ts +123 -7
  51. package/src/policy.ts +355 -2
  52. package/src/provenance.ts +29 -9
  53. package/src/sign.ts +45 -3
  54. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +0 -5
  55. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +0 -53
  56. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +0 -5
  57. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +0 -14
  58. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +0 -14
package/src/mission.ts CHANGED
@@ -7,9 +7,11 @@ import { checkTrail, formatIssues } from './integrity.ts';
7
7
  import { checkMissionArtifacts } from './check-artifacts.ts';
8
8
  import { generateRollback } from './rollback.ts';
9
9
  import { writeProvenance } from './provenance.ts';
10
+ import { loadPolicy } from './policy.ts';
11
+ import { verifyReport } from './sign.ts';
10
12
  import { rankFiles, renderRouting } from './routing.ts';
11
- import { formatFootprint, measureContextChars, readBudgetConfig } from './budget.ts';
12
- import { budgetForLane, costEnvelope, appendCostEvent } from './cost.ts';
13
+ import { formatFootprint, measureContextChars, readBudgetConfig, shouldCompress, compressThreshold } from './budget.ts';
14
+ import { budgetForLane, costEnvelope, appendCostEvent, COMPRESSED_KIND } from './cost.ts';
13
15
  import { loadRegistry } from './evidence.ts';
14
16
  import { computeContextMetrics, contextStatus } from './context.ts';
15
17
  import { buildCostLedger, renderAdaptationSection } from './reporting.ts';
@@ -31,6 +33,52 @@ function primaryState(dir: string, files: string[]): Record<string, unknown> | n
31
33
  }
32
34
  }
33
35
 
36
+ /** Extract tasks from state.json — handles nested tasks:{done,total} (current savepoint) and legacy flat fields. */
37
+ function tasksFromState(state: Record<string, unknown> | null): { done: number; total: number } {
38
+ if (!state) return { done: 0, total: 0 };
39
+ const t = (state as Record<string, unknown>).tasks as { done?: unknown; total?: unknown } | undefined;
40
+ if (t && typeof t.done === 'number' && typeof t.total === 'number') return { done: t.done, total: t.total };
41
+ const done = Number((state as Record<string, unknown>).tasks_done);
42
+ const total = Number((state as Record<string, unknown>).tasks_total);
43
+ return { done: Number.isFinite(done) ? done : 0, total: Number.isFinite(total) ? total : 0 };
44
+ }
45
+
46
+ /** Fresh task counts from plan.md, with sub-plan fallback for large campaigns (>3 phases). */
47
+ function countPlanTasks(missionDir: string): { done: number; total: number } {
48
+ let total = 0;
49
+ let done = 0;
50
+ const planFile = join(missionDir, 'plan.md');
51
+ if (existsSync(planFile)) {
52
+ try {
53
+ const text = readFileSync(planFile, 'utf8');
54
+ for (const line of text.split(/\r?\n/)) {
55
+ if (/^\s*-\s*\[[ xX]\]/.test(line)) {
56
+ total++;
57
+ if (/^\s*-\s*\[[xX]\]/.test(line)) done++;
58
+ }
59
+ }
60
+ } catch { /* best-effort */ }
61
+ }
62
+ if (total === 0) {
63
+ const subPlanDir = join(missionDir, 'sub-plan');
64
+ if (existsSync(subPlanDir)) {
65
+ try {
66
+ for (const f of readdirSync(subPlanDir)) {
67
+ if (!f.endsWith('.md')) continue;
68
+ const text = readFileSync(join(subPlanDir, f), 'utf8');
69
+ for (const line of text.split(/\r?\n/)) {
70
+ if (/^\s*-\s*\[[ xX]\]/.test(line)) {
71
+ total++;
72
+ if (/^\s*-\s*\[[xX]\]/.test(line)) done++;
73
+ }
74
+ }
75
+ }
76
+ } catch { /* best-effort */ }
77
+ }
78
+ }
79
+ return { done, total };
80
+ }
81
+
34
82
  /** Files the mission changed, base..branch. Empty on any git failure — routing is best-effort. */
35
83
  function changedFiles(projectDir: string, state: Record<string, unknown> | null): string[] {
36
84
  const base = typeof state?.base_sha === 'string' ? state.base_sha : '';
@@ -101,6 +149,11 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
101
149
  if (existsSync(join(root, 'lessons.md'))) kept.push('lessons.md');
102
150
  else if (existsSync(join(root, join('logs', 'lessons.md')))) kept.push(join('logs', 'lessons.md'));
103
151
  }
152
+ // index.md is the archive history — after a full reset (missions gone) its entries are dangling (point to deleted report.md). A reset is a fresh start, so clear it; next archive recreates "# Mission index" header.
153
+ if (removed.includes('missions') && existsSync(join(root, 'index.md'))) {
154
+ rmSync(join(root, 'index.md'));
155
+ removed.push('index.md');
156
+ }
104
157
  for (const f of ['config', 'manifest.json', 'backup']) {
105
158
  if (existsSync(join(root, f))) kept.push(f);
106
159
  }
@@ -122,10 +175,20 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
122
175
 
123
176
  // Closure integrity gate: the trail validates itself before it
124
177
  // folds. Dangling links, secrets, or missing evidence fail the archive.
178
+ // Card-number shapes are warn-only — they do not block archive.
125
179
  if (!dryRun) {
126
180
  const issues = checkTrail(dir, projectDir);
127
- if (issues.length) {
128
- throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(issues)}`);
181
+ const blocking = issues.filter((i) => i.kind !== 'secret-warn' && i.severity !== 'warn');
182
+ if (blocking.length) {
183
+ throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(blocking)}`);
184
+ }
185
+ if (issues.length && blocking.length === 0) {
186
+ // warn-only — log but do not fail
187
+ // console.warn is best-effort; archive proceeds
188
+ try {
189
+ const warnText = formatIssues(issues);
190
+ if (warnText) console.warn(`closure integrity warnings (non-blocking):\n${warnText}`);
191
+ } catch { /* ignore */ }
129
192
  }
130
193
  // Artifact gate (roadmap v0.8 item 4): Lane 2+ missions must carry
131
194
  // plan.md + flows/* evidence — a mission without its trail does not fold.
@@ -133,6 +196,20 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
133
196
  if (!artifacts.ok) {
134
197
  throw new Error(`archive artifact gate failed — missing: ${artifacts.missing.join(', ')} (lane ${artifacts.lane}). Write the evidence trail before archiving.`);
135
198
  }
199
+ // Attestation gate (D4): when attestation.required true, report must be signed and trusted.
200
+ try {
201
+ const policy = loadPolicy(projectDir);
202
+ if (policy?.attestation?.required) {
203
+ const v = verifyReport(projectDir, dir);
204
+ if (!v.ok) {
205
+ throw new Error(`closure integrity gate failed — attestation required but report not signed/trusted: ${v.message}`);
206
+ }
207
+ }
208
+ } catch (e) {
209
+ if ((e as Error).message.startsWith('closure integrity gate failed — attestation required')) throw e;
210
+ if ((e as Error).message.startsWith('unknown policy key')) throw e;
211
+ // other policy load errors are best-effort — do not block archive
212
+ }
136
213
  }
137
214
 
138
215
  const files = readdirSync(dir);
@@ -249,6 +326,38 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
249
326
  costSection += renderAdaptationSection(dir);
250
327
  } catch { /* best-effort */ }
251
328
 
329
+ // T4: auto-compress when context >80% budget — compress flows → stub, not throw
330
+ // (record compressed event; closure still recorded; hard gate only at 100%)
331
+ if (shouldCompress(budget, chars)) {
332
+ try {
333
+ const flowsDir = join(dir, 'flows');
334
+ const wavesDir = join(dir, 'waves');
335
+ const targetDir = existsSync(flowsDir) ? flowsDir : existsSync(wavesDir) ? wavesDir : null;
336
+ if (targetDir && existsSync(targetDir)) {
337
+ const flowFiles = readdirSync(targetDir).filter(f => f.endsWith('.md'));
338
+ if (flowFiles.length) {
339
+ const pct = Math.round((chars / budget) * 100);
340
+ const stub = `# Compressed trail\n\nTrail ${chars} chars exceeds ${pct}% of budget ${budget} (threshold ${compressThreshold(budget)}) — flows archived as stub to preserve budget. Original flows: ${flowFiles.join(', ')}\n`;
341
+ for (const f of flowFiles) {
342
+ try { rmSync(join(targetDir, f), { force: true }); } catch {}
343
+ }
344
+ writeFileSync(join(targetDir, '00-compressed.md'), stub);
345
+ }
346
+ }
347
+ appendCostEvent(dir, {
348
+ kind: COMPRESSED_KIND,
349
+ mission,
350
+ tokens_est: est,
351
+ budget: laneBudget,
352
+ status: 'compressed',
353
+ context_chars: chars,
354
+ context_status: ctxStatus,
355
+ context_metrics: metrics,
356
+ });
357
+ costSection += `\n| **Compressed** | yes — ${chars} chars >80% of ${budget} — flows stubbed as 00-compressed.md |`;
358
+ } catch { /* compress best-effort — never blocks archive */ }
359
+ }
360
+
252
361
  // Cost Governor: record the closure cost event — the mission's final
253
362
  // cost snapshot, folded into report.md with the rest of the trail.
254
363
  // (Phase 1 — native cost governor; pure append, never rewrites state.)
@@ -265,6 +374,7 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
265
374
  // M2: the closure event (with context_status possibly 'over') is recorded
266
375
  // BEFORE the hard gate throws — an over-budget closure still leaves a
267
376
  // ledger row so the over-budget condition is observable, never erased.
377
+ // T4: over 80% already compressed above; hard fail only at 100% preserves gate-selftest.
268
378
  if (budget && chars > budget) {
269
379
  throw new Error(`closure context budget failed — ${footprintLine}. Trim the trail or raise context_budget_chars.`);
270
380
  }
@@ -368,17 +478,23 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
368
478
  });
369
479
  if (rb) kept.push(join('missions', mission, rb.file));
370
480
  try {
481
+ const st = tasksFromState(state);
482
+ const pt = countPlanTasks(dir);
483
+ const tasks_done = pt.total > 0 ? pt.done : st.done;
484
+ const tasks_total = pt.total > 0 ? pt.total : st.total;
485
+ const baseShaForNote = typeof state.base_sha === 'string' ? state.base_sha : undefined;
371
486
  writeProvenance(projectDir, dir, {
372
487
  mission,
373
488
  actor: typeof state.actor === 'string' ? state.actor : '',
374
489
  lane: typeof state.lane === 'string' ? state.lane : '',
375
490
  mode: typeof state.mode === 'string' ? state.mode : '',
376
491
  branch: state.branch,
377
- tasks_done: Number(state.tasks_done) || 0,
378
- tasks_total: Number(state.tasks_total) || 0,
492
+ tasks_done,
493
+ tasks_total,
379
494
  evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
380
495
  models: stageModels,
381
- });
496
+ base_sha: baseShaForNote,
497
+ } as never, baseShaForNote);
382
498
  kept.push(join('missions', mission, 'provenance.md'));
383
499
  } catch { /* provenance is additive; archive proceeds */ }
384
500
  }
package/src/policy.ts CHANGED
@@ -20,10 +20,17 @@ export type MugiwaraPolicy = {
20
20
  require_human_approval?: string[];
21
21
  };
22
22
  evidence?: { required?: string[] };
23
+ integrity?: { extra_secret_patterns?: Array<{ pattern: string; label: string; severity?: 'block' | 'warn' }> };
24
+ attestation?: {
25
+ required?: boolean;
26
+ trusted_keys?: Array<{ id: string; pubkey: string; added?: string }>;
27
+ revoked?: Array<{ id: string; revoked?: string; reason?: string; pubkey?: string }>;
28
+ };
29
+ harness?: { require_enforcement?: boolean };
23
30
  };
24
31
 
25
32
  const POLICY_FILES = ['mugiwara.policy.yml', 'mugiwara.policy.yaml'];
26
- const KNOWN_ROOTS = ['lanes', 'gates', 'evidence'];
33
+ const KNOWN_ROOTS = ['lanes', 'gates', 'evidence', 'integrity', 'attestation', 'harness'];
27
34
 
28
35
  /**
29
36
  * Minimal YAML subset: maps, `- item` string lists, scalars.
@@ -97,11 +104,238 @@ function scalar(v: string): unknown {
97
104
  return t;
98
105
  }
99
106
 
107
+ /**
108
+ * Dedicated extractor for integrity.extra_secret_patterns list-of-maps.
109
+ * The minimal YAML subset parser only handles scalar lists; this scans the raw
110
+ * text line-by-line for map items so both forms work:
111
+ * - { pattern: "\\b...\\b", label: "NIK" }
112
+ * - - pattern: "\\b...\\b"
113
+ * label: "NIK"
114
+ * severity: warn
115
+ */
116
+ export function extractExtraSecretPatterns(text: string): Array<{ pattern: string; label: string; severity?: string }> {
117
+ const lines = text.split(/\r?\n/);
118
+ let inBlock = false;
119
+ let baseIndent = -1;
120
+ const out: Array<Record<string, string>> = [];
121
+ let current: Record<string, string> | null = null;
122
+ let currentIndent = -1;
123
+ for (const rawLine of lines) {
124
+ const noComment = rawLine.replace(/(^|\s)#.*$/, '');
125
+ if (!noComment.trim()) continue;
126
+ const indent = noComment.length - noComment.trimStart().length;
127
+ const trimmed = noComment.trim();
128
+ if (!inBlock) {
129
+ if (trimmed.startsWith('extra_secret_patterns:')) {
130
+ inBlock = true;
131
+ baseIndent = indent;
132
+ continue;
133
+ }
134
+ } else {
135
+ // exit if a sibling key at same or shallower indent (not a list item) appears
136
+ if (indent <= baseIndent && !trimmed.startsWith('-') && trimmed.includes(':')) {
137
+ break;
138
+ }
139
+ if (trimmed.startsWith('-')) {
140
+ if (current) out.push(current);
141
+ current = {};
142
+ currentIndent = indent;
143
+ const afterDash = trimmed.slice(1).trim();
144
+ if (!afterDash) continue;
145
+ if (afterDash.startsWith('{') && afterDash.endsWith('}')) {
146
+ const inner = afterDash.slice(1, -1);
147
+ for (const part of inner.split(',')) {
148
+ const colon = part.indexOf(':');
149
+ if (colon === -1) continue;
150
+ const k = part.slice(0, colon).trim();
151
+ const v = part.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
152
+ if (k && v) current[k] = v;
153
+ }
154
+ } else if (afterDash.includes(':')) {
155
+ const colon = afterDash.indexOf(':');
156
+ const k = afterDash.slice(0, colon).trim();
157
+ const v = afterDash.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
158
+ if (k && v) current[k] = v;
159
+ } else {
160
+ const v = afterDash.replace(/^["']|["']$/g, '');
161
+ if (v) current['pattern'] = v;
162
+ }
163
+ } else if (current && trimmed.includes(':')) {
164
+ if (indent > currentIndent) {
165
+ const colon = trimmed.indexOf(':');
166
+ const k = trimmed.slice(0, colon).trim();
167
+ const v = trimmed.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
168
+ if (k && v) current[k] = v;
169
+ } else if (indent <= baseIndent) {
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (current) out.push(current);
176
+ return out.filter((o) => typeof o.pattern === 'string' && typeof o.label === 'string' && o.pattern.length > 0) as Array<{ pattern: string; label: string; severity?: string }>;
177
+ }
178
+
179
+ /**
180
+ * Extract attestation block: handles both inline `{ id: "...", pubkey: "ed25519:..." }`
181
+ * and multiline lists. Scans inside `attestation:` indented block so
182
+ * `evidence: required:` is not confused.
183
+ */
184
+ export function extractAttestation(text: string): {
185
+ required?: boolean;
186
+ trusted_keys?: Array<Record<string, string>>;
187
+ revoked?: Array<Record<string, string>>;
188
+ } | null {
189
+ const lines = text.split(/\r?\n/);
190
+ let attBase = -1;
191
+ let inAtt = false;
192
+ let required: boolean | undefined;
193
+ const trusted: Array<Record<string, string>> = [];
194
+ const revoked: Array<Record<string, string>> = [];
195
+ // collection state
196
+ let collecting: 'trusted' | 'revoked' | null = null;
197
+ let collectBase = -1;
198
+ let current: Record<string, string> | null = null;
199
+ let curIndent = -1;
200
+
201
+ const flush = () => {
202
+ if (!current) return;
203
+ if (collecting === 'trusted') trusted.push(current);
204
+ else if (collecting === 'revoked') revoked.push(current);
205
+ current = null;
206
+ };
207
+
208
+ for (let idx = 0; idx < lines.length; idx++) {
209
+ const rawLine = lines[idx];
210
+ const noComment = rawLine.replace(/(^|\s)#.*$/, '');
211
+ if (!noComment.trim()) continue;
212
+ const indent = noComment.length - noComment.trimStart().length;
213
+ const trimmed = noComment.trim();
214
+
215
+ if (!inAtt) {
216
+ if (trimmed === 'attestation:' || trimmed.startsWith('attestation:')) {
217
+ // handle `attestation: { ... }` inline — not used, but parse required if present
218
+ const after = trimmed.slice('attestation:'.length).trim();
219
+ if (after.startsWith('{')) {
220
+ // inline map form not needed for MVP
221
+ return null;
222
+ }
223
+ inAtt = true;
224
+ attBase = indent;
225
+ // if there is a value after colon on same line (e.g., attestation: foo) ignore
226
+ continue;
227
+ }
228
+ continue;
229
+ }
230
+
231
+ // inside attestation block
232
+ // exit attestation when sibling root key at same/shallower indent
233
+ if (indent <= attBase && !trimmed.startsWith('-') && trimmed.includes(':')) {
234
+ flush();
235
+ break;
236
+ }
237
+
238
+ // handle collecting state first
239
+ if (collecting) {
240
+ // exiting collection to sibling key inside attestation (e.g., revoked: after trusted_keys:)
241
+ if (indent <= collectBase && !trimmed.startsWith('-') && trimmed.includes(':')) {
242
+ flush();
243
+ collecting = null;
244
+ // fall through to process this line as att child
245
+ } else if (trimmed.startsWith('-')) {
246
+ flush();
247
+ current = {};
248
+ curIndent = indent;
249
+ const afterDash = trimmed.slice(1).trim();
250
+ if (!afterDash) continue;
251
+ if (afterDash.startsWith('{') && afterDash.endsWith('}')) {
252
+ const inner = afterDash.slice(1, -1);
253
+ for (const part of inner.split(',')) {
254
+ const colon = part.indexOf(':');
255
+ if (colon === -1) continue;
256
+ const k = part.slice(0, colon).trim();
257
+ const v = part.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
258
+ if (k && v) current[k] = v;
259
+ }
260
+ } else if (afterDash.includes(':')) {
261
+ const colon = afterDash.indexOf(':');
262
+ const k = afterDash.slice(0, colon).trim();
263
+ const v = afterDash.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
264
+ if (k && v) current[k] = v;
265
+ } else {
266
+ const v = afterDash.replace(/^["']|["']$/g, '');
267
+ if (v) current['id'] = v;
268
+ }
269
+ continue;
270
+ } else if (current && trimmed.includes(':')) {
271
+ if (indent > curIndent) {
272
+ const colon = trimmed.indexOf(':');
273
+ const k = trimmed.slice(0, colon).trim();
274
+ const v = trimmed.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
275
+ if (k && v) current[k] = v;
276
+ continue;
277
+ }
278
+ }
279
+ // if still collecting but line is not part of current item, skip?
280
+ if (collecting) continue;
281
+ }
282
+
283
+ // not collecting (or just exited) — look for att children
284
+ if (trimmed.startsWith('required:')) {
285
+ const v = trimmed.slice('required:'.length).trim().replace(/^["']|["']$/g, '');
286
+ if (v === 'true') required = true;
287
+ else if (v === 'false') required = false;
288
+ continue;
289
+ }
290
+ if (trimmed.startsWith('trusted_keys:')) {
291
+ const after = trimmed.slice('trusted_keys:'.length).trim();
292
+ // handle inline empty `[]`
293
+ if (after === '[]') continue;
294
+ collecting = 'trusted';
295
+ collectBase = indent;
296
+ current = null;
297
+ // if inline list with one map on same line? e.g., trusted_keys: [{ id: "a", pubkey: "x" }]
298
+ // MVP not needed; empty case already handled
299
+ continue;
300
+ }
301
+ if (trimmed.startsWith('revoked:')) {
302
+ const after = trimmed.slice('revoked:'.length).trim();
303
+ if (after === '[]') continue;
304
+ collecting = 'revoked';
305
+ collectBase = indent;
306
+ current = null;
307
+ continue;
308
+ }
309
+ }
310
+ flush();
311
+ if (required === undefined && trusted.length === 0 && revoked.length === 0) return null;
312
+ const out: { required?: boolean; trusted_keys?: Array<Record<string, string>>; revoked?: Array<Record<string, string>> } = {};
313
+ if (required !== undefined) out.required = required;
314
+ if (trusted.length) out.trusted_keys = trusted;
315
+ if (revoked.length) out.revoked = revoked;
316
+ return out;
317
+ }
318
+
100
319
  export function loadPolicy(projectDir: string): MugiwaraPolicy | null {
101
320
  for (const name of POLICY_FILES) {
102
321
  const file = join(projectDir, name);
103
322
  if (!existsSync(file)) continue;
104
- return normalize(parsePolicyYaml(readFileSync(file, 'utf8')));
323
+ const text = readFileSync(file, 'utf8');
324
+ const raw = parsePolicyYaml(text);
325
+ const extra = extractExtraSecretPatterns(text);
326
+ if (extra.length) {
327
+ if (!raw.integrity || typeof raw.integrity !== 'object' || Array.isArray(raw.integrity)) raw.integrity = {};
328
+ (raw.integrity as Record<string, unknown>).extra_secret_patterns = extra;
329
+ }
330
+ const att = extractAttestation(text);
331
+ if (att) {
332
+ if (!raw.attestation || typeof raw.attestation !== 'object' || Array.isArray(raw.attestation)) raw.attestation = {};
333
+ const a = raw.attestation as Record<string, unknown>;
334
+ if (att.required !== undefined) a.required = att.required;
335
+ if (att.trusted_keys) a.trusted_keys = att.trusted_keys;
336
+ if (att.revoked) a.revoked = att.revoked;
337
+ }
338
+ return normalize(raw);
105
339
  }
106
340
  return null;
107
341
  }
@@ -128,6 +362,58 @@ function normalize(raw: Record<string, unknown>): MugiwaraPolicy {
128
362
  }
129
363
  const evidence = raw.evidence as Record<string, unknown> | undefined;
130
364
  if (evidence && Array.isArray(evidence.required)) out.evidence = { required: strings(evidence.required) };
365
+ const integrity = raw.integrity as Record<string, unknown> | undefined;
366
+ if (integrity && Array.isArray(integrity.extra_secret_patterns)) {
367
+ const arr = integrity.extra_secret_patterns as unknown[];
368
+ const cleaned: Array<{ pattern: string; label: string; severity?: 'block' | 'warn' }> = [];
369
+ for (const e of arr) {
370
+ if (!e || typeof e !== 'object') continue;
371
+ const rec = e as Record<string, unknown>;
372
+ if (typeof rec.pattern !== 'string' || typeof rec.label !== 'string') continue;
373
+ const sev = rec.severity === 'warn' ? 'warn' as const : rec.severity === 'block' ? 'block' as const : undefined;
374
+ const entry: { pattern: string; label: string; severity?: 'block' | 'warn' } = { pattern: rec.pattern, label: rec.label };
375
+ if (sev) entry.severity = sev;
376
+ cleaned.push(entry);
377
+ }
378
+ if (cleaned.length) out.integrity = { extra_secret_patterns: cleaned };
379
+ }
380
+ const att = raw.attestation as Record<string, unknown> | undefined;
381
+ if (att) {
382
+ const a: NonNullable<MugiwaraPolicy['attestation']> = {};
383
+ if (typeof att.required === 'boolean') a.required = att.required;
384
+ if (Array.isArray(att.trusted_keys)) {
385
+ const cleanedTk: Array<{ id: string; pubkey: string; added?: string }> = [];
386
+ for (const e of att.trusted_keys as unknown[]) {
387
+ if (!e || typeof e !== 'object') continue;
388
+ const rec = e as Record<string, unknown>;
389
+ if (typeof rec.id !== 'string' || typeof rec.pubkey !== 'string') continue;
390
+ if (!rec.id.trim() || !rec.pubkey.trim()) continue;
391
+ const entry: { id: string; pubkey: string; added?: string } = { id: rec.id.trim(), pubkey: rec.pubkey.trim() };
392
+ if (typeof rec.added === 'string' && rec.added.trim()) entry.added = rec.added.trim();
393
+ cleanedTk.push(entry);
394
+ }
395
+ if (cleanedTk.length) a.trusted_keys = cleanedTk;
396
+ }
397
+ if (Array.isArray(att.revoked)) {
398
+ const cleanedRv: Array<{ id: string; revoked?: string; reason?: string; pubkey?: string }> = [];
399
+ for (const e of att.revoked as unknown[]) {
400
+ if (!e || typeof e !== 'object') continue;
401
+ const rec = e as Record<string, unknown>;
402
+ if (typeof rec.id !== 'string' || !rec.id.trim()) continue;
403
+ const entry: { id: string; revoked?: string; reason?: string; pubkey?: string } = { id: rec.id.trim() };
404
+ if (typeof rec.revoked === 'string' && rec.revoked.trim()) entry.revoked = rec.revoked.trim();
405
+ if (typeof rec.reason === 'string' && rec.reason.trim()) entry.reason = rec.reason.trim();
406
+ if (typeof rec.pubkey === 'string' && rec.pubkey.trim()) entry.pubkey = rec.pubkey.trim();
407
+ cleanedRv.push(entry);
408
+ }
409
+ if (cleanedRv.length) a.revoked = cleanedRv;
410
+ }
411
+ if (a.required !== undefined || a.trusted_keys || a.revoked) out.attestation = a;
412
+ }
413
+ const harness = raw.harness as Record<string, unknown> | undefined;
414
+ if (harness && typeof harness.require_enforcement === 'boolean') {
415
+ out.harness = { require_enforcement: harness.require_enforcement };
416
+ }
131
417
  return out;
132
418
  }
133
419
 
@@ -154,3 +440,70 @@ export function matchedGlobs(paths: string[], globs: string[]): string[] {
154
440
  export function effectiveThreshold(configured: number, policyValue: number | undefined): number {
155
441
  return Math.max(configured, policyValue ?? 0);
156
442
  }
443
+
444
+ // ── Harness enforcement (D8) ────────────────────────────────────────────────
445
+ // Only opencode has runtime write-scope enforcement; the other 11 harnesses
446
+ // are rules-based. `harness.require_enforcement: true` refuses to run where
447
+ // the harness is not enforced.
448
+
449
+ /** Detect current harness. Mirrors savepoint.sh logic. */
450
+ export function detectHarness(projectDir?: string): string {
451
+ const e = process.env;
452
+ const has = (v: string | undefined) => v !== undefined && v !== '';
453
+ if (has(e.CLAUDECODE) || has(e.CLAUDE_CODE_ENTRYPOINT) || (typeof e.ANTHROPIC_MODEL === 'string' && /claude/i.test(e.ANTHROPIC_MODEL))) return 'claude';
454
+ if (has(e.OPENCODE) || has(e.OPENCODE_TOKENS_FILE)) return 'opencode';
455
+ const candidates = [
456
+ projectDir ? join(projectDir, '.opencode', 'config.json') : null,
457
+ join(process.cwd(), '.opencode', 'config.json'),
458
+ ].filter(Boolean) as string[];
459
+ for (const p of candidates) {
460
+ try { if (existsSync(p)) return 'opencode'; } catch { /* ignore */ }
461
+ }
462
+ if (has(e.CURSOR) || has(e.VSCODE_GIT_ASKPASS_NODE)) return 'cursor';
463
+ return 'unknown';
464
+ }
465
+
466
+ /** Only opencode is runtime-enforced. */
467
+ export function isEnforcedHarness(projectDir?: string): boolean {
468
+ return detectHarness(projectDir) === 'opencode';
469
+ }
470
+
471
+ /** Pure check: returns error message when policy requires enforcement but harness is rules-based, else null. */
472
+ export function getHarnessEnforcementError(projectDir: string): string | null {
473
+ let policy: MugiwaraPolicy | null;
474
+ try { policy = loadPolicy(projectDir); } catch (e) { throw e; }
475
+ if (!policy?.harness?.require_enforcement) return null;
476
+ if (isEnforcedHarness(projectDir)) return null;
477
+ const h = detectHarness(projectDir);
478
+ return `harness enforcement required but current harness is rules-based only \u2014 use opencode or set harness.require_enforcement:false (detected: ${h})`;
479
+ }
480
+
481
+ /** Fail closed when policy requires enforcement but harness is rules-based. */
482
+ export function enforceHarnessPolicy(projectDir: string): void {
483
+ const err = getHarnessEnforcementError(projectDir);
484
+ if (!err) return;
485
+ console.error(`\u2717 ${err}`);
486
+ process.exit(1);
487
+ }
488
+
489
+ /** Alias for CLI import convenience. */
490
+ export const checkHarnessEnforcement = enforceHarnessPolicy;
491
+
492
+ // ── Lane-aware gates (T3) ───────────────────────────────────────────────────
493
+ // Direct → minimal (typecheck+build only), lean → +validate-content, standard+
494
+ // → +evals/retrieval/conformance/benchmark. Single source for gate-selftest
495
+ // and the franky-gates skill doc. Full = 12 steps, direct = 3 steps.
496
+ export const GATE_STEPS_BY_LANE: Record<string, string[]> = {
497
+ direct: ['build-hooks:check', 'typecheck', 'build'],
498
+ lean: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links'],
499
+ standard: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links', 'test:coverage', 'coverage-gate', 'verify-install'],
500
+ full: ['build-hooks:check', 'typecheck', 'build', 'validate-content', 'lane-base', 'check-doc-links', 'test:coverage', 'coverage-gate', 'verify-install', 'run-evals', 'retrieval-eval', 'conformance'],
501
+ };
502
+
503
+ export function gatesForLane(lane: string): string[] {
504
+ return GATE_STEPS_BY_LANE[lane] ?? GATE_STEPS_BY_LANE.full;
505
+ }
506
+
507
+ export function isLaneAwareGateStep(step: string, lane: string): boolean {
508
+ return gatesForLane(lane).includes(step);
509
+ }
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
- let sha: string;
80
+ const range = baseSha ? `${baseSha}..${branch}` : branch;
81
+ let shas: string[] = [];
81
82
  try {
82
- sha = git(projectDir, ['rev-parse', '--verify', branch]);
83
+ const raw = git(projectDir, ['rev-list', range]);
84
+ shas = raw.split('\n').filter(Boolean);
83
85
  } catch {
84
- sha = git(projectDir, ['rev-parse', 'HEAD']);
86
+ // rev-list failed (unknown baseSha/branch) — fall back to single head
87
+ shas = [];
85
88
  }
86
- git(projectDir, ['notes', '--ref=mugiwara', 'add', '-f', '-m', note, sha]);
87
- return { sha };
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)}\n(no mugiwara provenance note on this commit)`;
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 attached = attachGitNote(projectDir, state.branch, note);
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
  }