@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.
- 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-onboard.md +15 -0
- 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 +0 -1
- package/.opencode/plugins/mugiwara.mjs +33 -22
- package/AGENTS.md +16 -5
- package/README.md +80 -27
- 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/luffy-orchestrator.md +14 -4
- package/content/agents/memory-keeper.md +1 -1
- package/content/agents/nami-planner.md +2 -2
- package/content/agents/onboarding-guide.md +24 -45
- 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 +6 -2
- 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-backend/SKILL.md +1 -1
- package/content/skills/mugiwara-brainstorm/SKILL.md +7 -1
- 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 +22 -22
- package/content/skills/mugiwara-execution/references/dispatch.md +4 -3
- package/content/skills/mugiwara-execution/references/resume-batching.md +4 -4
- package/content/skills/mugiwara-frontend/SKILL.md +1 -1
- package/content/skills/mugiwara-healing/SKILL.md +3 -38
- package/content/skills/mugiwara-healing/references/workers.md +38 -0
- package/content/skills/mugiwara-orchestration/SKILL.md +30 -33
- package/content/skills/mugiwara-orchestration/references/check-ins.md +42 -0
- package/content/skills/mugiwara-orchestration/references/closure.md +1 -1
- 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 -9
- package/content/skills/mugiwara-resume/SKILL.md +37 -22
- package/content/skills/mugiwara-ship/SKILL.md +2 -24
- package/content/skills/mugiwara-ship/references/cleanup.md +25 -0
- package/content/skills/mugiwara-sunset/SKILL.md +1 -1
- package/content/skills/mugiwara-workflow/SKILL.md +4 -2
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +16 -7
- package/content/skills/using-mugiwara/SKILL.md +5 -1
- package/dist/mugiwara.js +107 -37
- 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/scripts/evidence.sh +9 -0
- package/scripts/gate-selftest.ts +123 -0
- package/scripts/lane-base.ts +114 -0
- package/scripts/lane.sh +4 -2
- package/scripts/lib/lane-base.sh +19 -0
- package/scripts/lib/patterns.sh +15 -0
- package/scripts/mission-report.sh +21 -5
- package/scripts/onboard.ts +3 -29
- package/scripts/release-notes.ts +152 -75
- package/scripts/savepoint.sh +178 -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 +39 -24
- package/src/targets/claude.ts +5 -3
- package/src/targets/opencode.ts +12 -8
package/scripts/release-notes.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
package/scripts/savepoint.sh
CHANGED
|
@@ -1,30 +1,54 @@
|
|
|
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
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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="$
|
|
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> [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
standard) LANE_BASE
|
|
169
|
-
full) LANE_BASE
|
|
170
|
-
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 ;;
|
|
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=$((
|
|
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
|
|
185
|
-
standard) BUDGET
|
|
186
|
-
full) BUDGET
|
|
187
|
-
spike) BUDGET
|
|
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)"
|