@ionivetech/mugiwara 0.6.0 → 0.6.2

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 (54) 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/commands/mugiwara-onboard.md +15 -0
  7. package/.opencode/mugiwara-helpers.mjs +24 -0
  8. package/.opencode/plugins/mugiwara.mjs +16 -7
  9. package/AGENTS.md +1 -1
  10. package/README.md +118 -63
  11. package/content/agents/luffy-orchestrator.md +14 -4
  12. package/content/agents/onboarding-guide.md +24 -45
  13. package/content/agents/zoro-execution.md +4 -0
  14. package/content/skills/mugiwara-backend/SKILL.md +1 -1
  15. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -0
  16. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -1
  17. package/content/skills/mugiwara-claim-audit/SKILL.md +1 -1
  18. package/content/skills/mugiwara-execution/SKILL.md +44 -44
  19. package/content/skills/mugiwara-execution/references/dispatch.md +42 -0
  20. package/content/skills/mugiwara-frontend/SKILL.md +1 -1
  21. package/content/skills/mugiwara-healing/SKILL.md +1 -1
  22. package/content/skills/mugiwara-orchestration/SKILL.md +50 -49
  23. package/content/skills/mugiwara-orchestration/references/check-ins.md +34 -0
  24. package/content/skills/mugiwara-orchestration/references/closure.md +34 -0
  25. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +12 -11
  26. package/content/skills/mugiwara-planning/SKILL.md +6 -0
  27. package/content/skills/mugiwara-pr/SKILL.md +18 -8
  28. package/content/skills/mugiwara-quality/SKILL.md +7 -0
  29. package/content/skills/mugiwara-ship/SKILL.md +11 -9
  30. package/content/skills/mugiwara-sunset/SKILL.md +1 -1
  31. package/content/skills/mugiwara-testcases/SKILL.md +7 -0
  32. package/content/skills/mugiwara-workflow/SKILL.md +4 -2
  33. package/content/skills/mugiwara-workflow/references/workspace-layout.md +7 -6
  34. package/content/skills/using-mugiwara/SKILL.md +11 -1
  35. package/dist/mugiwara.js +186 -13
  36. package/gemini-extension.json +1 -1
  37. package/hooks/mugiwara-mode-tracker.ts +0 -0
  38. package/hooks/session-start.ts +0 -0
  39. package/package.json +1 -1
  40. package/plugin.json +1 -1
  41. package/scripts/evidence.sh +16 -1
  42. package/scripts/gate-selftest.ts +52 -1
  43. package/scripts/initiative.ts +34 -20
  44. package/scripts/lane.sh +4 -2
  45. package/scripts/mission-report.sh +152 -29
  46. package/scripts/onboard.ts +3 -29
  47. package/scripts/release-notes.ts +152 -75
  48. package/scripts/savepoint.sh +67 -15
  49. package/scripts/validate-content.ts +20 -0
  50. package/src/cli.ts +20 -3
  51. package/src/installer.ts +37 -1
  52. package/src/mission.ts +108 -1
  53. package/src/targets/claude.ts +29 -8
  54. package/src/targets/opencode.ts +12 -8
@@ -7,6 +7,14 @@ die() { echo "savepoint: $*" >&2; exit 1; }
7
7
 
8
8
  MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
9
9
 
10
+ # --- git identity for actor attribution ---
11
+ # Resolve once from repo git config; used when no actor is passed explicitly.
12
+ # GIT_AUTHOR_NAME is an env var usually unset — falling through to $USER
13
+ # attributes the savepoint to the OS user instead of the git identity.
14
+ GIT_NAME="$(git config user.name 2>/dev/null || true)"
15
+ GIT_EMAIL="$(git config user.email 2>/dev/null || true)"
16
+ if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL>"; else GIT_ID="$GIT_NAME"; fi
17
+
10
18
  # --- parse mission args ---
11
19
  # --branch flag must parse FIRST — it shifts positionals
12
20
  BRANCH_MODE=0
@@ -14,13 +22,13 @@ if [ "${1:-}" = "--branch" ]; then
14
22
  BRANCH_MODE=1
15
23
  shift
16
24
  MISSION="${1:-${STATE_MISSION:-}}"
17
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${USER:-}}}}"
25
+ ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}}"
18
26
  BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
19
27
  WAVE="${4:-${STATE_WAVE:-1}}"
20
28
  MODE="${5:-${STATE_MODE:-guided}}"
21
29
  else
22
30
  MISSION="${1:-${STATE_MISSION:-}}"
23
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${USER:-}}}}"
31
+ ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}}"
24
32
  BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
25
33
  WAVE="${4:-${STATE_WAVE:-1}}"
26
34
  MODE="${5:-${STATE_MODE:-guided}}"
@@ -31,6 +39,11 @@ fi
31
39
  case "$MISSION" in
32
40
  ""|*[!a-zA-Z0-9._-]*) die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-])" ;;
33
41
  esac
42
+ # dot-only names (".", "..", "...") pass the char allowlist but resolve upward
43
+ # through join(...,"..") — reject them before any path is built from MISSION.
44
+ if [[ "$MISSION" =~ ^\.+$ ]]; then
45
+ die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
46
+ fi
34
47
 
35
48
  # per-branch state file when --branch used
36
49
  BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
@@ -59,20 +72,35 @@ if [ "$BASE_SHA" != "unknown" ]; then
59
72
  fi
60
73
  [ -z "$LOC_DELTA" ] && LOC_DELTA=0
61
74
 
62
- SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env|config/|migration/|\.sql$|schema\.|\.prisma$"
75
+ SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env$|config/.*key|migration/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$"
63
76
  SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATTERNS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
64
77
 
65
78
  LANE="direct"
66
79
  LANE_REASON=""
67
- if [ "$FILES_TOUCHED" -ge 9 ] 2>/dev/null || [ -n "$SENSITIVE_PATHS" ]; then
80
+ # Sensitive-path escalation is unconditional it wins over any count-based
81
+ # lane AND over the docs-only downgrade below (bug C9).
82
+ if [ -n "$SENSITIVE_PATHS" ]; then
68
83
  LANE="full"
69
- LANE_REASON="$( [ -n "$SENSITIVE_PATHS" ] && echo "sensitive paths: $SENSITIVE_PATHS" || echo "$FILES_TOUCHED files")"
84
+ LANE_REASON="sensitive paths: $SENSITIVE_PATHS"
85
+ elif [ "$FILES_TOUCHED" -ge 9 ] 2>/dev/null; then
86
+ LANE="full"
87
+ LANE_REASON="$FILES_TOUCHED files"
70
88
  elif [ "$FILES_TOUCHED" -ge 3 ] 2>/dev/null; then
71
89
  LANE="standard"
72
90
  LANE_REASON="$FILES_TOUCHED files"
73
91
  elif [ "$FILES_TOUCHED" -ge 2 ] 2>/dev/null; then
74
92
  LANE="lean"
75
93
  LANE_REASON="$FILES_TOUCHED files"
94
+ elif [ "$FILES_TOUCHED" -eq 1 ] 2>/dev/null; then
95
+ # 1-file rule mirrors lane.sh: >=20 added LOC -> lean, else direct
96
+ ADDED=$(git diff --numstat "$BASE_SHA"..HEAD 2>/dev/null | awk '{s+=$1} END {print s+0}')
97
+ if [ "${ADDED:-0}" -ge 20 ] 2>/dev/null; then
98
+ LANE="lean"
99
+ LANE_REASON="1 file, $ADDED LOC"
100
+ else
101
+ LANE="direct"
102
+ LANE_REASON="1 file, <20 LOC"
103
+ fi
76
104
  else
77
105
  LANE="direct"
78
106
  LANE_REASON="$FILES_TOUCHED file(s) under 20 LOC"
@@ -82,7 +110,7 @@ fi
82
110
  # surface never escalate to full from file count alone; sensitive-path
83
111
  # escalation above still wins.
84
112
  PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
85
- if [ "$LANE" = "full" ] && [ -n "$CHANGED_FILES" ]; then
113
+ if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ]; then
86
114
  CODE_COUNT=$(echo "$CHANGED_FILES" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
87
115
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
88
116
  PREV="$LANE"
@@ -91,20 +119,26 @@ if [ "$LANE" = "full" ] && [ -n "$CHANGED_FILES" ]; then
91
119
  fi
92
120
  fi
93
121
 
94
- # task counts from plan doc
95
- PLAN_FILE=$(ls "$MUGIWARA_DIR/plans/${MISSION}.md" 2>/dev/null || true)
122
+ # task counts from plan doc — plan is written date-prefixed (plans/YYYY-MM-DD-<mission>.md)
123
+ # or bare (plans/<mission>.md); glob both, first match wins.
124
+ PLAN_FILE=$(ls "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md 2>/dev/null | head -1 || true)
96
125
  TASKS_DONE=0
97
126
  TASKS_TOTAL=0
98
127
  if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
99
- TASKS_TOTAL=$(grep -c '\[ \]' "$PLAN_FILE" 2>/dev/null || echo 0)
100
- TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || echo 0)
128
+ # total counts ALL task lines (checked + unchecked); done counts checked only.
129
+ # A fully-completed plan must read total=N done=N, never total=0 (the old
130
+ # unchecked-only grep degenerated a done plan to tasks.total=0).
131
+ TASKS_TOTAL=$(grep -cE '^\s*-\s*\[[ xX]\]' "$PLAN_FILE" 2>/dev/null || true)
132
+ TASKS_DONE=$(grep -c '\[x\]' "$PLAN_FILE" 2>/dev/null || true)
101
133
  fi
102
134
 
103
135
  # blocker count
104
136
  BLOCKERS_FILE=$(ls "$MUGIWARA_DIR/issues/${MISSION}-blockers.md" 2>/dev/null || true)
105
137
  BLOCKERS_OPEN=0
106
138
  if [ -n "$BLOCKERS_FILE" ] && [ -f "$BLOCKERS_FILE" ]; then
107
- BLOCKERS_OPEN=$(grep -c '|' "$BLOCKERS_FILE" 2>/dev/null || echo 0)
139
+ # data rows start with a wave number; header "| wave |" and separator
140
+ # "|---|" are excluded by the ^\| ?[0-9]+ \| pattern
141
+ BLOCKERS_OPEN=$(grep -cE '^\| ?[0-9]+ ?\|' "$BLOCKERS_FILE" 2>/dev/null || true)
108
142
  fi
109
143
 
110
144
  # heal cycle — count WAVE-8 banner occurrences in the trace, not the word
@@ -113,7 +147,7 @@ fi
113
147
  HEAL_CYCLE=1
114
148
  TRACE_FILE=$(ls "$MUGIWARA_DIR/results/${MISSION}/"*trace*.md 2>/dev/null | head -1 || true)
115
149
  if [ -n "$TRACE_FILE" ] && [ -f "$TRACE_FILE" ]; then
116
- HEAL_COUNT=$(grep -ci '^.*Wave 8.*\|wave 8' "$TRACE_FILE" 2>/dev/null || echo 0)
150
+ HEAL_COUNT=$(grep -ci '^.*Wave 8.*\|wave 8' "$TRACE_FILE" 2>/dev/null || true)
117
151
  HEAL_CYCLE=$((HEAL_COUNT + 1))
118
152
  fi
119
153
 
@@ -132,8 +166,25 @@ if [ -f package.json ]; then
132
166
  [ -f "$PKG_JSON" ] && SKILL_VERSION=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version.split('.')[0])}catch(e){console.log('1')}" "$PKG_JSON" 2>/dev/null || echo "1")
133
167
  fi
134
168
 
135
- # tokens from env var (harness exports estimated tokens consumed)
136
- TOKENS_EST=${MUGIWARA_TOKENS:-0}
169
+ # tokens proxy (F7): deterministic estimate when the harness does not report
170
+ # real usage. Monotonic beats precise — LANE_BASE stands in for the skills
171
+ # loaded this lane; loc_delta and written-artifact words scale with growth.
172
+ # MUGIWARA_TOKENS overrides as the reported value.
173
+ LANE_BASE=0
174
+ case "$LANE" in
175
+ lean) LANE_BASE=1500 ;;
176
+ standard) LANE_BASE=4000 ;;
177
+ full) LANE_BASE=9000 ;;
178
+ spike) LANE_BASE=1000 ;;
179
+ esac
180
+ DOC_WORDS=$(cat "$MUGIWARA_DIR"/results/${MISSION}/*.md "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md "$MUGIWARA_DIR"/spec/${MISSION}.md "$MUGIWARA_DIR"/spec/*-${MISSION}.md "$MUGIWARA_DIR"/logs/${MISSION}.md "$MUGIWARA_DIR"/logs/*-${MISSION}.md 2>/dev/null | wc -w | tr -d ' ')
181
+ LOC_TOKENS=$(( LOC_DELTA > 0 ? LOC_DELTA * 12 : 0 ))
182
+ TOKENS_SOURCE="computed"
183
+ TOKENS_EST=$(( LANE_BASE + DOC_WORDS * 135 / 100 + LOC_TOKENS ))
184
+ if [ -n "${MUGIWARA_TOKENS:-}" ]; then
185
+ TOKENS_EST="${MUGIWARA_TOKENS}"
186
+ TOKENS_SOURCE="reported"
187
+ fi
137
188
 
138
189
  # budget per lane
139
190
  BUDGET=0
@@ -191,6 +242,7 @@ const data = {
191
242
  blockers_open: parseInt(process.argv[15], 10),
192
243
  heal_cycle: parseInt(process.argv[16], 10),
193
244
  tokens_est: parseInt(process.argv[17], 10) || 0,
245
+ tokens_source: process.argv[26] || 'computed',
194
246
  budget: parseInt(process.argv[18], 10) || 0,
195
247
  budget_status: process.argv[19],
196
248
  skill_version: process.argv[20],
@@ -205,7 +257,7 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
205
257
  "$BLOCKERS_OPEN" "$HEAL_CYCLE" "$TOKENS_EST" "$BUDGET" \
206
258
  "$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
207
259
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
208
- "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE"
260
+ "$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE"
209
261
 
210
262
  if [ "$LANE_ROSE" = true ]; then
211
263
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
@@ -27,6 +27,7 @@ function checkFile(file: string, wantName: string, kind: 'skill' | 'agent'): Rec
27
27
  if (bullets.length === 0) errors.push(`skill ${file}: "## Skip when" needs ≥1 bullet`);
28
28
  if (bullets.length > 4) errors.push(`skill ${file}: "## Skip when" block exceeds 4 bullets`);
29
29
  }
30
+ if (kind === 'skill' && !body.includes('## Red flags')) errors.push(`skill ${file}: missing required "## Red flags" block`);
30
31
  if (kind === 'skill') {
31
32
  const lines = body.split(/\r?\n/);
32
33
  const headingRe = /^## /;
@@ -145,6 +146,25 @@ for (const f of agentFiles) {
145
146
  if (scope === 'source' && !SOURCE_SCOPED.has(name)) errors.push(`agent ${f}: only zoro-execution and brook-healing may declare write-scope: source`);
146
147
  }
147
148
 
149
+ // --- write-boundary gate (W1): the hub skill must carry the refusal rule ---
150
+ const hubFile = join(root, 'skills', 'mugiwara-orchestration', 'SKILL.md');
151
+ if (existsSync(hubFile) && !readFileSync(hubFile, 'utf8').includes('## Write boundary')) {
152
+ errors.push('skill mugiwara-orchestration: missing "## Write boundary" section (non-executors refuse source writes)');
153
+ }
154
+
155
+ // --- agent-count gate (F14): user-facing/internal split derivable from content/agents/ ---
156
+ const internalAgents = agentFiles.filter(f => {
157
+ const parsed = parseFrontmatter(readFileSync(join(agentDir, f), 'utf8'));
158
+ return parsed.data.internal === 'true';
159
+ });
160
+ const canonicalCount = `${agentFiles.length - internalAgents.length} agents (+${internalAgents.length} internal)`;
161
+ for (const doc of ['README.md', 'docs/index.md', 'docs/concepts/agents.md']) {
162
+ const p = join(import.meta.dirname, '..', doc);
163
+ if (existsSync(p) && !readFileSync(p, 'utf8').includes(canonicalCount)) {
164
+ errors.push(`${doc}: missing canonical agent count "${canonicalCount}"`);
165
+ }
166
+ }
167
+
148
168
  // --- hub-rule gate (F3): every non-Luffy agent carries both hub sections ---
149
169
  for (const f of agentFiles) {
150
170
  const name = f.replace(/\.md$/, '');
package/src/cli.ts CHANGED
@@ -7,15 +7,15 @@ import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs, type FlagValue, type Args } from './args.ts';
8
8
  import { createRl, choose, multiChoose, confirm } from './prompt.ts';
9
9
  import { targets, TARGET_IDS } from './targets/index.ts';
10
- import { installTo, removeInstalled, VERSION } from './installer.ts';
10
+ import { installTo, removeInstalled, VERSION, ensureProjectGitignore } from './installer.ts';
11
11
  import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
12
- import { resetMission } from './mission.ts';
12
+ import { resetMission, archiveMission } from './mission.ts';
13
13
 
14
14
  const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
15
15
  const flag = (v: FlagValue): boolean => v === true;
16
16
 
17
17
  export async function run(argv: string[]): Promise<void> {
18
- const { command, flags } = parseArgs(argv);
18
+ const { command, flags, _ } = parseArgs(argv);
19
19
  if (flag(flags.help) || command === 'help') return help();
20
20
  if (flag(flags.version)) { console.log(`mugiwara ${VERSION}`); return; }
21
21
  switch (command) {
@@ -24,6 +24,7 @@ export async function run(argv: string[]): Promise<void> {
24
24
  case 'uninstall': return uninstall(flags);
25
25
  case 'list': return list(flags);
26
26
  case 'reset': return resetCmd(flags);
27
+ case 'archive': return archive(flags, _);
27
28
  default: throw new Error(`Unknown command: ${command}`);
28
29
  }
29
30
  }
@@ -41,6 +42,17 @@ function resetCmd(flags: Args['flags']): void {
41
42
  if (result.kept.length) console.log(`kept: ${result.kept.join(', ')}`);
42
43
  }
43
44
 
45
+ function archive(flags: Args['flags'], positionals: string[]): void {
46
+ const projectDir = resolve(str(flags.project) ?? process.cwd());
47
+ const mission = positionals[1];
48
+ if (!mission) { console.error('usage: mugiwara archive <mission> [--project <dir>] [--dry-run]'); process.exit(1); }
49
+ const result = archiveMission(projectDir, mission, { dryRun: flag(flags.dryRun) });
50
+ if (result.report) console.log(`archive target: ${result.report}`);
51
+ if (result.removed.length) console.log(`${flag(flags.dryRun) ? 'would remove' : 'removed'}: ${result.removed.join(', ')}`);
52
+ if (result.kept.length) console.log(`kept: ${result.kept.join(', ')}`);
53
+ if (result.index) console.log(`index updated: ${result.index}`);
54
+ }
55
+
44
56
  async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; projectDir: string; targetIds: string[] }> {
45
57
  const interactive = !flag(flags.yes);
46
58
  if (interactive && !process.stdin.isTTY) {
@@ -95,6 +107,10 @@ async function install(flags: Args['flags']): Promise<void> {
95
107
  allFiles.push(...r.written);
96
108
  allNotes.push(...r.notes);
97
109
  }
110
+ if (scope === 'project') {
111
+ const gi = ensureProjectGitignore(projectDir, { dryRun: flag(flags.dryRun) });
112
+ allNotes.push(...gi.notes);
113
+ }
98
114
  if (flag(flags.dryRun)) { console.log('\nDry run — nothing written.'); return; }
99
115
  const file = manifestPath({ scope, projectDir, home });
100
116
  const prev = readManifest(file);
@@ -182,6 +198,7 @@ Usage:
182
198
  mugiwara list show installations
183
199
  mugiwara list --check health check: show installations + missing files
184
200
  mugiwara reset wipe mission state (spec/plans/results/review/issues[/logs])
201
+ mugiwara archive <m> fold a closed mission's evidence into its report, then remove loose files
185
202
  mugiwara --help this help
186
203
  mugiwara --version print version
187
204
 
package/src/installer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/installer.ts
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, lstatSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
@@ -171,3 +171,39 @@ export function removeInstalled(manifest: { files: string[] }, { dryRun = false
171
171
  }
172
172
  return removed;
173
173
  }
174
+
175
+ function assertNotSymlink(file: string): void {
176
+ if (!existsSync(file)) return;
177
+ try {
178
+ if (lstatSync(file).isSymbolicLink()) throw new Error(`refusing to follow symlink: ${file}`);
179
+ } catch (e) {
180
+ if ((e as { code?: string }).code === 'ENOENT') return;
181
+ throw e;
182
+ }
183
+ }
184
+
185
+ const GITIGNORE_MARKER = '# mugiwara';
186
+ const GITIGNORE_BLOCK = `# mugiwara — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
187
+ # Ignore session state and regenerated files.
188
+ .mugiwara/state.json
189
+ .mugiwara/state-*.json
190
+ .mugiwara/config
191
+ .mugiwara/continue.md
192
+ .mugiwara/refs/
193
+ `;
194
+
195
+ export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: boolean } = {}): { appended: boolean; notes: string[] } {
196
+ const { dryRun = false } = opts;
197
+ const path = join(projectDir, '.gitignore');
198
+ assertNotSymlink(path);
199
+ if (existsSync(path) && readFileSync(path, 'utf8').includes(GITIGNORE_MARKER)) {
200
+ return { appended: false, notes: [] };
201
+ }
202
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
203
+ const separator = existing.length && !existing.endsWith('\n') ? '\n' : '';
204
+ if (!dryRun) {
205
+ mkdirSync(dirname(path), { recursive: true });
206
+ writeFileSync(path, existing + separator + GITIGNORE_BLOCK);
207
+ }
208
+ return { appended: true, notes: [`.gitignore ${dryRun ? 'would append' : 'appended'} mugiwara audit-trail block`] };
209
+ }
package/src/mission.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/mission.ts
2
2
  // Mission-state helpers for the mugiwara CLI (installer + reset only).
3
- import { existsSync, rmSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  function activeActor(projectDir: string): string | null {
@@ -48,3 +48,110 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
48
48
  }
49
49
  return { removed, kept };
50
50
  }
51
+
52
+ export function archiveMission(projectDir: string, mission: string, opts: { dryRun?: boolean } = {}): { report: string | null; removed: string[]; kept: string[]; index?: string } {
53
+ const { dryRun = false } = opts;
54
+ const root = join(projectDir, '.mugiwara');
55
+ // mission allowlist — same as savepoint.sh / mission-report.sh. Dot-only
56
+ // names (".", "..") would resolve upward through join(...,"..") and let
57
+ // rmSync reach state.json/config outside the mission dir.
58
+ if (!mission || /[^a-zA-Z0-9._-]/.test(mission) || /^\.+$/.test(mission)) throw new Error(`invalid mission name "${mission}" (allowlist: [a-zA-Z0-9._-], not a dot-path)`);
59
+ const removed: string[] = [];
60
+ const kept: string[] = [];
61
+
62
+ // A file belongs to this mission when stripping the optional YYYY-MM-DD-
63
+ // prefix leaves `<mission>.md` or `<mission>-<suffix>.md`. Covers both the
64
+ // bare names and the date-prefixed names the prose writes (audit-trail.md).
65
+ const belongs = (f: string): boolean => {
66
+ const base = f.replace(/^\d{4}-\d{2}-\d{2}-/, '');
67
+ return base === `${mission}.md` || base.startsWith(`${mission}-`);
68
+ };
69
+
70
+ // locate the report (the archive target that must survive). Reports are
71
+ // date-prefixed (`reports/YYYY-MM-DD-<mission>.md`); compare the stripped
72
+ // mission name so `bar-foo.md` is not mistaken for mission `foo`.
73
+ let report: string | null = null;
74
+ const reportsDir = join(root, 'reports');
75
+ if (existsSync(reportsDir)) {
76
+ const f = readdirSync(reportsDir).find(n => {
77
+ const m = n.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
78
+ return !!m && m[2] === mission;
79
+ });
80
+ if (f) report = join('reports', f);
81
+ }
82
+
83
+ // step results 01..05 + todos.md are evidence — kept; archive removes
84
+ // only spec/review/issues/logs/continue.md
85
+ const resultsDir = join(root, 'results', mission);
86
+ if (existsSync(resultsDir)) {
87
+ for (const f of readdirSync(resultsDir)) {
88
+ kept.push(join('results', mission, f));
89
+ }
90
+ }
91
+
92
+ // spec, review, issues, per-mission decision log — bare + date-prefixed
93
+ const specDir = join(root, 'spec');
94
+ if (existsSync(specDir)) {
95
+ for (const f of readdirSync(specDir)) {
96
+ if (!belongs(f)) continue;
97
+ const p = join(specDir, f);
98
+ if (!dryRun) rmSync(p);
99
+ removed.push(join('spec', f));
100
+ }
101
+ }
102
+
103
+ for (const dir of ['review', 'issues']) {
104
+ const d = join(root, dir);
105
+ if (!existsSync(d)) continue;
106
+ for (const f of readdirSync(d)) {
107
+ if (!belongs(f)) continue;
108
+ const p = join(d, f);
109
+ if (!dryRun) rmSync(p, { force: true });
110
+ removed.push(join(dir, f));
111
+ }
112
+ }
113
+
114
+ const logsDir = join(root, 'logs');
115
+ if (existsSync(logsDir)) {
116
+ for (const f of readdirSync(logsDir)) {
117
+ if (!belongs(f)) continue;
118
+ const p = join(logsDir, f);
119
+ if (!dryRun) rmSync(p);
120
+ removed.push(join('logs', f));
121
+ }
122
+ }
123
+
124
+ // continue.md is a session handoff — only remove it if it belongs to THIS
125
+ // mission (its content references the mission name); otherwise leave it.
126
+ const cont = join(root, 'continue.md');
127
+ if (existsSync(cont)) {
128
+ try {
129
+ if (readFileSync(cont, 'utf8').includes(mission)) {
130
+ if (!dryRun) rmSync(cont);
131
+ removed.push('continue.md');
132
+ }
133
+ } catch { /* unreadable — leave it */ }
134
+ }
135
+
136
+ // kept: report + the audit-trail survivors
137
+ if (report) kept.push(report);
138
+ for (const k of ['plans', 'config', 'state.json', join('logs', 'lessons.md')]) {
139
+ if (existsSync(join(root, k))) kept.push(k);
140
+ }
141
+
142
+ // summary index: append one line per archived mission (retention aid),
143
+ // idempotently — never duplicate a line for an already-indexed mission.
144
+ let index: string | undefined;
145
+ const indexFile = join(root, 'reports', 'index.md');
146
+ const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}${report ? ` → ${report}` : ''}\n`;
147
+ if (!dryRun) {
148
+ mkdirSync(join(root, 'reports'), { recursive: true });
149
+ const existing = existsSync(indexFile) ? readFileSync(indexFile, 'utf8') : '';
150
+ if (!existing.split(/\r?\n/).some(l => l.startsWith(`- ${mission} —`))) {
151
+ const header = existing ? '' : '# Mission index\n\n';
152
+ appendFileSync(indexFile, header + line);
153
+ }
154
+ index = join('reports', 'index.md');
155
+ }
156
+ return { report, removed, kept, index };
157
+ }
@@ -6,9 +6,19 @@ import { stringifyFrontmatter, type FrontmatterData } from '../frontmatter.ts';
6
6
  import type { Target } from '../installer.ts';
7
7
 
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
- const HOOK_SRC = join(here, '..', '..', 'hooks', 'session-start.ts');
9
+ const HOOKS_SRC = join(here, '..', '..', 'hooks');
10
10
  const COMMANDS_SRC = join(here, '..', '..', '.claude', 'commands');
11
11
 
12
+ // Claude Code has no path-scoped permission. write-scope maps to a partial
13
+ // `tools:` list — applied to internal subagent-only agents only: artifacts
14
+ // agents lose Edit (cannot modify existing source) but keep Write (must create
15
+ // .mugiwara/**). User-facing crew run inline in the main thread and keep the
16
+ // default toolset (incl. Edit); discipline is enforced by rules, not tools.
17
+ function toolsFromScope(scope?: string): string | undefined {
18
+ if (scope === 'artifacts') return 'Read, Grep, Glob, Write, Bash, WebFetch, WebSearch';
19
+ return undefined;
20
+ }
21
+
12
22
  export const target: Target = {
13
23
  id: 'claude',
14
24
  label: 'Claude Code',
@@ -27,6 +37,10 @@ export const target: Target = {
27
37
  transformAgent(data: FrontmatterData, body: string) {
28
38
  const fm: FrontmatterData = { name: data.name, description: data.description };
29
39
  if (data.tools) fm.tools = data.tools;
40
+ else if (data['internal-agent'] === 'true') {
41
+ const generated = toolsFromScope(data['write-scope']);
42
+ if (generated) fm.tools = generated;
43
+ }
30
44
  return { relPath: `${data.name}.md`, text: stringifyFrontmatter(fm, body) };
31
45
  },
32
46
  refsDir({ scope, projectDir, home }, skillName: string) {
@@ -34,17 +48,24 @@ export const target: Target = {
34
48
  return join(root, 'skills', skillName, 'references');
35
49
  },
36
50
  postInstall({ scope, projectDir, home, dryRun }) {
37
- // Wire the SessionStart hook (inline doctrine) into the installed .claude dir.
51
+ // Wire hook scripts (SessionStart + UserPromptSubmit) into the installed .claude dir.
38
52
  const root = scope === 'global' ? join(home, '.claude') : join(projectDir, '.claude');
39
- const hookFile = join(root, 'hooks', 'session-start.ts');
40
53
  const written: string[] = [];
41
54
  const notes: string[] = [];
42
55
  if (dryRun) return { written: [], notes: [] };
43
- if (existsSync(HOOK_SRC) && !existsSync(hookFile)) {
44
- mkdirSync(dirname(hookFile), { recursive: true });
45
- copyFileSync(HOOK_SRC, hookFile);
46
- chmodSync(hookFile, 0o755);
47
- written.push(hookFile);
56
+ if (existsSync(HOOKS_SRC)) {
57
+ for (const f of readdirSync(HOOKS_SRC)) {
58
+ if (!f.endsWith('.ts')) continue;
59
+ const dst = join(root, 'hooks', f);
60
+ if (!existsSync(dst)) {
61
+ mkdirSync(dirname(dst), { recursive: true });
62
+ copyFileSync(join(HOOKS_SRC, f), dst);
63
+ // /bin/sh executes hooks via shebang — a non-executable copy is a
64
+ // "Permission denied" at first user prompt. chmod every hook file.
65
+ chmodSync(dst, 0o755);
66
+ written.push(dst);
67
+ }
68
+ }
48
69
  }
49
70
  // Port the /mugiwara commands into the installed .claude dir.
50
71
  if (existsSync(COMMANDS_SRC)) {
@@ -32,23 +32,27 @@ const CREW: Record<string, CrewConfig> = {
32
32
  'memory-keeper': { color: '#d946ef', temperature: 0.2, steps: 8 },
33
33
  };
34
34
 
35
- // write-scope is the single source of truth (content/agents/*.md frontmatter).
36
- // The path boundary (artifacts vs source) IS expressible in opencode:
37
- // permission.edit accepts glob/pattern -> action, last match wins. Artifacts
38
- // agents get deny-all-edit except .mugiwara/**; source agents (zoro, brook)
39
- // get full edit allow. Derived at install time from the frontmatter field.
35
+ // write-scope is the single source of truth (content/agents/*.md frontmatter),
36
+ // but it is a RULE for user-facing crew agents: they run inline in the main
37
+ // thread, so a runtime permission bound to the active-agent identity would
38
+ // force tab-switching per wave and break auto mode + resume. Runtime
39
+ // enforcement stays for internal subagent-only agents (internal: true) the
40
+ // path boundary IS expressible in opencode: permission.edit accepts
41
+ // glob/pattern -> action, last match wins. Artifacts internal agents get
42
+ // deny-all-edit except .mugiwara/**; source internal agents get full edit
43
+ // allow. Derived at install time from the frontmatter field.
40
44
  function permissionFromScope(scope: string | undefined): Record<string, string | Record<string, string>> | undefined {
41
45
  if (scope === 'source') return { edit: 'allow' };
42
46
  if (scope === 'artifacts') return { edit: { '*': 'deny', '.mugiwara/**': 'allow' } };
43
47
  return undefined;
44
48
  }
45
49
 
46
- function agentFrontmatter(name: string, description: string, writeScope?: string) {
50
+ function agentFrontmatter(name: string, description: string, internal: boolean, writeScope?: string) {
47
51
  const crew = CREW[name];
48
52
  const lines = [`description: ${description}`, `mode: all`];
49
53
  if (crew) {
50
54
  lines.push(`color: '${crew.color}'`, `temperature: ${crew.temperature}`, `steps: ${crew.steps}`);
51
- const perm = permissionFromScope(writeScope);
55
+ const perm = internal ? permissionFromScope(writeScope) : undefined;
52
56
  if (perm) {
53
57
  lines.push('permission:');
54
58
  for (const [k, v] of Object.entries(perm)) {
@@ -81,7 +85,7 @@ export const target: Target = {
81
85
  },
82
86
  transformAgent(data: FrontmatterData, body: string) {
83
87
  const desc = data['internal-agent'] === 'true' ? `[INTERNAL] ${data.description}` : data.description;
84
- const fm = agentFrontmatter(data.name, desc, data['write-scope']);
88
+ const fm = agentFrontmatter(data.name, desc, data['internal-agent'] === 'true', data['write-scope']);
85
89
  return { relPath: `${data.name}.md`, text: `---\n${fm}\n---\n${body}` };
86
90
  },
87
91
  refsDir({ scope, projectDir, home }, skillName: string) {