@ionivetech/mugiwara 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/mugiwara-helpers.mjs +1 -1
  7. package/.opencode/plugins/mugiwara.mjs +173 -1
  8. package/README.md +4 -4
  9. package/content/agents/luffy-orchestrator.md +15 -1
  10. package/content/agents/zoro-execution.md +1 -1
  11. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -0
  12. package/content/skills/mugiwara-execution/SKILL.md +5 -5
  13. package/content/skills/mugiwara-gates/SKILL.md +1 -0
  14. package/content/skills/mugiwara-healing/SKILL.md +1 -0
  15. package/content/skills/mugiwara-orchestration/SKILL.md +7 -3
  16. package/content/skills/mugiwara-orchestration/references/check-ins.md +4 -5
  17. package/content/skills/mugiwara-orchestration/references/output-contract.md +2 -2
  18. package/content/skills/mugiwara-planning/SKILL.md +1 -1
  19. package/content/skills/mugiwara-planning/references/sub-missions.md +2 -2
  20. package/content/skills/mugiwara-quality/SKILL.md +1 -0
  21. package/content/skills/mugiwara-review/SKILL.md +2 -0
  22. package/content/skills/mugiwara-security/SKILL.md +2 -2
  23. package/content/skills/mugiwara-ship/SKILL.md +12 -0
  24. package/content/skills/mugiwara-workflow/SKILL.md +2 -2
  25. package/dist/mugiwara.js +235 -82
  26. package/gemini-extension.json +1 -1
  27. package/hooks/engagement-marker.js +9 -1
  28. package/hooks/engagement-marker.ts +9 -1
  29. package/hooks/hooks.json +12 -0
  30. package/hooks/pipeline-guard.js +137 -3
  31. package/hooks/pipeline-guard.ts +161 -3
  32. package/hooks/pretool-guard.js +84 -0
  33. package/hooks/pretool-guard.ts +60 -0
  34. package/package.json +1 -1
  35. package/plugin.json +1 -1
  36. package/references/wave-banners.md +22 -27
  37. package/scripts/build-hooks.ts +1 -1
  38. package/scripts/gate-selftest.ts +342 -0
  39. package/scripts/savepoint.sh +16 -4
  40. package/scripts/validate-content.ts +190 -15
  41. package/scripts/write-metrics.ts +25 -1
  42. package/src/cli.ts +15 -0
  43. package/src/config.ts +1 -1
  44. package/src/guards.ts +40 -0
  45. package/src/initiative.ts +174 -0
  46. package/src/targets/claude.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mugiwara",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, healing.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -30,6 +30,8 @@ async function main() {
30
30
  let firstSeen = new Date().toISOString();
31
31
  let dispatchedAt = "";
32
32
  let plannedAt = "";
33
+ let bannerFlow = null;
34
+ let bannerFlowAt = "";
33
35
  if (existsSync(file)) {
34
36
  try {
35
37
  const prev = JSON.parse(readFileSync(file, "utf8"));
@@ -40,6 +42,10 @@ async function main() {
40
42
  dispatchedAt = prev.executor_dispatched_at;
41
43
  if (sameSession && typeof prev.planner_dispatched_at === "string")
42
44
  plannedAt = prev.planner_dispatched_at;
45
+ if (sameSession && typeof prev.last_banner_flow === "number")
46
+ bannerFlow = prev.last_banner_flow;
47
+ if (sameSession && typeof prev.last_banner_flow_at === "string")
48
+ bannerFlowAt = prev.last_banner_flow_at;
43
49
  } catch {}
44
50
  }
45
51
  if (dispatched)
@@ -51,7 +57,9 @@ async function main() {
51
57
  first_seen: firstSeen,
52
58
  touched_at: new Date().toISOString(),
53
59
  executor_dispatched_at: dispatchedAt,
54
- planner_dispatched_at: plannedAt
60
+ planner_dispatched_at: plannedAt,
61
+ last_banner_flow: bannerFlow,
62
+ last_banner_flow_at: bannerFlowAt
55
63
  }, null, 2) + `
56
64
  `);
57
65
  } catch {}
@@ -68,9 +68,11 @@ async function main(): Promise<void> {
68
68
  let firstSeen = new Date().toISOString();
69
69
  let dispatchedAt = '';
70
70
  let plannedAt = '';
71
+ let bannerFlow: number | null = null;
72
+ let bannerFlowAt = '';
71
73
  if (existsSync(file)) {
72
74
  try {
73
- const prev = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: string; first_seen?: string; executor_dispatched_at?: string; planner_dispatched_at?: string };
75
+ const prev = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: string; first_seen?: string; executor_dispatched_at?: string; planner_dispatched_at?: string; last_banner_flow?: number; last_banner_flow_at?: string };
74
76
  const sameSession = !sessionId || !prev.session_id || prev.session_id === sessionId;
75
77
  if (sameSession && typeof prev.first_seen === 'string') firstSeen = prev.first_seen;
76
78
  // A dispatch belongs to the session that made it. Carrying it into the
@@ -78,6 +80,10 @@ async function main(): Promise<void> {
78
80
  // because Zoro ran yesterday.
79
81
  if (sameSession && typeof prev.executor_dispatched_at === 'string') dispatchedAt = prev.executor_dispatched_at;
80
82
  if (sameSession && typeof prev.planner_dispatched_at === 'string') plannedAt = prev.planner_dispatched_at;
83
+ // A banner belongs to its session too — yesterday's banner must not
84
+ // silence today's missing one.
85
+ if (sameSession && typeof prev.last_banner_flow === 'number') bannerFlow = prev.last_banner_flow;
86
+ if (sameSession && typeof prev.last_banner_flow_at === 'string') bannerFlowAt = prev.last_banner_flow_at;
81
87
  } catch { /* corrupt marker — rewrite it */ }
82
88
  }
83
89
  if (dispatched) dispatchedAt = new Date().toISOString();
@@ -88,6 +94,8 @@ async function main(): Promise<void> {
88
94
  touched_at: new Date().toISOString(),
89
95
  executor_dispatched_at: dispatchedAt,
90
96
  planner_dispatched_at: plannedAt,
97
+ last_banner_flow: bannerFlow,
98
+ last_banner_flow_at: bannerFlowAt,
91
99
  }, null, 2) + '\n');
92
100
  } catch {
93
101
  // cannot write (read-only fs, permissions) — stay silent, guard stays off
package/hooks/hooks.json CHANGED
@@ -60,6 +60,18 @@
60
60
  }
61
61
  ]
62
62
  }
63
+ ],
64
+ "PreToolUse": [
65
+ {
66
+ "matcher": "Bash",
67
+ "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/pretool-guard.js",
71
+ "timeout": 10
72
+ }
73
+ ]
74
+ }
63
75
  ]
64
76
  }
65
77
  }
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // hooks/pipeline-guard.ts
5
- import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from "fs";
5
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync, writeFileSync } from "fs";
6
6
  import { execFileSync } from "child_process";
7
7
  import { homedir } from "os";
8
8
  import { join } from "path";
@@ -50,6 +50,44 @@ function sourceChanged() {
50
50
  return false;
51
51
  }
52
52
  }
53
+ function sessionStartFrom(markerFile) {
54
+ try {
55
+ const m = JSON.parse(readFileSync(markerFile, "utf8"));
56
+ return Date.parse(m.first_seen ?? "") || Date.parse(m.touched_at ?? "") || 0;
57
+ } catch {
58
+ return 0;
59
+ }
60
+ }
61
+ function artifactWorkNow() {
62
+ const markerFile = join(cwd, ".mugiwara", ".engaged");
63
+ if (!existsSync(markerFile))
64
+ return false;
65
+ const sessionStart = sessionStartFrom(markerFile);
66
+ if (!sessionStart)
67
+ return false;
68
+ try {
69
+ const stack = ["missions", "spec", "plans"].map((s) => join(cwd, ".mugiwara", s)).filter((p) => existsSync(p));
70
+ while (stack.length) {
71
+ const cur = stack.pop();
72
+ for (const e of readdirSync(cur, { withFileTypes: true })) {
73
+ const full = join(cur, e.name);
74
+ try {
75
+ if (e.isSymbolicLink())
76
+ continue;
77
+ if (e.isDirectory()) {
78
+ stack.push(full);
79
+ continue;
80
+ }
81
+ if (statSync(full).mtimeMs + 1000 >= sessionStart)
82
+ return true;
83
+ } catch {}
84
+ }
85
+ }
86
+ } catch {
87
+ return false;
88
+ }
89
+ return false;
90
+ }
53
91
  function newestMissionState() {
54
92
  const base = join(cwd, ".mugiwara", "missions");
55
93
  if (!existsSync(base))
@@ -141,6 +179,96 @@ function planTouched() {
141
179
  } catch {}
142
180
  return false;
143
181
  }
182
+ var TRANSCRIPT_BANNER_RE = /## .*Flow (\d+)\s*\u2014/g;
183
+ function extractBannerFlow(text) {
184
+ let best = 0;
185
+ for (const m of text.matchAll(TRANSCRIPT_BANNER_RE)) {
186
+ const n = Number(m[1]);
187
+ if (Number.isFinite(n) && n > best)
188
+ best = n;
189
+ }
190
+ return best;
191
+ }
192
+ function recordBannerFlow(flow, sessionId) {
193
+ const file = join(cwd, ".mugiwara", ".engaged");
194
+ try {
195
+ const prev = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
196
+ const sameSession = !sessionId || typeof prev.session_id !== "string" || prev.session_id === sessionId;
197
+ if (!sameSession)
198
+ return;
199
+ writeFileSync(file, JSON.stringify({
200
+ ...prev,
201
+ touched_at: new Date().toISOString(),
202
+ last_banner_flow: flow,
203
+ last_banner_flow_at: new Date().toISOString()
204
+ }, null, 2) + `
205
+ `);
206
+ } catch {}
207
+ }
208
+ function bannerRecorded(sessionId) {
209
+ const file = join(cwd, ".mugiwara", ".engaged");
210
+ if (!existsSync(file))
211
+ return false;
212
+ try {
213
+ const m = JSON.parse(readFileSync(file, "utf8"));
214
+ if (typeof m.last_banner_flow !== "number" || !m.last_banner_flow_at)
215
+ return false;
216
+ if (sessionId && m.session_id && m.session_id !== sessionId)
217
+ return false;
218
+ return Date.now() - (Date.parse(m.last_banner_flow_at) || 0) < MARKER_TTL_MS;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ function bannerFromTranscript(payload) {
224
+ const p = payload.transcript_path;
225
+ if (typeof p !== "string" || !p)
226
+ return 0;
227
+ try {
228
+ if (!existsSync(p))
229
+ return 0;
230
+ return extractBannerFlow(readFileSync(p, "utf8"));
231
+ } catch {
232
+ return 0;
233
+ }
234
+ }
235
+ function bannerThisSession() {
236
+ const markerFile = join(cwd, ".mugiwara", ".engaged");
237
+ if (!existsSync(markerFile))
238
+ return true;
239
+ const sessionStart = sessionStartFrom(markerFile);
240
+ if (!sessionStart)
241
+ return true;
242
+ const re = /^## .*Flow (\d+)\s*\u2014/m;
243
+ try {
244
+ const missionsDir = join(cwd, ".mugiwara", "missions");
245
+ for (const e of readdirSync(missionsDir, { withFileTypes: true })) {
246
+ if (!e.isDirectory())
247
+ continue;
248
+ const files = [`${join(missionsDir, e.name)}/decisions.md`];
249
+ const flowsDir = join(missionsDir, e.name, "flows");
250
+ if (existsSync(flowsDir)) {
251
+ for (const f of readdirSync(flowsDir)) {
252
+ if (f.endsWith(".md"))
253
+ files.push(join(flowsDir, f));
254
+ }
255
+ }
256
+ for (const f of files) {
257
+ try {
258
+ if (!existsSync(f))
259
+ continue;
260
+ if (statSync(f).mtimeMs + 1000 < sessionStart)
261
+ continue;
262
+ if (re.test(readFileSync(f, "utf8")))
263
+ return true;
264
+ } catch {}
265
+ }
266
+ }
267
+ } catch {
268
+ return true;
269
+ }
270
+ return false;
271
+ }
144
272
  async function main() {
145
273
  let input = "";
146
274
  for await (const chunk of process.stdin)
@@ -169,12 +297,18 @@ async function main() {
169
297
  process.stderr.write("\u26A0 Mugiwara: a plan doc (missions/<mission>/plan.md) was written this session, " + "but no planner (nami-planner / mugiwara-planning) was dispatched or embodied. " + "Only Nami writes the plan \u2014 dispatch nami-planner, or record the plan as a " + `deliberate exception in the decision log. Set enforce=off in .mugiwara/config to disable.
170
298
  `);
171
299
  }
300
+ const transcriptFlow = bannerFromTranscript(payload);
301
+ if (transcriptFlow > 0)
302
+ recordBannerFlow(transcriptFlow, sessionId);
303
+ if ((sourceChangedNow || planTouched()) && !bannerRecorded(sessionId) && !bannerThisSession()) {
304
+ process.stderr.write("\u26A0 Mugiwara: work recorded with no flow banner this session. The banner is the " + "only signal the user has that the pipeline ran. Open each stage with " + "`## <emoji> Flow N \u2014 Crew (Role)`.\n");
305
+ }
172
306
  return;
173
307
  }
174
308
  if (!state) {
175
- if (!sourceChangedNow)
309
+ if (!sourceChangedNow && !artifactWorkNow())
176
310
  return;
177
- const reason = "Mugiwara: source changed in this session but no Flow 0 triage is on disk. " + "Run Flow 0 (classify, size the lane, write the decision log) and record it with " + '`mugiwara savepoint <mission> "" 0 <mode>` \u2014 or, if this is Lane 0 trivial work, ' + "record a Lane 0 savepoint to say so. Set enforce=off in .mugiwara/config to disable this check.";
311
+ const reason = "Mugiwara: this session did work (source and/or .mugiwara artifacts) but no " + "Flow 0 triage is on disk. " + "Run Flow 0 (classify, size the lane, write the decision log) and record it with " + '`mugiwara savepoint <mission> "" 0 <mode>` \u2014 or, if this is Lane 0 trivial work, ' + "record a Lane 0 savepoint to say so. Set enforce=off in .mugiwara/config to disable this check.";
178
312
  if (enforce === "warn") {
179
313
  process.stderr.write(`\u26A0 ${reason}
180
314
  `);
@@ -19,7 +19,7 @@
19
19
  //
20
20
  // Fails OPEN on any internal error. A fence that can wedge a session gets
21
21
  // disabled by its users, and then it fences nothing.
22
- import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, lstatSync, writeFileSync } from 'node:fs';
23
23
  import { execFileSync } from 'node:child_process';
24
24
  import { homedir } from 'node:os';
25
25
  import { join } from 'node:path';
@@ -83,6 +83,53 @@ function sourceChanged(): boolean {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * Session anchor for the session-scoped scans: first_seen, else touched_at,
88
+ * else 0 (unknown). Shared by artifactWorkNow and bannerThisSession — one
89
+ * definition, not two copies. Fail open.
90
+ */
91
+ function sessionStartFrom(markerFile: string): number {
92
+ try {
93
+ const m = JSON.parse(readFileSync(markerFile, 'utf8')) as { first_seen?: string; touched_at?: string };
94
+ return Date.parse(m.first_seen ?? '') || Date.parse(m.touched_at ?? '') || 0;
95
+ } catch { return 0; }
96
+ }
97
+
98
+ /**
99
+ * Artifact work in this session: files written under .mugiwara/missions,
100
+ * .mugiwara/spec, or .mugiwara/plans since the session's first-seen marker.
101
+ * Work is not only source edits — a brainstorm that produced a spec, a plan,
102
+ * or a recommendation is work, and it escaped the source-only predicate. (E3)
103
+ * Session-scoped like planTouched: an absent/unreadable marker means untouched,
104
+ * never "everything counts". Fail open throughout.
105
+ */
106
+ function artifactWorkNow(): boolean {
107
+ const markerFile = join(cwd, '.mugiwara', '.engaged');
108
+ if (!existsSync(markerFile)) return false;
109
+ const sessionStart = sessionStartFrom(markerFile);
110
+ if (!sessionStart) return false;
111
+ try {
112
+ const stack = ['missions', 'spec', 'plans']
113
+ .map((s) => join(cwd, '.mugiwara', s))
114
+ .filter((p) => existsSync(p));
115
+ while (stack.length) {
116
+ const cur = stack.pop() as string;
117
+ for (const e of readdirSync(cur, { withFileTypes: true })) {
118
+ const full = join(cur, e.name);
119
+ try {
120
+ // Symlink check first: a symlink dirent reports isDirectory()
121
+ // false, so nesting this inside the directory branch never fires.
122
+ if (e.isSymbolicLink()) continue;
123
+ if (e.isDirectory()) { stack.push(full); continue; }
124
+ // 1s tolerance, same as planTouched (FS granularity / clock skew)
125
+ if (statSync(full).mtimeMs + 1000 >= sessionStart) return true;
126
+ } catch { /* unreadable entry — skip */ }
127
+ }
128
+ }
129
+ } catch { return false; }
130
+ return false;
131
+ }
132
+
86
133
  /**
87
134
  * The newest readable savepoint for any mission, or null when there is none.
88
135
  * Its existence IS the triage fact (check 1); its `lane` field is the input to
@@ -187,6 +234,99 @@ function planTouched(): boolean {
187
234
  return false;
188
235
  }
189
236
 
237
+ /**
238
+ * Flow-banner detection. The heading form is `## <emoji> Flow N — Crew (Role)`,
239
+ * so the match tolerates anything between `## ` and `Flow`. Warning only,
240
+ * never a block: banner detection matches response text, and a false block on
241
+ * a formatting variance is the outcome that gets the whole fence disabled.
242
+ */
243
+ // Transcripts are JSONL: the banner sits mid-line inside an escaped string,
244
+ // so the transcript scan is not line-anchored. Warning-only, so a discussion
245
+ // *about* a banner counting as one is benign.
246
+ const TRANSCRIPT_BANNER_RE = /## .*Flow (\d+)\s*—/g;
247
+
248
+ /** Highest flow stage announced in text, or 0 when no banner is present. */
249
+ function extractBannerFlow(text: string): number {
250
+ let best = 0;
251
+ for (const m of text.matchAll(TRANSCRIPT_BANNER_RE)) {
252
+ const n = Number(m[1]);
253
+ if (Number.isFinite(n) && n > best) best = n;
254
+ }
255
+ return best;
256
+ }
257
+
258
+ /**
259
+ * Record last_banner_flow in the engagement marker (schema owned by
260
+ * engagement-marker.ts — this only writes its two fields, same-session
261
+ * scoped like the dispatch facts). The marker itself never sees response
262
+ * text (its payload is tool input), so the Stop hook — whose payload may
263
+ * carry transcript_path — owns detection. Never throws.
264
+ */
265
+ function recordBannerFlow(flow: number, sessionId: string): void {
266
+ const file = join(cwd, '.mugiwara', '.engaged');
267
+ try {
268
+ const prev = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown> : {};
269
+ const sameSession = !sessionId || typeof prev.session_id !== 'string' || prev.session_id === sessionId;
270
+ if (!sameSession) return;
271
+ writeFileSync(file, JSON.stringify({
272
+ ...prev,
273
+ touched_at: new Date().toISOString(),
274
+ last_banner_flow: flow,
275
+ last_banner_flow_at: new Date().toISOString(),
276
+ }, null, 2) + '\n');
277
+ } catch { /* fail open */ }
278
+ }
279
+
280
+ /** A banner recorded for this session (fresh scan already stored, or stored earlier). */
281
+ function bannerRecorded(sessionId: string): boolean {
282
+ const file = join(cwd, '.mugiwara', '.engaged');
283
+ if (!existsSync(file)) return false;
284
+ try {
285
+ const m = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: string; last_banner_flow?: unknown; last_banner_flow_at?: string };
286
+ if (typeof m.last_banner_flow !== 'number' || !m.last_banner_flow_at) return false;
287
+ if (sessionId && m.session_id && m.session_id !== sessionId) return false;
288
+ return Date.now() - (Date.parse(m.last_banner_flow_at) || 0) < MARKER_TTL_MS;
289
+ } catch { return false; }
290
+ }
291
+
292
+ /** Highest banner in the Stop-hook transcript, or 0 when absent/unreadable. */
293
+ function bannerFromTranscript(payload: Record<string, unknown>): number {
294
+ const p = payload.transcript_path;
295
+ if (typeof p !== 'string' || !p) return 0;
296
+ try {
297
+ if (!existsSync(p)) return 0;
298
+ return extractBannerFlow(readFileSync(p, 'utf8'));
299
+ } catch { return 0; }
300
+ }
301
+ function bannerThisSession(): boolean {
302
+ const markerFile = join(cwd, '.mugiwara', '.engaged');
303
+ if (!existsSync(markerFile)) return true; // no session anchor — no opinion
304
+ const sessionStart = sessionStartFrom(markerFile);
305
+ if (!sessionStart) return true;
306
+ const re = /^## .*Flow (\d+)\s*—/m;
307
+ try {
308
+ const missionsDir = join(cwd, '.mugiwara', 'missions');
309
+ for (const e of readdirSync(missionsDir, { withFileTypes: true })) {
310
+ if (!e.isDirectory()) continue;
311
+ const files = [`${join(missionsDir, e.name)}/decisions.md`];
312
+ const flowsDir = join(missionsDir, e.name, 'flows');
313
+ if (existsSync(flowsDir)) {
314
+ for (const f of readdirSync(flowsDir)) {
315
+ if (f.endsWith('.md')) files.push(join(flowsDir, f));
316
+ }
317
+ }
318
+ for (const f of files) {
319
+ try {
320
+ if (!existsSync(f)) continue;
321
+ if (statSync(f).mtimeMs + 1000 < sessionStart) continue;
322
+ if (re.test(readFileSync(f, 'utf8'))) return true;
323
+ } catch { /* unreadable entry — skip */ }
324
+ }
325
+ }
326
+ } catch { return true; }
327
+ return false;
328
+ }
329
+
190
330
  async function main(): Promise<void> {
191
331
  let input = '';
192
332
  for await (const chunk of process.stdin) input += chunk;
@@ -242,6 +382,21 @@ async function main(): Promise<void> {
242
382
  'deliberate exception in the decision log. Set enforce=off in .mugiwara/config to disable.\n',
243
383
  );
244
384
  }
385
+ // --- check 4: the banner signal (warning only) -----------------------
386
+ // Work recorded with state on disk but no flow banner this session. The
387
+ // banner is the only visible signal the pipeline ran — its absence is how
388
+ // an off-pipeline session goes unnoticed. Warning, never a block.
389
+ // Signal order: fresh transcript scan (recorded to the marker) →
390
+ // marker's same-session record → mission-file fallback.
391
+ const transcriptFlow = bannerFromTranscript(payload);
392
+ if (transcriptFlow > 0) recordBannerFlow(transcriptFlow, sessionId);
393
+ if ((sourceChangedNow || planTouched()) && !bannerRecorded(sessionId) && !bannerThisSession()) {
394
+ process.stderr.write(
395
+ '⚠ Mugiwara: work recorded with no flow banner this session. The banner is the ' +
396
+ 'only signal the user has that the pipeline ran. Open each stage with ' +
397
+ '`## <emoji> Flow N — Crew (Role)`.\n',
398
+ );
399
+ }
245
400
  return;
246
401
  }
247
402
 
@@ -250,9 +405,12 @@ async function main(): Promise<void> {
250
405
  // the original invariant and it BLOCKS (the triage fact is a crisp on-disk
251
406
  // check, no absence-inference). Only fires when source actually changed.
252
407
  if (!state) {
253
- if (!sourceChangedNow) return;
408
+ // Any work at all — source edits OR artifacts written — with no triage on
409
+ // disk is the escape this guard exists to close. (E3)
410
+ if (!sourceChangedNow && !artifactWorkNow()) return;
254
411
  const reason =
255
- 'Mugiwara: source changed in this session but no Flow 0 triage is on disk. ' +
412
+ 'Mugiwara: this session did work (source and/or .mugiwara artifacts) but no ' +
413
+ 'Flow 0 triage is on disk. ' +
256
414
  'Run Flow 0 (classify, size the lane, write the decision log) and record it with ' +
257
415
  '`mugiwara savepoint <mission> "" 0 <mode>` — or, if this is Lane 0 trivial work, ' +
258
416
  'record a Lane 0 savepoint to say so. Set enforce=off in .mugiwara/config to disable this check.';
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // hooks/pretool-guard.ts
5
+ import { existsSync, readFileSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+
9
+ // src/guards.ts
10
+ var FORBIDDEN = [
11
+ [/\bgh\s+pr\s+(create|merge|ready)\b/, "opening or merging a PR"],
12
+ [/\bgh\s+release\s+create\b/, "creating a release"],
13
+ [/\bgit\s+merge\b/, "merging a branch"],
14
+ [/\bgit\s+push\b[^|;&]*\b(main|master|production|release)\b/, "pushing to a protected branch"],
15
+ [/\bgit\s+push\b[^|;&]*--force/, "force-pushing"],
16
+ [/\bnpm\s+publish\b|\byarn\s+publish\b|\bpnpm\s+publish\b/, "publishing a package"],
17
+ [/\bkubectl\s+(apply|delete|rollout)\b/, "changing a cluster"],
18
+ [/\bterraform\s+(apply|destroy)\b/, "changing infrastructure"],
19
+ [/\bdocker\s+push\b/, "pushing an image"],
20
+ [/\baws\s+\w+\s+(create|delete|update|put)\b/, "changing cloud resources"]
21
+ ];
22
+ function checkCommand(command) {
23
+ for (const [re, action] of FORBIDDEN) {
24
+ if (re.test(command))
25
+ return action;
26
+ }
27
+ return null;
28
+ }
29
+ function refusalMessage(action) {
30
+ return `Mugiwara: refusing to ${action}. The crew never creates a PR, merges, or ` + `deploys — the human does, from the branch and the verdict the crew hands over. ` + `Run it yourself, or set enforce=off in .mugiwara/config.`;
31
+ }
32
+
33
+ // hooks/pretool-guard.ts
34
+ var cwd = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
35
+ function readEnforce() {
36
+ for (const base of [cwd, homedir()]) {
37
+ if (!base)
38
+ continue;
39
+ const file = join(base, ".mugiwara", "config");
40
+ if (!existsSync(file))
41
+ continue;
42
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
43
+ const [k, v] = line.split("=").map((s) => s.trim());
44
+ if (k !== "enforce")
45
+ continue;
46
+ if (v === "off" || v === "warn" || v === "block")
47
+ return v;
48
+ process.stderr.write(`mugiwara: unknown enforce="${v}" in ${file}, using "block"
49
+ `);
50
+ return "block";
51
+ }
52
+ }
53
+ return "block";
54
+ }
55
+ async function main() {
56
+ let input = "";
57
+ for await (const chunk of process.stdin)
58
+ input += chunk;
59
+ let payload = {};
60
+ try {
61
+ payload = JSON.parse(input);
62
+ } catch {}
63
+ try {
64
+ const enforce = readEnforce();
65
+ if (enforce === "off")
66
+ return;
67
+ const toolInput = payload.tool_input ?? {};
68
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
69
+ if (!command)
70
+ return;
71
+ const action = checkCommand(command);
72
+ if (action) {
73
+ const reason = refusalMessage(action);
74
+ if (enforce === "warn") {
75
+ process.stderr.write(`\u26A0 ${reason}
76
+ `);
77
+ return;
78
+ }
79
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
80
+ return;
81
+ }
82
+ } catch {}
83
+ }
84
+ main().catch(() => {});
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ // hooks/pretool-guard.ts — PreToolUse on Bash: refuse irreversible commands.
3
+ //
4
+ // Luffy's rule 13 says the crew never creates a PR, merges, or deploys. That
5
+ // rule had zero mechanisms behind it; every other invariant in the repo has at
6
+ // least one. Prose enforcement measured 0-for-21 in this codebase. (E4)
7
+ //
8
+ // Fails OPEN on any internal error, like pipeline-guard: a fence that wedges a
9
+ // session gets disabled, and then it fences nothing.
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { checkCommand, refusalMessage } from '../src/guards.ts';
14
+
15
+ const cwd = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
16
+
17
+ type Enforce = 'off' | 'warn' | 'block';
18
+
19
+ function readEnforce(): Enforce {
20
+ // Same key as pipeline-guard (off | warn | block, default block).
21
+ for (const base of [cwd, homedir()]) {
22
+ if (!base) continue;
23
+ const file = join(base, '.mugiwara', 'config');
24
+ if (!existsSync(file)) continue;
25
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
26
+ const [k, v] = line.split('=').map((s) => s.trim());
27
+ if (k !== 'enforce') continue;
28
+ if (v === 'off' || v === 'warn' || v === 'block') return v;
29
+ process.stderr.write(`mugiwara: unknown enforce="${v}" in ${file}, using "block"\n`);
30
+ return 'block';
31
+ }
32
+ }
33
+ return 'block';
34
+ }
35
+
36
+ async function main(): Promise<void> {
37
+ let input = '';
38
+ for await (const chunk of process.stdin) input += chunk;
39
+ let payload: Record<string, unknown> = {};
40
+ try { payload = JSON.parse(input) as Record<string, unknown>; } catch { /* no payload — allow */ }
41
+ try {
42
+ const enforce = readEnforce();
43
+ if (enforce === 'off') return;
44
+ const toolInput = (payload.tool_input ?? {}) as Record<string, unknown>;
45
+ const command = typeof toolInput.command === 'string' ? toolInput.command : '';
46
+ if (!command) return;
47
+ const action = checkCommand(command);
48
+ if (action) {
49
+ const reason = refusalMessage(action);
50
+ if (enforce === 'warn') {
51
+ process.stderr.write(`⚠ ${reason}\n`);
52
+ return;
53
+ }
54
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
55
+ return;
56
+ }
57
+ } catch { /* fail open — never wedge a session */ }
58
+ }
59
+
60
+ main().catch(() => { /* a hook must never fail the turn */ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ionivetech/mugiwara",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, self-healing. Installs into Claude Code, opencode, Copilot, Gemini, Codex, Cursor, Kimi, pi, Windsurf, Cline, Kilo, Antigravity.",
5
5
  "homepage": "https://github.com/ionivetech/mugiwara#readme",
6
6
  "repository": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mugiwara",
3
3
  "description": "The Straw Hat crew of AI agents and skills: brainstorm, plan, execute, checkpoint, quality, gates, review, security, healing.",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "author": {
6
6
  "name": "ionivetech"
7
7
  },