@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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +3 -9
  3. package/.codex-plugin/plugin.json +2 -8
  4. package/.cursor-plugin/plugin.json +2 -8
  5. package/.kimi-plugin/plugin.json +2 -8
  6. package/.opencode/commands/mugiwara-continue.md +44 -31
  7. package/.opencode/commands/mugiwara-review.md +1 -1
  8. package/.opencode/commands/mugiwara-security.md +1 -1
  9. package/.opencode/commands/mugiwara.md +2 -2
  10. package/.opencode/plugins/mugiwara.mjs +3 -19
  11. package/AGENTS.md +6 -6
  12. package/GEMINI.md +3 -3
  13. package/README.md +82 -74
  14. package/content/agents/brook-healing.md +12 -11
  15. package/content/agents/chopper-checkpoint.md +14 -13
  16. package/content/agents/eval-runner.md +6 -9
  17. package/content/agents/franky-gates.md +7 -10
  18. package/content/agents/jinbe-security.md +10 -14
  19. package/content/agents/luffy-orchestrator.md +20 -22
  20. package/content/agents/memory-keeper.md +9 -12
  21. package/content/agents/nami-planner.md +7 -10
  22. package/content/agents/resume-coordinator.md +12 -15
  23. package/content/agents/robin-reviewer.md +13 -12
  24. package/content/agents/sanji-quality.md +9 -12
  25. package/content/agents/skeptic-verifier.md +7 -10
  26. package/content/agents/usopp-brainstorm.md +6 -9
  27. package/content/agents/zoro-execution.md +9 -12
  28. package/content/skills/mugiwara-backend/SKILL.md +4 -0
  29. package/content/skills/mugiwara-backend/references/database.md +61 -0
  30. package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
  31. package/content/skills/mugiwara-checkpoint/SKILL.md +17 -14
  32. package/content/skills/mugiwara-checkpoint/references/ledger-format.md +5 -5
  33. package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
  34. package/content/skills/mugiwara-execution/SKILL.md +24 -24
  35. package/content/skills/mugiwara-execution/references/dispatch.md +3 -3
  36. package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
  37. package/content/skills/mugiwara-frontend/SKILL.md +5 -6
  38. package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
  39. package/content/skills/mugiwara-gates/SKILL.md +14 -10
  40. package/content/skills/mugiwara-git/SKILL.md +7 -2
  41. package/content/skills/mugiwara-healing/SKILL.md +7 -5
  42. package/content/skills/mugiwara-healing/references/workers.md +2 -2
  43. package/content/skills/mugiwara-lessons/SKILL.md +2 -2
  44. package/content/skills/mugiwara-orchestration/SKILL.md +28 -26
  45. package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
  46. package/content/skills/mugiwara-orchestration/references/closure.md +18 -13
  47. package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
  48. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +32 -22
  49. package/content/skills/mugiwara-planning/SKILL.md +5 -18
  50. package/content/skills/mugiwara-planning/references/plan-template.md +3 -3
  51. package/content/skills/mugiwara-quality/SKILL.md +10 -7
  52. package/content/skills/mugiwara-resume/SKILL.md +20 -22
  53. package/content/skills/mugiwara-review/SKILL.md +7 -2
  54. package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
  55. package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
  56. package/content/skills/mugiwara-security/SKILL.md +6 -2
  57. package/content/skills/mugiwara-ship/SKILL.md +2 -2
  58. package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
  59. package/content/skills/mugiwara-testcases/SKILL.md +5 -3
  60. package/content/skills/mugiwara-workflow/SKILL.md +32 -35
  61. package/content/skills/mugiwara-workflow/references/workspace-layout.md +41 -40
  62. package/dist/mugiwara.js +1370 -153
  63. package/gemini-extension.json +1 -1
  64. package/hooks/auto-savepoint.js +114 -0
  65. package/hooks/auto-savepoint.ts +102 -0
  66. package/hooks/engagement-marker.js +59 -0
  67. package/hooks/engagement-marker.ts +97 -0
  68. package/hooks/hooks.json +41 -2
  69. package/hooks/mugiwara-mode-tracker.js +79 -0
  70. package/hooks/pipeline-guard.js +186 -0
  71. package/hooks/pipeline-guard.ts +270 -0
  72. package/hooks/session-start.js +109 -0
  73. package/hooks/session-start.ts +62 -45
  74. package/package.json +7 -3
  75. package/plugin.json +1 -1
  76. package/references/agent-protocol.md +15 -0
  77. package/references/complexity.md +77 -0
  78. package/references/definition-of-done.md +1 -1
  79. package/references/multi-actor.md +4 -7
  80. package/references/prose-style.md +54 -0
  81. package/references/skill-versioning.md +1 -1
  82. package/references/wave-banners.md +16 -16
  83. package/scripts/build-hooks.ts +56 -0
  84. package/scripts/check-doc-links.ts +40 -0
  85. package/scripts/conformance.ts +23 -16
  86. package/scripts/coverage-gate.ts +193 -0
  87. package/scripts/gate-selftest.ts +118 -7
  88. package/scripts/install.ps1 +5 -4
  89. package/scripts/install.sh +4 -4
  90. package/scripts/lane.sh +56 -6
  91. package/scripts/lib/patterns.sh +89 -0
  92. package/scripts/policy-force.ts +22 -0
  93. package/scripts/retrieval-eval.ts +1 -1
  94. package/scripts/savepoint.sh +249 -67
  95. package/scripts/validate-content.ts +22 -9
  96. package/scripts/verify-install.ts +156 -44
  97. package/src/args.ts +2 -2
  98. package/src/budget.ts +56 -0
  99. package/src/cli.ts +272 -4
  100. package/src/continue.ts +262 -0
  101. package/src/installer.ts +59 -15
  102. package/src/integrity.ts +158 -0
  103. package/src/mission.ts +228 -95
  104. package/src/policy.ts +156 -0
  105. package/src/provenance.ts +116 -0
  106. package/src/rollback.ts +95 -0
  107. package/src/routing.ts +69 -0
  108. package/src/run.ts +82 -0
  109. package/src/sign.ts +61 -0
  110. package/src/targets/claude.ts +140 -2
  111. package/src/targets/copilot.ts +20 -1
  112. package/src/targets/generic.ts +9 -4
  113. package/src/targets/opencode.ts +0 -1
  114. package/.opencode/commands/mugiwara-execute.md +0 -13
  115. package/.opencode/commands/mugiwara-heal.md +0 -13
  116. package/.opencode/commands/mugiwara-onboard.md +0 -15
  117. package/.opencode/commands/mugiwara-plan.md +0 -17
  118. package/.opencode/commands/mugiwara-ship.md +0 -13
  119. package/.opencode/commands/using-mugiwara.md +0 -20
  120. package/content/agents/onboarding-guide.md +0 -124
  121. package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
  122. package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
  123. package/content/skills/mugiwara-context-budget/SKILL.md +0 -64
  124. package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
  125. package/content/skills/mugiwara-pr/SKILL.md +0 -69
  126. package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
  127. package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
  128. package/content/skills/mugiwara-sunset/SKILL.md +0 -82
  129. package/content/skills/using-mugiwara/SKILL.md +0 -51
  130. package/references/token-budget.md +0 -34
  131. package/scripts/evidence.sh +0 -81
  132. package/scripts/initiative.ts +0 -296
  133. package/scripts/mission-report.sh +0 -292
  134. package/scripts/onboard.ts +0 -266
  135. package/scripts/probe.ts +0 -40
@@ -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 marker from v0.6.2 block was undelimited, single '# mugiwara'
188
- // header. Kept for detection so an old install is not double-appended.
189
- const GITIGNORE_MARKER = '# mugiwara';
190
- const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
191
- # Ignore session state and regenerated files.
192
- .mugiwara/state/
193
- .mugiwara/continue/
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/state/')) {
248
+ if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/missions/**/*.json')) {
207
249
  return { appended: false, notes: [] };
208
250
  }
209
- // Legacy (v0.6.2) or outdated delimited block → upgrade in place:
210
- // strip the old mugiwara entries (both forms), then append the new block.
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(GITIGNORE_MARKER) || existing.includes(GITIGNORE_BLOCK_START);
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(GITIGNORE_MARKER)) {
246
- // legacy (v0.6.2): strip the exact undelimited block — the header, the
247
- // explanatory comment, and the five known lines. Prefix-matching would
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(GITIGNORE_MARKER)) return false; // header + comment
302
+ if (t.startsWith('# mugiwara')) return false; // header + comment
259
303
  return !LEGACY_LINES.has(t);
260
304
  });
261
305
  cleaned = lines.join('\n');
@@ -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
+ }