@ionivetech/mugiwara 0.6.2 → 0.6.4
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-continue.md +42 -10
- package/.opencode/commands/mugiwara-execute.md +1 -1
- package/.opencode/commands/mugiwara-heal.md +1 -1
- package/.opencode/commands/mugiwara-plan.md +1 -1
- package/.opencode/commands/mugiwara-review.md +1 -1
- package/.opencode/commands/mugiwara-security.md +1 -1
- package/.opencode/commands/mugiwara-ship.md +1 -1
- package/.opencode/mugiwara-helpers.mjs +1 -1
- package/.opencode/plugins/mugiwara.mjs +62 -20
- package/AGENTS.md +16 -5
- package/README.md +67 -15
- package/content/agents/brook-healing.md +1 -1
- package/content/agents/chopper-checkpoint.md +1 -1
- package/content/agents/eval-runner.md +1 -1
- package/content/agents/franky-gates.md +1 -1
- package/content/agents/jinbe-security.md +1 -1
- package/content/agents/memory-keeper.md +1 -1
- package/content/agents/nami-planner.md +2 -2
- package/content/agents/resume-coordinator.md +11 -11
- package/content/agents/robin-reviewer.md +1 -1
- package/content/agents/sanji-quality.md +1 -1
- package/content/agents/skeptic-verifier.md +1 -1
- package/content/agents/usopp-brainstorm.md +2 -2
- package/content/agents/zoro-execution.md +3 -3
- package/content/skills/mugiwara-agent-security/SKILL.md +1 -18
- package/content/skills/mugiwara-agent-security/references/checklist.md +20 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +7 -1
- package/content/skills/mugiwara-execution/SKILL.md +14 -13
- package/content/skills/mugiwara-execution/references/resume-batching.md +4 -4
- package/content/skills/mugiwara-healing/SKILL.md +2 -37
- package/content/skills/mugiwara-healing/references/workers.md +38 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +11 -8
- package/content/skills/mugiwara-orchestration/references/check-ins.md +26 -7
- package/content/skills/mugiwara-orchestration/references/closure.md +9 -0
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +9 -12
- package/content/skills/mugiwara-planning/SKILL.md +7 -11
- package/content/skills/mugiwara-planning/references/plan-template.md +4 -4
- package/content/skills/mugiwara-pr/SKILL.md +11 -19
- package/content/skills/mugiwara-pr/references/verdict-format.md +31 -0
- package/content/skills/mugiwara-resume/SKILL.md +37 -22
- package/content/skills/mugiwara-ship/SKILL.md +1 -23
- package/content/skills/mugiwara-ship/references/cleanup.md +25 -0
- package/content/skills/mugiwara-workflow/SKILL.md +7 -3
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +11 -3
- package/content/skills/using-mugiwara/SKILL.md +1 -1
- package/dist/mugiwara.js +138 -42
- package/gemini-extension.json +1 -1
- package/hooks/session-start.ts +113 -2
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/references/multi-actor.md +39 -14
- package/references/skill-versioning.md +3 -3
- package/references/token-budget.md +1 -1
- package/references/wave-banners.md +75 -0
- package/scripts/evidence.sh +9 -0
- package/scripts/gate-selftest.ts +154 -0
- package/scripts/lane-base.ts +114 -0
- package/scripts/lane.sh +5 -3
- package/scripts/lib/lane-base.sh +19 -0
- package/scripts/lib/patterns.sh +21 -0
- package/scripts/mission-report.sh +21 -5
- package/scripts/savepoint.sh +170 -56
- package/scripts/setup-fixtures.ts +108 -0
- package/scripts/validate-content.ts +61 -0
- package/src/cli.ts +5 -1
- package/src/installer.ts +70 -8
- package/src/mission.ts +37 -19
- package/src/targets/opencode.ts +34 -6
package/scripts/savepoint.sh
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# scripts/savepoint.sh — write .mugiwara/state
|
|
3
|
-
# Computed from git + file counts; zero model judgement.
|
|
2
|
+
# scripts/savepoint.sh — write .mugiwara/state/<mission>/<member>.json at every
|
|
3
|
+
# wave boundary. Computed from git + file counts; zero model judgement.
|
|
4
|
+
# Identity is (mission, member), never branch. Solo = member empty → the file
|
|
5
|
+
# is named state.json. Usage:
|
|
6
|
+
# savepoint.sh <mission> [member] [wave] [mode]
|
|
4
7
|
set -u
|
|
5
8
|
|
|
6
9
|
die() { echo "savepoint: $*" >&2; exit 1; }
|
|
7
10
|
|
|
8
11
|
MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
|
|
9
12
|
|
|
13
|
+
# shared path patterns — single source of truth (D3)
|
|
14
|
+
# shellcheck source=scripts/lib/patterns.sh
|
|
15
|
+
source "$(dirname "$0")/lib/patterns.sh"
|
|
16
|
+
# shared lane budgets — single source of truth (D5), validated by lane-base.ts
|
|
17
|
+
# shellcheck source=scripts/lib/lane-base.sh
|
|
18
|
+
source "$(dirname "$0")/lib/lane-base.sh"
|
|
19
|
+
|
|
20
|
+
# lane ordering: direct < lean < standard < full < spike (spike resizes, not a rise)
|
|
21
|
+
lane_rank() {
|
|
22
|
+
case "$1" in
|
|
23
|
+
direct) echo 0 ;;
|
|
24
|
+
lean) echo 1 ;;
|
|
25
|
+
standard) echo 2 ;;
|
|
26
|
+
full) echo 3 ;;
|
|
27
|
+
spike) echo 4 ;;
|
|
28
|
+
*) echo 0 ;;
|
|
29
|
+
esac
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
# --- git identity for actor attribution ---
|
|
11
33
|
# Resolve once from repo git config; used when no actor is passed explicitly.
|
|
12
34
|
# GIT_AUTHOR_NAME is an env var usually unset — falling through to $USER
|
|
@@ -15,24 +37,18 @@ GIT_NAME="$(git config user.name 2>/dev/null || true)"
|
|
|
15
37
|
GIT_EMAIL="$(git config user.email 2>/dev/null || true)"
|
|
16
38
|
if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL>"; else GIT_ID="$GIT_NAME"; fi
|
|
17
39
|
|
|
18
|
-
# --- parse mission args ---
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
MISSION="${1:-${STATE_MISSION:-}}"
|
|
31
|
-
ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}}"
|
|
32
|
-
BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
|
|
33
|
-
WAVE="${4:-${STATE_WAVE:-1}}"
|
|
34
|
-
MODE="${5:-${STATE_MODE:-guided}}"
|
|
35
|
-
fi
|
|
40
|
+
# --- parse mission args: <mission> [member] [wave] [mode] ---
|
|
41
|
+
MISSION="${1:-${STATE_MISSION:-}}"
|
|
42
|
+
MEMBER="${2:-${STATE_MEMBER:-}}"
|
|
43
|
+
WAVE="${3:-${STATE_WAVE:-1}}"
|
|
44
|
+
MODE="${4:-${STATE_MODE:-guided}}"
|
|
45
|
+
ACTOR="${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${GIT_ID:-${USER:-}}}}"
|
|
46
|
+
BRANCH="$(git branch --show-current 2>/dev/null || echo 'unknown')"
|
|
47
|
+
|
|
48
|
+
# treat the legacy empty-actor placeholder '""' as "no member" (solo). The old
|
|
49
|
+
# interface used '""' for the actor slot; the new (mission, member) interface
|
|
50
|
+
# uses an empty arg. Accept both so callers need not know which one.
|
|
51
|
+
if [ "$MEMBER" = '""' ]; then MEMBER=""; fi
|
|
36
52
|
|
|
37
53
|
# mission allowlist — MISSION feeds paths + sed + node argv; traversal or
|
|
38
54
|
# sed metacharacters must not reach filesystem operations
|
|
@@ -45,15 +61,32 @@ if [[ "$MISSION" =~ ^\.+$ ]]; then
|
|
|
45
61
|
die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
|
|
46
62
|
fi
|
|
47
63
|
|
|
48
|
-
# per-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
# member allowlist — MEMBER feeds a per-member file name; same rules as MISSION.
|
|
65
|
+
case "$MEMBER" in
|
|
66
|
+
*[!a-zA-Z0-9._-]*) die "invalid member name \"$MEMBER\" (allowlist: [a-zA-Z0-9._-])" ;;
|
|
67
|
+
esac
|
|
68
|
+
if [[ "$MEMBER" =~ ^\.+$ ]]; then
|
|
69
|
+
die "invalid member name \"$MEMBER\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# BRANCH_SLUG sanitized to [A-Za-z0-9._-] — it feeds the continue branch field;
|
|
73
|
+
# a newline/control char in BRANCH would corrupt the JSON (F5). Dropping illegal
|
|
74
|
+
# chars is safe: slugs are keys, not content. Dot-only slugs emptied
|
|
75
|
+
# (BSD/macOS-safe: no \+ BRE).
|
|
76
|
+
BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-' | tr -cd 'A-Za-z0-9._-' | sed 's/^\.\{1,\}$//' )
|
|
77
|
+
|
|
78
|
+
# state + continue live per (mission, member). Solo (member empty) → state.json.
|
|
79
|
+
STATE_DIR="$MUGIWARA_DIR/state/$MISSION"
|
|
80
|
+
CONTINUE_DIR="$MUGIWARA_DIR/continue/$MISSION"
|
|
81
|
+
if [ -n "$MEMBER" ]; then
|
|
82
|
+
STATE_FILE="$STATE_DIR/$MEMBER.json"
|
|
83
|
+
CONTINUE_FILE="$CONTINUE_DIR/$MEMBER.json"
|
|
52
84
|
else
|
|
53
|
-
STATE_FILE="$
|
|
85
|
+
STATE_FILE="$STATE_DIR/state.json"
|
|
86
|
+
CONTINUE_FILE="$CONTINUE_DIR/state.json"
|
|
54
87
|
fi
|
|
55
88
|
|
|
56
|
-
[ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [
|
|
89
|
+
[ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode]"
|
|
57
90
|
[ -d .git ] || die "not a git repository"
|
|
58
91
|
|
|
59
92
|
# --- computed fields ---
|
|
@@ -63,17 +96,23 @@ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
|
|
63
96
|
CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || true)
|
|
64
97
|
FILES_TOUCHED=$( [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | wc -l | tr -d ' ' || echo 0 )
|
|
65
98
|
|
|
99
|
+
LOC_INS=0
|
|
100
|
+
LOC_DEL=0
|
|
66
101
|
LOC_DELTA=0
|
|
102
|
+
LOC_CHURN=0
|
|
67
103
|
if [ "$BASE_SHA" != "unknown" ]; then
|
|
68
104
|
STAT=$(git diff --shortstat "$BASE_SHA"..HEAD 2>/dev/null || echo "")
|
|
69
105
|
INS=$(echo "$STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
|
|
70
106
|
DEL=$(echo "$STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
|
|
71
|
-
|
|
107
|
+
LOC_INS=$(( ${INS:-0} + 0 ))
|
|
108
|
+
LOC_DEL=$(( ${DEL:-0} + 0 ))
|
|
109
|
+
LOC_DELTA=$(( LOC_INS - LOC_DEL ))
|
|
110
|
+
# churn is insertion+deletion — refactors and deletions are work too (D4)
|
|
111
|
+
LOC_CHURN=$(( LOC_INS + LOC_DEL ))
|
|
72
112
|
fi
|
|
73
113
|
[ -z "$LOC_DELTA" ] && LOC_DELTA=0
|
|
74
114
|
|
|
75
|
-
|
|
76
|
-
SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATTERNS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
|
|
115
|
+
SENSITIVE_PATHS=$(echo "$CHANGED_FILES" | grep -E "$SENSITIVE_PATS" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
|
|
77
116
|
|
|
78
117
|
LANE="direct"
|
|
79
118
|
LANE_REASON=""
|
|
@@ -109,7 +148,6 @@ fi
|
|
|
109
148
|
# path-weighted sizing (mirrors lane.sh): docs-only changes outside the product
|
|
110
149
|
# surface never escalate to full from file count alone; sensitive-path
|
|
111
150
|
# escalation above still wins.
|
|
112
|
-
PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
|
|
113
151
|
if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ]; then
|
|
114
152
|
CODE_COUNT=$(echo "$CHANGED_FILES" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
|
|
115
153
|
if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
|
|
@@ -119,6 +157,42 @@ if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ];
|
|
|
119
157
|
fi
|
|
120
158
|
fi
|
|
121
159
|
|
|
160
|
+
# --- lane-rise compare (F9) + monotonic clamp (D2): read previous state ---
|
|
161
|
+
# Clamp runs here — before LANE_BASE/BUDGET — so tokens and budget follow the
|
|
162
|
+
# held lane, never the raw shrunken one.
|
|
163
|
+
LANE_PREV=""
|
|
164
|
+
LANE_PEAK=""
|
|
165
|
+
LANE_ROSE=false
|
|
166
|
+
PREV_MISSION=""
|
|
167
|
+
if [ -f "$STATE_FILE" ]; then
|
|
168
|
+
PREV_JSON=$(node -e "try{const fs=require('fs');const s=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));process.stdout.write(JSON.stringify({mission:s.mission||'',lane:s.lane||'',peak:s.lane_peak||''}))}catch(e){process.stdout.write('{}')}" "$STATE_FILE" 2>/dev/null || true)
|
|
169
|
+
PREV_MISSION=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).mission||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
|
|
170
|
+
LANE_PREV=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).lane||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
|
|
171
|
+
LANE_PEAK=$(echo "$PREV_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).peak||'')}catch(e){process.stdout.write('')}})" 2>/dev/null || true)
|
|
172
|
+
fi
|
|
173
|
+
# fresh mission resets the clamp: a different mission in state.json is a new
|
|
174
|
+
# run, not a lane drop — no carry-over of peak or prev (case 10).
|
|
175
|
+
if [ -n "$PREV_MISSION" ] && [ "$PREV_MISSION" != "$MISSION" ]; then
|
|
176
|
+
LANE_PREV=""
|
|
177
|
+
LANE_PEAK=""
|
|
178
|
+
fi
|
|
179
|
+
# monotonic clamp: never drop below the previous peak; spike is a resize, not a rise.
|
|
180
|
+
if [ -n "$LANE_PEAK" ] && [ "$LANE_PEAK" != "spike" ] && [ "$(lane_rank "$LANE")" -lt "$(lane_rank "$LANE_PEAK")" ]; then
|
|
181
|
+
LANE="$LANE_PEAK"
|
|
182
|
+
LANE_REASON="$LANE_REASON (held at peak $LANE — clamp D2)"
|
|
183
|
+
fi
|
|
184
|
+
if [ -z "$LANE_PEAK" ] || [ "$LANE_PEAK" = "spike" ]; then
|
|
185
|
+
LANE_PEAK="$LANE"
|
|
186
|
+
elif [ "$(lane_rank "$LANE")" -gt "$(lane_rank "$LANE_PEAK")" ]; then
|
|
187
|
+
LANE_PEAK="$LANE"
|
|
188
|
+
fi
|
|
189
|
+
if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
|
|
190
|
+
case "$LANE_PREV:$LANE" in
|
|
191
|
+
direct:lean|direct:standard|direct:full|lean:standard|lean:full|standard:full) LANE_ROSE=true ;;
|
|
192
|
+
*) LANE_ROSE=false ;;
|
|
193
|
+
esac
|
|
194
|
+
fi
|
|
195
|
+
|
|
122
196
|
# task counts from plan doc — plan is written date-prefixed (plans/YYYY-MM-DD-<mission>.md)
|
|
123
197
|
# or bare (plans/<mission>.md); glob both, first match wins.
|
|
124
198
|
PLAN_FILE=$(ls "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md 2>/dev/null | head -1 || true)
|
|
@@ -168,17 +242,18 @@ fi
|
|
|
168
242
|
|
|
169
243
|
# tokens proxy (F7): deterministic estimate when the harness does not report
|
|
170
244
|
# real usage. Monotonic beats precise — LANE_BASE stands in for the skills
|
|
171
|
-
# loaded this lane
|
|
245
|
+
# loaded this lane (measured from content, validated by scripts/lane-base.ts);
|
|
246
|
+
# loc_churn and written-artifact words scale with growth.
|
|
172
247
|
# MUGIWARA_TOKENS overrides as the reported value.
|
|
173
248
|
LANE_BASE=0
|
|
174
249
|
case "$LANE" in
|
|
175
|
-
lean) LANE_BASE
|
|
176
|
-
standard) LANE_BASE
|
|
177
|
-
full) LANE_BASE
|
|
178
|
-
spike) LANE_BASE
|
|
250
|
+
lean) LANE_BASE=$LANE_BASE_lean ;;
|
|
251
|
+
standard) LANE_BASE=$LANE_BASE_standard ;;
|
|
252
|
+
full) LANE_BASE=$LANE_BASE_full ;;
|
|
253
|
+
spike) LANE_BASE=$LANE_BASE_spike ;;
|
|
179
254
|
esac
|
|
180
255
|
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=$((
|
|
256
|
+
LOC_TOKENS=$(( LOC_CHURN * 12 ))
|
|
182
257
|
TOKENS_SOURCE="computed"
|
|
183
258
|
TOKENS_EST=$(( LANE_BASE + DOC_WORDS * 135 / 100 + LOC_TOKENS ))
|
|
184
259
|
if [ -n "${MUGIWARA_TOKENS:-}" ]; then
|
|
@@ -189,10 +264,10 @@ fi
|
|
|
189
264
|
# budget per lane
|
|
190
265
|
BUDGET=0
|
|
191
266
|
case "$LANE" in
|
|
192
|
-
lean) BUDGET
|
|
193
|
-
standard) BUDGET
|
|
194
|
-
full) BUDGET
|
|
195
|
-
spike) BUDGET
|
|
267
|
+
lean) BUDGET=$BUDGET_lean ;;
|
|
268
|
+
standard) BUDGET=$BUDGET_standard ;;
|
|
269
|
+
full) BUDGET=$BUDGET_full ;;
|
|
270
|
+
spike) BUDGET=$BUDGET_spike ;;
|
|
196
271
|
*) BUDGET=0 ;;
|
|
197
272
|
esac
|
|
198
273
|
|
|
@@ -206,30 +281,18 @@ elif [ "$BUDGET" -gt 0 ] && [ "$TOKENS_EST" -ge "$WARN_AT" ] 2>/dev/null; then
|
|
|
206
281
|
STATUS="warn"
|
|
207
282
|
fi
|
|
208
283
|
|
|
209
|
-
mkdir -p "$MUGIWARA_DIR"
|
|
210
|
-
|
|
211
|
-
# --- lane-rise compare (F9): read previous state, flag escalation ---
|
|
212
|
-
LANE_PREV=""
|
|
213
|
-
LANE_ROSE=false
|
|
214
|
-
if [ -f "$STATE_FILE" ]; then
|
|
215
|
-
LANE_PREV=$(node -e "try{const s=require(process.argv[1]);process.stdout.write(s.lane||'')}catch(e){process.stdout.write('')}" "$STATE_FILE" 2>/dev/null || true)
|
|
216
|
-
if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
|
|
217
|
-
# lane order: direct < lean < standard < full < spike (spike resizes, not a rise)
|
|
218
|
-
case "$LANE_PREV:$LANE" in
|
|
219
|
-
direct:lean|direct:standard|direct:full|lean:standard|lean:full|standard:full) LANE_ROSE=true ;;
|
|
220
|
-
*) LANE_ROSE=false ;;
|
|
221
|
-
esac
|
|
222
|
-
fi
|
|
223
|
-
fi
|
|
284
|
+
mkdir -p "$MUGIWARA_DIR/state/$MISSION" "$MUGIWARA_DIR/continue/$MISSION"
|
|
224
285
|
|
|
225
286
|
node -e "
|
|
226
287
|
const data = {
|
|
227
288
|
mission: process.argv[1],
|
|
289
|
+
member: process.argv[31] || null,
|
|
228
290
|
actor: process.argv[2],
|
|
229
291
|
branch: process.argv[3],
|
|
230
292
|
lane: process.argv[4],
|
|
231
293
|
lane_reason: process.argv[5],
|
|
232
294
|
lane_prev: process.argv[24] || null,
|
|
295
|
+
lane_peak: process.argv[27] || null,
|
|
233
296
|
lane_rose: process.argv[25] === 'true',
|
|
234
297
|
wave: parseInt(process.argv[6], 10),
|
|
235
298
|
mode: process.argv[7],
|
|
@@ -237,6 +300,9 @@ const data = {
|
|
|
237
300
|
head_sha: process.argv[9],
|
|
238
301
|
files_touched: parseInt(process.argv[10], 10),
|
|
239
302
|
loc_delta: parseInt(process.argv[11], 10),
|
|
303
|
+
loc_ins: parseInt(process.argv[28], 10) || 0,
|
|
304
|
+
loc_del: parseInt(process.argv[29], 10) || 0,
|
|
305
|
+
loc_churn: parseInt(process.argv[30], 10) || 0,
|
|
240
306
|
sensitive_paths: process.argv[12] ? process.argv[12].split(',').filter(Boolean) : [],
|
|
241
307
|
tasks: { done: parseInt(process.argv[13], 10), total: parseInt(process.argv[14], 10) },
|
|
242
308
|
blockers_open: parseInt(process.argv[15], 10),
|
|
@@ -257,9 +323,57 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
|
|
|
257
323
|
"$BLOCKERS_OPEN" "$HEAL_CYCLE" "$TOKENS_EST" "$BUDGET" \
|
|
258
324
|
"$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
|
|
259
325
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
260
|
-
"$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE"
|
|
326
|
+
"$STATE_FILE" "$LANE_PREV" "$LANE_ROSE" "$TOKENS_SOURCE" "$LANE_PEAK" \
|
|
327
|
+
"$LOC_INS" "$LOC_DEL" "$LOC_CHURN" "$MEMBER"
|
|
261
328
|
|
|
262
329
|
if [ "$LANE_ROSE" = true ]; then
|
|
263
330
|
echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
|
|
264
331
|
fi
|
|
332
|
+
|
|
333
|
+
# --- continue/<mission>/<member>.json (D10): machine-written resume point ---
|
|
334
|
+
# Written alongside state.json so an interrupted session (step-limit truncation,
|
|
335
|
+
# crash, new session) can resume without human recall. Same trust as state.json:
|
|
336
|
+
# computed fields, never model judgement. The resume skill treats it as data to
|
|
337
|
+
# verify against the plan/todos, never verbatim instructions.
|
|
338
|
+
# Scoped by (mission, member) like state.json — solo writes state.json, team
|
|
339
|
+
# writes <member>.json, so parallel members never clobber each other. The
|
|
340
|
+
# crew-written next_session_prompt line is preserved across savepoints.
|
|
341
|
+
if [ -n "$MISSION" ]; then
|
|
342
|
+
NEXT_SESSION_PROMPT=""
|
|
343
|
+
if [ -f "$CONTINUE_FILE" ]; then
|
|
344
|
+
NEXT_SESSION_PROMPT=$(node -e "try{const s=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(s.next_session_prompt||'')}catch(e){process.stdout.write('')}" "$CONTINUE_FILE" 2>/dev/null || true)
|
|
345
|
+
fi
|
|
346
|
+
# N2: every field echoed into continue is validated first. MISSION + MEMBER
|
|
347
|
+
# are allowlisted upstream; WAVE numeric, MODE enum, BRANCH slug-sanitized.
|
|
348
|
+
CONT_WAVE=$(echo "$WAVE" | tr -cd '0-9')
|
|
349
|
+
case "$MODE" in
|
|
350
|
+
guided|semi|auto) CONT_MODE="$MODE" ;;
|
|
351
|
+
*) CONT_MODE="guided" ;;
|
|
352
|
+
esac
|
|
353
|
+
CONT_BRANCH="${BRANCH_SLUG:-unknown}"
|
|
354
|
+
node -e "
|
|
355
|
+
const fs = require('fs');
|
|
356
|
+
const data = {
|
|
357
|
+
mission: process.argv[1],
|
|
358
|
+
member: process.argv[2] || null,
|
|
359
|
+
actor: process.argv[13] || '',
|
|
360
|
+
branch: process.argv[3],
|
|
361
|
+
wave: parseInt(process.argv[4], 10) || 0,
|
|
362
|
+
mode: process.argv[5],
|
|
363
|
+
tasks_done: parseInt(process.argv[6], 10) || 0,
|
|
364
|
+
tasks_total: parseInt(process.argv[7], 10) || 0,
|
|
365
|
+
lane: process.argv[8],
|
|
366
|
+
lane_prev: process.argv[9] || null,
|
|
367
|
+
updated_at: process.argv[10],
|
|
368
|
+
next_action: 'verify this wave against the plan, then continue per plan (next wave or closure)'
|
|
369
|
+
};
|
|
370
|
+
try { data.next_session_prompt = JSON.parse(fs.readFileSync(process.argv[12],'utf8')).next_session_prompt || ''; } catch (e) { data.next_session_prompt = ''; }
|
|
371
|
+
fs.writeFileSync(process.argv[11], JSON.stringify(data, null, 2) + '\n');
|
|
372
|
+
" \
|
|
373
|
+
"$MISSION" "$MEMBER" "$CONT_BRANCH" "$CONT_WAVE" "$CONT_MODE" \
|
|
374
|
+
"$TASKS_DONE" "$TASKS_TOTAL" "$LANE" "$LANE_PREV" \
|
|
375
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
376
|
+
"$CONTINUE_FILE" "$CONTINUE_FILE" "$ACTOR"
|
|
377
|
+
fi
|
|
378
|
+
|
|
265
379
|
echo "✓ savepoint written: $STATE_FILE (lane=$LANE, wave=$WAVE, files=$FILES_TOUCHED)"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/setup-fixtures.ts — materialize test/fixtures/*.json into real git
|
|
3
|
+
// repos in a temp dir. Each fixture = a JSON manifest with base/branch file
|
|
4
|
+
// trees. The script writes the base tree on the trunk branch, creates the
|
|
5
|
+
// feature branch, applies the branch tree, and commits. Used by tests to get
|
|
6
|
+
// a deterministic repo per scenario. Never touches the main repo's .git.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// bun scripts/setup-fixtures.ts <fixture> <targetDir>
|
|
10
|
+
// bun scripts/setup-fixtures.ts --list
|
|
11
|
+
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const fixturesDir = join(import.meta.dirname, '..', 'test', 'fixtures');
|
|
17
|
+
|
|
18
|
+
function fixtureNames(): string[] {
|
|
19
|
+
return readdirSync(fixturesDir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (process.argv[2] === '--list') {
|
|
23
|
+
console.log(fixtureNames().join('\n'));
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const name = process.argv[2];
|
|
28
|
+
const target = process.argv[3];
|
|
29
|
+
if (!name || !target) {
|
|
30
|
+
console.error('usage: bun scripts/setup-fixtures.ts <fixture> <targetDir>');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Fixture = {
|
|
35
|
+
name: string;
|
|
36
|
+
baseBranch: string;
|
|
37
|
+
base: Record<string, string>;
|
|
38
|
+
branch: Record<string, string>;
|
|
39
|
+
binary?: string[];
|
|
40
|
+
gitInit?: boolean;
|
|
41
|
+
note?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const file = join(fixturesDir, name + '.json');
|
|
45
|
+
if (!existsSync(file)) {
|
|
46
|
+
console.error(`fixture not found: ${name} (have: ${fixtureNames().join(', ')})`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const fx = JSON.parse(readFileSync(file, 'utf8')) as Fixture;
|
|
50
|
+
|
|
51
|
+
// target must be a fresh empty dir
|
|
52
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
53
|
+
mkdirSync(target, { recursive: true });
|
|
54
|
+
|
|
55
|
+
if (fx.gitInit === false) {
|
|
56
|
+
// no-git fixture: just an empty dir with one file, no .git ever created
|
|
57
|
+
mkdirSync(join(target, 'src'), { recursive: true });
|
|
58
|
+
writeFileSync(join(target, 'src', 'main.ts'), 'export const main = () => 1;\n');
|
|
59
|
+
console.log(`fixture ${name}: materialized (no .git)`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const writeTree = (tree: Record<string, string>) => {
|
|
64
|
+
for (const [path, content] of Object.entries(tree)) {
|
|
65
|
+
const full = join(target, path);
|
|
66
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
67
|
+
writeFileSync(full, content);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// names feed unquoted git branch args — validate before interpolation (F3).
|
|
72
|
+
// Fixture files are repo-owned, but `name` is a CLI arg and `trunk` comes from
|
|
73
|
+
// fixture JSON; both must be safe git ref names.
|
|
74
|
+
const SAFE_REF = /^[A-Za-z0-9._/-]+$/;
|
|
75
|
+
if (!SAFE_REF.test(name) || !SAFE_REF.test(fx.baseBranch || '')) {
|
|
76
|
+
console.error(`fixture ${name}: unsafe branch name rejected`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
execSync('git init -q', { cwd: target });
|
|
81
|
+
execSync('git config user.email test@test.com && git config user.name Test', { cwd: target });
|
|
82
|
+
|
|
83
|
+
const trunk = fx.baseBranch || 'main';
|
|
84
|
+
// branch named after trunk if trunk isn't main (so savepoint base-resolve paths differ)
|
|
85
|
+
execSync(`git checkout -q -b ${trunk}`, { cwd: target });
|
|
86
|
+
|
|
87
|
+
// base commit on trunk
|
|
88
|
+
writeTree(fx.base);
|
|
89
|
+
for (const rel of fx.binary ?? []) {
|
|
90
|
+
if (fx.base[rel]) writeFileSync(join(target, rel), fx.base[rel]);
|
|
91
|
+
}
|
|
92
|
+
execSync('git add -A && git commit -q -m base', { cwd: target });
|
|
93
|
+
|
|
94
|
+
// feature branch applies the branch tree (files removed from base not in
|
|
95
|
+
// branch get deleted; contents updated; new files added)
|
|
96
|
+
execSync(`git checkout -q -b feat-${fx.name}`, { cwd: target });
|
|
97
|
+
for (const rel of Object.keys(fx.base)) {
|
|
98
|
+
if (!(rel in fx.branch)) {
|
|
99
|
+
execSync(`git rm -q --ignore-unmatch "${rel}"`, { cwd: target });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
writeTree(fx.branch);
|
|
103
|
+
for (const rel of fx.binary ?? []) {
|
|
104
|
+
if (fx.branch[rel]) writeFileSync(join(target, rel), fx.branch[rel]);
|
|
105
|
+
}
|
|
106
|
+
execSync('git add -A && git commit -q -m wip', { cwd: target });
|
|
107
|
+
|
|
108
|
+
console.log(`fixture ${name}: materialized at ${target} (trunk=${trunk}, branch=feat-${fx.name})`);
|
|
@@ -319,5 +319,66 @@ const agentsDoc = join(docsDir, 'concepts', 'agents.md');
|
|
|
319
319
|
if (docErrors === 0) console.log('✓ docs in sync with content/');
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// --- doc-integrity check: documented thresholds must match source constants ---
|
|
323
|
+
// Docs are claims; a claim that drifts from the source is a lie. LANE_BASE +
|
|
324
|
+
// BUDGET live in scripts/lib/lane-base.sh (validated by lane-base.ts); the
|
|
325
|
+
// cost docs must cite the same numbers. Evidence logs carry an exit code /
|
|
326
|
+
// verdict claim — a test must back it (lane-integrity cases 22-24).
|
|
327
|
+
const integrityArg = process.argv.indexOf('--check-doc-integrity');
|
|
328
|
+
if (integrityArg !== -1) {
|
|
329
|
+
const src = join(import.meta.dirname, '..', 'scripts', 'lib', 'lane-base.sh');
|
|
330
|
+
if (!existsSync(src)) {
|
|
331
|
+
errors.push('doc-integrity: scripts/lib/lane-base.sh not found — cannot verify thresholds');
|
|
332
|
+
} else {
|
|
333
|
+
const constants = readFileSync(src, 'utf8');
|
|
334
|
+
const docs = ['docs/concepts/cost.md', 'docs/concepts/lanes.md', 'README.md'];
|
|
335
|
+
const expected: [string, string][] = [
|
|
336
|
+
['lean', '7,000'], ['standard', '13,000'], ['full', '23,000'],
|
|
337
|
+
['lean', '12,000'], ['standard', '25,000'], ['full', '50,000'],
|
|
338
|
+
];
|
|
339
|
+
for (const doc of docs) {
|
|
340
|
+
const p = join(import.meta.dirname, '..', doc);
|
|
341
|
+
if (!existsSync(p)) { errors.push(`doc-integrity: ${doc} not found`); continue; }
|
|
342
|
+
const text = readFileSync(p, 'utf8');
|
|
343
|
+
for (const [lane, num] of expected) {
|
|
344
|
+
// each doc must cite the budget/LANE_BASE number for that lane; accept
|
|
345
|
+
// both the comma form (7,000) and the k-shorthand (7k / ~7k)
|
|
346
|
+
const compact = num.replace(',', '').replace(/0+$/, '');
|
|
347
|
+
const kForm = /^(\d+)000$/.exec(num);
|
|
348
|
+
const variants = [num, compact, ...(kForm ? [`${kForm[1]}k`, `~${kForm[1]}k`] : [])];
|
|
349
|
+
if (!variants.some(v => text.includes(v))) {
|
|
350
|
+
errors.push(`doc-integrity: ${doc} missing ${lane} threshold ${num} (source: ${src})`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// warn/stop are 1.5x / 3x of BUDGET (code: WARN_AT=BUDGET*3/2,
|
|
354
|
+
// STOP_AT=BUDGET*3) — presence is not enough, the arithmetic must be
|
|
355
|
+
// right. A doc claiming 1.5x/3x of LANE_BASE is the exact lie this gate
|
|
356
|
+
// exists to catch. Checked on the authoritative cost doc only.
|
|
357
|
+
if (doc === 'docs/concepts/cost.md') {
|
|
358
|
+
const budgets: [string, number][] = [['lean', 12000], ['standard', 25000], ['full', 50000]];
|
|
359
|
+
for (const [lane, budget] of budgets) {
|
|
360
|
+
const warn = budget * 3 / 2;
|
|
361
|
+
const stop = budget * 3;
|
|
362
|
+
const warnForms = [`${warn}`, `${Math.round(warn / 1000)}k`, `${warn.toLocaleString()}`, `${warn / 1000}k`];
|
|
363
|
+
const stopForms = [`${stop}`, `${stop / 1000}k`, `${stop.toLocaleString()}`];
|
|
364
|
+
const hasWarn = warnForms.some(f => text.includes(f));
|
|
365
|
+
const hasStop = stopForms.some(f => text.includes(f));
|
|
366
|
+
if (!hasWarn || !hasStop) {
|
|
367
|
+
errors.push(`doc-integrity: ${doc} ${lane} warn/stop must be 1.5x/3x of budget ${budget} (warn ${warn}, stop ${stop})`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!constants.includes('LANE_BASE_lean=7000')) errors.push('doc-integrity: source lane-base.sh lean base drifted (expected 7000)');
|
|
373
|
+
if (!constants.includes('BUDGET_full=50000')) errors.push('doc-integrity: source lane-base.sh full budget drifted (expected 50000)');
|
|
374
|
+
}
|
|
375
|
+
// evidence exit-code claim must be test-backed
|
|
376
|
+
const evTest = join(import.meta.dirname, '..', 'test', 'lane-integrity.test.ts');
|
|
377
|
+
const testText = existsSync(evTest) ? readFileSync(evTest, 'utf8') : '';
|
|
378
|
+
if (!testText.includes('# Exit:') || !testText.includes('# Verdict:')) {
|
|
379
|
+
errors.push('doc-integrity: audit-trail claims evidence logs carry exit code + verdict, but no test asserts them');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
322
383
|
if (errors.length) { console.error(errors.map(e => `✗ ${e}`).join('\n')); process.exit(1); }
|
|
323
384
|
console.log(`✓ content valid: ${skillDirs.length} skills, ${agentFiles.length} agents`);
|
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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, ensureProjectGitignore } from './installer.ts';
|
|
10
|
+
import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProjectGitignore } from './installer.ts';
|
|
11
11
|
import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
|
|
12
12
|
import { resetMission, archiveMission } from './mission.ts';
|
|
13
13
|
|
|
@@ -151,6 +151,10 @@ async function uninstall(flags: Args['flags']): Promise<void> {
|
|
|
151
151
|
}
|
|
152
152
|
const removed = removeInstalled(manifest, { dryRun: flag(flags.dryRun) });
|
|
153
153
|
if (!flag(flags.dryRun)) {
|
|
154
|
+
if (scope === 'project') {
|
|
155
|
+
const gi = removeProjectGitignore(projectDir);
|
|
156
|
+
for (const n of gi.notes) console.log(` note: ${n}`);
|
|
157
|
+
}
|
|
154
158
|
rmSync(file);
|
|
155
159
|
const mugiDir = join(scope === 'global' ? home : projectDir, '.mugiwara');
|
|
156
160
|
if (existsSync(mugiDir) && readdirSync(mugiDir).length === 0) {
|
package/src/installer.ts
CHANGED
|
@@ -182,28 +182,90 @@ function assertNotSymlink(file: string): void {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
const GITIGNORE_BLOCK_START = '# >>> mugiwara >>>';
|
|
186
|
+
const GITIGNORE_BLOCK_END = '# <<< mugiwara <<<';
|
|
187
|
+
// legacy marker from v0.6.2 — block was undelimited, single '# mugiwara'
|
|
188
|
+
// header. Kept for detection so an old install is not double-appended.
|
|
185
189
|
const GITIGNORE_MARKER = '# mugiwara';
|
|
186
|
-
const GITIGNORE_BLOCK = `# mugiwara — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
|
|
190
|
+
const GITIGNORE_BLOCK = `# >>> mugiwara >>> — audit trail is the product: commit reports/, results/, logs/, spec/, plans/.
|
|
187
191
|
# Ignore session state and regenerated files.
|
|
188
|
-
.mugiwara/state
|
|
189
|
-
.mugiwara/
|
|
192
|
+
.mugiwara/state/
|
|
193
|
+
.mugiwara/continue/
|
|
190
194
|
.mugiwara/config
|
|
191
|
-
.mugiwara/continue.md
|
|
192
195
|
.mugiwara/refs/
|
|
196
|
+
# <<< mugiwara <<<
|
|
193
197
|
`;
|
|
194
198
|
|
|
195
199
|
export function ensureProjectGitignore(projectDir: string, opts: { dryRun?: boolean } = {}): { appended: boolean; notes: string[] } {
|
|
196
200
|
const { dryRun = false } = opts;
|
|
197
201
|
const path = join(projectDir, '.gitignore');
|
|
198
202
|
assertNotSymlink(path);
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
let existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
204
|
+
if (existing) {
|
|
205
|
+
// Delimited block present with the current entries → nothing to do.
|
|
206
|
+
if (existing.includes(GITIGNORE_BLOCK_START) && existing.includes('.mugiwara/state/')) {
|
|
207
|
+
return { appended: false, notes: [] };
|
|
208
|
+
}
|
|
209
|
+
// Legacy (v0.6.2) or outdated delimited block → upgrade in place:
|
|
210
|
+
// strip the old mugiwara entries (both forms), then append the new block.
|
|
211
|
+
// Without this, upgraded projects keep committing per-wave state/continue
|
|
212
|
+
// JSON to git — session state must stay ignored (Robin MAJOR, Jinbe Low).
|
|
213
|
+
const hadOld = existing.includes(GITIGNORE_MARKER) || existing.includes(GITIGNORE_BLOCK_START);
|
|
214
|
+
if (hadOld) {
|
|
215
|
+
const clean = removeProjectGitignore(projectDir, { dryRun });
|
|
216
|
+
existing = clean.removed ? (existsSync(path) ? readFileSync(path, 'utf8') : '') : existing;
|
|
217
|
+
}
|
|
201
218
|
}
|
|
202
|
-
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
203
219
|
const separator = existing.length && !existing.endsWith('\n') ? '\n' : '';
|
|
204
220
|
if (!dryRun) {
|
|
205
221
|
mkdirSync(dirname(path), { recursive: true });
|
|
206
222
|
writeFileSync(path, existing + separator + GITIGNORE_BLOCK);
|
|
207
223
|
}
|
|
208
|
-
return { appended: true, notes: [`.gitignore ${dryRun ? 'would append' : '
|
|
224
|
+
return { appended: true, notes: [`.gitignore ${dryRun ? 'would upgrade+append' : 'upgraded'} mugiwara audit-trail block`] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// remove the delimited mugiwara block, preserving user lines. Handles both
|
|
228
|
+
// the current delimited block and the legacy undelimited block (v0.6.2).
|
|
229
|
+
export function removeProjectGitignore(projectDir: string, { dryRun = false }: { dryRun?: boolean } = {}): { removed: boolean; notes: string[] } {
|
|
230
|
+
const path = join(projectDir, '.gitignore');
|
|
231
|
+
assertNotSymlink(path);
|
|
232
|
+
if (!existsSync(path)) return { removed: false, notes: [] };
|
|
233
|
+
const current = readFileSync(path, 'utf8');
|
|
234
|
+
|
|
235
|
+
const delimited = current.includes(GITIGNORE_BLOCK_START);
|
|
236
|
+
let cleaned: string;
|
|
237
|
+
if (delimited) {
|
|
238
|
+
// strip everything between (and including) the delimiters. Both
|
|
239
|
+
// delimiters must be present — a missing END (hand-edit) with end=-1
|
|
240
|
+
// would slice() from char 18 and truncate the user's file.
|
|
241
|
+
const start = current.indexOf(GITIGNORE_BLOCK_START);
|
|
242
|
+
const end = current.indexOf(GITIGNORE_BLOCK_END);
|
|
243
|
+
if (end < start) return { removed: false, notes: ['delimiter mismatch — .gitignore left untouched'] };
|
|
244
|
+
cleaned = current.slice(0, start) + current.slice(end + GITIGNORE_BLOCK_END.length);
|
|
245
|
+
} else if (current.includes(GITIGNORE_MARKER)) {
|
|
246
|
+
// legacy (v0.6.2): strip the exact undelimited block — the header, the
|
|
247
|
+
// explanatory comment, and the five known lines. Prefix-matching would
|
|
248
|
+
// delete user-owned lines that merely start with a mugiwara path.
|
|
249
|
+
const LEGACY_LINES = new Set([
|
|
250
|
+
'.mugiwara/state.json',
|
|
251
|
+
'.mugiwara/state-*.json',
|
|
252
|
+
'.mugiwara/config',
|
|
253
|
+
'.mugiwara/continue.md',
|
|
254
|
+
'.mugiwara/refs/',
|
|
255
|
+
]);
|
|
256
|
+
const lines = current.split('\n').filter(l => {
|
|
257
|
+
const t = l.trim();
|
|
258
|
+
if (t.startsWith(GITIGNORE_MARKER)) return false; // header + comment
|
|
259
|
+
return !LEGACY_LINES.has(t);
|
|
260
|
+
});
|
|
261
|
+
cleaned = lines.join('\n');
|
|
262
|
+
} else {
|
|
263
|
+
return { removed: false, notes: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
cleaned = cleaned.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '\n');
|
|
267
|
+
if (cleaned.trim() === '') cleaned = '';
|
|
268
|
+
if (cleaned === current) return { removed: false, notes: [] };
|
|
269
|
+
if (!dryRun) writeFileSync(path, cleaned);
|
|
270
|
+
return { removed: true, notes: [`.gitignore ${dryRun ? 'would strip' : 'stripped'} mugiwara block`] };
|
|
209
271
|
}
|