@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.opencode/commands/mugiwara-onboard.md +15 -0
- package/.opencode/mugiwara-helpers.mjs +24 -0
- package/.opencode/plugins/mugiwara.mjs +16 -7
- package/AGENTS.md +1 -1
- package/README.md +118 -63
- package/content/agents/luffy-orchestrator.md +14 -4
- package/content/agents/onboarding-guide.md +24 -45
- package/content/agents/zoro-execution.md +4 -0
- package/content/skills/mugiwara-backend/SKILL.md +1 -1
- package/content/skills/mugiwara-brainstorm/SKILL.md +7 -0
- package/content/skills/mugiwara-checkpoint/SKILL.md +1 -1
- package/content/skills/mugiwara-claim-audit/SKILL.md +1 -1
- package/content/skills/mugiwara-execution/SKILL.md +44 -44
- package/content/skills/mugiwara-execution/references/dispatch.md +42 -0
- package/content/skills/mugiwara-frontend/SKILL.md +1 -1
- package/content/skills/mugiwara-healing/SKILL.md +1 -1
- package/content/skills/mugiwara-orchestration/SKILL.md +50 -49
- package/content/skills/mugiwara-orchestration/references/check-ins.md +34 -0
- package/content/skills/mugiwara-orchestration/references/closure.md +34 -0
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +12 -11
- package/content/skills/mugiwara-planning/SKILL.md +6 -0
- package/content/skills/mugiwara-pr/SKILL.md +18 -8
- package/content/skills/mugiwara-quality/SKILL.md +7 -0
- package/content/skills/mugiwara-ship/SKILL.md +11 -9
- package/content/skills/mugiwara-sunset/SKILL.md +1 -1
- package/content/skills/mugiwara-testcases/SKILL.md +7 -0
- package/content/skills/mugiwara-workflow/SKILL.md +4 -2
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +7 -6
- package/content/skills/using-mugiwara/SKILL.md +11 -1
- package/dist/mugiwara.js +186 -13
- package/gemini-extension.json +1 -1
- package/hooks/mugiwara-mode-tracker.ts +0 -0
- package/hooks/session-start.ts +0 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/evidence.sh +16 -1
- package/scripts/gate-selftest.ts +52 -1
- package/scripts/initiative.ts +34 -20
- package/scripts/lane.sh +4 -2
- package/scripts/mission-report.sh +152 -29
- package/scripts/onboard.ts +3 -29
- package/scripts/release-notes.ts +152 -75
- package/scripts/savepoint.sh +67 -15
- package/scripts/validate-content.ts +20 -0
- package/src/cli.ts +20 -3
- package/src/installer.ts +37 -1
- package/src/mission.ts +108 -1
- package/src/targets/claude.ts +29 -8
- package/src/targets/opencode.ts +12 -8
package/scripts/savepoint.sh
CHANGED
|
@@ -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|
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
136
|
-
|
|
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
|
+
}
|
package/src/targets/claude.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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)) {
|
package/src/targets/opencode.ts
CHANGED
|
@@ -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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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) {
|