@ionivetech/mugiwara 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +3 -9
- package/.codex-plugin/plugin.json +2 -8
- package/.cursor-plugin/plugin.json +2 -8
- package/.kimi-plugin/plugin.json +2 -8
- package/.opencode/commands/mugiwara-continue.md +44 -31
- package/.opencode/commands/mugiwara-review.md +1 -1
- package/.opencode/commands/mugiwara-security.md +1 -1
- package/.opencode/commands/mugiwara.md +2 -2
- package/.opencode/plugins/mugiwara.mjs +3 -19
- package/AGENTS.md +6 -6
- package/GEMINI.md +3 -3
- package/README.md +82 -74
- package/content/agents/brook-healing.md +12 -11
- package/content/agents/chopper-checkpoint.md +14 -13
- package/content/agents/eval-runner.md +6 -9
- package/content/agents/franky-gates.md +7 -10
- package/content/agents/jinbe-security.md +10 -14
- package/content/agents/luffy-orchestrator.md +20 -22
- package/content/agents/memory-keeper.md +9 -12
- package/content/agents/nami-planner.md +7 -10
- package/content/agents/resume-coordinator.md +12 -15
- package/content/agents/robin-reviewer.md +13 -12
- package/content/agents/sanji-quality.md +9 -12
- package/content/agents/skeptic-verifier.md +7 -10
- package/content/agents/usopp-brainstorm.md +6 -9
- package/content/agents/zoro-execution.md +9 -12
- package/content/skills/mugiwara-backend/SKILL.md +4 -0
- package/content/skills/mugiwara-backend/references/database.md +61 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +17 -14
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +5 -5
- package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
- package/content/skills/mugiwara-execution/SKILL.md +24 -24
- package/content/skills/mugiwara-execution/references/dispatch.md +3 -3
- package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
- package/content/skills/mugiwara-frontend/SKILL.md +5 -6
- package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
- package/content/skills/mugiwara-gates/SKILL.md +14 -10
- package/content/skills/mugiwara-git/SKILL.md +7 -2
- package/content/skills/mugiwara-healing/SKILL.md +7 -5
- package/content/skills/mugiwara-healing/references/workers.md +2 -2
- package/content/skills/mugiwara-lessons/SKILL.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +28 -26
- package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
- package/content/skills/mugiwara-orchestration/references/closure.md +18 -13
- package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +32 -22
- package/content/skills/mugiwara-planning/SKILL.md +5 -18
- package/content/skills/mugiwara-planning/references/plan-template.md +3 -3
- package/content/skills/mugiwara-quality/SKILL.md +10 -7
- package/content/skills/mugiwara-resume/SKILL.md +20 -22
- package/content/skills/mugiwara-review/SKILL.md +7 -2
- 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 +6 -2
- package/content/skills/mugiwara-ship/SKILL.md +2 -2
- package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
- package/content/skills/mugiwara-testcases/SKILL.md +5 -3
- package/content/skills/mugiwara-workflow/SKILL.md +32 -35
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +41 -40
- package/dist/mugiwara.js +1370 -153
- package/gemini-extension.json +1 -1
- package/hooks/auto-savepoint.js +114 -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 +186 -0
- package/hooks/pipeline-guard.ts +270 -0
- package/hooks/session-start.js +109 -0
- package/hooks/session-start.ts +62 -45
- package/package.json +7 -3
- package/plugin.json +1 -1
- package/references/agent-protocol.md +15 -0
- package/references/complexity.md +77 -0
- package/references/definition-of-done.md +1 -1
- package/references/multi-actor.md +4 -7
- package/references/prose-style.md +54 -0
- package/references/skill-versioning.md +1 -1
- package/references/wave-banners.md +16 -16
- package/scripts/build-hooks.ts +56 -0
- package/scripts/check-doc-links.ts +40 -0
- package/scripts/conformance.ts +23 -16
- package/scripts/coverage-gate.ts +193 -0
- package/scripts/gate-selftest.ts +118 -7
- package/scripts/install.ps1 +5 -4
- package/scripts/install.sh +4 -4
- package/scripts/lane.sh +56 -6
- package/scripts/lib/patterns.sh +89 -0
- package/scripts/policy-force.ts +22 -0
- package/scripts/retrieval-eval.ts +1 -1
- package/scripts/savepoint.sh +249 -67
- package/scripts/validate-content.ts +22 -9
- package/scripts/verify-install.ts +156 -44
- package/src/args.ts +2 -2
- package/src/budget.ts +56 -0
- package/src/cli.ts +272 -4
- package/src/continue.ts +262 -0
- package/src/installer.ts +59 -15
- package/src/integrity.ts +158 -0
- package/src/mission.ts +228 -95
- package/src/policy.ts +156 -0
- package/src/provenance.ts +116 -0
- package/src/rollback.ts +95 -0
- package/src/routing.ts +69 -0
- package/src/run.ts +82 -0
- package/src/sign.ts +61 -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/.opencode/commands/mugiwara-execute.md +0 -13
- package/.opencode/commands/mugiwara-heal.md +0 -13
- package/.opencode/commands/mugiwara-onboard.md +0 -15
- package/.opencode/commands/mugiwara-plan.md +0 -17
- package/.opencode/commands/mugiwara-ship.md +0 -13
- package/.opencode/commands/using-mugiwara.md +0 -20
- package/content/agents/onboarding-guide.md +0 -124
- package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
- package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
- package/content/skills/mugiwara-context-budget/SKILL.md +0 -64
- package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
- package/content/skills/mugiwara-pr/SKILL.md +0 -69
- package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
- package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
- package/content/skills/mugiwara-sunset/SKILL.md +0 -82
- package/content/skills/using-mugiwara/SKILL.md +0 -51
- package/references/token-budget.md +0 -34
- package/scripts/evidence.sh +0 -81
- package/scripts/initiative.ts +0 -296
- package/scripts/mission-report.sh +0 -292
- package/scripts/onboard.ts +0 -266
- package/scripts/probe.ts +0 -40
package/src/continue.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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/missions/<mission>/state.json (solo state)
|
|
10
|
+
// .mugiwara/missions/<mission>/<member>.json (team state)
|
|
11
|
+
// .mugiwara/missions/<mission>/continue.json (solo resume point)
|
|
12
|
+
// .mugiwara/missions/<mission>/continue-<member>.json (team resume point)
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** Mission/member allowlist — identical to savepoint.sh and mission.ts. */
|
|
18
|
+
const SAFE = /^[A-Za-z0-9._-]+$/;
|
|
19
|
+
const isSafeKey = (s: string): boolean => SAFE.test(s) && !/^\.+$/.test(s);
|
|
20
|
+
|
|
21
|
+
export type ContinueEntry = {
|
|
22
|
+
mission: string;
|
|
23
|
+
member: string | null;
|
|
24
|
+
actor: string;
|
|
25
|
+
branch: string;
|
|
26
|
+
flow: number;
|
|
27
|
+
mode: string;
|
|
28
|
+
tasks_done: number;
|
|
29
|
+
tasks_total: number;
|
|
30
|
+
lane: string;
|
|
31
|
+
next_action: string;
|
|
32
|
+
next_session_prompt: string;
|
|
33
|
+
updated_at: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type StateEntry = ContinueEntry & {
|
|
37
|
+
lane_reason: string;
|
|
38
|
+
lane_rose: boolean;
|
|
39
|
+
lane_prev: string;
|
|
40
|
+
lane_peak: string;
|
|
41
|
+
base_sha: string;
|
|
42
|
+
sensitive_paths: string[];
|
|
43
|
+
blockers_open: number;
|
|
44
|
+
heal_cycle: number;
|
|
45
|
+
heal_max_cycles: number;
|
|
46
|
+
heal_halt: boolean;
|
|
47
|
+
delegate_threshold: number;
|
|
48
|
+
delegate_due: boolean;
|
|
49
|
+
tokens_est: number;
|
|
50
|
+
budget: number;
|
|
51
|
+
budget_status: string;
|
|
52
|
+
files_touched: number;
|
|
53
|
+
evidence: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const num = (v: unknown): number => {
|
|
57
|
+
const n = Number(v);
|
|
58
|
+
return Number.isFinite(n) ? n : 0;
|
|
59
|
+
};
|
|
60
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the git actor exactly as scripts/savepoint.sh does:
|
|
64
|
+
* STATE_ACTOR → GIT_AUTHOR_NAME → "name <email>" → USER. A byte difference
|
|
65
|
+
* here silently filters every mission out of the listing.
|
|
66
|
+
*/
|
|
67
|
+
export function gitActor(cwd: string): string {
|
|
68
|
+
const env = (process.env.STATE_ACTOR ?? '').trim();
|
|
69
|
+
if (env) return env;
|
|
70
|
+
const author = (process.env.GIT_AUTHOR_NAME ?? '').trim();
|
|
71
|
+
if (author) return author;
|
|
72
|
+
const git = (args: string[]): string => {
|
|
73
|
+
try {
|
|
74
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const name = git(['config', 'user.name']);
|
|
80
|
+
const email = git(['config', 'user.email']);
|
|
81
|
+
if (name && email) return `${name} <${email}>`;
|
|
82
|
+
// USERNAME is the Windows spelling of USER; savepoint.sh resolves the same
|
|
83
|
+
// chain, and the two must stay byte-identical or the actor filter drops rows.
|
|
84
|
+
return name || process.env.USER || process.env.USERNAME || '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read every mission dir under `.mugiwara/missions/<mission>/`, picking the
|
|
89
|
+
* files this reader owns: state readers take `state.json` / `<member>.json`,
|
|
90
|
+
* continue readers take `continue.json` / `continue-<member>.json`. Corrupt
|
|
91
|
+
* files are skipped.
|
|
92
|
+
*/
|
|
93
|
+
function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
|
|
94
|
+
const base = join(projectDir, '.mugiwara', 'missions');
|
|
95
|
+
if (!existsSync(base)) return [];
|
|
96
|
+
const out: T[] = [];
|
|
97
|
+
const missions = readdirSync(base, { withFileTypes: true })
|
|
98
|
+
.filter((e) => e.isDirectory() && isSafeKey(e.name))
|
|
99
|
+
.map((e) => e.name)
|
|
100
|
+
.sort();
|
|
101
|
+
for (const mission of missions) {
|
|
102
|
+
const dir = join(base, mission);
|
|
103
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
|
|
104
|
+
const stem = f.slice(0, -'.json'.length);
|
|
105
|
+
// state files: `state` (solo) or `<member>`; continue files: `continue`
|
|
106
|
+
// (solo) or `continue-<member>`. Each scan ignores the other kind.
|
|
107
|
+
let member: string | null;
|
|
108
|
+
if (kind === 'state') {
|
|
109
|
+
if (stem === 'continue' || stem.startsWith('continue-')) continue;
|
|
110
|
+
member = stem === 'state' ? null : stem;
|
|
111
|
+
} else {
|
|
112
|
+
if (stem === 'continue') member = null;
|
|
113
|
+
else if (stem.startsWith('continue-')) member = stem.slice('continue-'.length);
|
|
114
|
+
else continue;
|
|
115
|
+
}
|
|
116
|
+
if (member !== null && !isSafeKey(member)) continue;
|
|
117
|
+
try {
|
|
118
|
+
const raw = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
|
|
119
|
+
// trust the path over the file body: a mission field that disagrees
|
|
120
|
+
// with its own directory is a corrupt or hand-edited file
|
|
121
|
+
if (text(raw.mission) !== mission) continue;
|
|
122
|
+
out.push(map(raw, member));
|
|
123
|
+
} catch {
|
|
124
|
+
// corrupt savepoint — skip, never crash the listing
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readContinue(projectDir: string): ContinueEntry[] {
|
|
132
|
+
return scan(projectDir, 'continue', (r, member) => ({
|
|
133
|
+
mission: text(r.mission),
|
|
134
|
+
member,
|
|
135
|
+
actor: text(r.actor),
|
|
136
|
+
branch: text(r.branch),
|
|
137
|
+
flow: num(r.flow ?? r.wave),
|
|
138
|
+
mode: text(r.mode) || 'guided',
|
|
139
|
+
tasks_done: num(r.tasks_done),
|
|
140
|
+
tasks_total: num(r.tasks_total),
|
|
141
|
+
lane: text(r.lane) || 'direct',
|
|
142
|
+
next_action: text(r.next_action),
|
|
143
|
+
next_session_prompt: text(r.next_session_prompt),
|
|
144
|
+
updated_at: text(r.updated_at),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function readState(projectDir: string): StateEntry[] {
|
|
149
|
+
return scan(projectDir, 'state', (r, member) => {
|
|
150
|
+
const tasks = (r.tasks ?? {}) as Record<string, unknown>;
|
|
151
|
+
return {
|
|
152
|
+
mission: text(r.mission),
|
|
153
|
+
member,
|
|
154
|
+
actor: text(r.actor),
|
|
155
|
+
branch: text(r.branch),
|
|
156
|
+
flow: num(r.flow ?? r.wave),
|
|
157
|
+
mode: text(r.mode) || 'guided',
|
|
158
|
+
tasks_done: num(tasks.done),
|
|
159
|
+
tasks_total: num(tasks.total),
|
|
160
|
+
lane: text(r.lane) || 'direct',
|
|
161
|
+
lane_reason: text(r.lane_reason),
|
|
162
|
+
lane_rose: r.lane_rose === true,
|
|
163
|
+
lane_prev: text(r.lane_prev),
|
|
164
|
+
lane_peak: text(r.lane_peak),
|
|
165
|
+
base_sha: text(r.base_sha),
|
|
166
|
+
sensitive_paths: Array.isArray(r.sensitive_paths) ? r.sensitive_paths.map(text).filter(Boolean) : [],
|
|
167
|
+
next_action: text(r.next_action),
|
|
168
|
+
next_session_prompt: text(r.next_session_prompt),
|
|
169
|
+
updated_at: text(r.updated_at),
|
|
170
|
+
blockers_open: num(r.blockers_open),
|
|
171
|
+
heal_cycle: num(r.heal_cycle),
|
|
172
|
+
heal_max_cycles: num(r.heal_max_cycles) || 3,
|
|
173
|
+
heal_halt: r.heal_halt === true,
|
|
174
|
+
delegate_threshold: num(r.delegate_threshold) || 60,
|
|
175
|
+
delegate_due: r.delegate_due === true,
|
|
176
|
+
tokens_est: num(r.tokens_est),
|
|
177
|
+
budget: num(r.budget),
|
|
178
|
+
budget_status: text(r.budget_status) || 'ok',
|
|
179
|
+
files_touched: num(r.files_touched),
|
|
180
|
+
evidence: Array.isArray(r.evidence) ? r.evidence.map(text).filter(Boolean) : [],
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type Resolution =
|
|
186
|
+
| { kind: 'none' }
|
|
187
|
+
| { kind: 'missions'; entries: ContinueEntry[] }
|
|
188
|
+
| { kind: 'members'; mission: string; entries: ContinueEntry[] }
|
|
189
|
+
| { kind: 'unknown-mission'; mission: string; known: string[] }
|
|
190
|
+
| { kind: 'unknown-member'; mission: string; member: string; known: string[] }
|
|
191
|
+
| { kind: 'resume'; entry: ContinueEntry };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The three command forms, decided from disk alone:
|
|
195
|
+
* (none) → list every in-flight mission
|
|
196
|
+
* <mission> → solo resumes; team lists its members
|
|
197
|
+
* <mission> <member>→ resume that member
|
|
198
|
+
*
|
|
199
|
+
* Solo-vs-team needs no plan-doc parse: savepoint writes `state.json` with a
|
|
200
|
+
* null member for solo and `<member>.json` per team member.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveContinue(entries: ContinueEntry[], mission?: string, member?: string): Resolution {
|
|
203
|
+
if (!entries.length) return { kind: 'none' };
|
|
204
|
+
|
|
205
|
+
if (!mission) {
|
|
206
|
+
const missions = [...new Set(entries.map((e) => e.mission))];
|
|
207
|
+
if (missions.length === 1) return resolveContinue(entries, missions[0], member);
|
|
208
|
+
return { kind: 'missions', entries };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const inMission = entries.filter((e) => e.mission === mission);
|
|
212
|
+
if (!inMission.length) {
|
|
213
|
+
return { kind: 'unknown-mission', mission, known: [...new Set(entries.map((e) => e.mission))] };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (member) {
|
|
217
|
+
const hit = inMission.find((e) => e.member === member);
|
|
218
|
+
if (!hit) {
|
|
219
|
+
return {
|
|
220
|
+
kind: 'unknown-member',
|
|
221
|
+
mission,
|
|
222
|
+
member,
|
|
223
|
+
known: inMission.map((e) => e.member ?? '(solo)'),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { kind: 'resume', entry: hit };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const solo = inMission.find((e) => e.member === null);
|
|
230
|
+
// a solo file resumes directly; anything else needs the member picked, and
|
|
231
|
+
// is never guessed — resuming the wrong member's work is unrecoverable
|
|
232
|
+
if (solo && inMission.length === 1) return { kind: 'resume', entry: solo };
|
|
233
|
+
return { kind: 'members', mission, entries: inMission };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pad(s: string, n: number): string {
|
|
237
|
+
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Fixed-width table — the whole point is that the host prints this verbatim. */
|
|
241
|
+
export function formatTable(entries: ContinueEntry[]): string {
|
|
242
|
+
const rows = entries.map((e) => [
|
|
243
|
+
e.mission,
|
|
244
|
+
e.member ?? '—',
|
|
245
|
+
String(e.flow),
|
|
246
|
+
`${e.tasks_done}/${e.tasks_total}`,
|
|
247
|
+
e.lane,
|
|
248
|
+
e.mode,
|
|
249
|
+
]);
|
|
250
|
+
const head = ['MISSION', 'MEMBER', 'FLOW', 'TASKS', 'LANE', 'MODE'];
|
|
251
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
252
|
+
const line = (cells: string[]): string =>
|
|
253
|
+
' ' + cells.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
|
|
254
|
+
return [line(head), ...rows.map(line)].join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The single resume line the mugiwara-resume skill contracts for. */
|
|
258
|
+
export function formatResume(e: ContinueEntry): string {
|
|
259
|
+
const scope = e.member ? ` [${e.member}]` : '';
|
|
260
|
+
const next = e.next_session_prompt || '(no next_session_prompt recorded)';
|
|
261
|
+
return `Resumed: ${e.mission}${scope}, Flow ${e.flow}, ${e.tasks_done}/${e.tasks_total} tasks — next_action: ${e.next_action} — run: ${next}`;
|
|
262
|
+
}
|
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,38 @@ 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
|
+
// A fresh install must be immediately usable — write a default
|
|
165
|
+
// .mugiwara/config so no key silently falls back. Only for project scope;
|
|
166
|
+
// global installs don't own a project config. Never overwrite an existing
|
|
167
|
+
// 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
|
+
'verify_merged=off',
|
|
185
|
+
'delegate_threshold=60',
|
|
186
|
+
'heal_max_cycles=3',
|
|
187
|
+
'verbosity=normal',
|
|
188
|
+
'# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
|
|
189
|
+
].join('\n') + '\n';
|
|
190
|
+
if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
|
|
191
|
+
result.written.push(configPath);
|
|
192
|
+
result.notes.push(`default config written: ${configPath} (edit it to customise)`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
return result;
|
|
155
196
|
}
|
|
156
197
|
|
|
@@ -184,13 +225,14 @@ function assertNotSymlink(file: string): void {
|
|
|
184
225
|
|
|
185
226
|
const GITIGNORE_BLOCK_START = '# >>> mugiwara >>>';
|
|
186
227
|
const GITIGNORE_BLOCK_END = '# <<< mugiwara <<<';
|
|
187
|
-
// legacy
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
.mugiwara/state
|
|
193
|
-
.mugiwara/
|
|
228
|
+
// legacy markers — pre-0.7 blocks ignored per-type dirs (.mugiwara/state/,
|
|
229
|
+
// .mugiwara/continue/) instead of the mission-first layout. Kept for
|
|
230
|
+
// detection so an old install is upgraded, not double-appended.
|
|
231
|
+
const GITIGNORE_LEGACY = ['.mugiwara/state/', '# mugiwara'];
|
|
232
|
+
const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit plan.md, flows/, review.md,
|
|
233
|
+
# decisions.md, blockers.md, report.md under .mugiwara/missions/. Ignore session state.
|
|
234
|
+
.mugiwara/missions/**/*.json
|
|
235
|
+
.mugiwara/index.md
|
|
194
236
|
.mugiwara/config
|
|
195
237
|
.mugiwara/refs/
|
|
196
238
|
# <<< mugiwara <<<
|
|
@@ -203,14 +245,14 @@ export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: bool
|
|
|
203
245
|
let existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
204
246
|
if (existing) {
|
|
205
247
|
// Delimited block present with the current entries → nothing to do.
|
|
206
|
-
if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/
|
|
248
|
+
if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/missions/**/*.json')) {
|
|
207
249
|
return { appended: false, notes: [] };
|
|
208
250
|
}
|
|
209
|
-
// Legacy (
|
|
210
|
-
// strip the old mugiwara entries
|
|
251
|
+
// Legacy (pre-0.7) or outdated delimited block → upgrade in place:
|
|
252
|
+
// strip the old mugiwara entries, then append the new block.
|
|
211
253
|
// Without this, upgraded projects keep committing per-wave state/continue
|
|
212
254
|
// JSON to git — session state must stay ignored (Robin MAJOR, Jinbe Low).
|
|
213
|
-
const hadOld = existing.includes(
|
|
255
|
+
const hadOld = GITIGNORE_LEGACY.some((m) => existing.includes(m)) || existing.includes(GITIGNORE_BLOCK_START);
|
|
214
256
|
if (hadOld) {
|
|
215
257
|
const clean = removeProjectGitignore(projectDir, { dryRun });
|
|
216
258
|
existing = clean.removed ? (existsSync(path) ? readFileSync(path, 'utf8') : '') : existing;
|
|
@@ -242,20 +284,22 @@ export function removeProjectGitignore(projectDir: string, { dryRun = false }: {
|
|
|
242
284
|
const end = current.indexOf(GITIGNORE_BLOCK_END);
|
|
243
285
|
if (end < start) return { removed: false, notes: ['delimiter mismatch — .gitignore left untouched'] };
|
|
244
286
|
cleaned = current.slice(0, start) + current.slice(end + GITIGNORE_BLOCK_END.length);
|
|
245
|
-
} else if (current.includes(
|
|
246
|
-
// legacy (
|
|
247
|
-
// explanatory comment, and the
|
|
287
|
+
} else if (current.includes('# mugiwara')) {
|
|
288
|
+
// legacy (pre-0.7): strip the exact undelimited block — the header, the
|
|
289
|
+
// explanatory comment, and the known lines. Prefix-matching would
|
|
248
290
|
// delete user-owned lines that merely start with a mugiwara path.
|
|
249
291
|
const LEGACY_LINES = new Set([
|
|
250
292
|
'.mugiwara/state.json',
|
|
251
293
|
'.mugiwara/state-*.json',
|
|
252
294
|
'.mugiwara/config',
|
|
295
|
+
'.mugiwara/state/',
|
|
296
|
+
'.mugiwara/continue/',
|
|
253
297
|
'.mugiwara/continue.md',
|
|
254
298
|
'.mugiwara/refs/',
|
|
255
299
|
]);
|
|
256
300
|
const lines = current.split('\n').filter(l => {
|
|
257
301
|
const t = l.trim();
|
|
258
|
-
if (t.startsWith(
|
|
302
|
+
if (t.startsWith('# mugiwara')) return false; // header + comment
|
|
259
303
|
return !LEGACY_LINES.has(t);
|
|
260
304
|
});
|
|
261
305
|
cleaned = lines.join('\n');
|
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/integrity.ts
|
|
2
|
+
// Closure integrity gate: the audit trail validates itself
|
|
3
|
+
// at archive time. Deterministic checks only — a violation fails the archive
|
|
4
|
+
// with an actionable message instead of shipping a broken or leaking artifact.
|
|
5
|
+
//
|
|
6
|
+
// Three checks:
|
|
7
|
+
// 1. Paths — every relative markdown link in the trail resolves to a file
|
|
8
|
+
// (mission-relative or repo-root).
|
|
9
|
+
// 2. Secrets — no trail file matches known secret shapes.
|
|
10
|
+
// 3. Evidence — cited wave/evidence paths exist.
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export type IntegrityIssue = { kind: 'dangling-path' | 'secret' | 'evidence' | 'evidence-thin'; detail: string };
|
|
15
|
+
|
|
16
|
+
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
|
17
|
+
[/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
|
|
18
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key block'],
|
|
19
|
+
[/gh[pousr]_[A-Za-z0-9]{20,}/, 'GitHub token'],
|
|
20
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
|
|
21
|
+
[/sk-[A-Za-z0-9]{32,}/, 'API key (sk-…)'],
|
|
22
|
+
[/eyJhbGciOi[A-Za-z0-9_.-]{20,}/, 'JWT pasted verbatim'],
|
|
23
|
+
[/(api[_-]?key|secret|passwd|password)\s*[=:]\s*["'][^"'\s]{8,}["']/i, 'credential assignment'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ALLOW_SECRET = 'mugiwara:allow-secret';
|
|
27
|
+
|
|
28
|
+
/** Secret shapes per line; a line carrying the allow marker is skipped — deliberate examples stay possible. */
|
|
29
|
+
function findSecrets(body: string): Array<{ label: string; hit: string }> {
|
|
30
|
+
const out: Array<{ label: string; hit: string }> = [];
|
|
31
|
+
for (const line of body.split(/\r?\n/)) {
|
|
32
|
+
if (line.includes(ALLOW_SECRET)) continue;
|
|
33
|
+
for (const [re, label] of SECRET_PATTERNS) {
|
|
34
|
+
const hit = line.match(re);
|
|
35
|
+
if (hit) out.push({ label, hit: hit[0] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TRAIL_EXTS = new Set(['.md', '.json', '.sh']);
|
|
42
|
+
|
|
43
|
+
function trailFiles(dir: string): string[] {
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
const walk = (d: string): void => {
|
|
46
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
47
|
+
const p = join(d, e.name);
|
|
48
|
+
if (e.isDirectory()) walk(p);
|
|
49
|
+
else if (TRAIL_EXTS.has(e.name.slice(e.name.lastIndexOf('.')) || '')) out.push(p);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(dir);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Relative markdown-link targets like `](../src/x.ts)` — skip URLs and anchors. */
|
|
57
|
+
function linkedPaths(md: string): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
|
|
60
|
+
const t = m[1];
|
|
61
|
+
if (/^(https?:|mailto:|#|\/\/)/.test(t)) continue;
|
|
62
|
+
out.push(t.split('#')[0]);
|
|
63
|
+
}
|
|
64
|
+
return out.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasCommandOutputShape(body: string): boolean {
|
|
68
|
+
return /`[^`]+`/.test(body) || /\b(exit\s+[01]|✓|✗|\bPASS\b|\bFAIL\b|passed|failed|\d+\s+(passed|failed))\b/i.test(body);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectPassCitedPaths(missionDir: string): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (const f of trailFiles(missionDir)) {
|
|
74
|
+
let body: string;
|
|
75
|
+
try { body = readFileSync(f, 'utf8'); } catch { continue; }
|
|
76
|
+
for (const line of body.split(/\r?\n/)) {
|
|
77
|
+
if (!/\bPASS\b/.test(line)) continue;
|
|
78
|
+
for (const p of linkedPaths(line)) out.push(p);
|
|
79
|
+
// also catch bare repo-path mentions like flows/04-gates.md or evidence/foo.md
|
|
80
|
+
for (const m of line.matchAll(/(?:^|[\s"'(])([a-zA-Z0-9._\/-]+\.(?:md|txt|log|json))\b/g)) {
|
|
81
|
+
const cand = m[1];
|
|
82
|
+
if (cand.includes('/')) out.push(cand);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...new Set(out)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function checkTrail(missionDir: string, projectRoot: string): IntegrityIssue[] {
|
|
90
|
+
const issues: IntegrityIssue[] = [];
|
|
91
|
+
const files = trailFiles(missionDir);
|
|
92
|
+
|
|
93
|
+
// 1 + 2: per-file link resolution and secret scan
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
let body: string;
|
|
96
|
+
try { body = readFileSync(f, 'utf8'); } catch { continue; }
|
|
97
|
+
for (const target of linkedPaths(body)) {
|
|
98
|
+
if (isAbsolute(target)) continue;
|
|
99
|
+
const fromMission = join(missionDir, target);
|
|
100
|
+
const fromRoot = join(projectRoot, target);
|
|
101
|
+
if (!existsSync(fromMission) && !existsSync(fromRoot)) {
|
|
102
|
+
issues.push({
|
|
103
|
+
kind: 'dangling-path',
|
|
104
|
+
detail: `${relative(projectRoot, f)} links "${target}" — no such file (mission dir or repo root)`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const { label, hit } of findSecrets(body)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
kind: 'secret',
|
|
111
|
+
detail: `${relative(projectRoot, f)} matches ${label}: ${hit.slice(0, 12)}…`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3: evidence entries recorded as repo paths must exist
|
|
117
|
+
const evidencePaths: string[] = [];
|
|
118
|
+
const evidenceFile = join(missionDir, 'state.json');
|
|
119
|
+
if (existsSync(evidenceFile)) {
|
|
120
|
+
try {
|
|
121
|
+
const s = JSON.parse(readFileSync(evidenceFile, 'utf8')) as { evidence?: unknown };
|
|
122
|
+
if (Array.isArray(s.evidence)) {
|
|
123
|
+
for (const e of s.evidence) {
|
|
124
|
+
if (typeof e !== 'string' || !e.trim()) continue;
|
|
125
|
+
evidencePaths.push(e);
|
|
126
|
+
const cand = join(projectRoot, e);
|
|
127
|
+
if (!isAbsolute(e) && !existsSync(cand) && !existsSync(join(missionDir, e))) {
|
|
128
|
+
issues.push({ kind: 'evidence', detail: `state.json evidence "${e}" does not exist` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch { /* corrupt state — the state reader owns that error */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4: evidence-content spot check (T7): a PASS verdict that cites an evidence
|
|
136
|
+
// path must point at a file that exists AND contains command-output shape
|
|
137
|
+
// (backticked command or exit-status token). Fake-but-consistent trails
|
|
138
|
+
// defeat existence-only checks — this raises the bar cheaply.
|
|
139
|
+
const passCited = collectPassCitedPaths(missionDir);
|
|
140
|
+
for (const e of passCited) {
|
|
141
|
+
if (!e.trim() || isAbsolute(e)) continue;
|
|
142
|
+
const candMission = join(missionDir, e);
|
|
143
|
+
const candRoot = join(projectRoot, e);
|
|
144
|
+
const resolved = existsSync(candMission) ? candMission : existsSync(candRoot) ? candRoot : null;
|
|
145
|
+
if (!resolved) continue; // already reported as evidence/dangling elsewhere
|
|
146
|
+
let body: string;
|
|
147
|
+
try { body = readFileSync(resolved, 'utf8'); } catch { continue; }
|
|
148
|
+
if (!hasCommandOutputShape(body)) {
|
|
149
|
+
issues.push({ kind: 'evidence-thin', detail: `evidence "${e}" exists but lacks command output (no backticked command or exit-status token)` });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return issues;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatIssues(issues: IntegrityIssue[]): string {
|
|
157
|
+
return issues.map((i) => ` ✗ [${i.kind}] ${i.detail}`).join('\n');
|
|
158
|
+
}
|