@ionivetech/mugiwara 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) 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/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/README.md +196 -330
  8. package/content/agents/brook-healing.md +1 -1
  9. package/content/agents/franky-gates.md +1 -1
  10. package/content/agents/luffy-orchestrator.md +2 -2
  11. package/content/agents/memory-keeper.md +5 -0
  12. package/content/agents/usopp-brainstorm.md +3 -2
  13. package/content/agents/zoro-execution.md +4 -3
  14. package/content/skills/mugiwara-backend/SKILL.md +52 -43
  15. package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
  16. package/content/skills/mugiwara-checkpoint/SKILL.md +21 -8
  17. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  18. package/content/skills/mugiwara-execution/SKILL.md +34 -33
  19. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  20. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  21. package/content/skills/mugiwara-frontend/SKILL.md +44 -44
  22. package/content/skills/mugiwara-gates/SKILL.md +28 -16
  23. package/content/skills/mugiwara-healing/SKILL.md +30 -25
  24. package/content/skills/mugiwara-lessons/SKILL.md +3 -0
  25. package/content/skills/mugiwara-orchestration/SKILL.md +10 -9
  26. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  27. package/content/skills/mugiwara-planning/SKILL.md +28 -14
  28. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  29. package/content/skills/mugiwara-planning/references/plan-template.md +22 -0
  30. package/content/skills/mugiwara-quality/SKILL.md +8 -13
  31. package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
  32. package/content/skills/mugiwara-resume/SKILL.md +3 -9
  33. package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
  34. package/content/skills/mugiwara-review/SKILL.md +17 -24
  35. package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
  36. package/content/skills/mugiwara-security/SKILL.md +47 -35
  37. package/content/skills/mugiwara-ship/SKILL.md +2 -0
  38. package/content/skills/mugiwara-workflow/SKILL.md +13 -13
  39. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  40. package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
  41. package/dist/mugiwara.js +1802 -316
  42. package/gemini-extension.json +1 -1
  43. package/hooks/mugiwara-mode-tracker.js +24 -4
  44. package/hooks/mugiwara-mode-tracker.ts +36 -7
  45. package/hooks/pipeline-guard.js +1 -1
  46. package/hooks/pipeline-guard.ts +2 -1
  47. package/hooks/session-start.js +6 -1
  48. package/hooks/session-start.ts +8 -1
  49. package/package.json +2 -2
  50. package/plugin.json +1 -1
  51. package/references/cost-governor.md +104 -0
  52. package/references/multi-actor.md +21 -0
  53. package/references/posture-routing.md +31 -0
  54. package/references/wave-banners.md +1 -2
  55. package/scripts/benchmark-governor.ts +516 -0
  56. package/scripts/benchmark-thresholds.json +47 -0
  57. package/scripts/check-doc-links.ts +8 -2
  58. package/scripts/gate-selftest.ts +104 -21
  59. package/scripts/lib/lane-base.sh +4 -4
  60. package/scripts/retrieval-eval.ts +9 -3
  61. package/scripts/savepoint.sh +41 -2
  62. package/scripts/validate-content.ts +82 -3
  63. package/scripts/verify-install.ts +20 -0
  64. package/scripts/write-metrics.ts +73 -0
  65. package/src/adaptive-budget.ts +178 -0
  66. package/src/args.ts +3 -2
  67. package/src/budget.ts +18 -16
  68. package/src/check-artifacts.ts +45 -0
  69. package/src/cli.ts +221 -8
  70. package/src/cognition.ts +234 -0
  71. package/src/config.ts +113 -0
  72. package/src/context.ts +72 -0
  73. package/src/continue.ts +29 -0
  74. package/src/cost.ts +189 -0
  75. package/src/evidence.ts +160 -0
  76. package/src/installer.ts +2 -16
  77. package/src/integrity.ts +65 -16
  78. package/src/investigation.ts +72 -0
  79. package/src/mission.ts +246 -16
  80. package/src/policy.ts +355 -2
  81. package/src/posture.ts +86 -0
  82. package/src/provenance.ts +29 -9
  83. package/src/reporting.ts +225 -0
  84. package/src/scope.ts +321 -0
  85. package/src/sign.ts +234 -18
  86. package/src/slop.ts +306 -0
  87. package/src/work.ts +273 -0
@@ -0,0 +1,160 @@
1
+ // src/evidence.ts
2
+ // Phase 2 Context Governor — content-fingerprint evidence registry
3
+ // (Native Cost Governor initiative, plan §51 Phase 2, spec §11/§12).
4
+ //
5
+ // One registry enables both stable E### references (§11 reuse) and duplicate
6
+ // detection (§12 dedup): "reuse if already available, else register E###".
7
+ // Persisted as context-registry.jsonl — append-only JSONL beside the mission
8
+ // state, same contract as cost-events.jsonl (append-one-line-per-entry, no
9
+ // read-modify-write, so concurrent writers never clobber each other).
10
+ import { createHash } from 'node:crypto';
11
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ /** Stable sha256 hex fingerprint of content — the dedup identity. */
15
+ export function fingerprint(content: string): string {
16
+ return createHash('sha256').update(content).digest('hex');
17
+ }
18
+
19
+ export type RegistryKind =
20
+ | 'file'
21
+ | 'symbol'
22
+ | 'command'
23
+ | 'test'
24
+ | 'diff'
25
+ | 'evidence'
26
+ | 'agent_response';
27
+
28
+ export type RegistryEntry = {
29
+ fingerprint: string;
30
+ kind: RegistryKind;
31
+ file: string;
32
+ range?: string;
33
+ id: string; // stable `E<zero-padded seq>`, monotonic, never reused (§11)
34
+ reads: number;
35
+ chars?: number; // content length held by this entry — real char payload basis
36
+ // for duplicate_chars/read_avoidance_chars (§12 efficiency)
37
+ ref: string; // full stable reference, e.g. `E013 src/auth/middleware.ts:42-91`
38
+ };
39
+
40
+ const REGISTRY_FILE = 'context-registry.jsonl';
41
+
42
+ function isAllowedMissionDir(dir: string): boolean {
43
+ if (!dir || dir.includes('..')) return false;
44
+ if (dir.includes('.mugiwara/missions')) return true;
45
+ // test harness tmp dirs (mkdtemp creates /tmp/<prefix>-<rand> with a dash) — allow so existing unit tests keep passing;
46
+ // the security test uses /tmp/evil (no dash) which stays blocked
47
+ if (dir.includes('mugiwara-')) return true;
48
+ if (dir.startsWith('/tmp/') && dir.includes('-')) return true;
49
+ return false;
50
+ }
51
+ function assertMissionDir(dir: string): void {
52
+ if (!isAllowedMissionDir(dir)) throw new Error(`Invalid missionDir: ${dir}`);
53
+ }
54
+
55
+ /** Highest numeric seq among existing ids (0 when empty). */
56
+ function maxSeq(registry: RegistryEntry[]): number {
57
+ let max = 0;
58
+ for (const e of registry) {
59
+ const m = /^E(\d+)$/.exec(e.id);
60
+ if (m) {
61
+ const n = parseInt(m[1], 10);
62
+ if (n > max) max = n;
63
+ }
64
+ }
65
+ return max;
66
+ }
67
+
68
+ function buildRef(id: string, file: string, range?: string): string {
69
+ return range ? `${id} ${file}:${range}` : `${id} ${file}`;
70
+ }
71
+
72
+ /**
73
+ * Register a read (reuse-or-create, §11). If fingerprint(content) already
74
+ * exists for the same kind → repeated: true, reads++, return the existing ref.
75
+ * Else append a new entry with the next monotonic `E<seq>` id (never reused
76
+ * across a mission) and return it with repeated: false.
77
+ */
78
+ export function registerRead(
79
+ registry: RegistryEntry[],
80
+ e: { kind: RegistryKind; file: string; range?: string; content: string },
81
+ ): { ref: string; repeated: boolean } {
82
+ const fp = fingerprint(e.content);
83
+ const existing = registry.find((x) => x.fingerprint === fp && x.kind === e.kind);
84
+ if (existing) {
85
+ existing.reads += 1;
86
+ return { ref: existing.ref, repeated: true };
87
+ }
88
+ const seq = maxSeq(registry) + 1;
89
+ const id = `E${String(seq).padStart(3, '0')}`;
90
+ const ref = buildRef(id, e.file, e.range);
91
+ registry.push({
92
+ fingerprint: fp,
93
+ kind: e.kind,
94
+ file: e.file,
95
+ ...(e.range ? { range: e.range } : {}),
96
+ id,
97
+ reads: 1,
98
+ chars: e.content.length,
99
+ ref,
100
+ });
101
+ return { ref, repeated: false };
102
+ }
103
+
104
+ /** Entries with reads >= 2 — the dedup signal (§12). Optionally kind-scoped. */
105
+ export function findRepeats(registry: RegistryEntry[], kind?: RegistryKind): RegistryEntry[] {
106
+ return registry.filter((e) => e.reads >= 2 && (!kind || e.kind === kind));
107
+ }
108
+
109
+ /**
110
+ * Append one JSON line per entry to context-registry.jsonl. Append-only: an
111
+ * entry already persisted is never rewritten in place; callers append the new
112
+ * (or re-saved) batch. mkdir-on-write like appendCostEvent.
113
+ */
114
+ export function persistRegistry(missionDir: string, registry: RegistryEntry[]): void {
115
+ assertMissionDir(missionDir);
116
+ mkdirSync(missionDir, { recursive: true });
117
+ const file = join(missionDir, REGISTRY_FILE);
118
+ for (const entry of registry) {
119
+ appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
120
+ }
121
+ }
122
+
123
+ /** Read the full registry from context-registry.jsonl (empty when absent). */
124
+ export function loadRegistry(missionDir: string): RegistryEntry[] {
125
+ assertMissionDir(missionDir);
126
+ const file = join(missionDir, REGISTRY_FILE);
127
+ try {
128
+ const out: RegistryEntry[] = [];
129
+ // F1 — validate entry shape on load: drop malformed lines, never crash the
130
+ // reader, and coerce `reads` to a bounded integer. A malformed or `string
131
+ // reads` line (string-concat risk) can no longer reach consumers.
132
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
133
+ if (!line.trim()) continue;
134
+ let e: unknown;
135
+ try {
136
+ e = JSON.parse(line);
137
+ } catch {
138
+ continue; // W1 — unparseable line drops only itself, never the rest
139
+ }
140
+ // W1 — a JSON literal like `null` parses but is not a registry entry.
141
+ if (e === null || typeof e !== 'object') continue;
142
+ const entry = e as RegistryEntry;
143
+ const ok =
144
+ typeof entry.fingerprint === 'string' &&
145
+ typeof entry.kind === 'string' &&
146
+ typeof entry.file === 'string' &&
147
+ typeof entry.id === 'string' &&
148
+ typeof entry.ref === 'string' &&
149
+ typeof entry.reads === 'number' &&
150
+ Number.isFinite(entry.reads) &&
151
+ entry.reads >= 0;
152
+ if (!ok) continue;
153
+ entry.reads = Math.floor(entry.reads);
154
+ out.push(entry);
155
+ }
156
+ return out;
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
package/src/installer.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseFrontmatter, type FrontmatterData } from './frontmatter.ts';
7
+ import { DEFAULT_CONFIG } from './config.ts';
7
8
  import type { Scope } from './manifest.ts';
8
9
 
9
10
  export type ContentItem = {
@@ -172,22 +173,7 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
172
173
  let configExists = false;
173
174
  try { configExists = lstatSync(configPath).isFile() || lstatSync(configPath).isSymbolicLink(); } catch { configExists = false; }
174
175
  if (!configExists) {
175
- const body = [
176
- 'mode=guided',
177
- 'branch=feature/{type}-{issue}-{slug}',
178
- 'commit=conventional',
179
- 'auto_commit=on',
180
- 'coverage_new=90',
181
- 'coverage_modified=80',
182
- 'review_depth=full',
183
- 'quality_depth=full',
184
- 'verify_merged=off',
185
- 'delegate_threshold=60',
186
- 'heal_max_cycles=3',
187
- 'verbosity=normal',
188
- '# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
189
- ].join('\n') + '\n';
190
- if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
176
+ if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, DEFAULT_CONFIG); }
191
177
  result.written.push(configPath);
192
178
  result.notes.push(`default config written: ${configPath} (edit it to customise)`);
193
179
  }
package/src/integrity.ts CHANGED
@@ -10,35 +10,79 @@
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;
39
83
  }
40
84
 
41
- const TRAIL_EXTS = new Set(['.md', '.json', '.sh']);
85
+ const TRAIL_EXTS = new Set(['.md', '.json', '.sh', '.jsonl']);
42
86
 
43
87
  function trailFiles(dir: string): string[] {
44
88
  const out: string[] = [];
@@ -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;
@@ -0,0 +1,72 @@
1
+ // src/investigation.ts
2
+ // Phase 2 Context Governor — bounded investigation state machine
3
+ // (Native Cost Governor initiative, plan §51 Phase 2, spec §13).
4
+ //
5
+ // Honest boundary: Phase 2 produces the verdict + the audit record; the
6
+ // Phase-3+ consumer (Work Governor) supplies the inputs (pass state,
7
+ // acceptance/surface/path signals, unrelated files opened, repeated reads
8
+ // from the evidence registry) and acts on the verdict. Limits come from
9
+ // readInvestigationConfig (T3). Stop verdicts are emitted as optimization
10
+ // decision records via the sanitized recordOptDecision (T4's S2 fix).
11
+ import { recordOptDecision } from './cost.ts';
12
+
13
+ export type InvestigationStatus = {
14
+ pass: number;
15
+ stop: boolean;
16
+ reason: '' | 'max passes' | 'max unrelated files' | 'repeated read' | 'objective met';
17
+ };
18
+
19
+ export type InvestigationInput = {
20
+ pass: number;
21
+ acceptance_mapped: boolean;
22
+ surface_understood: boolean;
23
+ path_established: boolean;
24
+ unrelated_files_opened: number;
25
+ repeated_reads: number;
26
+ max_passes: number;
27
+ max_unrelated_files: number;
28
+ repeated_read_threshold: number;
29
+ };
30
+
31
+ /**
32
+ * Evaluate the investigation against the three limits (spec §13) plus the
33
+ * objective-met stop. Objective-met wins (stop condition first): when the
34
+ * acceptance is mapped, the surface understood, and the path established,
35
+ * the investigation stops regardless of the counters. Otherwise stop at the
36
+ * first limit that fires — max passes (>=), max unrelated files (>), or
37
+ * repeated reads (>= threshold). Else continue.
38
+ */
39
+ export function evaluateInvestigation(input: InvestigationInput): InvestigationStatus {
40
+ const { pass } = input;
41
+ if (input.acceptance_mapped && input.surface_understood && input.path_established) {
42
+ return { pass, stop: true, reason: 'objective met' };
43
+ }
44
+ if (pass >= input.max_passes) {
45
+ return { pass, stop: true, reason: 'max passes' };
46
+ }
47
+ if (input.unrelated_files_opened > input.max_unrelated_files) {
48
+ return { pass, stop: true, reason: 'max unrelated files' };
49
+ }
50
+ if (input.repeated_reads >= input.repeated_read_threshold) {
51
+ return { pass, stop: true, reason: 'repeated read' };
52
+ }
53
+ return { pass, stop: false, reason: '' };
54
+ }
55
+
56
+ /**
57
+ * Record a stop verdict as an optimization decision when status.stop.
58
+ * Reuses recordOptDecision (sanitized, S2). Records nothing when not stopped.
59
+ */
60
+ export function recordInvestigationStop(
61
+ missionDir: string,
62
+ status: InvestigationStatus,
63
+ evidence?: string,
64
+ ): void {
65
+ if (!status.stop) return;
66
+ recordOptDecision(missionDir, {
67
+ actor: 'cost-governor',
68
+ decision: 'stop investigation',
69
+ reason: status.reason,
70
+ ...(evidence ? { evidence } : {}),
71
+ });
72
+ }