@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
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/write-metrics.ts — generate .metrics/latest.json from gate outputs
3
+ // Deterministic, no network. Runs retrieval-eval --json and verify-install --json.
4
+
5
+ import { execSync } from 'node:child_process';
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const root = join(import.meta.dirname, '..');
10
+
11
+ function extractJson(output: string): any {
12
+ const idx = output.indexOf('{');
13
+ if (idx === -1) throw new Error('no JSON found in output: ' + output.slice(0, 200));
14
+ return JSON.parse(output.slice(idx));
15
+ }
16
+
17
+ function runJson(cmd: string): any {
18
+ // execSync returns stdout only; retrieval prints a rank line before JSON on stdout
19
+ // so we slice from first '{'
20
+ const out = execSync(cmd, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
21
+ return extractJson(out);
22
+ }
23
+
24
+ // retrieval: need probes, rank1_rate, index_size
25
+ let ret: any;
26
+ let ver: any;
27
+ try {
28
+ ret = runJson('bun scripts/retrieval-eval.ts --json');
29
+ } catch (e: any) {
30
+ // if process exits non-zero, stdout still contains JSON + rank line; try to parse from error stdout
31
+ const out = e.stdout?.toString() ?? e.message ?? '';
32
+ if (out.includes('{')) ret = extractJson(out);
33
+ else throw e;
34
+ }
35
+
36
+ try {
37
+ ver = runJson('bun scripts/verify-install.ts --json');
38
+ } catch (e: any) {
39
+ const out = e.stdout?.toString() ?? e.message ?? '';
40
+ if (out.includes('{')) ver = extractJson(out);
41
+ else throw e;
42
+ }
43
+
44
+ const rank1Str: string = ret.rank1_rate ?? ret.rank1 ?? '';
45
+ const rank1Num = typeof rank1Str === 'string' ? parseFloat(rank1Str.replace('%', '')) : Number(rank1Str);
46
+ const probes = ret.probes ?? ret.totalProbes ?? 0;
47
+ const pointersTotal = ver.pointers_total ?? ver.pointers ?? 0;
48
+ const pointersTargets = ver.pointers_targets ?? ver.targets ?? 0;
49
+ const indexSize = ret.index_size ?? 0;
50
+ const updated = new Date().toISOString().split('T')[0];
51
+
52
+ const metrics = {
53
+ retrieval_rank1: rank1Num,
54
+ retrieval_rank1_rate: rank1Str,
55
+ retrieval_probes: probes,
56
+ retrieval_rank1_count: ret.rank1_count ?? null,
57
+ retrieval_positives: ret.positives ?? null,
58
+ retrieval_negatives: ret.negatives ?? null,
59
+ retrieval_index_size: indexSize,
60
+ retrieval_index_terms: ret.index_terms ?? null,
61
+ pointers_total: pointersTotal,
62
+ pointers_targets: pointersTargets,
63
+ pointers_broken: ver.pointers_broken ?? ver.broken_pointers ?? 0,
64
+ index_size: indexSize,
65
+ updated,
66
+ };
67
+
68
+ const outDir = join(root, '.metrics');
69
+ mkdirSync(outDir, { recursive: true });
70
+ const outPath = join(outDir, 'latest.json');
71
+ writeFileSync(outPath, JSON.stringify(metrics, null, 2) + '\n');
72
+ console.log(`✓ wrote ${outPath}`);
73
+ console.log(JSON.stringify(metrics, null, 2));
package/src/budget.ts CHANGED
@@ -45,3 +45,14 @@ export function formatFootprint(chars: number, budget: number): string {
45
45
  ? `${base} — OVER budget ${budget}`
46
46
  : `${base} (budget ${budget})`;
47
47
  }
48
+
49
+ // ── Auto-compress threshold (T4) — 80% of budget ───────────────────────────
50
+ export const COMPRESS_THRESHOLD_PCT = 0.8;
51
+
52
+ export function shouldCompress(budget: number, chars: number): boolean {
53
+ return budget > 0 && chars > Math.floor(budget * COMPRESS_THRESHOLD_PCT);
54
+ }
55
+
56
+ export function compressThreshold(budget: number): number {
57
+ return Math.floor(budget * COMPRESS_THRESHOLD_PCT);
58
+ }
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // src/cli.ts
3
- import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
@@ -12,7 +12,7 @@ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProj
12
12
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
13
13
  import { resetMission, archiveMission } from './mission.ts';
14
14
  import { runScript, RUNNABLE } from './run.ts';
15
- import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor } from './continue.ts';
15
+ import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor, hasLegacyLayout, CURRENT_SCHEMA_VERSION } from './continue.ts';
16
16
  import { blamePath } from './provenance.ts';
17
17
  import { signReport, verifyReport, ensurePureKey, hasMinisign } from './sign.ts';
18
18
  import { ensureConfig } from './config.ts';
@@ -20,6 +20,7 @@ import { costEnvelope } from './cost.ts';
20
20
  import { computeLiveSlop } from './slop.ts';
21
21
  import { loadRegistry } from './evidence.ts';
22
22
  import { buildCostLedger, toCostJSON } from './reporting.ts';
23
+ import { enforceHarnessPolicy } from './policy.ts';
23
24
 
24
25
  const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
25
26
  const flag = (v: FlagValue): boolean => v === true;
@@ -28,16 +29,20 @@ export async function run(argv: string[]): Promise<void> {
28
29
  const { command, flags, _ } = parseArgs(argv);
29
30
  if (flag(flags.help) || command === 'help') return help();
30
31
  if (flag(flags.version)) { console.log(`mugiwara ${VERSION}`); return; }
31
- // `continue` and `status` are read-only position commands: dispatch before
32
- // config bootstrap so a fresh project never gets a .mugiwara/config created
33
- // and no setup chatter is printed before missions/members are listed.
34
- if (command === 'continue' || command === 'status') {
35
- return command === 'continue' ? continueCmd(flags, _) : statusCmd(flags);
32
+ // harness.require_enforcement enterprise gate: refuse rules-based harnesses
33
+ // (only opencode is runtime-enforced). Covers run/savepoint/archive/status
34
+ // + other workflow commands; install/update/uninstall/list are setup and bypass.
35
+ {
36
+ const bypass = new Set(['install', 'update', 'uninstall', 'list']);
37
+ if (!bypass.has(command)) {
38
+ const projectDirForHarness = resolve(str(flags.project) ?? process.cwd());
39
+ enforceHarnessPolicy(projectDirForHarness);
40
+ }
36
41
  }
37
- // A command on a fresh project must be immediately usable bootstrap the
38
- // default .mugiwara/config when it is missing (not only at install time).
39
- // Skipped for install/update --dry-run: a dry run must not mutate the
40
- // project (the installer writes the config itself on a real install).
42
+ // Bootstrap default .mugiwara/config on any command when missingincluding
43
+ // `continue`/`status` so tier-3 agents that only mkdir .mugiwara still get
44
+ // a config. Also covered by readConfig() auto-create for non-CLI entry.
45
+ // Skipped for install/update --dry-run: a dry run must not mutate the project.
41
46
  const isDryRunInstall = (command === 'install' || command === 'update') && flag(flags.dryRun);
42
47
  if (!isDryRunInstall) {
43
48
  const projectDir = resolve(str(flags.project) ?? process.cwd());
@@ -45,6 +50,9 @@ export async function run(argv: string[]): Promise<void> {
45
50
  console.log(`default .mugiwara/config written at ${join(projectDir, '.mugiwara', 'config')} (edit it to customise)`);
46
51
  }
47
52
  }
53
+ if (command === 'continue' || command === 'status') {
54
+ return command === 'continue' ? continueCmd(flags, _) : statusCmd(flags);
55
+ }
48
56
  switch (command) {
49
57
  case 'install': return install(flags);
50
58
  case 'update': return install({ ...flags, force: true });
@@ -61,6 +69,7 @@ export async function run(argv: string[]): Promise<void> {
61
69
  case 'blame': return blameCmd(flags, _);
62
70
  case 'handoff': return handoffCmd(flags, _);
63
71
  case 'sign': return signCmd(flags, _);
72
+ case 'migrate': return migrateCmd(flags);
64
73
  default: throw new Error(`Unknown command: ${command}`);
65
74
  }
66
75
  }
@@ -282,9 +291,27 @@ async function uninstall(flags: Args['flags']): Promise<void> {
282
291
  console.log(`OK removed ${removed.length} files`);
283
292
  }
284
293
 
294
+ function legacyWarning(projectDir: string): void {
295
+ if (hasLegacyLayout(projectDir)) {
296
+ console.error('⚠ legacy layout detected (v0.6 .mugiwara/state/ — run `mugiwara migrate` to move to missions/)');
297
+ }
298
+ }
299
+
300
+ function schemaWarnings(projectDir: string): void {
301
+ const states = readState(projectDir);
302
+ for (const s of states) {
303
+ const v = s.schema_version;
304
+ if (v !== CURRENT_SCHEMA_VERSION) {
305
+ const wrote = v === null || v === undefined || v === '' ? 'unknown' : String(v);
306
+ console.error(`⚠ state written by v${wrote} (mission ${s.mission}${s.member ? `/${s.member}` : ''}) — current expects v${CURRENT_SCHEMA_VERSION} — run \`mugiwara migrate\``);
307
+ }
308
+ }
309
+ }
310
+
285
311
  function list(flags: Args['flags']): void {
286
312
  const home = homedir();
287
313
  const projectDir = resolve(str(flags.project) ?? process.cwd());
314
+ legacyWarning(projectDir);
288
315
  let found = false;
289
316
  for (const [label, file] of [
290
317
  ['project', manifestPath({ scope: 'project', projectDir, home })],
@@ -316,6 +343,8 @@ function list(flags: Args['flags']): void {
316
343
  */
317
344
  function continueCmd(flags: Args['flags'], positionals: string[]): void {
318
345
  const projectDir = resolve(str(flags.project) ?? process.cwd());
346
+ legacyWarning(projectDir);
347
+ schemaWarnings(projectDir);
319
348
  const [mission, member] = positionals.slice(1);
320
349
  let entries = readContinue(projectDir);
321
350
 
@@ -358,6 +387,8 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
358
387
  /** `mugiwara status` — one screen of computed mission state, no model needed. */
359
388
  function statusCmd(flags: Args['flags']): void {
360
389
  const projectDir = resolve(str(flags.project) ?? process.cwd());
390
+ legacyWarning(projectDir);
391
+ schemaWarnings(projectDir);
361
392
  const states = readState(projectDir);
362
393
  if (!states.length) { console.log('No mission state on disk.'); return; }
363
394
  const actor = flag(flags.all) ? null : gitActor(projectDir);
@@ -508,6 +539,88 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
508
539
  console.log(`\nwritten: ${out}`);
509
540
  }
510
541
 
542
+ export function migrateCmd(flags: Args['flags']): void {
543
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
544
+ const dryRun = flag(flags.dryRun);
545
+ const legacyState = join(projectDir, '.mugiwara', 'state');
546
+ const legacyContinue = join(projectDir, '.mugiwara', 'continue');
547
+ const missionsRoot = join(projectDir, '.mugiwara', 'missions');
548
+ const moves: Array<{ src: string; dest: string }> = [];
549
+
550
+ const collect = (srcRoot: string, isContinue: boolean) => {
551
+ if (!existsSync(srcRoot)) return;
552
+ const walk = (dir: string) => {
553
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
554
+ const full = join(dir, e.name);
555
+ if (e.isDirectory()) walk(full);
556
+ else if (e.isFile() && e.name.endsWith('.json')) {
557
+ const rel = full.slice(srcRoot.length + 1);
558
+ let destRel: string;
559
+ if (isContinue) {
560
+ const parts = rel.split('/');
561
+ const file = parts.pop()!;
562
+ const mission = parts.join('/');
563
+ const stem = file.slice(0, -'.json'.length);
564
+ let destFile: string;
565
+ if (stem === 'state') destFile = 'continue.json';
566
+ else destFile = `continue-${stem}.json`;
567
+ destRel = mission ? join(mission, destFile) : destFile;
568
+ } else {
569
+ destRel = rel;
570
+ }
571
+ moves.push({ src: full, dest: join(missionsRoot, destRel) });
572
+ }
573
+ }
574
+ };
575
+ walk(srcRoot);
576
+ };
577
+ collect(legacyState, false);
578
+ collect(legacyContinue, true);
579
+
580
+ // legacy flat: .mugiwara/state.json style? treat any top-level .mugiwara/state*.json as not legacy missions but still warn
581
+ // Already covered by state/ dir; nothing more to collect.
582
+
583
+ if (!moves.length) {
584
+ if (!existsSync(legacyState) && !existsSync(legacyContinue)) {
585
+ console.log('no legacy layout found (.mugiwara/state/ does not exist)');
586
+ } else {
587
+ console.log('no legacy state files to migrate');
588
+ }
589
+ return;
590
+ }
591
+
592
+ for (const m of moves) {
593
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
594
+ if (!dryRun) {
595
+ mkdirSync(dirname(m.dest), { recursive: true });
596
+ try {
597
+ const raw = JSON.parse(readFileSync(m.src, 'utf8')) as Record<string, unknown>;
598
+ raw.schema_version = CURRENT_SCHEMA_VERSION;
599
+ writeFileSync(m.dest, JSON.stringify(raw, null, 2) + '\n');
600
+ rmSync(m.src, { force: true });
601
+ } catch {
602
+ try { renameSync(m.src, m.dest); } catch { /* ignore */ }
603
+ }
604
+ }
605
+ }
606
+ if (!dryRun) {
607
+ const prune = (root: string) => {
608
+ if (!existsSync(root)) return;
609
+ const walkPrune = (dir: string) => {
610
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
611
+ if (e.isDirectory()) walkPrune(join(dir, e.name));
612
+ }
613
+ try { if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
614
+ };
615
+ walkPrune(root);
616
+ try { if (existsSync(root) && readdirSync(root).length === 0) rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
617
+ };
618
+ prune(legacyState);
619
+ prune(legacyContinue);
620
+ }
621
+ console.log(`${dryRun ? 'would migrate' : 'migrated'} ${moves.length} file(s)${dryRun ? ' (dry run)' : ''}`);
622
+ }
623
+
511
624
  /** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
512
625
  function signCmd(flags: Args['flags'], _: string[]): void {
513
626
  const projectDir = resolve(str(flags.project) ?? process.cwd());
@@ -562,10 +675,12 @@ Usage:
562
675
  mugiwara sign <m> attestation: sign report.md (auto/minisign/pure/off; --verify to check)
563
676
  mugiwara sign --gen-key [--backend pure|minisign]
564
677
  create signing keys (pure ed25519 default)
678
+ mugiwara migrate [--dry-run] [--project <dir>]
679
+ move legacy .mugiwara/state/ layout to .mugiwara/missions/
565
680
  mugiwara run <script> [args...]
566
- run a bundled harness script here (${RUNNABLE.join(', ')})
681
+ run a bundled harness script here (${RUNNABLE.join(', ')})
567
682
  mugiwara savepoint <mission> [member] [flow] [mode]
568
- shorthand for: mugiwara run savepoint.sh ...
683
+ shorthand for: mugiwara run savepoint.sh ...
569
684
  mugiwara --help this help
570
685
  mugiwara --version print version
571
686
 
package/src/config.ts CHANGED
@@ -40,6 +40,12 @@ function configPaths(projectDir: string): string[] {
40
40
  * blank lines are skipped; values are trimmed.
41
41
  */
42
42
  export function readConfig(projectDir: string): Record<string, string> {
43
+ try {
44
+ const file = join(projectDir, '.mugiwara', 'config');
45
+ let exists = false;
46
+ try { exists = lstatSync(file).isFile() || lstatSync(file).isSymbolicLink(); } catch { exists = false; }
47
+ if (!exists) ensureConfig(projectDir);
48
+ } catch { /* best-effort: auto-create must not break reads */ }
43
49
  const out: Record<string, string> = {};
44
50
  for (const file of configPaths(projectDir)) {
45
51
  if (!existsSync(file)) continue;
package/src/continue.ts CHANGED
@@ -14,6 +14,32 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
16
 
17
+ export const CURRENT_SCHEMA_VERSION = 2;
18
+
19
+ /**
20
+ * v0.6 legacy layout: `.mugiwara/state/<mission>/<member>.json`
21
+ * (and `.mugiwara/continue/<mission>/...`) is invisible to v0.8
22
+ * `missions/` readers. Detect it so status/continue can warn.
23
+ */
24
+ export function hasLegacyLayout(projectDir: string): boolean {
25
+ for (const legacy of [join(projectDir, '.mugiwara', 'state'), join(projectDir, '.mugiwara', 'continue')]) {
26
+ if (!existsSync(legacy)) continue;
27
+ try {
28
+ const walk = (dir: string): boolean => {
29
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
30
+ if (e.isFile() && e.name.endsWith('.json')) return true;
31
+ if (e.isDirectory() && walk(join(dir, e.name))) return true;
32
+ }
33
+ return false;
34
+ };
35
+ if (walk(legacy)) return true;
36
+ } catch { /* ignore */ }
37
+ }
38
+ // also legacy flat files at .mugiwara/state.json style already handled by missions scan,
39
+ // but treat top-level .mugiwara/state/*.json absence as no legacy
40
+ return false;
41
+ }
42
+
17
43
  /** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
18
44
  const SAFE = /^[A-Za-z0-9._-]+$/;
19
45
  const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
@@ -51,6 +77,7 @@ export type StateEntry = ContinueEntry & {
51
77
  budget_status: string;
52
78
  files_touched: number;
53
79
  evidence: string[];
80
+ schema_version: number | string | null;
54
81
  };
55
82
 
56
83
  const num = (v: unknown): number => {
@@ -148,6 +175,7 @@ export function readContinue(projectDir: string): ContinueEntry[] {
148
175
  export function readState(projectDir: string): StateEntry[] {
149
176
  return scan(projectDir, 'state', (r, member) => {
150
177
  const tasks = (r.tasks ?? {}) as Record<string, unknown>;
178
+ const sv = r.schema_version;
151
179
  return {
152
180
  mission: text(r.mission),
153
181
  member,
@@ -178,6 +206,7 @@ export function readState(projectDir: string): StateEntry[] {
178
206
  budget_status: text(r.budget_status) || 'ok',
179
207
  files_touched: num(r.files_touched),
180
208
  evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
209
+ schema_version: typeof sv === 'number' || typeof sv === 'string' ? sv : null,
181
210
  };
182
211
  });
183
212
  }
package/src/cost.ts CHANGED
@@ -149,6 +149,9 @@ export function appendCostEvent(missionDir: string, event: Omit<CostEvent, 'ts'>
149
149
  appendFileSync(join(missionDir, COST_EVENTS_FILE), JSON.stringify(line) + '\n', 'utf8');
150
150
  }
151
151
 
152
+ // ── Auto-compress (T4) — compressed event kind ───────────────────────────────
153
+ export const COMPRESSED_KIND = 'compressed';
154
+
152
155
  // ── Optimization decision records — structured rows in decisions.md ──
153
156
 
154
157
  export type OptDecision = {
package/src/integrity.ts CHANGED
@@ -10,29 +10,73 @@
10
10
  // 3. Evidence — cited wave/evidence paths exist.
11
11
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { isAbsolute, join, relative } from 'node:path';
13
+ import { loadPolicy } from './policy.ts';
13
14
 
14
- export type IntegrityIssue = { kind: 'dangling-path' | 'secret' | 'evidence' | 'evidence-thin'; detail: string };
15
+ export type IntegrityIssue = {
16
+ kind: 'dangling-path' | 'secret' | 'secret-warn' | 'evidence' | 'evidence-thin';
17
+ detail: string;
18
+ severity?: 'block' | 'warn';
19
+ };
15
20
 
16
- const SECRET_PATTERNS: Array<[RegExp, string]> = [
17
- [/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
18
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key block'],
19
- [/gh[pousr]_[A-Za-z0-9]{20,}/, 'GitHub token'],
20
- [/xox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
21
- [/sk-[A-Za-z0-9]{32,}/, 'API key (sk-…)'],
22
- [/eyJhbGciOi[A-Za-z0-9_.-]{20,}/, 'JWT pasted verbatim'],
23
- [/(api[_-]?key|secret|passwd|password)\s*[=:]\s*["'][^"'\s]{8,}["']/i, 'credential assignment'],
21
+ export type SecretSeverity = 'block' | 'warn';
22
+ export type SecretPattern = [RegExp, string, SecretSeverity];
23
+
24
+ export const SECRET_PATTERNS: Array<SecretPattern> = [
25
+ [/AKIA[0-9A-Z]{16}/, 'AWS access key id', 'block'],
26
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key block', 'block'],
27
+ [/gh[pousr]_[A-Za-z0-9]{20,}/, 'GitHub token', 'block'],
28
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token', 'block'],
29
+ [/sk-[A-Za-z0-9]{32,}/, 'API key (sk-…)', 'block'],
30
+ [/eyJhbGciOi[A-Za-z0-9_.-]{20,}/, 'JWT pasted verbatim', 'block'],
31
+ [/(api[_-]?key|secret|passwd|password)\s*[=:]\s*["'][^"'\s]{8,}["']/i, 'credential assignment', 'block'],
32
+ [/AIza[0-9A-Za-z_-]{35}/, 'Google API key', 'block'],
33
+ [/ya29\.[0-9A-Za-z_-]{20,}/, 'Google OAuth token', 'block'],
34
+ [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]{4,}@[^\s/]+/i, 'connection string with inline credential', 'block'],
35
+ [/\bAC[a-f0-9]{32}\b/, 'Twilio account SID', 'block'],
36
+ [/\bSK[a-f0-9]{32}\b/, 'Twilio API key', 'block'],
37
+ [/\bglpat-[A-Za-z0-9_-]{20,}/, 'GitLab token', 'block'],
38
+ [/\bnpm_[A-Za-z0-9]{36}\b/, 'npm token', 'block'],
39
+ [/\bdop_v1_[a-f0-9]{64}\b/, 'DigitalOcean token', 'block'],
40
+ [/\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13})\b/, 'card-number shape (verify before committing)', 'warn'],
24
41
  ];
25
42
 
26
43
  const ALLOW_SECRET = 'mugiwara:allow-secret';
27
44
 
45
+ function loadExtraPatterns(projectRoot: string): Array<SecretPattern> {
46
+ try {
47
+ const policy = loadPolicy(projectRoot);
48
+ const extras = (policy as unknown as { integrity?: { extra_secret_patterns?: Array<{ pattern: string; label: string; severity?: SecretSeverity }> } })?.integrity?.extra_secret_patterns;
49
+ if (!extras || !Array.isArray(extras)) return [];
50
+ const out: Array<SecretPattern> = [];
51
+ for (const e of extras) {
52
+ if (!e || typeof (e as Record<string, unknown>).pattern !== 'string' || typeof (e as Record<string, unknown>).label !== 'string') continue;
53
+ const rec = e as { pattern: string; label: string; severity?: SecretSeverity };
54
+ const sev: SecretSeverity = rec.severity === 'warn' ? 'warn' : 'block';
55
+ try {
56
+ const re = new RegExp(rec.pattern);
57
+ out.push([re, rec.label, sev]);
58
+ } catch {
59
+ // invalid regex — skip
60
+ }
61
+ }
62
+ return out;
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
28
68
  /** Secret shapes per line; a line carrying the allow marker is skipped — deliberate examples stay possible. */
29
- function findSecrets(body: string): Array<{ label: string; hit: string }> {
30
- const out: Array<{ label: string; hit: string }> = [];
69
+ export function findSecrets(
70
+ body: string,
71
+ extra?: Array<SecretPattern>,
72
+ ): Array<{ label: string; hit: string; severity: SecretSeverity }> {
73
+ const out: Array<{ label: string; hit: string; severity: SecretSeverity }> = [];
74
+ const patterns: Array<SecretPattern> = extra ? [...SECRET_PATTERNS, ...extra] : SECRET_PATTERNS;
31
75
  for (const line of body.split(/\r?\n/)) {
32
76
  if (line.includes(ALLOW_SECRET)) continue;
33
- for (const [re, label] of SECRET_PATTERNS) {
77
+ for (const [re, label, severity] of patterns) {
34
78
  const hit = line.match(re);
35
- if (hit) out.push({ label, hit: hit[0] });
79
+ if (hit) out.push({ label, hit: hit[0], severity: (severity ?? 'block') as SecretSeverity });
36
80
  }
37
81
  }
38
82
  return out;
@@ -89,6 +133,7 @@ function collectPassCitedPaths(missionDir: string): string[] {
89
133
  export function checkTrail(missionDir: string, projectRoot: string): IntegrityIssue[] {
90
134
  const issues: IntegrityIssue[] = [];
91
135
  const files = trailFiles(missionDir);
136
+ const extraPatterns = loadExtraPatterns(projectRoot);
92
137
 
93
138
  // 1 + 2: per-file link resolution and secret scan
94
139
  for (const f of files) {
@@ -105,10 +150,12 @@ export function checkTrail(missionDir: string, projectRoot: string): IntegrityIs
105
150
  });
106
151
  }
107
152
  }
108
- for (const { label, hit } of findSecrets(body)) {
153
+ for (const { label, hit, severity } of findSecrets(body, extraPatterns.length ? extraPatterns : undefined)) {
154
+ const isWarn = severity === 'warn';
109
155
  issues.push({
110
- kind: 'secret',
156
+ kind: isWarn ? 'secret-warn' : 'secret',
111
157
  detail: `${relative(projectRoot, f)} matches ${label}: ${hit.slice(0, 12)}…`,
158
+ severity: isWarn ? 'warn' : 'block',
112
159
  });
113
160
  }
114
161
  }
@@ -139,6 +186,8 @@ export function checkTrail(missionDir: string, projectRoot: string): IntegrityIs
139
186
  const passCited = collectPassCitedPaths(missionDir);
140
187
  for (const e of passCited) {
141
188
  if (!e.trim() || isAbsolute(e)) continue;
189
+ // state/continue are machine-generated JSON, not evidence with command output — skip thin check
190
+ if (/(?:^|\/)(state|continue)(-[^\/]*)?\.json$/.test(e)) continue;
142
191
  const candMission = join(missionDir, e);
143
192
  const candRoot = join(projectRoot, e);
144
193
  const resolved = existsSync(candMission) ? candMission : existsSync(candRoot) ? candRoot : null;