@ionivetech/mugiwara 0.6.5 → 0.6.6
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 -3
- package/.codex-plugin/plugin.json +1 -2
- package/.cursor-plugin/plugin.json +1 -2
- package/.kimi-plugin/plugin.json +1 -2
- package/.opencode/commands/mugiwara-continue.md +44 -31
- package/.opencode/commands/mugiwara-execute.md +1 -1
- package/.opencode/commands/mugiwara-heal.md +1 -1
- package/.opencode/commands/mugiwara-onboard.md +8 -9
- package/.opencode/commands/mugiwara-plan.md +1 -1
- package/.opencode/commands/mugiwara-review.md +1 -1
- package/.opencode/commands/mugiwara-security.md +1 -1
- package/.opencode/commands/mugiwara-ship.md +1 -1
- package/.opencode/commands/mugiwara.md +2 -2
- package/.opencode/plugins/mugiwara.mjs +1 -2
- package/AGENTS.md +1 -2
- package/README.md +33 -31
- package/content/agents/brook-healing.md +5 -8
- package/content/agents/chopper-checkpoint.md +8 -11
- package/content/agents/eval-runner.md +2 -5
- package/content/agents/franky-gates.md +4 -7
- package/content/agents/jinbe-security.md +4 -7
- package/content/agents/luffy-orchestrator.md +13 -13
- package/content/agents/memory-keeper.md +5 -8
- package/content/agents/nami-planner.md +4 -7
- package/content/agents/resume-coordinator.md +7 -10
- package/content/agents/robin-reviewer.md +6 -9
- package/content/agents/sanji-quality.md +7 -10
- package/content/agents/skeptic-verifier.md +5 -8
- package/content/agents/usopp-brainstorm.md +3 -6
- package/content/agents/zoro-execution.md +7 -10
- package/content/skills/mugiwara-backend/SKILL.md +2 -0
- package/content/skills/mugiwara-checkpoint/SKILL.md +13 -11
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +4 -4
- package/content/skills/mugiwara-context-budget/SKILL.md +5 -3
- package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
- package/content/skills/mugiwara-execution/SKILL.md +20 -20
- package/content/skills/mugiwara-execution/references/dispatch.md +2 -2
- package/content/skills/mugiwara-execution/references/resume-batching.md +1 -1
- package/content/skills/mugiwara-frontend/SKILL.md +2 -4
- package/content/skills/mugiwara-gates/SKILL.md +8 -7
- package/content/skills/mugiwara-git/SKILL.md +7 -2
- package/content/skills/mugiwara-healing/SKILL.md +6 -4
- package/content/skills/mugiwara-healing/references/workers.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +26 -24
- package/content/skills/mugiwara-orchestration/references/check-ins.md +16 -15
- package/content/skills/mugiwara-orchestration/references/closure.md +5 -5
- package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +22 -22
- package/content/skills/mugiwara-planning/SKILL.md +3 -1
- package/content/skills/mugiwara-planning/references/plan-template.md +2 -2
- package/content/skills/mugiwara-pr/SKILL.md +6 -6
- package/content/skills/mugiwara-pr/references/verdict-format.md +3 -3
- package/content/skills/mugiwara-quality/SKILL.md +7 -7
- package/content/skills/mugiwara-resume/SKILL.md +18 -20
- package/content/skills/mugiwara-review/SKILL.md +5 -1
- package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
- package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
- package/content/skills/mugiwara-security/SKILL.md +5 -1
- package/content/skills/mugiwara-ship/SKILL.md +1 -1
- package/content/skills/mugiwara-ship/references/cleanup.md +1 -1
- package/content/skills/mugiwara-testcases/SKILL.md +5 -3
- package/content/skills/mugiwara-workflow/SKILL.md +27 -27
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +15 -15
- package/content/skills/using-mugiwara/SKILL.md +5 -5
- package/dist/mugiwara.js +808 -33
- package/gemini-extension.json +1 -1
- package/hooks/auto-savepoint.js +106 -0
- package/hooks/auto-savepoint.ts +102 -0
- package/hooks/engagement-marker.js +59 -0
- package/hooks/engagement-marker.ts +97 -0
- package/hooks/hooks.json +41 -2
- package/hooks/mugiwara-mode-tracker.js +79 -0
- package/hooks/pipeline-guard.js +182 -0
- package/hooks/pipeline-guard.ts +267 -0
- package/hooks/session-start.js +106 -0
- package/hooks/session-start.ts +44 -32
- package/package.json +7 -3
- package/plugin.json +1 -1
- package/references/agent-protocol.md +15 -0
- package/references/complexity.md +54 -0
- package/references/definition-of-done.md +1 -1
- package/references/multi-actor.md +5 -5
- package/references/skill-versioning.md +1 -1
- package/references/token-budget.md +30 -8
- package/references/wave-banners.md +16 -16
- package/scripts/build-hooks.ts +56 -0
- package/scripts/conformance.ts +17 -0
- package/scripts/coverage-gate.ts +179 -0
- package/scripts/install.ps1 +5 -4
- package/scripts/install.sh +4 -4
- package/scripts/lane.sh +6 -2
- package/scripts/lib/patterns.sh +84 -0
- package/scripts/mission-report.sh +12 -11
- package/scripts/savepoint.sh +141 -37
- package/scripts/verify-install.ts +154 -44
- package/src/args.ts +1 -1
- package/src/cli.ts +217 -0
- package/src/continue.ts +243 -0
- package/src/installer.ts +39 -0
- package/src/onboard.ts +207 -0
- package/src/run.ts +82 -0
- package/src/targets/claude.ts +140 -2
- package/src/targets/copilot.ts +20 -1
- package/src/targets/generic.ts +9 -4
- package/src/targets/opencode.ts +0 -1
- package/content/agents/onboarding-guide.md +0 -124
- package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
- package/scripts/onboard.ts +0 -266
- package/scripts/probe.ts +0 -40
package/src/onboard.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// src/onboard.ts — zero-LLM onboarding wizard (6 questions, writes .mugiwara/config).
|
|
2
|
+
// Runs on every platform via the `mugiwara onboard` CLI command. No LLM, no network.
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createRl, choose, confirm, type Rl } from './prompt.ts';
|
|
6
|
+
|
|
7
|
+
// Canonical key order for the written config. Keys the wizard does not ask
|
|
8
|
+
// about (auto_commit, delegate_threshold, heal_max_cycles, verbosity) are
|
|
9
|
+
// preserved from an existing config, never reset to defaults.
|
|
10
|
+
const DEFAULT_ORDER = [
|
|
11
|
+
'mode', 'branch', 'commit', 'auto_commit', 'coverage_new', 'coverage_modified',
|
|
12
|
+
'review_depth', 'quality_depth', 'delegate_threshold', 'heal_max_cycles', 'verbosity',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Defaults for keys the wizard does not ask about — used only when the key is
|
|
16
|
+
// absent from an existing config (fresh install). Existing values always win.
|
|
17
|
+
const DEFAULTS: Record<string, string> = {
|
|
18
|
+
auto_commit: 'on',
|
|
19
|
+
delegate_threshold: '60',
|
|
20
|
+
heal_max_cycles: '3',
|
|
21
|
+
verbosity: 'normal',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const BRANCH_PRESETS: Record<number, string> = {
|
|
25
|
+
1: 'feature/{type}-{issue}-{slug}',
|
|
26
|
+
2: 'feature/{slug}',
|
|
27
|
+
3: 'feat/{slug}',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const COMMIT_PRESETS: Record<number, string> = {
|
|
31
|
+
1: 'conventional',
|
|
32
|
+
2: 'gitmoji',
|
|
33
|
+
3: 'plain',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function parseConfig(lines: string[]): Map<string, string> {
|
|
37
|
+
const map = new Map<string, string>();
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const t = line.trim();
|
|
40
|
+
if (!t || t.startsWith('#')) continue;
|
|
41
|
+
const eq = t.indexOf('=');
|
|
42
|
+
if (eq === -1) continue;
|
|
43
|
+
const k = t.slice(0, eq).trim();
|
|
44
|
+
const v = t.slice(eq + 1).trim();
|
|
45
|
+
if (!map.has(k)) map.set(k, v);
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildConfig(existing: Map<string, string>, updates: Record<string, string>): string {
|
|
51
|
+
const merged = new Map(existing);
|
|
52
|
+
for (const [k, v] of Object.entries(updates)) merged.set(k, v);
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
for (const k of DEFAULT_ORDER) {
|
|
55
|
+
const v = merged.get(k) ?? DEFAULTS[k];
|
|
56
|
+
if (v === undefined) continue;
|
|
57
|
+
lines.push(`${k}=${v}`);
|
|
58
|
+
merged.delete(k);
|
|
59
|
+
}
|
|
60
|
+
for (const [k, v] of merged) lines.push(`${k}=${v}`);
|
|
61
|
+
return lines.join('\n') + '\n';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function askFreeText(rl: Rl, prompt: string): Promise<string> {
|
|
65
|
+
for (;;) {
|
|
66
|
+
const v = (await rl.question(prompt)).trim();
|
|
67
|
+
if (v) return v;
|
|
68
|
+
console.log(' Cannot be empty.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Same guard the runtime helpers use: a symlinked config must never be
|
|
73
|
+
// overwritten through the wizard (the target file is not ours to write).
|
|
74
|
+
function assertNotSymlink(file: string): void {
|
|
75
|
+
if (!existsSync(file)) return;
|
|
76
|
+
try {
|
|
77
|
+
if (lstatSync(file).isSymbolicLink()) throw new Error(`refusing to follow symlink: ${file}`);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if ((e as { code?: string }).code === 'ENOENT') return;
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runOnboard(projectDir: string): Promise<void> {
|
|
85
|
+
if (!process.stdin.isTTY) {
|
|
86
|
+
throw new Error('Not a terminal. Run `mugiwara onboard` in a terminal, or edit .mugiwara/config directly.');
|
|
87
|
+
}
|
|
88
|
+
const mugiwaraDir = join(projectDir, '.mugiwara');
|
|
89
|
+
const configPath = join(mugiwaraDir, 'config');
|
|
90
|
+
assertNotSymlink(configPath);
|
|
91
|
+
const existing = parseConfig(existsSync(configPath) ? readFileSync(configPath, 'utf8').split(/\r?\n/) : []);
|
|
92
|
+
|
|
93
|
+
const rl = createRl();
|
|
94
|
+
try {
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log('╔══════════════════════════════════════╗');
|
|
97
|
+
console.log('║ Mugiwara Onboarding Wizard ║');
|
|
98
|
+
console.log('╚══════════════════════════════════════╝');
|
|
99
|
+
console.log('');
|
|
100
|
+
|
|
101
|
+
if (existing.size > 0) {
|
|
102
|
+
const redo = await confirm(rl, 'Existing .mugiwara/config found. Re-onboard?');
|
|
103
|
+
if (!redo) {
|
|
104
|
+
console.log('Onboarding cancelled. Config preserved.');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Q1 — branch pattern
|
|
111
|
+
const branchChoice = await choose(rl, 'Q1 — Branch naming pattern:', [
|
|
112
|
+
'Trunk-based — feature/{type}-{issue}-{slug}',
|
|
113
|
+
'GitFlow — feature/{slug}',
|
|
114
|
+
'GitHub Flow — feat/{slug}',
|
|
115
|
+
'Custom — enter your own pattern',
|
|
116
|
+
]);
|
|
117
|
+
let branch: string;
|
|
118
|
+
if (branchChoice === 3) {
|
|
119
|
+
branch = await askFreeText(rl, ' Branch pattern (placeholders: {type} {issue} {slug}, e.g. {issue}-{slug}): ');
|
|
120
|
+
} else {
|
|
121
|
+
branch = BRANCH_PRESETS[branchChoice + 1];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Q2 — autonomy mode
|
|
125
|
+
const modeIdx = await choose(rl, 'Q2 — Autonomy mode:', [
|
|
126
|
+
'guided — ask before every wave transition',
|
|
127
|
+
'semi — auto-advance through waves, pause on failures',
|
|
128
|
+
'auto — full auto-pilot',
|
|
129
|
+
]);
|
|
130
|
+
const mode = ['guided', 'semi', 'auto'][modeIdx];
|
|
131
|
+
|
|
132
|
+
// Q3 — review depth
|
|
133
|
+
const reviewIdx = await choose(rl, 'Q3 — Code review depth:', [
|
|
134
|
+
'full — breaking-change map, five-axis review, <=3 cycles',
|
|
135
|
+
'standard — five-axis review, 1 cycle',
|
|
136
|
+
'quick — diff-only, no caller-map',
|
|
137
|
+
]);
|
|
138
|
+
const reviewDepth = ['full', 'standard', 'quick'][reviewIdx];
|
|
139
|
+
|
|
140
|
+
// Q4 — quality depth
|
|
141
|
+
const qualityIdx = await choose(rl, 'Q4 — Quality check depth:', [
|
|
142
|
+
'full — format, lint, typecheck, test, build',
|
|
143
|
+
'standard — lint, typecheck, test',
|
|
144
|
+
'quick — test only',
|
|
145
|
+
]);
|
|
146
|
+
const qualityDepth = ['full', 'standard', 'quick'][qualityIdx];
|
|
147
|
+
|
|
148
|
+
// Q5 — coverage
|
|
149
|
+
const covIdx = await choose(rl, 'Q5 — Test coverage threshold:', [
|
|
150
|
+
'90/80 — new code 90%, modified 80%',
|
|
151
|
+
'80/70 — new code 80%, modified 70%',
|
|
152
|
+
'custom — enter your own values',
|
|
153
|
+
'none — 0/0, no coverage enforcement',
|
|
154
|
+
]);
|
|
155
|
+
let coverageNew = 90;
|
|
156
|
+
let coverageModified = 80;
|
|
157
|
+
if (covIdx === 1) { coverageNew = 80; coverageModified = 70; }
|
|
158
|
+
else if (covIdx === 2) {
|
|
159
|
+
coverageNew = Number.parseInt(await askFreeText(rl, ' Coverage for new code (%): '), 10) || 0;
|
|
160
|
+
coverageModified = Number.parseInt(await askFreeText(rl, ' Coverage for modified code (%): '), 10) || 0;
|
|
161
|
+
} else if (covIdx === 3) { coverageNew = 0; coverageModified = 0; }
|
|
162
|
+
|
|
163
|
+
// Q6 — commit style
|
|
164
|
+
const commitChoice = await choose(rl, 'Q6 — Commit style:', [
|
|
165
|
+
'Conventional Commits — feat:, fix:, chore:, docs:',
|
|
166
|
+
'Gitmoji — emoji-prefixed conventional',
|
|
167
|
+
'Plain — no prefix, imperative sentence',
|
|
168
|
+
'Custom template — enter your own (placeholders: {type} {issue} {title})',
|
|
169
|
+
]);
|
|
170
|
+
let commit: string;
|
|
171
|
+
if (commitChoice === 3) {
|
|
172
|
+
commit = await askFreeText(rl, ' Commit template (e.g. {issue}: {title}): ');
|
|
173
|
+
} else {
|
|
174
|
+
commit = COMMIT_PRESETS[commitChoice + 1];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Write config ----
|
|
178
|
+
if (!existsSync(mugiwaraDir)) mkdirSync(mugiwaraDir, { recursive: true });
|
|
179
|
+
const body = buildConfig(existing, {
|
|
180
|
+
mode,
|
|
181
|
+
branch,
|
|
182
|
+
commit,
|
|
183
|
+
coverage_new: String(coverageNew),
|
|
184
|
+
coverage_modified: String(coverageModified),
|
|
185
|
+
review_depth: reviewDepth,
|
|
186
|
+
quality_depth: qualityDepth,
|
|
187
|
+
});
|
|
188
|
+
writeFileSync(configPath, `# .mugiwara/config — written by mugiwara onboard on ${new Date().toISOString().slice(0, 10)}\n${body}`);
|
|
189
|
+
|
|
190
|
+
// ---- Summary ----
|
|
191
|
+
console.log('╔══════════════════════════════════════╗');
|
|
192
|
+
console.log('║ Onboarding Complete ║');
|
|
193
|
+
console.log('╚══════════════════════════════════════╝');
|
|
194
|
+
console.log('');
|
|
195
|
+
console.log(` Branch pattern: ${branch}`);
|
|
196
|
+
console.log(` Autonomy mode: ${mode}`);
|
|
197
|
+
console.log(` Review depth: ${reviewDepth}`);
|
|
198
|
+
console.log(` Quality depth: ${qualityDepth}`);
|
|
199
|
+
console.log(` Coverage: ${coverageNew}/${coverageModified}`);
|
|
200
|
+
console.log(` Commit style: ${commit}`);
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(` Config written: ${configPath}`);
|
|
203
|
+
console.log('');
|
|
204
|
+
} finally {
|
|
205
|
+
rl.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/run.ts
|
|
2
|
+
// `mugiwara run <script> [args...]` — runs a bundled harness script against the
|
|
3
|
+
// current project.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: the skills tell the crew to run `scripts/savepoint.sh`, but
|
|
6
|
+
// the installer only ever copied `content/` and `references/` into a project.
|
|
7
|
+
// That path resolved against the project's cwd, where the file does not exist,
|
|
8
|
+
// so every savepoint / lane / evidence / mission-report call silently did
|
|
9
|
+
// nothing. Resolving from the package root fixes it for every install target at
|
|
10
|
+
// once.
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
// src/ during dev, dist/ once bundled — scripts/ sits beside both
|
|
18
|
+
export const SCRIPTS_DIR = join(here, '..', 'scripts');
|
|
19
|
+
|
|
20
|
+
/** Scripts a project is meant to call. Anything else stays internal tooling. */
|
|
21
|
+
export const RUNNABLE = ['savepoint.sh', 'lane.sh', 'evidence.sh', 'mission-report.sh'] as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Locate a POSIX shell. Windows has none natively, but Git for Windows ships
|
|
25
|
+
* one and mugiwara is git-backed anyway — savepoint reads git to compute state,
|
|
26
|
+
* so a git-less machine cannot run a mission regardless.
|
|
27
|
+
*/
|
|
28
|
+
export function findBash(): string | null {
|
|
29
|
+
const explicit = process.env.MUGIWARA_BASH?.trim();
|
|
30
|
+
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
31
|
+
const candidates =
|
|
32
|
+
process.platform === 'win32'
|
|
33
|
+
? [
|
|
34
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
35
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
36
|
+
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
37
|
+
]
|
|
38
|
+
: // /bin/bash is not universal on Linux — Alpine and other busybox-based
|
|
39
|
+
// images ship /bin/sh only. Probing beats assuming: an assumed path
|
|
40
|
+
// spawns ENOENT, which surfaces as a stack trace instead of the
|
|
41
|
+
// actionable "no bash found" message below.
|
|
42
|
+
['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash'];
|
|
43
|
+
for (const p of candidates) {
|
|
44
|
+
if (p && existsSync(p)) return p;
|
|
45
|
+
}
|
|
46
|
+
// last resort: whatever is on PATH (Git Bash, WSL, MSYS, a nonstandard prefix)
|
|
47
|
+
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['bash'], {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
});
|
|
50
|
+
const first = probe.stdout?.split(/\r?\n/).find((l) => l.trim());
|
|
51
|
+
return first?.trim() || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Platform-correct remedy for a missing bash — "install Git for Windows" is wrong advice on Linux. */
|
|
55
|
+
export function noBashMessage(): string {
|
|
56
|
+
const remedy =
|
|
57
|
+
process.platform === 'win32'
|
|
58
|
+
? 'Install Git for Windows (which ships bash)'
|
|
59
|
+
: 'Install bash with your package manager (e.g. `apk add bash`, `apt install bash`)';
|
|
60
|
+
return `no bash found. ${remedy}, or set MUGIWARA_BASH to a bash executable.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runScript(name: string, args: string[], projectDir: string): number {
|
|
64
|
+
// no path separators: `run` takes a script name, never an arbitrary path
|
|
65
|
+
if (!/^[a-z0-9-]+\.sh$/.test(name)) {
|
|
66
|
+
throw new Error(`invalid script name "${name}" (expected one of: ${RUNNABLE.join(', ')})`);
|
|
67
|
+
}
|
|
68
|
+
const script = join(SCRIPTS_DIR, name);
|
|
69
|
+
if (!existsSync(script)) {
|
|
70
|
+
const have = existsSync(SCRIPTS_DIR) ? readdirSync(SCRIPTS_DIR).filter((f) => f.endsWith('.sh')) : [];
|
|
71
|
+
throw new Error(`script not found: ${name}${have.length ? ` (available: ${have.join(', ')})` : ''}`);
|
|
72
|
+
}
|
|
73
|
+
const bash = findBash();
|
|
74
|
+
if (!bash) throw new Error(noBashMessage());
|
|
75
|
+
const r = spawnSync(bash, [script, ...args], {
|
|
76
|
+
cwd: resolve(projectDir),
|
|
77
|
+
stdio: 'inherit',
|
|
78
|
+
env: process.env,
|
|
79
|
+
});
|
|
80
|
+
if (r.error) throw r.error;
|
|
81
|
+
return r.status ?? 1;
|
|
82
|
+
}
|
package/src/targets/claude.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/targets/claude.ts
|
|
2
|
-
import { existsSync, readFileSync, mkdirSync, copyFileSync, chmodSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { stringifyFrontmatter, type FrontmatterData } from '../frontmatter.ts';
|
|
@@ -19,6 +19,128 @@ function toolsFromScope(scope?: string): string | undefined {
|
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Register the copied hooks in settings.json.
|
|
24
|
+
*
|
|
25
|
+
* Copying hook files into .claude/hooks/ is not enough — Claude Code only runs
|
|
26
|
+
* a hook that settings.json names. Without this the CLI install left three
|
|
27
|
+
* dead files on disk while the plugin install (which reads hooks/hooks.json
|
|
28
|
+
* itself) worked, so the two install paths silently disagreed.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: an entry whose command already points at our hook is left alone,
|
|
31
|
+
* and unrelated user hooks are never touched.
|
|
32
|
+
*/
|
|
33
|
+
const SCRIPTS_SRC = join(here, '..', '..', 'scripts');
|
|
34
|
+
|
|
35
|
+
/** Copy the harness shell scripts (and their shared lib) next to the hooks. */
|
|
36
|
+
function copyScripts(dstDir: string): string[] {
|
|
37
|
+
if (!existsSync(SCRIPTS_SRC)) return [];
|
|
38
|
+
const written: string[] = [];
|
|
39
|
+
const copyDir = (src: string, dst: string): void => {
|
|
40
|
+
if (!existsSync(src)) return;
|
|
41
|
+
for (const f of readdirSync(src)) {
|
|
42
|
+
// install.sh is the npx bootstrap, not a harness script — never shipped
|
|
43
|
+
// into a project. initiative.ts ships too: `mugiwara initiative` runs it.
|
|
44
|
+
if ((!f.endsWith('.sh') && f !== 'initiative.ts') || f === 'install.sh') continue;
|
|
45
|
+
const to = join(dst, f);
|
|
46
|
+
mkdirSync(dst, { recursive: true });
|
|
47
|
+
copyFileSync(join(src, f), to);
|
|
48
|
+
chmodSync(to, 0o755);
|
|
49
|
+
written.push(to);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
copyDir(SCRIPTS_SRC, dstDir);
|
|
53
|
+
copyDir(join(SCRIPTS_SRC, 'lib'), join(dstDir, 'lib'));
|
|
54
|
+
return written;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type HookEntry = { type: string; command: string; timeout?: number };
|
|
58
|
+
type HookGroup = { matcher?: string; hooks: HookEntry[] };
|
|
59
|
+
|
|
60
|
+
function wireSettings(root: string, hooksDir: string): { written: string[]; notes: string[] } {
|
|
61
|
+
const file = join(root, 'settings.json');
|
|
62
|
+
const events: Record<string, { file: string; timeout: number; matcher?: string }> = {
|
|
63
|
+
SessionStart: { file: 'session-start.js', timeout: 10 },
|
|
64
|
+
UserPromptSubmit: { file: 'mugiwara-mode-tracker.js', timeout: 5 },
|
|
65
|
+
Stop: { file: 'auto-savepoint.js', timeout: 20 },
|
|
66
|
+
SubagentStop: { file: 'pipeline-guard.js', timeout: 15 },
|
|
67
|
+
// matcher scopes the marker to crew invocations — without it the hook would
|
|
68
|
+
// fire on every tool call in the session for no benefit
|
|
69
|
+
PostToolUse: { file: 'engagement-marker.js', timeout: 5, matcher: 'Task|Skill' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let settings: Record<string, unknown> = {};
|
|
73
|
+
if (existsSync(file)) {
|
|
74
|
+
try {
|
|
75
|
+
settings = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>;
|
|
76
|
+
} catch {
|
|
77
|
+
// a hand-broken settings.json is the user's to fix — never overwrite it
|
|
78
|
+
return { written: [], notes: [`${file} is not valid JSON — add the mugiwara hooks manually`] };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const hooks = (settings.hooks ?? {}) as Record<string, HookGroup[]>;
|
|
82
|
+
let changed = false;
|
|
83
|
+
for (const [event, spec] of Object.entries(events)) {
|
|
84
|
+
const command = join(hooksDir, spec.file);
|
|
85
|
+
if (!existsSync(command)) continue;
|
|
86
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
87
|
+
const already = groups.some((g) => g.hooks?.some((h) => h.command?.includes(spec.file)));
|
|
88
|
+
if (already) continue;
|
|
89
|
+
const group: HookGroup = { hooks: [{ type: 'command', command: JSON.stringify(command), timeout: spec.timeout }] };
|
|
90
|
+
if (spec.matcher) group.matcher = spec.matcher;
|
|
91
|
+
groups.push(group);
|
|
92
|
+
hooks[event] = groups;
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
if (!changed) return { written: [], notes: [] };
|
|
96
|
+
settings.hooks = hooks;
|
|
97
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
98
|
+
// NOT reported as `written`: the manifest is an uninstall delete-list, and
|
|
99
|
+
// settings.json belongs to the user — we only merged into it. It is un-merged
|
|
100
|
+
// by unwireSettings instead.
|
|
101
|
+
return { written: [], notes: [`hooks registered in ${file}`] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Remove only the hook entries mugiwara added, leaving every other setting and
|
|
106
|
+
* any user-authored hook untouched. Identified by their command pointing into
|
|
107
|
+
* our hooks dir.
|
|
108
|
+
*/
|
|
109
|
+
function unwireSettings(root: string): { changed: string[]; notes: string[] } {
|
|
110
|
+
const file = join(root, 'settings.json');
|
|
111
|
+
if (!existsSync(file)) return { changed: [], notes: [] };
|
|
112
|
+
let settings: Record<string, unknown>;
|
|
113
|
+
try {
|
|
114
|
+
settings = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>;
|
|
115
|
+
} catch {
|
|
116
|
+
return { changed: [], notes: [`${file} is not valid JSON — left untouched`] };
|
|
117
|
+
}
|
|
118
|
+
const hooks = settings.hooks as Record<string, HookGroup[]> | undefined;
|
|
119
|
+
if (!hooks) return { changed: [], notes: [] };
|
|
120
|
+
|
|
121
|
+
const ours = new Set(['session-start.js', 'mugiwara-mode-tracker.js', 'auto-savepoint.js', 'pipeline-guard.js', 'engagement-marker.js']);
|
|
122
|
+
const isOurs = (h: HookEntry): boolean =>
|
|
123
|
+
[...ours].some((f) => h.command?.includes(join('hooks', f)) || h.command?.includes(`hooks/${f}`));
|
|
124
|
+
|
|
125
|
+
let changed = false;
|
|
126
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
127
|
+
if (!Array.isArray(groups)) continue;
|
|
128
|
+
const kept = groups
|
|
129
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isOurs(h)) }))
|
|
130
|
+
.filter((g) => g.hooks.length > 0);
|
|
131
|
+
if (kept.length !== groups.length || kept.some((g, i) => g.hooks.length !== groups[i]?.hooks?.length)) {
|
|
132
|
+
changed = true;
|
|
133
|
+
}
|
|
134
|
+
if (kept.length) hooks[event] = kept;
|
|
135
|
+
else { delete hooks[event]; changed = true; }
|
|
136
|
+
}
|
|
137
|
+
if (!changed) return { changed: [], notes: [] };
|
|
138
|
+
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
139
|
+
// an emptied settings.json still belongs to the user — rewrite, never remove
|
|
140
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
141
|
+
return { changed: [file], notes: [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
22
144
|
export const target: Target = {
|
|
23
145
|
id: 'claude',
|
|
24
146
|
label: 'Claude Code',
|
|
@@ -55,7 +177,9 @@ export const target: Target = {
|
|
|
55
177
|
if (dryRun) return { written: [], notes: [] };
|
|
56
178
|
if (existsSync(HOOKS_SRC)) {
|
|
57
179
|
for (const f of readdirSync(HOOKS_SRC)) {
|
|
58
|
-
|
|
180
|
+
// .js is what settings.json runs (node-only, no bun needed); .ts ships
|
|
181
|
+
// beside it as the readable source. hooks.json is for plugin installs.
|
|
182
|
+
if (!/\.(ts|js|json)$/.test(f)) continue;
|
|
59
183
|
const dst = join(root, 'hooks', f);
|
|
60
184
|
if (!existsSync(dst)) {
|
|
61
185
|
mkdirSync(dirname(dst), { recursive: true });
|
|
@@ -66,6 +190,15 @@ export const target: Target = {
|
|
|
66
190
|
written.push(dst);
|
|
67
191
|
}
|
|
68
192
|
}
|
|
193
|
+
// The Stop hook shells out to savepoint.sh, resolved beside its own
|
|
194
|
+
// directory. A plugin install already has <plugin>/scripts/; a CLI
|
|
195
|
+
// install needs the shell scripts copied next to the hooks or the hook
|
|
196
|
+
// finds nothing and mission state stays empty — the exact bug this
|
|
197
|
+
// whole path exists to fix.
|
|
198
|
+
written.push(...copyScripts(join(root, 'scripts')));
|
|
199
|
+
const wired = wireSettings(root, join(root, 'hooks'));
|
|
200
|
+
written.push(...wired.written);
|
|
201
|
+
notes.push(...wired.notes);
|
|
69
202
|
}
|
|
70
203
|
// Port the /mugiwara commands into the installed .claude dir.
|
|
71
204
|
if (existsSync(COMMANDS_SRC)) {
|
|
@@ -81,4 +214,9 @@ export const target: Target = {
|
|
|
81
214
|
}
|
|
82
215
|
return { written, notes };
|
|
83
216
|
},
|
|
217
|
+
postUninstall({ scope, projectDir, home, dryRun }) {
|
|
218
|
+
if (dryRun) return { changed: [], notes: [] };
|
|
219
|
+
const root = scope === 'global' ? join(home, '.claude') : join(projectDir, '.claude');
|
|
220
|
+
return unwireSettings(root);
|
|
221
|
+
},
|
|
84
222
|
};
|
package/src/targets/copilot.ts
CHANGED
|
@@ -12,10 +12,29 @@ export const target: Target = {
|
|
|
12
12
|
const root = scope === 'global' ? join(home, '.copilot') : join(projectDir, '.github');
|
|
13
13
|
return { skillsDir: join(root, 'instructions'), agentsDir: join(root, 'agents') };
|
|
14
14
|
},
|
|
15
|
+
// Copilot injects EVERY matching instruction file into EVERY request — so
|
|
16
|
+
// the full corpus (26 files, ~27k tokens) was per-request cost, not
|
|
17
|
+
// per-session. Ship a routing stub here and park the full body in the
|
|
18
|
+
// per-skill refs dir, which the agent reads on demand. applyTo stays '**/*'
|
|
19
|
+
// so the routing line is always present; it is a few lines, not a skill.
|
|
15
20
|
transformSkill(data: FrontmatterData, body: string) {
|
|
21
|
+
void body;
|
|
16
22
|
return {
|
|
17
23
|
relPath: `${data.name}.instructions.md`,
|
|
18
|
-
text: stringifyFrontmatter(
|
|
24
|
+
text: stringifyFrontmatter(
|
|
25
|
+
{ description: data.description, applyTo: '**/*' },
|
|
26
|
+
`# ${data.name}
|
|
27
|
+
|
|
28
|
+
> ${data.description}
|
|
29
|
+
|
|
30
|
+
Full skill: read \`.mugiwara/refs/${data.name}/${data.name}.md\` when the crew invokes this role.`,
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
transformSkillFull(data: FrontmatterData, body: string) {
|
|
35
|
+
return {
|
|
36
|
+
relPath: `${data.name}.md`,
|
|
37
|
+
text: stringifyFrontmatter({ description: data.description }, body),
|
|
19
38
|
};
|
|
20
39
|
},
|
|
21
40
|
transformAgent(data: FrontmatterData, body: string) {
|
package/src/targets/generic.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function makeGeneric(opts: {
|
|
|
31
31
|
// tokens at session start. The agent reads the full file on demand.
|
|
32
32
|
return {
|
|
33
33
|
relPath: `${data.name}.md`,
|
|
34
|
-
text: `# ${data.name}\n\n> ${data.description}\n\n## Skip when\n\nFull skill: \`${data.name}\` — read \`.mugiwara/refs/${data.name}.md\` when the crew invokes this role.`,
|
|
34
|
+
text: `# ${data.name}\n\n> ${data.description}\n\n## Skip when\n\nFull skill: \`${data.name}\` — read \`.mugiwara/refs/${data.name}/${data.name}.md\` when the crew invokes this role.`,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
return { relPath: `${data.name}.md`, text: `# ${data.name}\n\n> ${data.description}\n\n${body}` };
|
|
@@ -44,7 +44,7 @@ export function makeGeneric(opts: {
|
|
|
44
44
|
if (stubOnly) {
|
|
45
45
|
return {
|
|
46
46
|
relPath: `agent-${data.name}.md`,
|
|
47
|
-
text: `# Agent: ${data.name}\n\n> ${data.description}\n\nSkills: ${data.skills ?? ''}. Read \`.mugiwara/refs/${data.name}.md\` when embodying this role.\n\nOnly zoro-execution and brook-healing may modify source code.\nReturn your output to luffy-orchestrator; do not choose the next step.`,
|
|
47
|
+
text: `# Agent: ${data.name}\n\n> ${data.description}\n\nSkills: ${data.skills ?? ''}. Read \`.mugiwara/refs/${data.name}/${data.name}.md\` when embodying this role.\n\nOnly zoro-execution and brook-healing may modify source code.\nReturn your output to luffy-orchestrator; do not choose the next step.`,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
return { relPath: `agent-${data.name}.md`, text: `# Agent: ${data.name}\n\n> ${data.description}\n\nSkills used: ${data.skills ?? ''}\n\n${body}` };
|
|
@@ -53,10 +53,15 @@ export function makeGeneric(opts: {
|
|
|
53
53
|
if (!stubOnly) return null;
|
|
54
54
|
return { relPath: `${data.name}.md`, text: `# Agent: ${data.name}\n\n> ${data.description}\n\nSkills used: ${data.skills ?? ''}\n\n${body}` };
|
|
55
55
|
},
|
|
56
|
-
refsDir({ projectDir }: { scope: Scope; projectDir: string; home: string }) {
|
|
56
|
+
refsDir({ projectDir }: { scope: Scope; projectDir: string; home: string }, skillName: string) {
|
|
57
57
|
// Keep reference detail outside the rules dir so the harness never
|
|
58
58
|
// glob-loads it; the skill names the path and the agent reads on demand.
|
|
59
|
-
|
|
59
|
+
// Per-skill subdir is load-bearing, not tidiness: a flat refs dir let
|
|
60
|
+
// same-named references collide (agent-security/checklist.md vs
|
|
61
|
+
// frontend/checklist.md, contract-first/process.md vs
|
|
62
|
+
// root-cause/process.md) and first-writer-wins silently served the
|
|
63
|
+
// wrong document to the skill that asked for it.
|
|
64
|
+
return join(projectDir, '.mugiwara', 'refs', skillName);
|
|
60
65
|
},
|
|
61
66
|
postInstall({ projectDir, dryRun }) {
|
|
62
67
|
if (!bootstrapFile) return { written: [], notes: [] };
|
package/src/targets/opencode.ts
CHANGED
|
@@ -33,7 +33,6 @@ const CREW: Record<string, CrewConfig> = {
|
|
|
33
33
|
'eval-runner': { color: '#14b8a6', temperature: 0.2, steps: 15 },
|
|
34
34
|
'resume-coordinator': { color: '#d97706', temperature: 0.2, steps: 10 },
|
|
35
35
|
'memory-keeper': { color: '#d946ef', temperature: 0.2, steps: 8 },
|
|
36
|
-
'onboarding-guide': { color: '#0ea5e9', temperature: 0.3, steps: 15 },
|
|
37
36
|
};
|
|
38
37
|
|
|
39
38
|
// Crew colors from the wave-banners table (single source of truth). Returns
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: onboarding-guide
|
|
3
|
-
description: Persona for using-mugiwara. Onboarding wizard: host-native question flow, writes config only. No network.
|
|
4
|
-
skills: using-mugiwara, mugiwara-orchestration
|
|
5
|
-
write-scope: artifacts
|
|
6
|
-
permissions: read-only
|
|
7
|
-
---
|
|
8
|
-
# Onboarding Guide
|
|
9
|
-
## Role
|
|
10
|
-
|
|
11
|
-
Runs the onboarding wizard via host-native question tool (opencode `question`,
|
|
12
|
-
Claude Code `AskUserQuestion`, Copilot `askQuestion` when present), or plain
|
|
13
|
-
conversation otherwise. Writes `.mugiwara/config` only. No network. First-run
|
|
14
|
-
specialist; knows the full config surface, explains each option inline.
|
|
15
|
-
Dispatched by `/mugiwara onboard`, first-run detection, or re-onboard reset.
|
|
16
|
-
|
|
17
|
-
## Rules
|
|
18
|
-
|
|
19
|
-
1. Never modify the questions — fixed and validated (9 questions).
|
|
20
|
-
2. Never skip a question; all 9 answered before writing config.
|
|
21
|
-
3. Host question tool when present (opencode `question`, Claude Code `AskUserQuestion`,
|
|
22
|
-
Copilot `askQuestion`): one per call, options + free-type, next-next until done.
|
|
23
|
-
No tool → conversation: numbered choices + "type your own answer".
|
|
24
|
-
4. Write `.mugiwara/config` only after all 9 answers. Never write
|
|
25
|
-
`.mugiwara/onboard.json`; delete a stale copy if one exists.
|
|
26
|
-
5. Print a config summary after completion so user can verify.
|
|
27
|
-
6. All prompts static — no network, no LLM-generated questions.
|
|
28
|
-
7. CLI users: point to `bun scripts/onboard.ts` (terminal wizard for non-interactive hosts).
|
|
29
|
-
|
|
30
|
-
## The 9 Questions
|
|
31
|
-
|
|
32
|
-
### Phase 1: Project Context
|
|
33
|
-
|
|
34
|
-
**Q1 — Project type:**
|
|
35
|
-
```
|
|
36
|
-
[1] Web application
|
|
37
|
-
[2] Mobile application
|
|
38
|
-
[3] CLI tool
|
|
39
|
-
[4] Library/SDK
|
|
40
|
-
[5] Backend service / API
|
|
41
|
-
[6] Other
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Q2 — Primary language:**
|
|
45
|
-
```
|
|
46
|
-
[1] TypeScript
|
|
47
|
-
[2] JavaScript
|
|
48
|
-
[3] Python
|
|
49
|
-
[4] Go
|
|
50
|
-
[5] Rust
|
|
51
|
-
[6] Java
|
|
52
|
-
[7] Other
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
**Q3 — Team size:**
|
|
56
|
-
```
|
|
57
|
-
[1] Solo
|
|
58
|
-
[2] 2–5
|
|
59
|
-
[3] 6–15
|
|
60
|
-
[4] 16+
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
**Q4 — Git workflow:**
|
|
64
|
-
```
|
|
65
|
-
[1] Trunk-based (feature/{type}-{issue}-{slug})
|
|
66
|
-
[2] GitFlow (feature/{slug})
|
|
67
|
-
[3] GitHub Flow (feat/{slug})
|
|
68
|
-
[4] Other (feature/{slug})
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
**Q5 — CI/CD platform:**
|
|
72
|
-
```
|
|
73
|
-
[1] GitHub Actions
|
|
74
|
-
[2] GitLab CI
|
|
75
|
-
[3] CircleCI
|
|
76
|
-
[4] Jenkins
|
|
77
|
-
[5] None / manual
|
|
78
|
-
[6] Other
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### Phase 2: Mugiwara Preferences
|
|
82
|
-
|
|
83
|
-
**Q6 — Autonomy mode:**
|
|
84
|
-
```
|
|
85
|
-
[1] guided — ask before every wave transition
|
|
86
|
-
[2] semi — auto-advance through waves, pause on failures
|
|
87
|
-
[3] auto — full auto-pilot
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**Q7 — Code review depth:**
|
|
91
|
-
```
|
|
92
|
-
[1] full — breaking-change map, five-axis review, ≤3 cycles
|
|
93
|
-
[2] standard — five-axis review, 1 cycle
|
|
94
|
-
[3] quick — diff-only, no caller-map
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
**Q8 — Quality check depth:**
|
|
98
|
-
```
|
|
99
|
-
[1] full — format, lint, typecheck, test, build
|
|
100
|
-
[2] standard — lint, typecheck, test
|
|
101
|
-
[3] quick — test only
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
**Q9 — Test coverage threshold:**
|
|
105
|
-
```
|
|
106
|
-
[1] 90/80 — new code 90%, modified 80%
|
|
107
|
-
[2] 80/70 — new code 80%, modified 70%
|
|
108
|
-
[3] custom — enter your own values
|
|
109
|
-
[4] none — 0/0, no coverage enforcement
|
|
110
|
-
```
|
|
111
|
-
## Output
|
|
112
|
-
|
|
113
|
-
After all 9 answers: write `.mugiwara/config` (mode, branch, coverage,
|
|
114
|
-
review_depth, quality_depth). Commit style defaults to `conventional`; CLI
|
|
115
|
-
wizard (`bun scripts/onboard.ts`) also writes `commit` and can set a
|
|
116
|
-
custom style. Print a summary of chosen values before exit.
|
|
117
|
-
|
|
118
|
-
## Before you start
|
|
119
|
-
|
|
120
|
-
Dispatched by Luffy only; check `.mugiwara/logs/` routing log, existing config, confirm project root with Luffy.
|
|
121
|
-
|
|
122
|
-
## Return to Luffy
|
|
123
|
-
|
|
124
|
-
Report: config written with timestamp, all 9 answers, warnings (custom coverage). Luffy uses this for wave 0 routing.
|