@ionivetech/mugiwara 0.6.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -7
  3. package/.codex-plugin/plugin.json +2 -7
  4. package/.cursor-plugin/plugin.json +2 -7
  5. package/.kimi-plugin/plugin.json +2 -7
  6. package/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/.opencode/plugins/mugiwara.mjs +3 -18
  8. package/AGENTS.md +5 -4
  9. package/GEMINI.md +3 -3
  10. package/README.md +203 -331
  11. package/content/agents/brook-healing.md +8 -4
  12. package/content/agents/chopper-checkpoint.md +7 -3
  13. package/content/agents/eval-runner.md +4 -4
  14. package/content/agents/franky-gates.md +4 -4
  15. package/content/agents/jinbe-security.md +6 -7
  16. package/content/agents/luffy-orchestrator.md +11 -13
  17. package/content/agents/memory-keeper.md +4 -4
  18. package/content/agents/nami-planner.md +4 -4
  19. package/content/agents/resume-coordinator.md +7 -7
  20. package/content/agents/robin-reviewer.md +7 -3
  21. package/content/agents/sanji-quality.md +2 -2
  22. package/content/agents/skeptic-verifier.md +2 -2
  23. package/content/agents/usopp-brainstorm.md +3 -3
  24. package/content/agents/zoro-execution.md +6 -6
  25. package/content/skills/mugiwara-backend/SKILL.md +54 -43
  26. package/content/skills/mugiwara-backend/references/database.md +61 -0
  27. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  28. package/content/skills/mugiwara-checkpoint/SKILL.md +23 -11
  29. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
  30. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  31. package/content/skills/mugiwara-execution/SKILL.md +37 -37
  32. package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
  33. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  34. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  35. package/content/skills/mugiwara-frontend/SKILL.md +44 -43
  36. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  37. package/content/skills/mugiwara-gates/SKILL.md +23 -14
  38. package/content/skills/mugiwara-healing/SKILL.md +26 -25
  39. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  40. package/content/skills/mugiwara-orchestration/SKILL.md +11 -11
  41. package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
  42. package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
  43. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  44. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
  45. package/content/skills/mugiwara-planning/SKILL.md +26 -29
  46. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  47. package/content/skills/mugiwara-planning/references/plan-template.md +24 -2
  48. package/content/skills/mugiwara-quality/SKILL.md +21 -12
  49. package/content/skills/mugiwara-resume/SKILL.md +8 -3
  50. package/content/skills/mugiwara-review/SKILL.md +19 -13
  51. package/content/skills/mugiwara-security/SKILL.md +47 -36
  52. package/content/skills/mugiwara-ship/SKILL.md +1 -1
  53. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  54. package/content/skills/mugiwara-workflow/SKILL.md +11 -17
  55. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
  56. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
  57. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
  58. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  59. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
  60. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +42 -38
  62. package/dist/mugiwara.js +1642 -528
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +18 -10
  65. package/hooks/auto-savepoint.ts +23 -23
  66. package/hooks/engagement-marker.js +1 -1
  67. package/hooks/engagement-marker.ts +1 -1
  68. package/hooks/pipeline-guard.js +17 -13
  69. package/hooks/pipeline-guard.ts +24 -20
  70. package/hooks/session-start.js +13 -10
  71. package/hooks/session-start.ts +24 -19
  72. package/package.json +2 -2
  73. package/plugin.json +1 -1
  74. package/references/complexity.md +27 -4
  75. package/references/definition-of-done.md +1 -1
  76. package/references/multi-actor.md +20 -2
  77. package/references/posture-routing.md +31 -0
  78. package/references/prose-style.md +54 -0
  79. package/scripts/benchmark-governor.ts +516 -0
  80. package/scripts/benchmark-thresholds.json +47 -0
  81. package/scripts/check-doc-links.ts +46 -0
  82. package/scripts/conformance.ts +6 -16
  83. package/scripts/coverage-gate.ts +20 -6
  84. package/scripts/gate-selftest.ts +138 -7
  85. package/scripts/lane.sh +54 -8
  86. package/scripts/lib/lane-base.sh +4 -4
  87. package/scripts/lib/patterns.sh +5 -0
  88. package/scripts/policy-force.ts +22 -0
  89. package/scripts/retrieval-eval.ts +10 -4
  90. package/scripts/savepoint.sh +155 -58
  91. package/scripts/validate-content.ts +43 -11
  92. package/scripts/verify-install.ts +9 -7
  93. package/src/adaptive-budget.ts +178 -0
  94. package/src/args.ts +4 -3
  95. package/src/budget.ts +47 -0
  96. package/src/check-artifacts.ts +45 -0
  97. package/src/cli.ts +257 -108
  98. package/src/cognition.ts +234 -0
  99. package/src/config.ts +107 -0
  100. package/src/context.ts +72 -0
  101. package/src/continue.ts +29 -10
  102. package/src/cost.ts +186 -0
  103. package/src/evidence.ts +160 -0
  104. package/src/installer.ts +25 -34
  105. package/src/integrity.ts +158 -0
  106. package/src/investigation.ts +72 -0
  107. package/src/mission.ts +341 -94
  108. package/src/policy.ts +156 -0
  109. package/src/posture.ts +86 -0
  110. package/src/provenance.ts +116 -0
  111. package/src/reporting.ts +225 -0
  112. package/src/rollback.ts +95 -0
  113. package/src/routing.ts +69 -0
  114. package/src/run.ts +2 -2
  115. package/src/scope.ts +321 -0
  116. package/src/sign.ts +235 -0
  117. package/src/slop.ts +306 -0
  118. package/src/targets/claude.ts +2 -2
  119. package/src/work.ts +273 -0
  120. package/.opencode/commands/mugiwara-execute.md +0 -13
  121. package/.opencode/commands/mugiwara-heal.md +0 -13
  122. package/.opencode/commands/mugiwara-onboard.md +0 -14
  123. package/.opencode/commands/mugiwara-plan.md +0 -17
  124. package/.opencode/commands/mugiwara-ship.md +0 -13
  125. package/.opencode/commands/using-mugiwara.md +0 -20
  126. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  127. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  128. package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
  129. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  130. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  131. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  132. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  133. package/content/skills/using-mugiwara/SKILL.md +0 -51
  134. package/references/token-budget.md +0 -56
  135. package/scripts/evidence.sh +0 -81
  136. package/scripts/initiative.ts +0 -296
  137. package/scripts/mission-report.sh +0 -293
  138. package/src/onboard.ts +0 -207
@@ -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 = {
@@ -161,10 +162,10 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
161
162
  result.notes.push(...post.notes);
162
163
  }
163
164
 
164
- // TASK 8: a fresh install must be immediately usable — write a default
165
- // .mugiwara/config (the same defaults `mugiwara onboard` would write) so no
166
- // key silently falls back. Only for project scope; global installs don't own
167
- // a project config. Never overwrite an existing config.
165
+ // A fresh install must be immediately usable — write a default
166
+ // .mugiwara/config so no key silently falls back. Only for project scope;
167
+ // global installs don't own a project config. Never overwrite an existing
168
+ // config.
168
169
  if (scope === 'project') {
169
170
  const configPath = join(projectDir, '.mugiwara', 'config');
170
171
  // lstat, not existsSync: a pre-created symlinked config must not be
@@ -172,22 +173,9 @@ 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
- 'delegate_threshold=60',
185
- 'heal_max_cycles=3',
186
- 'verbosity=normal',
187
- ].join('\n') + '\n';
188
- if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
176
+ if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, DEFAULT_CONFIG); }
189
177
  result.written.push(configPath);
190
- result.notes.push(`default config written: ${configPath} (run \`mugiwara onboard\` to customise)`);
178
+ result.notes.push(`default config written: ${configPath} (edit it to customise)`);
191
179
  }
192
180
  }
193
181
  return result;
@@ -223,13 +211,14 @@ function assertNotSymlink(file: string): void {
223
211
 
224
212
  const GITIGNORE_BLOCK_START = '# >>> mugiwara >>>';
225
213
  const GITIGNORE_BLOCK_END = '# <<< mugiwara <<<';
226
- // legacy marker from v0.6.2 block was undelimited, single '# mugiwara'
227
- // header. Kept for detection so an old install is not double-appended.
228
- const GITIGNORE_MARKER = '# mugiwara';
229
- const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
230
- # Ignore session state and regenerated files.
231
- .mugiwara/state/
232
- .mugiwara/continue/
214
+ // legacy markers pre-0.7 blocks ignored per-type dirs (.mugiwara/state/,
215
+ // .mugiwara/continue/) instead of the mission-first layout. Kept for
216
+ // detection so an old install is upgraded, not double-appended.
217
+ const GITIGNORE_LEGACY = ['.mugiwara/state/', '# mugiwara'];
218
+ const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit plan.md, flows/, review.md,
219
+ # decisions.md, blockers.md, report.md under .mugiwara/missions/. Ignore session state.
220
+ .mugiwara/missions/**/*.json
221
+ .mugiwara/index.md
233
222
  .mugiwara/config
234
223
  .mugiwara/refs/
235
224
  # <<< mugiwara <<<
@@ -242,14 +231,14 @@ export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: bool
242
231
  let existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
243
232
  if (existing) {
244
233
  // Delimited block present with the current entries → nothing to do.
245
- if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/state/')) {
234
+ if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/missions/**/*.json')) {
246
235
  return { appended: false, notes: [] };
247
236
  }
248
- // Legacy (v0.6.2) or outdated delimited block → upgrade in place:
249
- // strip the old mugiwara entries (both forms), then append the new block.
237
+ // Legacy (pre-0.7) or outdated delimited block → upgrade in place:
238
+ // strip the old mugiwara entries, then append the new block.
250
239
  // Without this, upgraded projects keep committing per-wave state/continue
251
240
  // JSON to git — session state must stay ignored (Robin MAJOR, Jinbe Low).
252
- const hadOld = existing.includes(GITIGNORE_MARKER) || existing.includes(GITIGNORE_BLOCK_START);
241
+ const hadOld = GITIGNORE_LEGACY.some((m) => existing.includes(m)) || existing.includes(GITIGNORE_BLOCK_START);
253
242
  if (hadOld) {
254
243
  const clean = removeProjectGitignore(projectDir, { dryRun });
255
244
  existing = clean.removed ? (existsSync(path) ? readFileSync(path, 'utf8') : '') : existing;
@@ -281,20 +270,22 @@ export function removeProjectGitignore(projectDir: string, { dryRun = false }: {
281
270
  const end = current.indexOf(GITIGNORE_BLOCK_END);
282
271
  if (end < start) return { removed: false, notes: ['delimiter mismatch — .gitignore left untouched'] };
283
272
  cleaned = current.slice(0, start) + current.slice(end + GITIGNORE_BLOCK_END.length);
284
- } else if (current.includes(GITIGNORE_MARKER)) {
285
- // legacy (v0.6.2): strip the exact undelimited block — the header, the
286
- // explanatory comment, and the five known lines. Prefix-matching would
273
+ } else if (current.includes('# mugiwara')) {
274
+ // legacy (pre-0.7): strip the exact undelimited block — the header, the
275
+ // explanatory comment, and the known lines. Prefix-matching would
287
276
  // delete user-owned lines that merely start with a mugiwara path.
288
277
  const LEGACY_LINES = new Set([
289
278
  '.mugiwara/state.json',
290
279
  '.mugiwara/state-*.json',
291
280
  '.mugiwara/config',
281
+ '.mugiwara/state/',
282
+ '.mugiwara/continue/',
292
283
  '.mugiwara/continue.md',
293
284
  '.mugiwara/refs/',
294
285
  ]);
295
286
  const lines = current.split('\n').filter(l => {
296
287
  const t = l.trim();
297
- if (t.startsWith(GITIGNORE_MARKER)) return false; // header + comment
288
+ if (t.startsWith('# mugiwara')) return false; // header + comment
298
289
  return !LEGACY_LINES.has(t);
299
290
  });
300
291
  cleaned = lines.join('\n');
@@ -0,0 +1,158 @@
1
+ // src/integrity.ts
2
+ // Closure integrity gate: the audit trail validates itself
3
+ // at archive time. Deterministic checks only — a violation fails the archive
4
+ // with an actionable message instead of shipping a broken or leaking artifact.
5
+ //
6
+ // Three checks:
7
+ // 1. Paths — every relative markdown link in the trail resolves to a file
8
+ // (mission-relative or repo-root).
9
+ // 2. Secrets — no trail file matches known secret shapes.
10
+ // 3. Evidence — cited wave/evidence paths exist.
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { isAbsolute, join, relative } from 'node:path';
13
+
14
+ export type IntegrityIssue = { kind: 'dangling-path' | 'secret' | 'evidence' | 'evidence-thin'; detail: string };
15
+
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'],
24
+ ];
25
+
26
+ const ALLOW_SECRET = 'mugiwara:allow-secret';
27
+
28
+ /** 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 }> = [];
31
+ for (const line of body.split(/\r?\n/)) {
32
+ if (line.includes(ALLOW_SECRET)) continue;
33
+ for (const [re, label] of SECRET_PATTERNS) {
34
+ const hit = line.match(re);
35
+ if (hit) out.push({ label, hit: hit[0] });
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const TRAIL_EXTS = new Set(['.md', '.json', '.sh', '.jsonl']);
42
+
43
+ function trailFiles(dir: string): string[] {
44
+ const out: string[] = [];
45
+ const walk = (d: string): void => {
46
+ for (const e of readdirSync(d, { withFileTypes: true })) {
47
+ const p = join(d, e.name);
48
+ if (e.isDirectory()) walk(p);
49
+ else if (TRAIL_EXTS.has(e.name.slice(e.name.lastIndexOf('.')) || '')) out.push(p);
50
+ }
51
+ };
52
+ walk(dir);
53
+ return out;
54
+ }
55
+
56
+ /** Relative markdown-link targets like `](../src/x.ts)` — skip URLs and anchors. */
57
+ function linkedPaths(md: string): string[] {
58
+ const out: string[] = [];
59
+ for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
60
+ const t = m[1];
61
+ if (/^(https?:|mailto:|#|\/\/)/.test(t)) continue;
62
+ out.push(t.split('#')[0]);
63
+ }
64
+ return out.filter(Boolean);
65
+ }
66
+
67
+ function hasCommandOutputShape(body: string): boolean {
68
+ return /`[^`]+`/.test(body) || /\b(exit\s+[01]|✓|✗|\bPASS\b|\bFAIL\b|passed|failed|\d+\s+(passed|failed))\b/i.test(body);
69
+ }
70
+
71
+ function collectPassCitedPaths(missionDir: string): string[] {
72
+ const out: string[] = [];
73
+ for (const f of trailFiles(missionDir)) {
74
+ let body: string;
75
+ try { body = readFileSync(f, 'utf8'); } catch { continue; }
76
+ for (const line of body.split(/\r?\n/)) {
77
+ if (!/\bPASS\b/.test(line)) continue;
78
+ for (const p of linkedPaths(line)) out.push(p);
79
+ // also catch bare repo-path mentions like flows/04-gates.md or evidence/foo.md
80
+ for (const m of line.matchAll(/(?:^|[\s"'(])([a-zA-Z0-9._\/-]+\.(?:md|txt|log|json))\b/g)) {
81
+ const cand = m[1];
82
+ if (cand.includes('/')) out.push(cand);
83
+ }
84
+ }
85
+ }
86
+ return [...new Set(out)];
87
+ }
88
+
89
+ export function checkTrail(missionDir: string, projectRoot: string): IntegrityIssue[] {
90
+ const issues: IntegrityIssue[] = [];
91
+ const files = trailFiles(missionDir);
92
+
93
+ // 1 + 2: per-file link resolution and secret scan
94
+ for (const f of files) {
95
+ let body: string;
96
+ try { body = readFileSync(f, 'utf8'); } catch { continue; }
97
+ for (const target of linkedPaths(body)) {
98
+ if (isAbsolute(target)) continue;
99
+ const fromMission = join(missionDir, target);
100
+ const fromRoot = join(projectRoot, target);
101
+ if (!existsSync(fromMission) && !existsSync(fromRoot)) {
102
+ issues.push({
103
+ kind: 'dangling-path',
104
+ detail: `${relative(projectRoot, f)} links "${target}" — no such file (mission dir or repo root)`,
105
+ });
106
+ }
107
+ }
108
+ for (const { label, hit } of findSecrets(body)) {
109
+ issues.push({
110
+ kind: 'secret',
111
+ detail: `${relative(projectRoot, f)} matches ${label}: ${hit.slice(0, 12)}…`,
112
+ });
113
+ }
114
+ }
115
+
116
+ // 3: evidence entries recorded as repo paths must exist
117
+ const evidencePaths: string[] = [];
118
+ const evidenceFile = join(missionDir, 'state.json');
119
+ if (existsSync(evidenceFile)) {
120
+ try {
121
+ const s = JSON.parse(readFileSync(evidenceFile, 'utf8')) as { evidence?: unknown };
122
+ if (Array.isArray(s.evidence)) {
123
+ for (const e of s.evidence) {
124
+ if (typeof e !== 'string' || !e.trim()) continue;
125
+ evidencePaths.push(e);
126
+ const cand = join(projectRoot, e);
127
+ if (!isAbsolute(e) && !existsSync(cand) && !existsSync(join(missionDir, e))) {
128
+ issues.push({ kind: 'evidence', detail: `state.json evidence "${e}" does not exist` });
129
+ }
130
+ }
131
+ }
132
+ } catch { /* corrupt state — the state reader owns that error */ }
133
+ }
134
+
135
+ // 4: evidence-content spot check (T7): a PASS verdict that cites an evidence
136
+ // path must point at a file that exists AND contains command-output shape
137
+ // (backticked command or exit-status token). Fake-but-consistent trails
138
+ // defeat existence-only checks — this raises the bar cheaply.
139
+ const passCited = collectPassCitedPaths(missionDir);
140
+ for (const e of passCited) {
141
+ if (!e.trim() || isAbsolute(e)) continue;
142
+ const candMission = join(missionDir, e);
143
+ const candRoot = join(projectRoot, e);
144
+ const resolved = existsSync(candMission) ? candMission : existsSync(candRoot) ? candRoot : null;
145
+ if (!resolved) continue; // already reported as evidence/dangling elsewhere
146
+ let body: string;
147
+ try { body = readFileSync(resolved, 'utf8'); } catch { continue; }
148
+ if (!hasCommandOutputShape(body)) {
149
+ issues.push({ kind: 'evidence-thin', detail: `evidence "${e}" exists but lacks command output (no backticked command or exit-status token)` });
150
+ }
151
+ }
152
+
153
+ return issues;
154
+ }
155
+
156
+ export function formatIssues(issues: IntegrityIssue[]): string {
157
+ return issues.map((i) => ` ✗ [${i.kind}] ${i.detail}`).join('\n');
158
+ }
@@ -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
+ }