@ionivetech/mugiwara 0.6.1 → 0.6.3

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 (83) 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-continue.md +42 -10
  7. package/.opencode/commands/mugiwara-execute.md +1 -1
  8. package/.opencode/commands/mugiwara-heal.md +1 -1
  9. package/.opencode/commands/mugiwara-onboard.md +15 -0
  10. package/.opencode/commands/mugiwara-plan.md +1 -1
  11. package/.opencode/commands/mugiwara-review.md +1 -1
  12. package/.opencode/commands/mugiwara-security.md +1 -1
  13. package/.opencode/commands/mugiwara-ship.md +1 -1
  14. package/.opencode/mugiwara-helpers.mjs +0 -1
  15. package/.opencode/plugins/mugiwara.mjs +33 -22
  16. package/AGENTS.md +16 -5
  17. package/README.md +80 -27
  18. package/content/agents/brook-healing.md +1 -1
  19. package/content/agents/chopper-checkpoint.md +1 -1
  20. package/content/agents/eval-runner.md +1 -1
  21. package/content/agents/franky-gates.md +1 -1
  22. package/content/agents/jinbe-security.md +1 -1
  23. package/content/agents/luffy-orchestrator.md +14 -4
  24. package/content/agents/memory-keeper.md +1 -1
  25. package/content/agents/nami-planner.md +2 -2
  26. package/content/agents/onboarding-guide.md +24 -45
  27. package/content/agents/resume-coordinator.md +11 -11
  28. package/content/agents/robin-reviewer.md +1 -1
  29. package/content/agents/sanji-quality.md +1 -1
  30. package/content/agents/skeptic-verifier.md +1 -1
  31. package/content/agents/usopp-brainstorm.md +2 -2
  32. package/content/agents/zoro-execution.md +6 -2
  33. package/content/skills/mugiwara-agent-security/SKILL.md +1 -18
  34. package/content/skills/mugiwara-agent-security/references/checklist.md +20 -0
  35. package/content/skills/mugiwara-backend/SKILL.md +1 -1
  36. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -1
  37. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -1
  38. package/content/skills/mugiwara-claim-audit/SKILL.md +1 -1
  39. package/content/skills/mugiwara-execution/SKILL.md +22 -22
  40. package/content/skills/mugiwara-execution/references/dispatch.md +4 -3
  41. package/content/skills/mugiwara-execution/references/resume-batching.md +4 -4
  42. package/content/skills/mugiwara-frontend/SKILL.md +1 -1
  43. package/content/skills/mugiwara-healing/SKILL.md +3 -38
  44. package/content/skills/mugiwara-healing/references/workers.md +38 -0
  45. package/content/skills/mugiwara-orchestration/SKILL.md +30 -33
  46. package/content/skills/mugiwara-orchestration/references/check-ins.md +42 -0
  47. package/content/skills/mugiwara-orchestration/references/closure.md +1 -1
  48. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +9 -12
  49. package/content/skills/mugiwara-planning/SKILL.md +7 -11
  50. package/content/skills/mugiwara-planning/references/plan-template.md +4 -4
  51. package/content/skills/mugiwara-pr/SKILL.md +11 -9
  52. package/content/skills/mugiwara-resume/SKILL.md +37 -22
  53. package/content/skills/mugiwara-ship/SKILL.md +2 -24
  54. package/content/skills/mugiwara-ship/references/cleanup.md +25 -0
  55. package/content/skills/mugiwara-sunset/SKILL.md +1 -1
  56. package/content/skills/mugiwara-workflow/SKILL.md +4 -2
  57. package/content/skills/mugiwara-workflow/references/workspace-layout.md +16 -7
  58. package/content/skills/using-mugiwara/SKILL.md +5 -1
  59. package/dist/mugiwara.js +107 -37
  60. package/gemini-extension.json +1 -1
  61. package/hooks/session-start.ts +113 -2
  62. package/package.json +3 -3
  63. package/plugin.json +1 -1
  64. package/references/multi-actor.md +39 -14
  65. package/references/skill-versioning.md +3 -3
  66. package/references/token-budget.md +1 -1
  67. package/scripts/evidence.sh +9 -0
  68. package/scripts/gate-selftest.ts +123 -0
  69. package/scripts/lane-base.ts +114 -0
  70. package/scripts/lane.sh +4 -2
  71. package/scripts/lib/lane-base.sh +19 -0
  72. package/scripts/lib/patterns.sh +15 -0
  73. package/scripts/mission-report.sh +21 -5
  74. package/scripts/onboard.ts +3 -29
  75. package/scripts/release-notes.ts +152 -75
  76. package/scripts/savepoint.sh +178 -56
  77. package/scripts/setup-fixtures.ts +108 -0
  78. package/scripts/validate-content.ts +61 -0
  79. package/src/cli.ts +5 -1
  80. package/src/installer.ts +70 -8
  81. package/src/mission.ts +39 -24
  82. package/src/targets/claude.ts +5 -3
  83. package/src/targets/opencode.ts +12 -8
@@ -1,94 +1,171 @@
1
1
  #!/usr/bin/env bun
2
2
  // scripts/release-notes.ts — generates a detailed GitHub Release description
3
3
  // from git history between the previous tag and HEAD. Pulls the full commit
4
- // message (subject + body), groups by conventional-commit type, flags breaking
5
- // changes, and lists the affected scopes. No gh dependency.
4
+ // message (subject + body), groups by conventional-commit scope (feature area)
5
+ // in first-appearance order, falls back to type for unscoped commits, flags
6
+ // breaking changes, and lists the affected scopes. No gh dependency.
6
7
  //
7
8
  // bun scripts/release-notes.ts notes since the last tag → stdout
8
9
  // bun scripts/release-notes.ts --since v0.3.0 notes from that tag → stdout
9
10
  import { execFileSync } from 'node:child_process';
10
11
 
11
- const args = process.argv;
12
- const sinceIdx = args.indexOf('--since');
13
- let since = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
14
-
15
- if (!since) {
16
- // The release workflow tags HEAD BEFORE generating notes, so the newest tag
17
- // is the release tag itself. The "since" boundary must be the tag BEFORE it,
18
- // otherwise range = "<release>..HEAD" is empty and the notes come out blank.
19
- // With only one tag (first release), there is no previous tag — show all.
20
- const tags = execFileSync('git', ['tag', '--sort=-version:refname'], { encoding: 'utf8' })
21
- .split(/\r?\n/).filter(Boolean);
22
- since = tags.length >= 2 ? tags[1] : null;
23
- }
24
-
25
- const range = since ? `${since}..HEAD` : '';
26
- const raw = execFileSync('git', ['log', '--pretty=%H%n%s%n%n%b%n__END__', ...(range ? [range] : [])], { encoding: 'utf8' });
27
- const commits = raw
28
- .split('__END__\n')
29
- .map(c => c.trim())
30
- .filter(Boolean)
31
- .map(c => {
32
- const [sha, subject, ...rest] = c.split('\n');
33
- return { sha: sha.slice(0, 7), subject: subject ?? '', body: rest.join('\n').trim() };
34
- });
35
-
36
- const groups: Record<string, { label: string; items: string[] }> = {
37
- feat: { label: 'New', items: [] },
38
- fix: { label: 'Fixed', items: [] },
39
- refactor: { label: 'Refactored', items: [] },
40
- perf: { label: 'Performance', items: [] },
41
- docs: { label: 'Docs', items: [] },
42
- chore: { label: 'Housekeeping', items: [] },
43
- test: { label: 'Housekeeping', items: [] },
44
- ci: { label: 'Housekeeping', items: [] },
12
+ // human label for a conventional-commit type: section fallback + per-bullet tag
13
+ const TYPE_LABEL: Record<string, string> = {
14
+ feat: 'New',
15
+ fix: 'Fixed',
16
+ refactor: 'Refactored',
17
+ perf: 'Performance',
18
+ docs: 'Docs',
19
+ chore: 'Housekeeping',
20
+ test: 'Housekeeping',
21
+ ci: 'Housekeeping',
45
22
  };
46
23
 
47
- const order = ['feat', 'fix', 'refactor', 'perf', 'docs', 'chore'];
24
+ const TYPE_ORDER = ['feat', 'fix', 'refactor', 'perf', 'docs', 'chore'];
48
25
 
49
26
  // strip signature trailers (Co-authored-by, Signed-off-by, review notes)
50
27
  const TRAILER = /^(Co-authored-by|Signed-off-by|Reviewed-by|Helped-by|Reported-by|Tested-by|Acked-by):/i;
51
28
 
52
- for (const commit of commits) {
53
- const m = /^(\w+)(?:\((.*?)\))?!?: (.*)/.exec(commit.subject);
54
- const type = m ? m[1] : 'chore';
55
- const scope = m?.[2] || '';
56
- const text = m ? m[3] : commit.subject;
57
- const breaking = Boolean(m?.[0]?.includes('!')) || /^BREAKING CHANGE:/m.test(commit.body);
58
-
59
- const title = text.charAt(0).toUpperCase() + text.slice(1);
60
- const scopeTag = scope ? ` \`${scope}\`` : '';
61
- const breakingTag = breaking ? ' ⚠️ **BREAKING**' : '';
62
-
63
- const bodyLines = commit.body
64
- .split(/\r?\n/)
65
- .map(l => l.trim())
66
- .filter(l => l && !TRAILER.test(l) && l !== '---')
67
- // the release-version marker commit repeats the subject as body — drop it
68
- .filter(l => l !== text);
69
-
70
- let entry = `- ${title}${scopeTag}${breakingTag} \`${commit.sha}\``;
71
- if (bodyLines.length) {
72
- entry += '\n' + bodyLines.map(l => ` - ${l}`).join('\n');
73
- }
74
- const g = groups[type] ?? groups.chore;
75
- g.items.push(entry);
29
+ // capitalize the first letter of each word; split on non-alphanumerics
30
+ // (opencode Opencode, release-notes → Release Notes)
31
+ const titleCase = (s: string) =>
32
+ s
33
+ .split(/[^a-zA-Z0-9]+/)
34
+ .filter(Boolean)
35
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
36
+ .join(' ');
37
+
38
+ export interface ReleaseCommit {
39
+ sha: string;
40
+ subject: string;
41
+ body: string;
76
42
  }
77
43
 
78
- const lines: string[] = [];
79
- for (const t of order) {
80
- const g = groups[t];
81
- if (!g.items.length) continue;
82
- lines.push(`## ${g.label}`);
83
- lines.push('');
84
- for (const item of g.items) lines.push(item);
85
- lines.push('');
44
+ /**
45
+ * Group parsed commits into per-scope sections (first-appearance order) with a
46
+ * type-fallback for unscoped commits. Returns the rendered markdown body and
47
+ * the change count. Pure — no git access, deterministic. Exported so tests
48
+ * exercise the grouping without invoking git.
49
+ */
50
+ export function buildNotes(commits: ReleaseCommit[]): { count: number; markdown: string } {
51
+ // scoped (feature-area) sections, keyed by lowercased scope, first-appearance order
52
+ const scoped = new Map<string, { label: string; items: string[] }>();
53
+ const scopedOrder: string[] = [];
54
+ // unscoped fallback sections by type, rendered in canonical type order
55
+ const byType = new Map<string, { label: string; items: string[] }>();
56
+ for (const t of TYPE_ORDER) byType.set(t, { label: TYPE_LABEL[t], items: [] });
57
+
58
+ for (const commit of commits) {
59
+ const m = /^(\w+)(?:\((.*?)\))?(!)?: (.*)/.exec(commit.subject);
60
+ const type = m ? m[1] : 'chore';
61
+ const scope = m?.[2] || '';
62
+ const text = m ? m[4] : commit.subject;
63
+ // the conventional-commits `!` breaking marker sits between scope and
64
+ // colon; capture it positionally so `fix: handle a! in parser` is NOT
65
+ // flagged breaking (a bare `!` anywhere in the subject would be)
66
+ const breaking = m?.[3] === '!' || /^BREAKING CHANGE:/m.test(commit.body);
67
+
68
+ const title = text.charAt(0).toUpperCase() + text.slice(1);
69
+ const breakingTag = breaking ? ' ⚠️ **BREAKING**' : '';
70
+
71
+ const bodyLines = commit.body
72
+ .split(/\r?\n/)
73
+ .map(l => l.trim())
74
+ .filter(l => l && !TRAILER.test(l) && l !== '---')
75
+ // the release-version marker commit repeats the subject as body — drop it
76
+ .filter(l => l !== text);
77
+
78
+ const typeLabel = TYPE_LABEL[type] ?? 'Housekeeping';
79
+ const base = `${title}${breakingTag} \`${commit.sha}\``;
80
+ const bodyBlock = bodyLines.length ? '\n' + bodyLines.map(l => ` - ${l}`).join('\n') : '';
81
+
82
+ if (scope) {
83
+ const key = scope.toLowerCase();
84
+ let g = scoped.get(key);
85
+ if (!g) {
86
+ g = { label: titleCase(scope), items: [] };
87
+ scoped.set(key, g);
88
+ scopedOrder.push(key);
89
+ }
90
+ // scoped (feature-area) sections: the type label adds information because
91
+ // the heading names the area, not the type
92
+ g.items.push(`- **${typeLabel}** ${base}${bodyBlock}`);
93
+ } else {
94
+ // type-fallback sections: the heading already names the type, so a
95
+ // per-bullet label would be redundant (R5a)
96
+ (byType.get(type) ?? byType.get('chore')!).items.push(`- ${base}${bodyBlock}`);
97
+ }
98
+ }
99
+
100
+ // Render into a heading-keyed map (case-insensitive) so a type-fallback
101
+ // heading that collides with an earlier scoped heading merges into it
102
+ // instead of emitting a duplicate section (R5b).
103
+ const sections = new Map<string, { heading: string; items: string[] }>();
104
+ const order: string[] = [];
105
+ const addSection = (heading: string, items: string[]) => {
106
+ const key = heading.toLowerCase();
107
+ let s = sections.get(key);
108
+ if (!s) {
109
+ s = { heading, items: [] };
110
+ sections.set(key, s);
111
+ order.push(key);
112
+ }
113
+ s.items.push(...items);
114
+ };
115
+
116
+ // scoped (feature-area) sections first, in order of first appearance
117
+ for (const key of scopedOrder) addSection(scoped.get(key)!.label, scoped.get(key)!.items);
118
+ // then unscoped type-fallback sections, canonical type order
119
+ for (const t of TYPE_ORDER) {
120
+ const g = byType.get(t)!;
121
+ if (g.items.length) addSection(g.label, g.items);
122
+ }
123
+
124
+ const lines: string[] = [];
125
+ for (const key of order) {
126
+ const s = sections.get(key)!;
127
+ lines.push(`## ${s.heading}`);
128
+ lines.push('');
129
+ for (const item of s.items) lines.push(item);
130
+ lines.push('');
131
+ }
132
+
133
+ const bumpIdx = commits.findIndex(c => /chore: release|chore: bump/.test(c.subject));
134
+ const body = commits.slice(0, bumpIdx === -1 ? commits.length : bumpIdx);
135
+ const count = body.length || commits.length;
136
+
137
+ return { count, markdown: lines.join('\n').trimEnd() };
86
138
  }
87
139
 
88
- const bumpIdx = commits.findIndex(c => /chore: release|chore: bump/.test(c.subject));
89
- const body = commits.slice(0, bumpIdx === -1 ? commits.length : bumpIdx);
90
- const count = body.length || commits.length;
140
+ // Guard the CLI path so importing buildNotes in tests does not run git.
141
+ if (import.meta.main) {
142
+ const args = process.argv;
143
+ const sinceIdx = args.indexOf('--since');
144
+ let since = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
145
+
146
+ if (!since) {
147
+ // The release workflow tags HEAD BEFORE generating notes, so the newest tag
148
+ // is the release tag itself. The "since" boundary must be the tag BEFORE it,
149
+ // otherwise range = "<release>..HEAD" is empty and the notes come out blank.
150
+ // With only one tag (first release), there is no previous tag — show all.
151
+ const tags = execFileSync('git', ['tag', '--sort=-version:refname'], { encoding: 'utf8' })
152
+ .split(/\r?\n/).filter(Boolean);
153
+ since = tags.length >= 2 ? tags[1] : null;
154
+ }
91
155
 
92
- console.log(`**${count} change${count === 1 ? '' : 's'} since ${since ?? 'the start'}.**`);
93
- console.log('');
94
- console.log(lines.join('\n').trimEnd());
156
+ const range = since ? `${since}..HEAD` : '';
157
+ const raw = execFileSync('git', ['log', '--pretty=%H%n%s%n%n%b%n__END__', ...(range ? [range] : [])], { encoding: 'utf8' });
158
+ const commits: ReleaseCommit[] = raw
159
+ .split('__END__\n')
160
+ .map(c => c.trim())
161
+ .filter(Boolean)
162
+ .map(c => {
163
+ const [sha, subject, ...rest] = c.split('\n');
164
+ return { sha: sha.slice(0, 7), subject: subject ?? '', body: rest.join('\n').trim() };
165
+ });
166
+
167
+ const { count, markdown } = buildNotes(commits);
168
+ console.log(`**${count} change${count === 1 ? '' : 's'} since ${since ?? 'the start.'}**`);
169
+ console.log('');
170
+ console.log(markdown);
171
+ }
@@ -1,30 +1,54 @@
1
1
  #!/usr/bin/env bash
2
- # scripts/savepoint.sh — write .mugiwara/state.json at every wave boundary.
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
 
10
- # --- parse mission args ---
11
- # --branch flag must parse FIRST — it shifts positionals
12
- BRANCH_MODE=0
13
- if [ "${1:-}" = "--branch" ]; then
14
- BRANCH_MODE=1
15
- shift
16
- MISSION="${1:-${STATE_MISSION:-}}"
17
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${USER:-}}}}"
18
- BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
19
- WAVE="${4:-${STATE_WAVE:-1}}"
20
- MODE="${5:-${STATE_MODE:-guided}}"
21
- else
22
- MISSION="${1:-${STATE_MISSION:-}}"
23
- ACTOR="${2:-${STATE_ACTOR:-${GIT_AUTHOR_NAME:-${USER:-}}}}"
24
- BRANCH="${3:-$(git branch --show-current 2>/dev/null || echo 'unknown')}"
25
- WAVE="${4:-${STATE_WAVE:-1}}"
26
- MODE="${5:-${STATE_MODE:-guided}}"
27
- fi
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
+
32
+ # --- git identity for actor attribution ---
33
+ # Resolve once from repo git config; used when no actor is passed explicitly.
34
+ # GIT_AUTHOR_NAME is an env var usually unset — falling through to $USER
35
+ # attributes the savepoint to the OS user instead of the git identity.
36
+ GIT_NAME="$(git config user.name 2>/dev/null || true)"
37
+ GIT_EMAIL="$(git config user.email 2>/dev/null || true)"
38
+ if [ -n "$GIT_NAME" ] && [ -n "$GIT_EMAIL" ]; then GIT_ID="$GIT_NAME <$GIT_EMAIL>"; else GIT_ID="$GIT_NAME"; fi
39
+
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
28
52
 
29
53
  # mission allowlist — MISSION feeds paths + sed + node argv; traversal or
30
54
  # sed metacharacters must not reach filesystem operations
@@ -37,15 +61,32 @@ if [[ "$MISSION" =~ ^\.+$ ]]; then
37
61
  die "invalid mission name \"$MISSION\" (allowlist: [a-zA-Z0-9._-], not a dot-path)"
38
62
  fi
39
63
 
40
- # per-branch state file when --branch used
41
- BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-')
42
- if [ "$BRANCH_MODE" -eq 1 ]; then
43
- STATE_FILE="$MUGIWARA_DIR/state-${BRANCH_SLUG}.json"
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"
44
84
  else
45
- STATE_FILE="$MUGIWARA_DIR/state.json"
85
+ STATE_FILE="$STATE_DIR/state.json"
86
+ CONTINUE_FILE="$CONTINUE_DIR/state.json"
46
87
  fi
47
88
 
48
- [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [actor] [branch] [wave] [mode]"
89
+ [ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode]"
49
90
  [ -d .git ] || die "not a git repository"
50
91
 
51
92
  # --- computed fields ---
@@ -55,17 +96,23 @@ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
55
96
  CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || true)
56
97
  FILES_TOUCHED=$( [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | wc -l | tr -d ' ' || echo 0 )
57
98
 
99
+ LOC_INS=0
100
+ LOC_DEL=0
58
101
  LOC_DELTA=0
102
+ LOC_CHURN=0
59
103
  if [ "$BASE_SHA" != "unknown" ]; then
60
104
  STAT=$(git diff --shortstat "$BASE_SHA"..HEAD 2>/dev/null || echo "")
61
105
  INS=$(echo "$STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
62
106
  DEL=$(echo "$STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
63
- LOC_DELTA=$(( ${INS:-0} - ${DEL:-0} ))
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 ))
64
112
  fi
65
113
  [ -z "$LOC_DELTA" ] && LOC_DELTA=0
66
114
 
67
- SENSITIVE_PATTERNS="auth/|payment/|billing/|crypto/|secrets/|\.env$|config/.*key|migration/|\.sql$|schema\.|\.prisma$|\.terraform|\.tf$"
68
- 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)
69
116
 
70
117
  LANE="direct"
71
118
  LANE_REASON=""
@@ -101,7 +148,6 @@ fi
101
148
  # path-weighted sizing (mirrors lane.sh): docs-only changes outside the product
102
149
  # surface never escalate to full from file count alone; sensitive-path
103
150
  # escalation above still wins.
104
- PRODUCT_PAT="^content/|^src/|^scripts/|^test/|^hooks/|^\.opencode/|^\.claude/|^evals/"
105
151
  if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ]; then
106
152
  CODE_COUNT=$(echo "$CHANGED_FILES" | grep -E "$PRODUCT_PAT" 2>/dev/null | grep -c . || true)
107
153
  if [ -z "$CODE_COUNT" ] || [ "$CODE_COUNT" -eq 0 ] 2>/dev/null; then
@@ -111,6 +157,42 @@ if [ "$LANE" = "full" ] && [ -z "$SENSITIVE_PATHS" ] && [ -n "$CHANGED_FILES" ];
111
157
  fi
112
158
  fi
113
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
+
114
196
  # task counts from plan doc — plan is written date-prefixed (plans/YYYY-MM-DD-<mission>.md)
115
197
  # or bare (plans/<mission>.md); glob both, first match wins.
116
198
  PLAN_FILE=$(ls "$MUGIWARA_DIR"/plans/${MISSION}.md "$MUGIWARA_DIR"/plans/*-${MISSION}.md 2>/dev/null | head -1 || true)
@@ -160,17 +242,18 @@ fi
160
242
 
161
243
  # tokens proxy (F7): deterministic estimate when the harness does not report
162
244
  # real usage. Monotonic beats precise — LANE_BASE stands in for the skills
163
- # loaded this lane; loc_delta and written-artifact words scale with growth.
245
+ # loaded this lane (measured from content, validated by scripts/lane-base.ts);
246
+ # loc_churn and written-artifact words scale with growth.
164
247
  # MUGIWARA_TOKENS overrides as the reported value.
165
248
  LANE_BASE=0
166
249
  case "$LANE" in
167
- lean) LANE_BASE=1500 ;;
168
- standard) LANE_BASE=4000 ;;
169
- full) LANE_BASE=9000 ;;
170
- spike) LANE_BASE=1000 ;;
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 ;;
171
254
  esac
172
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 ' ')
173
- LOC_TOKENS=$(( LOC_DELTA > 0 ? LOC_DELTA * 12 : 0 ))
256
+ LOC_TOKENS=$(( LOC_CHURN * 12 ))
174
257
  TOKENS_SOURCE="computed"
175
258
  TOKENS_EST=$(( LANE_BASE + DOC_WORDS * 135 / 100 + LOC_TOKENS ))
176
259
  if [ -n "${MUGIWARA_TOKENS:-}" ]; then
@@ -181,10 +264,10 @@ fi
181
264
  # budget per lane
182
265
  BUDGET=0
183
266
  case "$LANE" in
184
- lean) BUDGET=4000 ;;
185
- standard) BUDGET=10000 ;;
186
- full) BUDGET=20000 ;;
187
- spike) BUDGET=3000 ;;
267
+ lean) BUDGET=$BUDGET_lean ;;
268
+ standard) BUDGET=$BUDGET_standard ;;
269
+ full) BUDGET=$BUDGET_full ;;
270
+ spike) BUDGET=$BUDGET_spike ;;
188
271
  *) BUDGET=0 ;;
189
272
  esac
190
273
 
@@ -198,30 +281,18 @@ elif [ "$BUDGET" -gt 0 ] && [ "$TOKENS_EST" -ge "$WARN_AT" ] 2>/dev/null; then
198
281
  STATUS="warn"
199
282
  fi
200
283
 
201
- mkdir -p "$MUGIWARA_DIR"
202
-
203
- # --- lane-rise compare (F9): read previous state, flag escalation ---
204
- LANE_PREV=""
205
- LANE_ROSE=false
206
- if [ -f "$STATE_FILE" ]; then
207
- 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)
208
- if [ -n "$LANE_PREV" ] && [ "$LANE_PREV" != "$LANE" ]; then
209
- # lane order: direct < lean < standard < full < spike (spike resizes, not a rise)
210
- case "$LANE_PREV:$LANE" in
211
- direct:lean|direct:standard|direct:full|lean:standard|lean:full|standard:full) LANE_ROSE=true ;;
212
- *) LANE_ROSE=false ;;
213
- esac
214
- fi
215
- fi
284
+ mkdir -p "$MUGIWARA_DIR/state/$MISSION" "$MUGIWARA_DIR/continue/$MISSION"
216
285
 
217
286
  node -e "
218
287
  const data = {
219
288
  mission: process.argv[1],
289
+ member: process.argv[31] || null,
220
290
  actor: process.argv[2],
221
291
  branch: process.argv[3],
222
292
  lane: process.argv[4],
223
293
  lane_reason: process.argv[5],
224
294
  lane_prev: process.argv[24] || null,
295
+ lane_peak: process.argv[27] || null,
225
296
  lane_rose: process.argv[25] === 'true',
226
297
  wave: parseInt(process.argv[6], 10),
227
298
  mode: process.argv[7],
@@ -229,6 +300,9 @@ const data = {
229
300
  head_sha: process.argv[9],
230
301
  files_touched: parseInt(process.argv[10], 10),
231
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,
232
306
  sensitive_paths: process.argv[12] ? process.argv[12].split(',').filter(Boolean) : [],
233
307
  tasks: { done: parseInt(process.argv[13], 10), total: parseInt(process.argv[14], 10) },
234
308
  blockers_open: parseInt(process.argv[15], 10),
@@ -249,9 +323,57 @@ require('fs').writeFileSync(process.argv[23], JSON.stringify(data, null, 2) + '\
249
323
  "$BLOCKERS_OPEN" "$HEAL_CYCLE" "$TOKENS_EST" "$BUDGET" \
250
324
  "$STATUS" "$SKILL_VERSION" "$EVIDENCE" \
251
325
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
252
- "$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"
253
328
 
254
329
  if [ "$LANE_ROSE" = true ]; then
255
330
  echo "⚠ LANE ROSE: $LANE_PREV → $LANE ($LANE_REASON) — escalate per check-in protocol"
256
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
+
257
379
  echo "✓ savepoint written: $STATE_FILE (lane=$LANE, wave=$WAVE, files=$FILES_TOUCHED)"