@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.opencode/mugiwara-helpers.mjs +2 -2
- package/README.md +196 -330
- package/content/agents/brook-healing.md +1 -1
- package/content/agents/franky-gates.md +1 -1
- package/content/agents/luffy-orchestrator.md +2 -2
- package/content/agents/memory-keeper.md +5 -0
- package/content/agents/usopp-brainstorm.md +3 -2
- package/content/agents/zoro-execution.md +4 -3
- package/content/skills/mugiwara-backend/SKILL.md +52 -43
- package/content/skills/mugiwara-brainstorm/SKILL.md +5 -3
- package/content/skills/mugiwara-checkpoint/SKILL.md +21 -8
- package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
- package/content/skills/mugiwara-execution/SKILL.md +34 -33
- package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
- package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
- package/content/skills/mugiwara-frontend/SKILL.md +44 -44
- package/content/skills/mugiwara-gates/SKILL.md +28 -16
- package/content/skills/mugiwara-healing/SKILL.md +30 -25
- package/content/skills/mugiwara-lessons/SKILL.md +3 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +10 -9
- package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
- package/content/skills/mugiwara-planning/SKILL.md +28 -14
- package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
- package/content/skills/mugiwara-planning/references/plan-template.md +22 -0
- package/content/skills/mugiwara-quality/SKILL.md +8 -13
- package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
- package/content/skills/mugiwara-resume/SKILL.md +3 -9
- package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
- package/content/skills/mugiwara-review/SKILL.md +17 -24
- package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
- package/content/skills/mugiwara-security/SKILL.md +47 -35
- package/content/skills/mugiwara-ship/SKILL.md +2 -0
- package/content/skills/mugiwara-workflow/SKILL.md +13 -13
- package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
- package/dist/mugiwara.js +1802 -316
- package/gemini-extension.json +1 -1
- package/hooks/mugiwara-mode-tracker.js +24 -4
- package/hooks/mugiwara-mode-tracker.ts +36 -7
- package/hooks/pipeline-guard.js +1 -1
- package/hooks/pipeline-guard.ts +2 -1
- package/hooks/session-start.js +6 -1
- package/hooks/session-start.ts +8 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/references/cost-governor.md +104 -0
- package/references/multi-actor.md +21 -0
- package/references/posture-routing.md +31 -0
- package/references/wave-banners.md +1 -2
- package/scripts/benchmark-governor.ts +516 -0
- package/scripts/benchmark-thresholds.json +47 -0
- package/scripts/check-doc-links.ts +8 -2
- package/scripts/gate-selftest.ts +104 -21
- package/scripts/lib/lane-base.sh +4 -4
- package/scripts/retrieval-eval.ts +9 -3
- package/scripts/savepoint.sh +41 -2
- package/scripts/validate-content.ts +82 -3
- package/scripts/verify-install.ts +20 -0
- package/scripts/write-metrics.ts +73 -0
- package/src/adaptive-budget.ts +178 -0
- package/src/args.ts +3 -2
- package/src/budget.ts +18 -16
- package/src/check-artifacts.ts +45 -0
- package/src/cli.ts +221 -8
- package/src/cognition.ts +234 -0
- package/src/config.ts +113 -0
- package/src/context.ts +72 -0
- package/src/continue.ts +29 -0
- package/src/cost.ts +189 -0
- package/src/evidence.ts +160 -0
- package/src/installer.ts +2 -16
- package/src/integrity.ts +65 -16
- package/src/investigation.ts +72 -0
- package/src/mission.ts +246 -16
- package/src/policy.ts +355 -2
- package/src/posture.ts +86 -0
- package/src/provenance.ts +29 -9
- package/src/reporting.ts +225 -0
- package/src/scope.ts +321 -0
- package/src/sign.ts +234 -18
- package/src/slop.ts +306 -0
- package/src/work.ts +273 -0
package/src/evidence.ts
ADDED
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
[/
|
|
21
|
-
[
|
|
22
|
-
[/
|
|
23
|
-
[/
|
|
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(
|
|
30
|
-
|
|
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
|
|
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
|
+
}
|