@ionivetech/mugiwara 0.6.4 → 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 +48 -2
- package/.cursor-plugin/plugin.json +48 -2
- package/.kimi-plugin/plugin.json +48 -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/mugiwara-helpers.mjs +3 -0
- package/.opencode/plugins/mugiwara.mjs +1 -1
- package/AGENTS.md +12 -2
- package/README.md +53 -32
- 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 +14 -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 +27 -27
- 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 +9 -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-lessons/SKILL.md +1 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +34 -35
- package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
- package/content/skills/mugiwara-orchestration/references/closure.md +5 -5
- package/content/skills/mugiwara-orchestration/references/output-contract.md +77 -0
- 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 +8 -7
- package/content/skills/mugiwara-resume/SKILL.md +19 -20
- package/content/skills/mugiwara-review/SKILL.md +6 -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 +38 -39
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +15 -15
- package/content/skills/using-mugiwara/SKILL.md +5 -5
- package/dist/mugiwara.js +807 -31
- 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 +21 -31
- package/scripts/build-hooks.ts +56 -0
- package/scripts/conformance.ts +232 -0
- package/scripts/coverage-gate.ts +179 -0
- package/scripts/evidence.sh +17 -4
- package/scripts/gate-selftest.ts +14 -0
- package/scripts/initiative.ts +51 -17
- 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 +152 -37
- package/scripts/validate-content.ts +22 -2
- 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/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/continue.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// src/continue.ts
|
|
2
|
+
// Deterministic resume-point reader. Powers `mugiwara continue` and
|
|
3
|
+
// `mugiwara status` — pure fs + JSON, zero model judgement. The prose skills
|
|
4
|
+
// used to describe this scan so the host model would perform it; that cost a
|
|
5
|
+
// full reasoning turn to answer "what is in flight?". This is the same answer
|
|
6
|
+
// in one process spawn.
|
|
7
|
+
//
|
|
8
|
+
// Reads what scripts/savepoint.sh writes:
|
|
9
|
+
// .mugiwara/continue/<mission>/state.json (solo)
|
|
10
|
+
// .mugiwara/continue/<mission>/<member>.json (team)
|
|
11
|
+
// .mugiwara/state/<mission>/... (same shape, more fields)
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
|
|
17
|
+
const SAFE = /^[A-Za-z0-9._-]+$/;
|
|
18
|
+
const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
|
|
19
|
+
|
|
20
|
+
export type ContinueEntry = {
|
|
21
|
+
mission: string;
|
|
22
|
+
member: string | null;
|
|
23
|
+
actor: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
flow: number;
|
|
26
|
+
mode: string;
|
|
27
|
+
tasks_done: number;
|
|
28
|
+
tasks_total: number;
|
|
29
|
+
lane: string;
|
|
30
|
+
next_action: string;
|
|
31
|
+
next_session_prompt: string;
|
|
32
|
+
updated_at: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type StateEntry = ContinueEntry & {
|
|
36
|
+
lane_reason: string;
|
|
37
|
+
lane_rose: boolean;
|
|
38
|
+
lane_prev: string;
|
|
39
|
+
lane_peak: string;
|
|
40
|
+
blockers_open: number;
|
|
41
|
+
heal_cycle: number;
|
|
42
|
+
heal_max_cycles: number;
|
|
43
|
+
heal_halt: boolean;
|
|
44
|
+
delegate_threshold: number;
|
|
45
|
+
delegate_due: boolean;
|
|
46
|
+
tokens_est: number;
|
|
47
|
+
budget: number;
|
|
48
|
+
budget_status: string;
|
|
49
|
+
files_touched: number;
|
|
50
|
+
evidence: string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const num = (v: unknown): number => {
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return Number.isFinite(n) ? n : 0;
|
|
56
|
+
};
|
|
57
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the git actor exactly as scripts/savepoint.sh does:
|
|
61
|
+
* STATE_ACTOR → GIT_AUTHOR_NAME → "name <email>" → USER. A byte difference
|
|
62
|
+
* here silently filters every mission out of the listing.
|
|
63
|
+
*/
|
|
64
|
+
export function gitActor(cwd: string): string {
|
|
65
|
+
const env = (process.env.STATE_ACTOR ?? '').trim();
|
|
66
|
+
if (env) return env;
|
|
67
|
+
const author = (process.env.GIT_AUTHOR_NAME ?? '').trim();
|
|
68
|
+
if (author) return author;
|
|
69
|
+
const git = (args: string[]): string => {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
72
|
+
} catch {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const name = git(['config', 'user.name']);
|
|
77
|
+
const email = git(['config', 'user.email']);
|
|
78
|
+
if (name && email) return `${name} <${email}>`;
|
|
79
|
+
// USERNAME is the Windows spelling of USER; savepoint.sh resolves the same
|
|
80
|
+
// chain, and the two must stay byte-identical or the actor filter drops rows.
|
|
81
|
+
return name || process.env.USER || process.env.USERNAME || '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read every JSON under `.mugiwara/<root>/<mission>/*.json`. Corrupt files are skipped. */
|
|
85
|
+
function scan<T>(projectDir: string, root: string, map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
|
|
86
|
+
const base = join(projectDir, '.mugiwara', root);
|
|
87
|
+
if (!existsSync(base)) return [];
|
|
88
|
+
const out: T[] = [];
|
|
89
|
+
const missions = readdirSync(base, { withFileTypes: true })
|
|
90
|
+
.filter((e) => e.isDirectory() && isSafeKey(e.name))
|
|
91
|
+
.map((e) => e.name)
|
|
92
|
+
.sort();
|
|
93
|
+
for (const mission of missions) {
|
|
94
|
+
const dir = join(base, mission);
|
|
95
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
|
|
96
|
+
// state.json is the solo (member-less) file; <member>.json is a team member
|
|
97
|
+
const stem = f.slice(0, -'.json'.length);
|
|
98
|
+
const member = stem === 'state' ? null : stem;
|
|
99
|
+
if (member !== null && !isSafeKey(member)) continue;
|
|
100
|
+
try {
|
|
101
|
+
const raw = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
|
|
102
|
+
// trust the path over the file body: a mission field that disagrees
|
|
103
|
+
// with its own directory is a corrupt or hand-edited file
|
|
104
|
+
if (text(raw.mission) !== mission) continue;
|
|
105
|
+
out.push(map(raw, member));
|
|
106
|
+
} catch {
|
|
107
|
+
// corrupt savepoint — skip, never crash the listing
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function readContinue(projectDir: string): ContinueEntry[] {
|
|
115
|
+
return scan(projectDir, 'continue', (r, member) => ({
|
|
116
|
+
mission: text(r.mission),
|
|
117
|
+
member,
|
|
118
|
+
actor: text(r.actor),
|
|
119
|
+
branch: text(r.branch),
|
|
120
|
+
flow: num(r.flow ?? r.wave),
|
|
121
|
+
mode: text(r.mode) || 'guided',
|
|
122
|
+
tasks_done: num(r.tasks_done),
|
|
123
|
+
tasks_total: num(r.tasks_total),
|
|
124
|
+
lane: text(r.lane) || 'direct',
|
|
125
|
+
next_action: text(r.next_action),
|
|
126
|
+
next_session_prompt: text(r.next_session_prompt),
|
|
127
|
+
updated_at: text(r.updated_at),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readState(projectDir: string): StateEntry[] {
|
|
132
|
+
return scan(projectDir, 'state', (r, member) => {
|
|
133
|
+
const tasks = (r.tasks ?? {}) as Record<string, unknown>;
|
|
134
|
+
return {
|
|
135
|
+
mission: text(r.mission),
|
|
136
|
+
member,
|
|
137
|
+
actor: text(r.actor),
|
|
138
|
+
branch: text(r.branch),
|
|
139
|
+
flow: num(r.flow ?? r.wave),
|
|
140
|
+
mode: text(r.mode) || 'guided',
|
|
141
|
+
tasks_done: num(tasks.done),
|
|
142
|
+
tasks_total: num(tasks.total),
|
|
143
|
+
lane: text(r.lane) || 'direct',
|
|
144
|
+
lane_reason: text(r.lane_reason),
|
|
145
|
+
lane_rose: r.lane_rose === true,
|
|
146
|
+
lane_prev: text(r.lane_prev),
|
|
147
|
+
lane_peak: text(r.lane_peak),
|
|
148
|
+
next_action: '',
|
|
149
|
+
next_session_prompt: '',
|
|
150
|
+
updated_at: text(r.updated_at),
|
|
151
|
+
blockers_open: num(r.blockers_open),
|
|
152
|
+
heal_cycle: num(r.heal_cycle),
|
|
153
|
+
heal_max_cycles: num(r.heal_max_cycles) || 3,
|
|
154
|
+
heal_halt: r.heal_halt === true,
|
|
155
|
+
delegate_threshold: num(r.delegate_threshold) || 60,
|
|
156
|
+
delegate_due: r.delegate_due === true,
|
|
157
|
+
tokens_est: num(r.tokens_est),
|
|
158
|
+
budget: num(r.budget),
|
|
159
|
+
budget_status: text(r.budget_status) || 'ok',
|
|
160
|
+
files_touched: num(r.files_touched),
|
|
161
|
+
evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type Resolution =
|
|
167
|
+
| { kind: 'none' }
|
|
168
|
+
| { kind: 'missions'; entries: ContinueEntry[] }
|
|
169
|
+
| { kind: 'members'; mission: string; entries: ContinueEntry[] }
|
|
170
|
+
| { kind: 'unknown-mission'; mission: string; known: string[] }
|
|
171
|
+
| { kind: 'unknown-member'; mission: string; member: string; known: string[] }
|
|
172
|
+
| { kind: 'resume'; entry: ContinueEntry };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The three command forms, decided from disk alone:
|
|
176
|
+
* (none) → list every in-flight mission
|
|
177
|
+
* <mission> → solo resumes; team lists its members
|
|
178
|
+
* <mission> <member>→ resume that member
|
|
179
|
+
*
|
|
180
|
+
* Solo-vs-team needs no plan-doc parse: savepoint writes `state.json` with a
|
|
181
|
+
* null member for solo and `<member>.json` per team member.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveContinue(entries: ContinueEntry[], mission?: string, member?: string): Resolution {
|
|
184
|
+
if (!entries.length) return { kind: 'none' };
|
|
185
|
+
|
|
186
|
+
if (!mission) {
|
|
187
|
+
const missions = [...new Set(entries.map((e) => e.mission))];
|
|
188
|
+
if (missions.length === 1) return resolveContinue(entries, missions[0], member);
|
|
189
|
+
return { kind: 'missions', entries };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const inMission = entries.filter((e) => e.mission === mission);
|
|
193
|
+
if (!inMission.length) {
|
|
194
|
+
return { kind: 'unknown-mission', mission, known: [...new Set(entries.map((e) => e.mission))] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (member) {
|
|
198
|
+
const hit = inMission.find((e) => e.member === member);
|
|
199
|
+
if (!hit) {
|
|
200
|
+
return {
|
|
201
|
+
kind: 'unknown-member',
|
|
202
|
+
mission,
|
|
203
|
+
member,
|
|
204
|
+
known: inMission.map((e) => e.member ?? '(solo)'),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return { kind: 'resume', entry: hit };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const solo = inMission.find((e) => e.member === null);
|
|
211
|
+
// a solo file resumes directly; anything else needs the member picked, and
|
|
212
|
+
// is never guessed — resuming the wrong member's work is unrecoverable
|
|
213
|
+
if (solo && inMission.length === 1) return { kind: 'resume', entry: solo };
|
|
214
|
+
return { kind: 'members', mission, entries: inMission };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pad(s: string, n: number): string {
|
|
218
|
+
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Fixed-width table — the whole point is that the host prints this verbatim. */
|
|
222
|
+
export function formatTable(entries: ContinueEntry[]): string {
|
|
223
|
+
const rows = entries.map((e) => [
|
|
224
|
+
e.mission,
|
|
225
|
+
e.member ?? '—',
|
|
226
|
+
String(e.flow),
|
|
227
|
+
`${e.tasks_done}/${e.tasks_total}`,
|
|
228
|
+
e.lane,
|
|
229
|
+
e.mode,
|
|
230
|
+
]);
|
|
231
|
+
const head = ['MISSION', 'MEMBER', 'FLOW', 'TASKS', 'LANE', 'MODE'];
|
|
232
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
233
|
+
const line = (cells: string[]): string =>
|
|
234
|
+
' ' + cells.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
|
|
235
|
+
return [line(head), ...rows.map(line)].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The single resume line the mugiwara-resume skill contracts for. */
|
|
239
|
+
export function formatResume(e: ContinueEntry): string {
|
|
240
|
+
const scope = e.member ? ` [${e.member}]` : '';
|
|
241
|
+
const next = e.next_session_prompt || '(no next_session_prompt recorded)';
|
|
242
|
+
return `Resumed: ${e.mission}${scope}, Flow ${e.flow}, ${e.tasks_done}/${e.tasks_total} tasks — next_action: ${e.next_action} — run: ${next}`;
|
|
243
|
+
}
|
package/src/installer.ts
CHANGED
|
@@ -44,6 +44,15 @@ export interface Target {
|
|
|
44
44
|
transformSkillFull?(data: FrontmatterData, body: string): TransformOut | null;
|
|
45
45
|
transformAgentFull?(data: FrontmatterData, body: string): TransformOut | null;
|
|
46
46
|
postInstall?(opts: { scope: Scope; projectDir: string; home: string; dryRun: boolean; files: string[] }): { written: string[]; notes: string[] };
|
|
47
|
+
/**
|
|
48
|
+
* Undo anything postInstall did to a file mugiwara does not own.
|
|
49
|
+
*
|
|
50
|
+
* Files listed in the manifest are deleted wholesale on uninstall, so a
|
|
51
|
+
* shared file we merely EDITED (settings.json) must never be reported as
|
|
52
|
+
* written — deleting it destroys the user's own configuration. Such files are
|
|
53
|
+
* un-merged here instead.
|
|
54
|
+
*/
|
|
55
|
+
postUninstall?(opts: { scope: Scope; projectDir: string; home: string; dryRun: boolean }): { changed: string[]; notes: string[] };
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'content');
|
|
@@ -151,6 +160,36 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
|
|
|
151
160
|
result.written.push(...post.written);
|
|
152
161
|
result.notes.push(...post.notes);
|
|
153
162
|
}
|
|
163
|
+
|
|
164
|
+
// TASK 8: a fresh install must be immediately usable — write a default
|
|
165
|
+
// .mugiwara/config (the same defaults `mugiwara onboard` would write) so no
|
|
166
|
+
// key silently falls back. Only for project scope; global installs don't own
|
|
167
|
+
// a project config. Never overwrite an existing config.
|
|
168
|
+
if (scope === 'project') {
|
|
169
|
+
const configPath = join(projectDir, '.mugiwara', 'config');
|
|
170
|
+
// lstat, not existsSync: a pre-created symlinked config must not be
|
|
171
|
+
// followed and overwritten (TOCTOU / symlink defense-in-depth).
|
|
172
|
+
let configExists = false;
|
|
173
|
+
try { configExists = lstatSync(configPath).isFile() || lstatSync(configPath).isSymbolicLink(); } catch { configExists = false; }
|
|
174
|
+
if (!configExists) {
|
|
175
|
+
const body = [
|
|
176
|
+
'mode=guided',
|
|
177
|
+
'branch=feature/{type}-{issue}-{slug}',
|
|
178
|
+
'commit=conventional',
|
|
179
|
+
'auto_commit=on',
|
|
180
|
+
'coverage_new=90',
|
|
181
|
+
'coverage_modified=80',
|
|
182
|
+
'review_depth=full',
|
|
183
|
+
'quality_depth=full',
|
|
184
|
+
'delegate_threshold=60',
|
|
185
|
+
'heal_max_cycles=3',
|
|
186
|
+
'verbosity=normal',
|
|
187
|
+
].join('\n') + '\n';
|
|
188
|
+
if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
|
|
189
|
+
result.written.push(configPath);
|
|
190
|
+
result.notes.push(`default config written: ${configPath} (run \`mugiwara onboard\` to customise)`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
154
193
|
return result;
|
|
155
194
|
}
|
|
156
195
|
|
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
|
+
}
|