@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -7
- package/.codex-plugin/plugin.json +2 -7
- package/.cursor-plugin/plugin.json +2 -7
- package/.kimi-plugin/plugin.json +2 -7
- package/.opencode/mugiwara-helpers.mjs +2 -2
- package/.opencode/plugins/mugiwara.mjs +3 -18
- package/AGENTS.md +5 -4
- package/GEMINI.md +3 -3
- package/README.md +203 -331
- package/content/agents/brook-healing.md +8 -4
- package/content/agents/chopper-checkpoint.md +7 -3
- package/content/agents/eval-runner.md +4 -4
- package/content/agents/franky-gates.md +4 -4
- package/content/agents/jinbe-security.md +6 -7
- package/content/agents/luffy-orchestrator.md +11 -13
- package/content/agents/memory-keeper.md +4 -4
- package/content/agents/nami-planner.md +4 -4
- package/content/agents/resume-coordinator.md +7 -7
- package/content/agents/robin-reviewer.md +7 -3
- package/content/agents/sanji-quality.md +2 -2
- package/content/agents/skeptic-verifier.md +2 -2
- package/content/agents/usopp-brainstorm.md +3 -3
- package/content/agents/zoro-execution.md +6 -6
- package/content/skills/mugiwara-backend/SKILL.md +54 -43
- package/content/skills/mugiwara-backend/references/database.md +61 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +23 -11
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +1 -1
- package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
- package/content/skills/mugiwara-execution/SKILL.md +37 -37
- 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-execution/references/resume-batching.md +2 -2
- package/content/skills/mugiwara-frontend/SKILL.md +44 -43
- package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
- package/content/skills/mugiwara-gates/SKILL.md +23 -14
- package/content/skills/mugiwara-healing/SKILL.md +26 -25
- package/content/skills/mugiwara-lessons/SKILL.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +11 -11
- package/content/skills/mugiwara-orchestration/references/check-ins.md +2 -2
- package/content/skills/mugiwara-orchestration/references/closure.md +17 -12
- package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +11 -1
- package/content/skills/mugiwara-planning/SKILL.md +26 -29
- package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
- package/content/skills/mugiwara-planning/references/plan-template.md +24 -2
- package/content/skills/mugiwara-quality/SKILL.md +21 -12
- package/content/skills/mugiwara-resume/SKILL.md +8 -3
- package/content/skills/mugiwara-review/SKILL.md +19 -13
- package/content/skills/mugiwara-security/SKILL.md +47 -36
- package/content/skills/mugiwara-ship/SKILL.md +1 -1
- package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
- package/content/skills/mugiwara-workflow/SKILL.md +11 -17
- package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
- package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
- package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
- package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +42 -38
- package/dist/mugiwara.js +1642 -528
- package/gemini-extension.json +1 -1
- package/hooks/auto-savepoint.js +18 -10
- package/hooks/auto-savepoint.ts +23 -23
- package/hooks/engagement-marker.js +1 -1
- package/hooks/engagement-marker.ts +1 -1
- package/hooks/pipeline-guard.js +17 -13
- package/hooks/pipeline-guard.ts +24 -20
- package/hooks/session-start.js +13 -10
- package/hooks/session-start.ts +24 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/references/complexity.md +27 -4
- package/references/definition-of-done.md +1 -1
- package/references/multi-actor.md +20 -2
- package/references/posture-routing.md +31 -0
- package/references/prose-style.md +54 -0
- package/scripts/benchmark-governor.ts +516 -0
- package/scripts/benchmark-thresholds.json +47 -0
- package/scripts/check-doc-links.ts +46 -0
- package/scripts/conformance.ts +6 -16
- package/scripts/coverage-gate.ts +20 -6
- package/scripts/gate-selftest.ts +138 -7
- package/scripts/lane.sh +54 -8
- package/scripts/lib/lane-base.sh +4 -4
- package/scripts/lib/patterns.sh +5 -0
- package/scripts/policy-force.ts +22 -0
- package/scripts/retrieval-eval.ts +10 -4
- package/scripts/savepoint.sh +155 -58
- package/scripts/validate-content.ts +43 -11
- package/scripts/verify-install.ts +9 -7
- package/src/adaptive-budget.ts +178 -0
- package/src/args.ts +4 -3
- package/src/budget.ts +47 -0
- package/src/check-artifacts.ts +45 -0
- package/src/cli.ts +257 -108
- package/src/cognition.ts +234 -0
- package/src/config.ts +107 -0
- package/src/context.ts +72 -0
- package/src/continue.ts +29 -10
- package/src/cost.ts +186 -0
- package/src/evidence.ts +160 -0
- package/src/installer.ts +25 -34
- package/src/integrity.ts +158 -0
- package/src/investigation.ts +72 -0
- package/src/mission.ts +341 -94
- package/src/policy.ts +156 -0
- package/src/posture.ts +86 -0
- package/src/provenance.ts +116 -0
- package/src/reporting.ts +225 -0
- package/src/rollback.ts +95 -0
- package/src/routing.ts +69 -0
- package/src/run.ts +2 -2
- package/src/scope.ts +321 -0
- package/src/sign.ts +235 -0
- package/src/slop.ts +306 -0
- package/src/targets/claude.ts +2 -2
- package/src/work.ts +273 -0
- package/.opencode/commands/mugiwara-execute.md +0 -13
- package/.opencode/commands/mugiwara-heal.md +0 -13
- package/.opencode/commands/mugiwara-onboard.md +0 -14
- package/.opencode/commands/mugiwara-plan.md +0 -17
- package/.opencode/commands/mugiwara-ship.md +0 -13
- package/.opencode/commands/using-mugiwara.md +0 -20
- package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
- package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
- package/content/skills/mugiwara-context-budget/SKILL.md +0 -66
- package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
- package/content/skills/mugiwara-pr/SKILL.md +0 -69
- package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
- package/content/skills/mugiwara-sunset/SKILL.md +0 -82
- package/content/skills/using-mugiwara/SKILL.md +0 -51
- package/references/token-budget.md +0 -56
- package/scripts/evidence.sh +0 -81
- package/scripts/initiative.ts +0 -296
- package/scripts/mission-report.sh +0 -293
- package/src/onboard.ts +0 -207
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 = {
|
|
@@ -161,10 +162,10 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
|
|
|
161
162
|
result.notes.push(...post.notes);
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
//
|
|
165
|
-
// .mugiwara/config
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
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} (
|
|
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
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
.mugiwara/state
|
|
232
|
-
.mugiwara/
|
|
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/
|
|
234
|
+
if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/missions/**/*.json')) {
|
|
246
235
|
return { appended: false, notes: [] };
|
|
247
236
|
}
|
|
248
|
-
// Legacy (
|
|
249
|
-
// strip the old mugiwara entries
|
|
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(
|
|
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(
|
|
285
|
-
// legacy (
|
|
286
|
-
// explanatory comment, and the
|
|
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(
|
|
288
|
+
if (t.startsWith('# mugiwara')) return false; // header + comment
|
|
298
289
|
return !LEGACY_LINES.has(t);
|
|
299
290
|
});
|
|
300
291
|
cleaned = lines.join('\n');
|
package/src/integrity.ts
ADDED
|
@@ -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
|
+
}
|