@ionivetech/mugiwara 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/README.md +2 -2
- package/content/agents/brook-healing.md +1 -1
- 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-brainstorm/SKILL.md +5 -3
- package/content/skills/mugiwara-checkpoint/SKILL.md +2 -0
- package/content/skills/mugiwara-execution/SKILL.md +4 -3
- package/content/skills/mugiwara-execution/references/dispatch.md +1 -1
- package/content/skills/mugiwara-gates/SKILL.md +6 -0
- package/content/skills/mugiwara-healing/SKILL.md +5 -1
- package/content/skills/mugiwara-lessons/SKILL.md +3 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +5 -4
- package/content/skills/mugiwara-planning/SKILL.md +2 -0
- package/content/skills/mugiwara-quality/SKILL.md +3 -14
- package/content/skills/mugiwara-quality/references/order-checklist.md +18 -0
- package/content/skills/mugiwara-resume/SKILL.md +3 -14
- package/content/skills/mugiwara-resume/references/resume-protocol.md +16 -0
- package/content/skills/mugiwara-review/SKILL.md +3 -15
- package/content/skills/mugiwara-review/references/red-flags-review.md +17 -0
- package/content/skills/mugiwara-security/SKILL.md +1 -0
- package/content/skills/mugiwara-ship/SKILL.md +2 -0
- package/content/skills/mugiwara-workflow/SKILL.md +10 -7
- package/dist/mugiwara.js +1190 -376
- package/gemini-extension.json +1 -1
- package/hooks/mugiwara-mode-tracker.js +24 -4
- package/hooks/mugiwara-mode-tracker.ts +36 -7
- 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/wave-banners.md +1 -2
- package/scripts/gate-selftest.ts +84 -21
- package/scripts/savepoint.sh +22 -2
- package/scripts/validate-content.ts +60 -0
- package/scripts/verify-install.ts +20 -0
- package/scripts/write-metrics.ts +73 -0
- package/src/budget.ts +11 -0
- package/src/cli.ts +128 -13
- package/src/config.ts +6 -0
- package/src/continue.ts +29 -0
- package/src/cost.ts +3 -0
- package/src/integrity.ts +64 -15
- package/src/mission.ts +123 -7
- package/src/policy.ts +355 -2
- package/src/provenance.ts +29 -9
- package/src/sign.ts +45 -3
- package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +0 -5
- package/content/skills/mugiwara-workflow/references/benchmark-governor.md +0 -53
- package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +0 -5
- package/content/skills/mugiwara-workflow/references/scope-code-governor.md +0 -14
- package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +0 -14
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/write-metrics.ts — generate .metrics/latest.json from gate outputs
|
|
3
|
+
// Deterministic, no network. Runs retrieval-eval --json and verify-install --json.
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const root = join(import.meta.dirname, '..');
|
|
10
|
+
|
|
11
|
+
function extractJson(output: string): any {
|
|
12
|
+
const idx = output.indexOf('{');
|
|
13
|
+
if (idx === -1) throw new Error('no JSON found in output: ' + output.slice(0, 200));
|
|
14
|
+
return JSON.parse(output.slice(idx));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runJson(cmd: string): any {
|
|
18
|
+
// execSync returns stdout only; retrieval prints a rank line before JSON on stdout
|
|
19
|
+
// so we slice from first '{'
|
|
20
|
+
const out = execSync(cmd, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
21
|
+
return extractJson(out);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// retrieval: need probes, rank1_rate, index_size
|
|
25
|
+
let ret: any;
|
|
26
|
+
let ver: any;
|
|
27
|
+
try {
|
|
28
|
+
ret = runJson('bun scripts/retrieval-eval.ts --json');
|
|
29
|
+
} catch (e: any) {
|
|
30
|
+
// if process exits non-zero, stdout still contains JSON + rank line; try to parse from error stdout
|
|
31
|
+
const out = e.stdout?.toString() ?? e.message ?? '';
|
|
32
|
+
if (out.includes('{')) ret = extractJson(out);
|
|
33
|
+
else throw e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
ver = runJson('bun scripts/verify-install.ts --json');
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
const out = e.stdout?.toString() ?? e.message ?? '';
|
|
40
|
+
if (out.includes('{')) ver = extractJson(out);
|
|
41
|
+
else throw e;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rank1Str: string = ret.rank1_rate ?? ret.rank1 ?? '';
|
|
45
|
+
const rank1Num = typeof rank1Str === 'string' ? parseFloat(rank1Str.replace('%', '')) : Number(rank1Str);
|
|
46
|
+
const probes = ret.probes ?? ret.totalProbes ?? 0;
|
|
47
|
+
const pointersTotal = ver.pointers_total ?? ver.pointers ?? 0;
|
|
48
|
+
const pointersTargets = ver.pointers_targets ?? ver.targets ?? 0;
|
|
49
|
+
const indexSize = ret.index_size ?? 0;
|
|
50
|
+
const updated = new Date().toISOString().split('T')[0];
|
|
51
|
+
|
|
52
|
+
const metrics = {
|
|
53
|
+
retrieval_rank1: rank1Num,
|
|
54
|
+
retrieval_rank1_rate: rank1Str,
|
|
55
|
+
retrieval_probes: probes,
|
|
56
|
+
retrieval_rank1_count: ret.rank1_count ?? null,
|
|
57
|
+
retrieval_positives: ret.positives ?? null,
|
|
58
|
+
retrieval_negatives: ret.negatives ?? null,
|
|
59
|
+
retrieval_index_size: indexSize,
|
|
60
|
+
retrieval_index_terms: ret.index_terms ?? null,
|
|
61
|
+
pointers_total: pointersTotal,
|
|
62
|
+
pointers_targets: pointersTargets,
|
|
63
|
+
pointers_broken: ver.pointers_broken ?? ver.broken_pointers ?? 0,
|
|
64
|
+
index_size: indexSize,
|
|
65
|
+
updated,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const outDir = join(root, '.metrics');
|
|
69
|
+
mkdirSync(outDir, { recursive: true });
|
|
70
|
+
const outPath = join(outDir, 'latest.json');
|
|
71
|
+
writeFileSync(outPath, JSON.stringify(metrics, null, 2) + '\n');
|
|
72
|
+
console.log(`✓ wrote ${outPath}`);
|
|
73
|
+
console.log(JSON.stringify(metrics, null, 2));
|
package/src/budget.ts
CHANGED
|
@@ -45,3 +45,14 @@ export function formatFootprint(chars: number, budget: number): string {
|
|
|
45
45
|
? `${base} — OVER budget ${budget}`
|
|
46
46
|
: `${base} (budget ${budget})`;
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// ── Auto-compress threshold (T4) — 80% of budget ───────────────────────────
|
|
50
|
+
export const COMPRESS_THRESHOLD_PCT = 0.8;
|
|
51
|
+
|
|
52
|
+
export function shouldCompress(budget: number, chars: number): boolean {
|
|
53
|
+
return budget > 0 && chars > Math.floor(budget * COMPRESS_THRESHOLD_PCT);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function compressThreshold(budget: number): number {
|
|
57
|
+
return Math.floor(budget * COMPRESS_THRESHOLD_PCT);
|
|
58
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// src/cli.ts
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -12,7 +12,7 @@ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProj
|
|
|
12
12
|
import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
|
|
13
13
|
import { resetMission, archiveMission } from './mission.ts';
|
|
14
14
|
import { runScript, RUNNABLE } from './run.ts';
|
|
15
|
-
import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor } from './continue.ts';
|
|
15
|
+
import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor, hasLegacyLayout, CURRENT_SCHEMA_VERSION } from './continue.ts';
|
|
16
16
|
import { blamePath } from './provenance.ts';
|
|
17
17
|
import { signReport, verifyReport, ensurePureKey, hasMinisign } from './sign.ts';
|
|
18
18
|
import { ensureConfig } from './config.ts';
|
|
@@ -20,6 +20,7 @@ import { costEnvelope } from './cost.ts';
|
|
|
20
20
|
import { computeLiveSlop } from './slop.ts';
|
|
21
21
|
import { loadRegistry } from './evidence.ts';
|
|
22
22
|
import { buildCostLedger, toCostJSON } from './reporting.ts';
|
|
23
|
+
import { enforceHarnessPolicy } from './policy.ts';
|
|
23
24
|
|
|
24
25
|
const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
|
|
25
26
|
const flag = (v: FlagValue): boolean => v === true;
|
|
@@ -28,16 +29,20 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
28
29
|
const { command, flags, _ } = parseArgs(argv);
|
|
29
30
|
if (flag(flags.help) || command === 'help') return help();
|
|
30
31
|
if (flag(flags.version)) { console.log(`mugiwara ${VERSION}`); return; }
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// harness.require_enforcement — enterprise gate: refuse rules-based harnesses
|
|
33
|
+
// (only opencode is runtime-enforced). Covers run/savepoint/archive/status
|
|
34
|
+
// + other workflow commands; install/update/uninstall/list are setup and bypass.
|
|
35
|
+
{
|
|
36
|
+
const bypass = new Set(['install', 'update', 'uninstall', 'list']);
|
|
37
|
+
if (!bypass.has(command)) {
|
|
38
|
+
const projectDirForHarness = resolve(str(flags.project) ?? process.cwd());
|
|
39
|
+
enforceHarnessPolicy(projectDirForHarness);
|
|
40
|
+
}
|
|
36
41
|
}
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
42
|
+
// Bootstrap default .mugiwara/config on any command when missing — including
|
|
43
|
+
// `continue`/`status` — so tier-3 agents that only mkdir .mugiwara still get
|
|
44
|
+
// a config. Also covered by readConfig() auto-create for non-CLI entry.
|
|
45
|
+
// Skipped for install/update --dry-run: a dry run must not mutate the project.
|
|
41
46
|
const isDryRunInstall = (command === 'install' || command === 'update') && flag(flags.dryRun);
|
|
42
47
|
if (!isDryRunInstall) {
|
|
43
48
|
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
@@ -45,6 +50,9 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
45
50
|
console.log(`default .mugiwara/config written at ${join(projectDir, '.mugiwara', 'config')} (edit it to customise)`);
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
if (command === 'continue' || command === 'status') {
|
|
54
|
+
return command === 'continue' ? continueCmd(flags, _) : statusCmd(flags);
|
|
55
|
+
}
|
|
48
56
|
switch (command) {
|
|
49
57
|
case 'install': return install(flags);
|
|
50
58
|
case 'update': return install({ ...flags, force: true });
|
|
@@ -61,6 +69,7 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
61
69
|
case 'blame': return blameCmd(flags, _);
|
|
62
70
|
case 'handoff': return handoffCmd(flags, _);
|
|
63
71
|
case 'sign': return signCmd(flags, _);
|
|
72
|
+
case 'migrate': return migrateCmd(flags);
|
|
64
73
|
default: throw new Error(`Unknown command: ${command}`);
|
|
65
74
|
}
|
|
66
75
|
}
|
|
@@ -282,9 +291,27 @@ async function uninstall(flags: Args['flags']): Promise<void> {
|
|
|
282
291
|
console.log(`OK removed ${removed.length} files`);
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
function legacyWarning(projectDir: string): void {
|
|
295
|
+
if (hasLegacyLayout(projectDir)) {
|
|
296
|
+
console.error('⚠ legacy layout detected (v0.6 .mugiwara/state/ — run `mugiwara migrate` to move to missions/)');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function schemaWarnings(projectDir: string): void {
|
|
301
|
+
const states = readState(projectDir);
|
|
302
|
+
for (const s of states) {
|
|
303
|
+
const v = s.schema_version;
|
|
304
|
+
if (v !== CURRENT_SCHEMA_VERSION) {
|
|
305
|
+
const wrote = v === null || v === undefined || v === '' ? 'unknown' : String(v);
|
|
306
|
+
console.error(`⚠ state written by v${wrote} (mission ${s.mission}${s.member ? `/${s.member}` : ''}) — current expects v${CURRENT_SCHEMA_VERSION} — run \`mugiwara migrate\``);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
285
311
|
function list(flags: Args['flags']): void {
|
|
286
312
|
const home = homedir();
|
|
287
313
|
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
314
|
+
legacyWarning(projectDir);
|
|
288
315
|
let found = false;
|
|
289
316
|
for (const [label, file] of [
|
|
290
317
|
['project', manifestPath({ scope: 'project', projectDir, home })],
|
|
@@ -316,6 +343,8 @@ function list(flags: Args['flags']): void {
|
|
|
316
343
|
*/
|
|
317
344
|
function continueCmd(flags: Args['flags'], positionals: string[]): void {
|
|
318
345
|
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
346
|
+
legacyWarning(projectDir);
|
|
347
|
+
schemaWarnings(projectDir);
|
|
319
348
|
const [mission, member] = positionals.slice(1);
|
|
320
349
|
let entries = readContinue(projectDir);
|
|
321
350
|
|
|
@@ -358,6 +387,8 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
358
387
|
/** `mugiwara status` — one screen of computed mission state, no model needed. */
|
|
359
388
|
function statusCmd(flags: Args['flags']): void {
|
|
360
389
|
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
390
|
+
legacyWarning(projectDir);
|
|
391
|
+
schemaWarnings(projectDir);
|
|
361
392
|
const states = readState(projectDir);
|
|
362
393
|
if (!states.length) { console.log('No mission state on disk.'); return; }
|
|
363
394
|
const actor = flag(flags.all) ? null : gitActor(projectDir);
|
|
@@ -508,6 +539,88 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
508
539
|
console.log(`\nwritten: ${out}`);
|
|
509
540
|
}
|
|
510
541
|
|
|
542
|
+
export function migrateCmd(flags: Args['flags']): void {
|
|
543
|
+
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
544
|
+
const dryRun = flag(flags.dryRun);
|
|
545
|
+
const legacyState = join(projectDir, '.mugiwara', 'state');
|
|
546
|
+
const legacyContinue = join(projectDir, '.mugiwara', 'continue');
|
|
547
|
+
const missionsRoot = join(projectDir, '.mugiwara', 'missions');
|
|
548
|
+
const moves: Array<{ src: string; dest: string }> = [];
|
|
549
|
+
|
|
550
|
+
const collect = (srcRoot: string, isContinue: boolean) => {
|
|
551
|
+
if (!existsSync(srcRoot)) return;
|
|
552
|
+
const walk = (dir: string) => {
|
|
553
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
554
|
+
const full = join(dir, e.name);
|
|
555
|
+
if (e.isDirectory()) walk(full);
|
|
556
|
+
else if (e.isFile() && e.name.endsWith('.json')) {
|
|
557
|
+
const rel = full.slice(srcRoot.length + 1);
|
|
558
|
+
let destRel: string;
|
|
559
|
+
if (isContinue) {
|
|
560
|
+
const parts = rel.split('/');
|
|
561
|
+
const file = parts.pop()!;
|
|
562
|
+
const mission = parts.join('/');
|
|
563
|
+
const stem = file.slice(0, -'.json'.length);
|
|
564
|
+
let destFile: string;
|
|
565
|
+
if (stem === 'state') destFile = 'continue.json';
|
|
566
|
+
else destFile = `continue-${stem}.json`;
|
|
567
|
+
destRel = mission ? join(mission, destFile) : destFile;
|
|
568
|
+
} else {
|
|
569
|
+
destRel = rel;
|
|
570
|
+
}
|
|
571
|
+
moves.push({ src: full, dest: join(missionsRoot, destRel) });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
walk(srcRoot);
|
|
576
|
+
};
|
|
577
|
+
collect(legacyState, false);
|
|
578
|
+
collect(legacyContinue, true);
|
|
579
|
+
|
|
580
|
+
// legacy flat: .mugiwara/state.json style? treat any top-level .mugiwara/state*.json as not legacy missions but still warn
|
|
581
|
+
// Already covered by state/ dir; nothing more to collect.
|
|
582
|
+
|
|
583
|
+
if (!moves.length) {
|
|
584
|
+
if (!existsSync(legacyState) && !existsSync(legacyContinue)) {
|
|
585
|
+
console.log('no legacy layout found (.mugiwara/state/ does not exist)');
|
|
586
|
+
} else {
|
|
587
|
+
console.log('no legacy state files to migrate');
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
for (const m of moves) {
|
|
593
|
+
console.log(`${dryRun ? 'would migrate' : 'migrated'} ${m.src} → ${m.dest}`);
|
|
594
|
+
if (!dryRun) {
|
|
595
|
+
mkdirSync(dirname(m.dest), { recursive: true });
|
|
596
|
+
try {
|
|
597
|
+
const raw = JSON.parse(readFileSync(m.src, 'utf8')) as Record<string, unknown>;
|
|
598
|
+
raw.schema_version = CURRENT_SCHEMA_VERSION;
|
|
599
|
+
writeFileSync(m.dest, JSON.stringify(raw, null, 2) + '\n');
|
|
600
|
+
rmSync(m.src, { force: true });
|
|
601
|
+
} catch {
|
|
602
|
+
try { renameSync(m.src, m.dest); } catch { /* ignore */ }
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!dryRun) {
|
|
607
|
+
const prune = (root: string) => {
|
|
608
|
+
if (!existsSync(root)) return;
|
|
609
|
+
const walkPrune = (dir: string) => {
|
|
610
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
611
|
+
if (e.isDirectory()) walkPrune(join(dir, e.name));
|
|
612
|
+
}
|
|
613
|
+
try { if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
614
|
+
};
|
|
615
|
+
walkPrune(root);
|
|
616
|
+
try { if (existsSync(root) && readdirSync(root).length === 0) rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
617
|
+
};
|
|
618
|
+
prune(legacyState);
|
|
619
|
+
prune(legacyContinue);
|
|
620
|
+
}
|
|
621
|
+
console.log(`${dryRun ? 'would migrate' : 'migrated'} ${moves.length} file(s)${dryRun ? ' (dry run)' : ''}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
511
624
|
/** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
|
|
512
625
|
function signCmd(flags: Args['flags'], _: string[]): void {
|
|
513
626
|
const projectDir = resolve(str(flags.project) ?? process.cwd());
|
|
@@ -562,10 +675,12 @@ Usage:
|
|
|
562
675
|
mugiwara sign <m> attestation: sign report.md (auto/minisign/pure/off; --verify to check)
|
|
563
676
|
mugiwara sign --gen-key [--backend pure|minisign]
|
|
564
677
|
create signing keys (pure ed25519 default)
|
|
678
|
+
mugiwara migrate [--dry-run] [--project <dir>]
|
|
679
|
+
move legacy .mugiwara/state/ layout to .mugiwara/missions/
|
|
565
680
|
mugiwara run <script> [args...]
|
|
566
|
-
|
|
681
|
+
run a bundled harness script here (${RUNNABLE.join(', ')})
|
|
567
682
|
mugiwara savepoint <mission> [member] [flow] [mode]
|
|
568
|
-
|
|
683
|
+
shorthand for: mugiwara run savepoint.sh ...
|
|
569
684
|
mugiwara --help this help
|
|
570
685
|
mugiwara --version print version
|
|
571
686
|
|
package/src/config.ts
CHANGED
|
@@ -40,6 +40,12 @@ function configPaths(projectDir: string): string[] {
|
|
|
40
40
|
* blank lines are skipped; values are trimmed.
|
|
41
41
|
*/
|
|
42
42
|
export function readConfig(projectDir: string): Record<string, string> {
|
|
43
|
+
try {
|
|
44
|
+
const file = join(projectDir, '.mugiwara', 'config');
|
|
45
|
+
let exists = false;
|
|
46
|
+
try { exists = lstatSync(file).isFile() || lstatSync(file).isSymbolicLink(); } catch { exists = false; }
|
|
47
|
+
if (!exists) ensureConfig(projectDir);
|
|
48
|
+
} catch { /* best-effort: auto-create must not break reads */ }
|
|
43
49
|
const out: Record<string, string> = {};
|
|
44
50
|
for (const file of configPaths(projectDir)) {
|
|
45
51
|
if (!existsSync(file)) continue;
|
package/src/continue.ts
CHANGED
|
@@ -14,6 +14,32 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
+
export const CURRENT_SCHEMA_VERSION = 2;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* v0.6 legacy layout: `.mugiwara/state/<mission>/<member>.json`
|
|
21
|
+
* (and `.mugiwara/continue/<mission>/...`) is invisible to v0.8
|
|
22
|
+
* `missions/` readers. Detect it so status/continue can warn.
|
|
23
|
+
*/
|
|
24
|
+
export function hasLegacyLayout(projectDir: string): boolean {
|
|
25
|
+
for (const legacy of [join(projectDir, '.mugiwara', 'state'), join(projectDir, '.mugiwara', 'continue')]) {
|
|
26
|
+
if (!existsSync(legacy)) continue;
|
|
27
|
+
try {
|
|
28
|
+
const walk = (dir: string): boolean => {
|
|
29
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
if (e.isFile() && e.name.endsWith('.json')) return true;
|
|
31
|
+
if (e.isDirectory() && walk(join(dir, e.name))) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
if (walk(legacy)) return true;
|
|
36
|
+
} catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
// also legacy flat files at .mugiwara/state.json style already handled by missions scan,
|
|
39
|
+
// but treat top-level .mugiwara/state/*.json absence as no legacy
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
/** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
|
|
18
44
|
const SAFE = /^[A-Za-z0-9._-]+$/;
|
|
19
45
|
const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
|
|
@@ -51,6 +77,7 @@ export type StateEntry = ContinueEntry & {
|
|
|
51
77
|
budget_status: string;
|
|
52
78
|
files_touched: number;
|
|
53
79
|
evidence: string[];
|
|
80
|
+
schema_version: number | string | null;
|
|
54
81
|
};
|
|
55
82
|
|
|
56
83
|
const num = (v: unknown): number => {
|
|
@@ -148,6 +175,7 @@ export function readContinue(projectDir: string): ContinueEntry[] {
|
|
|
148
175
|
export function readState(projectDir: string): StateEntry[] {
|
|
149
176
|
return scan(projectDir, 'state', (r, member) => {
|
|
150
177
|
const tasks = (r.tasks ?? {}) as Record<string, unknown>;
|
|
178
|
+
const sv = r.schema_version;
|
|
151
179
|
return {
|
|
152
180
|
mission: text(r.mission),
|
|
153
181
|
member,
|
|
@@ -178,6 +206,7 @@ export function readState(projectDir: string): StateEntry[] {
|
|
|
178
206
|
budget_status: text(r.budget_status) || 'ok',
|
|
179
207
|
files_touched: num(r.files_touched),
|
|
180
208
|
evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
|
|
209
|
+
schema_version: typeof sv === 'number' || typeof sv === 'string' ? sv : null,
|
|
181
210
|
};
|
|
182
211
|
});
|
|
183
212
|
}
|
package/src/cost.ts
CHANGED
|
@@ -149,6 +149,9 @@ export function appendCostEvent(missionDir: string, event: Omit<CostEvent, 'ts'>
|
|
|
149
149
|
appendFileSync(join(missionDir, COST_EVENTS_FILE), JSON.stringify(line) + '\n', 'utf8');
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// ── Auto-compress (T4) — compressed event kind ───────────────────────────────
|
|
153
|
+
export const COMPRESSED_KIND = 'compressed';
|
|
154
|
+
|
|
152
155
|
// ── Optimization decision records — structured rows in decisions.md ──
|
|
153
156
|
|
|
154
157
|
export type OptDecision = {
|
package/src/integrity.ts
CHANGED
|
@@ -10,29 +10,73 @@
|
|
|
10
10
|
// 3. Evidence — cited wave/evidence paths exist.
|
|
11
11
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { isAbsolute, join, relative } from 'node:path';
|
|
13
|
+
import { loadPolicy } from './policy.ts';
|
|
13
14
|
|
|
14
|
-
export type IntegrityIssue = {
|
|
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;
|
|
@@ -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;
|