@jjlabsio/claude-crew 0.1.45 → 0.1.47

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.
@@ -11,7 +11,7 @@
11
11
  "name": "claude-crew",
12
12
  "source": "./",
13
13
  "description": "오케스트레이터 + PM, 플래너, 개발, QA, 마케팅 에이전트 팀으로 단일 제품의 개발과 마케팅을 통합 관리",
14
- "version": "0.1.45",
14
+ "version": "0.1.47",
15
15
  "author": {
16
16
  "name": "Jaejin Song",
17
17
  "email": "wowlxx28@gmail.com"
@@ -28,5 +28,5 @@
28
28
  "category": "workflow"
29
29
  }
30
30
  ],
31
- "version": "0.1.45"
31
+ "version": "0.1.47"
32
32
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crew",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": {
6
6
  "name": "Jaejin Song",
package/hooks/hooks.json CHANGED
@@ -5,6 +5,11 @@
5
5
  {
6
6
  "matcher": "*",
7
7
  "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"$CLAUDE_PLUGIN_ROOT/scripts/crew-session-restore.mjs\"",
11
+ "timeout": 5
12
+ },
8
13
  {
9
14
  "type": "command",
10
15
  "command": "node \"$CLAUDE_PLUGIN_ROOT/scripts/setup-hud.mjs\"",
@@ -13,6 +18,30 @@
13
18
  ]
14
19
  }
15
20
  ],
21
+ "PreCompact": [
22
+ {
23
+ "matcher": "*",
24
+ "hooks": [
25
+ {
26
+ "type": "command",
27
+ "command": "node \"$CLAUDE_PLUGIN_ROOT/scripts/crew-pre-compact.mjs\"",
28
+ "timeout": 10
29
+ }
30
+ ]
31
+ }
32
+ ],
33
+ "Stop": [
34
+ {
35
+ "matcher": "*",
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"$CLAUDE_PLUGIN_ROOT/scripts/crew-context-guard-stop.mjs\"",
40
+ "timeout": 5
41
+ }
42
+ ]
43
+ }
44
+ ],
16
45
  "PreToolUse": [
17
46
  {
18
47
  "matcher": "Agent|Task",
@@ -0,0 +1,72 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+
5
+ const STATE_VERSION = 1;
6
+ const CURRENT_RUN_FILE = "current-run.json";
7
+
8
+ export function currentRunPath(crewDir) {
9
+ return join(crewDir, "state", CURRENT_RUN_FILE);
10
+ }
11
+
12
+ export function checkpointsDir(crewDir) {
13
+ return join(crewDir, "state", "checkpoints");
14
+ }
15
+
16
+ export async function readRunState(crewDir) {
17
+ const path = currentRunPath(crewDir);
18
+ if (!existsSync(path)) return null;
19
+
20
+ try {
21
+ return JSON.parse(await readFile(path, "utf8"));
22
+ } catch (error) {
23
+ throw new Error(`Failed to read crew run state at ${path}: ${error.message}`);
24
+ }
25
+ }
26
+
27
+ export async function writeRunState(crewDir, state) {
28
+ const stateDir = join(crewDir, "state");
29
+ await mkdir(stateDir, { recursive: true });
30
+
31
+ const nextState = {
32
+ ...state,
33
+ version: state.version ?? STATE_VERSION,
34
+ lastUpdatedAt: new Date().toISOString()
35
+ };
36
+
37
+ const destination = currentRunPath(crewDir);
38
+ const tempPath = join(
39
+ stateDir,
40
+ `.${CURRENT_RUN_FILE}.${process.pid}.${Date.now()}.tmp`
41
+ );
42
+
43
+ await writeFile(tempPath, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
44
+ await rename(tempPath, destination);
45
+ return nextState;
46
+ }
47
+
48
+ export async function createCheckpoint(crewDir) {
49
+ const state = await readRunState(crewDir);
50
+ if (!state) return null;
51
+
52
+ const dir = checkpointsDir(crewDir);
53
+ await mkdir(dir, { recursive: true });
54
+
55
+ const timestamp = new Date().toISOString().replace(/:/g, "-");
56
+ const checkpointPath = join(dir, `checkpoint-${timestamp}.json`);
57
+ await writeFile(checkpointPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
58
+ return checkpointPath;
59
+ }
60
+
61
+ export async function getLatestCheckpoint(crewDir) {
62
+ const dir = checkpointsDir(crewDir);
63
+ if (!existsSync(dir)) return null;
64
+
65
+ const { readdir } = await import("node:fs/promises");
66
+ const files = (await readdir(dir))
67
+ .filter((file) => /^checkpoint-.+\.json$/.test(file))
68
+ .sort((a, b) => a.localeCompare(b));
69
+
70
+ if (files.length === 0) return null;
71
+ return join(dir, basename(files.at(-1)));
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlabsio/claude-crew",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": "Jaejin Song <wowlxx28@gmail.com>",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  ".claude-plugin/",
20
20
  "agents/",
21
21
  "data/",
22
+ "lib/",
22
23
  "skills/",
23
24
  "hooks/",
24
25
  "hud/",
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const BYPASS_REASONS = [
9
+ "context_limit",
10
+ "context_window",
11
+ "context_full",
12
+ "max_tokens",
13
+ "conversation_too_long",
14
+ "abort",
15
+ "cancel",
16
+ "interrupt",
17
+ "auth"
18
+ ];
19
+
20
+ async function readStdin(timeoutMs = 3000) {
21
+ if (process.stdin.isTTY) return null;
22
+ return new Promise((resolve) => {
23
+ let data = "";
24
+ const timer = setTimeout(() => resolve(data || null), timeoutMs);
25
+ process.stdin.setEncoding("utf8");
26
+ process.stdin.on("data", (chunk) => { data += chunk; });
27
+ process.stdin.on("end", () => {
28
+ clearTimeout(timer);
29
+ resolve(data || null);
30
+ });
31
+ process.stdin.on("error", () => {
32
+ clearTimeout(timer);
33
+ resolve(null);
34
+ });
35
+ });
36
+ }
37
+
38
+ function pass() {
39
+ return { continue: true };
40
+ }
41
+
42
+ function sessionId(event) {
43
+ return String(event.session_id || event.sessionId || event.session?.id || "unknown")
44
+ .replace(/[^a-zA-Z0-9_.-]/g, "_");
45
+ }
46
+
47
+ function reasonText(event) {
48
+ return [
49
+ event.stop_reason,
50
+ event.stopReason,
51
+ event.reason,
52
+ event.error,
53
+ event.message
54
+ ].filter(Boolean).join(" ").toLowerCase();
55
+ }
56
+
57
+ function shouldBypass(event) {
58
+ const text = reasonText(event);
59
+ return BYPASS_REASONS.some((reason) => text.includes(reason));
60
+ }
61
+
62
+ function collectUsage(value, found = { inputTokens: null, contextWindow: null }) {
63
+ if (!value || typeof value !== "object") return found;
64
+
65
+ for (const [key, nested] of Object.entries(value)) {
66
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
67
+ if (
68
+ ["inputtokens", "inputtoken", "tokensinput", "totalinputtokens"].includes(normalized)
69
+ && Number.isFinite(Number(nested))
70
+ ) {
71
+ found.inputTokens = Math.max(found.inputTokens ?? 0, Number(nested));
72
+ }
73
+ if (
74
+ ["contextwindow", "contextwindowsize", "contextlimit", "maxcontexttokens"].includes(normalized)
75
+ && Number.isFinite(Number(nested))
76
+ ) {
77
+ found.contextWindow = Math.max(found.contextWindow ?? 0, Number(nested));
78
+ }
79
+ collectUsage(nested, found);
80
+ }
81
+
82
+ return found;
83
+ }
84
+
85
+ function parseUsageFromText(text) {
86
+ const inputMatches = [...text.matchAll(/"?(?:input_tokens|inputTokens|inputToken|total_input_tokens)"?\s*[:=]\s*(\d+)/gi)];
87
+ const windowMatches = [...text.matchAll(/"?(?:context_window|contextWindow|context_limit|max_context_tokens)"?\s*[:=]\s*(\d+)/gi)];
88
+
89
+ return {
90
+ inputTokens: inputMatches.length > 0 ? Math.max(...inputMatches.map((match) => Number(match[1]))) : null,
91
+ contextWindow: windowMatches.length > 0 ? Math.max(...windowMatches.map((match) => Number(match[1]))) : null
92
+ };
93
+ }
94
+
95
+ async function usageFromTranscript(event) {
96
+ const transcriptPath = event.transcript_path || event.transcriptPath;
97
+ if (!transcriptPath || !existsSync(transcriptPath)) {
98
+ return { inputTokens: null, contextWindow: null };
99
+ }
100
+
101
+ try {
102
+ const text = await readFile(transcriptPath, "utf8");
103
+ return parseUsageFromText(text.slice(-128 * 1024));
104
+ } catch {
105
+ return { inputTokens: null, contextWindow: null };
106
+ }
107
+ }
108
+
109
+ async function usageFor(event) {
110
+ const fromEvent = collectUsage(event);
111
+ const fromTranscript = await usageFromTranscript(event);
112
+ return {
113
+ inputTokens: fromEvent.inputTokens ?? fromTranscript.inputTokens,
114
+ contextWindow: fromEvent.contextWindow ?? fromTranscript.contextWindow
115
+ };
116
+ }
117
+
118
+ function counterPath(id) {
119
+ return join(tmpdir(), `crew-stop-guard-${id}.json`);
120
+ }
121
+
122
+ async function readCount(id) {
123
+ const path = counterPath(id);
124
+ if (!existsSync(path)) return 0;
125
+ try {
126
+ const payload = JSON.parse(await readFile(path, "utf8"));
127
+ return Number(payload.count) || 0;
128
+ } catch {
129
+ return 0;
130
+ }
131
+ }
132
+
133
+ async function writeCount(id, count) {
134
+ await writeFile(
135
+ counterPath(id),
136
+ `${JSON.stringify({ count, updatedAt: new Date().toISOString() }, null, 2)}\n`,
137
+ "utf8"
138
+ );
139
+ }
140
+
141
+ function blockMessage(percent) {
142
+ return [
143
+ "Crew context guard: this session appears to be near the context window.",
144
+ `Estimated usage: ${Math.round(percent)}%.`,
145
+ "Run /compact before continuing so the crew workflow can checkpoint and preserve pending role, artifacts, and resume handles."
146
+ ].join("\n");
147
+ }
148
+
149
+ async function main() {
150
+ const raw = await readStdin();
151
+ if (!raw) {
152
+ console.log(JSON.stringify(pass()));
153
+ return;
154
+ }
155
+
156
+ let event;
157
+ try { event = JSON.parse(raw); } catch {
158
+ console.log(JSON.stringify(pass()));
159
+ return;
160
+ }
161
+
162
+ if (shouldBypass(event)) {
163
+ console.log(JSON.stringify(pass()));
164
+ return;
165
+ }
166
+
167
+ const { inputTokens, contextWindow } = await usageFor(event);
168
+ if (!inputTokens || !contextWindow) {
169
+ console.log(JSON.stringify(pass()));
170
+ return;
171
+ }
172
+
173
+ const percent = (inputTokens / contextWindow) * 100;
174
+ if (percent < 75 || percent >= 95) {
175
+ console.log(JSON.stringify(pass()));
176
+ return;
177
+ }
178
+
179
+ const id = sessionId(event);
180
+ const count = await readCount(id);
181
+ if (count >= 2) {
182
+ console.log(JSON.stringify(pass()));
183
+ return;
184
+ }
185
+
186
+ await writeCount(id, count + 1);
187
+ const message = blockMessage(percent);
188
+ console.log(JSON.stringify({
189
+ continue: true,
190
+ decision: "block",
191
+ reason: message,
192
+ systemMessage: message,
193
+ hookSpecificOutput: {
194
+ hookEventName: "Stop",
195
+ additionalContext: message
196
+ }
197
+ }));
198
+ }
199
+
200
+ main();
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { relative, resolve } from "node:path";
4
+
5
+ import { createCheckpoint, readRunState } from "../lib/crew-state.mjs";
6
+
7
+ async function readStdin(timeoutMs = 3000) {
8
+ if (process.stdin.isTTY) return null;
9
+ return new Promise((resolveInput) => {
10
+ let data = "";
11
+ const timer = setTimeout(() => resolveInput(data || null), timeoutMs);
12
+ process.stdin.setEncoding("utf8");
13
+ process.stdin.on("data", (chunk) => { data += chunk; });
14
+ process.stdin.on("end", () => {
15
+ clearTimeout(timer);
16
+ resolveInput(data || null);
17
+ });
18
+ process.stdin.on("error", () => {
19
+ clearTimeout(timer);
20
+ resolveInput(null);
21
+ });
22
+ });
23
+ }
24
+
25
+ function emptyResponse() {
26
+ return { continue: true };
27
+ }
28
+
29
+ function hookResponse(message) {
30
+ return {
31
+ continue: true,
32
+ systemMessage: message,
33
+ hookSpecificOutput: {
34
+ hookEventName: "PreCompact",
35
+ additionalContext: message
36
+ }
37
+ };
38
+ }
39
+
40
+ function formatPath(cwd, path) {
41
+ if (!path) return null;
42
+ if (!path.startsWith("/")) return path;
43
+ return relative(cwd, path) || ".";
44
+ }
45
+
46
+ function pendingText(state) {
47
+ return state.pendingStatus || state.pending || "none";
48
+ }
49
+
50
+ function buildMessage({ state, checkpointPath, cwd }) {
51
+ const artifacts = Array.isArray(state.artifactPaths) ? state.artifactPaths : [];
52
+ const handles = state.agentHandles && typeof state.agentHandles === "object"
53
+ ? Object.entries(state.agentHandles)
54
+ : [];
55
+
56
+ return [
57
+ "# Crew PreCompact Checkpoint",
58
+ "",
59
+ `Workflow: ${state.workflow || "unknown"}`,
60
+ `Phase: ${state.phase || "unknown"}`,
61
+ `Task: ${state.taskFile || state.taskId || "unknown"}`,
62
+ `Active role: ${state.activeRole || "unknown"}`,
63
+ `Pending: ${pendingText(state)}`,
64
+ `Checkpoint: ${formatPath(cwd, checkpointPath)}`,
65
+ "Artifacts:",
66
+ ...(artifacts.length > 0 ? artifacts.map((path) => `- ${path}`) : ["- none"]),
67
+ "Resume handles:",
68
+ ...(handles.length > 0 ? handles.map(([role, handle]) => `- ${role}: ${handle}`) : ["- none"]),
69
+ "",
70
+ "After compaction, inspect the checkpoint and continue from the pending phase unless the user's newest request overrides it."
71
+ ].join("\n");
72
+ }
73
+
74
+ async function main() {
75
+ const raw = await readStdin();
76
+ let event = {};
77
+ if (raw) {
78
+ try { event = JSON.parse(raw); } catch { /* ignore */ }
79
+ }
80
+
81
+ const cwd = resolve(event.cwd || process.cwd());
82
+ const crewDir = resolve(cwd, ".crew");
83
+
84
+ try {
85
+ const state = await readRunState(crewDir);
86
+ if (!state) {
87
+ console.log(JSON.stringify(emptyResponse()));
88
+ return;
89
+ }
90
+
91
+ const checkpointPath = await createCheckpoint(crewDir);
92
+ console.log(JSON.stringify(hookResponse(buildMessage({ state, checkpointPath, cwd }))));
93
+ } catch (error) {
94
+ console.log(JSON.stringify({
95
+ continue: true,
96
+ hookSpecificOutput: {
97
+ hookEventName: "PreCompact",
98
+ additionalContext: `CREW checkpoint failed: ${error.message}`
99
+ }
100
+ }));
101
+ }
102
+ }
103
+
104
+ main();
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { relative, resolve } from "node:path";
6
+
7
+ import { getLatestCheckpoint, readRunState } from "../lib/crew-state.mjs";
8
+
9
+ async function readStdin(timeoutMs = 3000) {
10
+ if (process.stdin.isTTY) return null;
11
+ return new Promise((resolveInput) => {
12
+ let data = "";
13
+ const timer = setTimeout(() => resolveInput(data || null), timeoutMs);
14
+ process.stdin.setEncoding("utf8");
15
+ process.stdin.on("data", (chunk) => { data += chunk; });
16
+ process.stdin.on("end", () => {
17
+ clearTimeout(timer);
18
+ resolveInput(data || null);
19
+ });
20
+ process.stdin.on("error", () => {
21
+ clearTimeout(timer);
22
+ resolveInput(null);
23
+ });
24
+ });
25
+ }
26
+
27
+ function emptyResponse() {
28
+ return { continue: true };
29
+ }
30
+
31
+ function hookResponse(message) {
32
+ return {
33
+ continue: true,
34
+ systemMessage: message,
35
+ hookSpecificOutput: {
36
+ hookEventName: "SessionStart",
37
+ additionalContext: message
38
+ }
39
+ };
40
+ }
41
+
42
+ function formatPath(cwd, path) {
43
+ if (!path) return null;
44
+ if (!path.startsWith("/")) return path;
45
+ return relative(cwd, path) || ".";
46
+ }
47
+
48
+ function pendingText(state) {
49
+ return state.pendingStatus || state.pending || "none";
50
+ }
51
+
52
+ function lastArtifact(state) {
53
+ if (state.pendingAgentResultPath) return state.pendingAgentResultPath;
54
+ if (Array.isArray(state.artifactPaths) && state.artifactPaths.length > 0) {
55
+ return state.artifactPaths.at(-1);
56
+ }
57
+ return "none";
58
+ }
59
+
60
+ async function readJson(path) {
61
+ return JSON.parse(await readFile(path, "utf8"));
62
+ }
63
+
64
+ async function findActiveState(crewDir) {
65
+ const current = await readRunState(crewDir);
66
+ if (current?.active) {
67
+ return { state: current, checkpointPath: await getLatestCheckpoint(crewDir) };
68
+ }
69
+
70
+ const checkpointPath = await getLatestCheckpoint(crewDir);
71
+ if (!checkpointPath || !existsSync(checkpointPath)) return null;
72
+
73
+ const checkpoint = await readJson(checkpointPath);
74
+ if (!checkpoint?.active) return null;
75
+ return { state: checkpoint, checkpointPath };
76
+ }
77
+
78
+ function buildMessage({ state, checkpointPath, cwd }) {
79
+ const checkpointLine = checkpointPath
80
+ ? formatPath(cwd, checkpointPath)
81
+ : "none";
82
+
83
+ return [
84
+ "<crew-session-restore>",
85
+ "Active crew workflow detected.",
86
+ "",
87
+ `Workflow: ${state.workflow || "unknown"}`,
88
+ `Phase: ${state.phase || "unknown"}`,
89
+ `Task: ${state.taskFile || state.taskId || "unknown"}`,
90
+ `Pending: ${pendingText(state)}`,
91
+ `Last artifact: ${lastArtifact(state)}`,
92
+ `Checkpoint: ${checkpointLine}`,
93
+ "",
94
+ "Treat this as prior-session context. Prioritize the user's newest request. Resume the crew workflow only if the user asks to continue.",
95
+ "</crew-session-restore>"
96
+ ].join("\n");
97
+ }
98
+
99
+ async function main() {
100
+ const raw = await readStdin();
101
+ let event = {};
102
+ if (raw) {
103
+ try { event = JSON.parse(raw); } catch { /* ignore */ }
104
+ }
105
+
106
+ const cwd = resolve(event.cwd || process.cwd());
107
+ const crewDir = resolve(cwd, ".crew");
108
+
109
+ try {
110
+ const active = await findActiveState(crewDir);
111
+ if (!active) {
112
+ console.log(JSON.stringify(emptyResponse()));
113
+ return;
114
+ }
115
+
116
+ console.log(JSON.stringify(hookResponse(buildMessage({ ...active, cwd }))));
117
+ } catch (error) {
118
+ console.log(JSON.stringify({
119
+ continue: true,
120
+ hookSpecificOutput: {
121
+ hookEventName: "SessionStart",
122
+ additionalContext: `CREW restore failed: ${error.message}`
123
+ }
124
+ }));
125
+ }
126
+ }
127
+
128
+ main();
@@ -11,6 +11,7 @@ import { execSync } from 'node:child_process';
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
+ import { PLUGIN_ROOT } from './lib/pluginRoot.mjs';
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Read stdin (with timeout)
@@ -33,6 +34,39 @@ function gitExec(cmd, cwd) {
33
34
  } catch { return null; }
34
35
  }
35
36
 
37
+ function readPluginVersion() {
38
+ const packageJson = JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf-8'));
39
+ return packageJson.version;
40
+ }
41
+
42
+ function updatePluginRootCache(pluginRoot) {
43
+ try {
44
+ const crewDir = join(homedir(), '.claude', 'crew');
45
+ const cachePath = join(crewDir, 'plugin-root.json');
46
+
47
+ let shouldWrite = !existsSync(cachePath);
48
+ if (!shouldWrite) {
49
+ try {
50
+ const current = JSON.parse(readFileSync(cachePath, 'utf-8'));
51
+ shouldWrite = current.pluginRoot !== pluginRoot;
52
+ } catch {
53
+ shouldWrite = true;
54
+ }
55
+ }
56
+
57
+ if (!shouldWrite) return;
58
+
59
+ mkdirSync(crewDir, { recursive: true });
60
+ writeFileSync(cachePath, JSON.stringify({
61
+ pluginRoot,
62
+ version: readPluginVersion(),
63
+ updatedAt: new Date().toISOString(),
64
+ }, null, 2));
65
+ } catch {
66
+ // Best-effort cache for runner fallback. HUD setup must continue on failure.
67
+ }
68
+ }
69
+
36
70
  // ---------------------------------------------------------------------------
37
71
  // Main
38
72
  // ---------------------------------------------------------------------------
@@ -74,6 +108,8 @@ async function main() {
74
108
  writeFileSync(localSettingsPath, JSON.stringify(localSettings, null, 2));
75
109
  }
76
110
 
111
+ updatePluginRootCache(pluginRoot);
112
+
77
113
  console.log(JSON.stringify({ continue: true }));
78
114
  } catch (e) {
79
115
  console.log(JSON.stringify({
@@ -7,20 +7,33 @@ description: 모든 crew 에이전트 dispatch의 중앙 규약 — provider별
7
7
 
8
8
  crew 업무 스킬은 에이전트 provider별 호출 세부사항을 직접 구현하지 않고 이 중앙 규약을 따른다. 본 스킬은 prepare, resolve, dispatch, resume, followup 주입, retry/fallback/escalate 판단의 공통 표면을 정의한다.
9
9
 
10
- 설치 후 drift 차단용 pre-commit hook은 `node scripts/crew-agent-runner.mjs install-hooks`로 설치한다.
10
+ 설치 후 drift 차단용 pre-commit hook은 `node "$CREW_ROOT/scripts/crew-agent-runner.mjs" install-hooks`로 설치한다.
11
11
  (plugin 개발자 전용 — 사용자는 호출하지 않습니다. build/validate는 plugin source repo의 drift 차단 도구입니다.)
12
12
 
13
13
  ## Dispatch 절차
14
14
 
15
15
  업무 스킬(crew-plan/crew-interview/crew-dev)이 role을 실행해야 할 때 본 절차를 따른다.
16
16
 
17
+ ### Step 0. Runner 경로 결정
18
+
19
+ 오케스트레이터는 runner 호출 전에 plugin root를 결정하고, 결정된 경로를 `CREW_ROOT`로 사용한다.
20
+
21
+ Fallback 체인 (순서 중요):
22
+
23
+ 1. `$CLAUDE_PLUGIN_ROOT`가 있으면 사용한다. 정상 Claude Code plugin 환경이므로 추가 검증하지 않는다.
24
+ 2. `~/.claude/crew/plugin-root.json`의 `pluginRoot`를 읽는다. 해당 경로에 `scripts/crew-agent-runner.mjs`가 존재하면 사용하고, 없으면 다음 fallback으로 진행한다.
25
+ 3. `git rev-parse --show-toplevel` 결과를 dev 환경 전용 후보로 사용한다. 해당 경로의 `package.json`에서 `name`이 `@jjlabsio/claude-crew`인지 검증하고, 맞으면 사용한다. 아니면 다음 fallback으로 진행한다.
26
+ 4. 모든 fallback이 실패하면 `'/crew-setup을 먼저 실행하세요'` 에러 메시지로 중단한다.
27
+
28
+ 이후 모든 runner 호출은 `node "$CREW_ROOT/scripts/crew-agent-runner.mjs"` 형식을 사용한다.
29
+
17
30
  ### 1. request 객체 작성
18
31
 
19
32
  `{ role, inputs (path+content), instruction, successGate, failureHandling, taskId }` 형태의 임시 JSON 파일을 작성한다.
20
33
 
21
34
  ### 2. prepare
22
35
 
23
- 오케스트레이터는 `node "$CLAUDE_PLUGIN_ROOT/scripts/crew-agent-runner.mjs" prepare --role <role> --request-file <request-file> --json`을 실행한다.
36
+ 오케스트레이터는 `node "$CREW_ROOT/scripts/crew-agent-runner.mjs" prepare --role <role> --request-file <request-file> --json`을 실행한다.
24
37
  prepare는 provider/model/contract를 해석하고 다음 action 중 하나를 반환한다.
25
38
 
26
39
  ### 3a. dispatch action
@@ -67,8 +80,8 @@ capability를 넘어선 도구 요청이다. 오케스트레이터가 `contract.
67
80
 
68
81
  ### Codex 경로
69
82
 
70
- 1. `node scripts/crew-agent-runner.mjs render-followup --previous-result <file> --new-input <file>` 실행 → followup prompt 문자열 → 임시 파일에 저장.
71
- 2. `node scripts/crew-agent-runner.mjs dispatch --role <role> --request-file <new-request-with-followup-prompt> --resume-handle <agent_handle> --json` 실행.
83
+ 1. `node "$CREW_ROOT/scripts/crew-agent-runner.mjs" render-followup --previous-result <file> --new-input <file>` 실행 → followup prompt 문자열 → 임시 파일에 저장.
84
+ 2. `node "$CREW_ROOT/scripts/crew-agent-runner.mjs" dispatch --role <role> --request-file <new-request-with-followup-prompt> --resume-handle <agent_handle> --json` 실행.
72
85
  - 내부적으로 runner가 `crew-codex-companion.mjs task-resume-candidate`로 thread 일치 검증 후 `task --resume-last`를 호출하고 AgentResult를 정규화한다.
73
86
  3. AgentResult JSON을 받아 다음 상태 처리.
74
87
 
@@ -102,6 +102,27 @@ push는 하지 않는다.
102
102
 
103
103
  ---
104
104
 
105
+ ## Step 2b — Plugin Root 영속화
106
+
107
+ `$CLAUDE_PLUGIN_ROOT`가 살아있는 setup 시점에 runner fallback용 plugin root를 사용자 데이터 영역에 저장한다.
108
+
109
+ 1. `~/.claude/crew/` 디렉토리가 없으면 생성한다.
110
+ 2. `~/.claude/crew/plugin-root.json`을 다음 형식으로 쓴다. 기존 파일이 있으면 덮어쓴다.
111
+ ```json
112
+ {
113
+ "pluginRoot": "<$CLAUDE_PLUGIN_ROOT 값>",
114
+ "version": "<현재 플러그인 버전>",
115
+ "updatedAt": "<ISO timestamp>"
116
+ }
117
+ ```
118
+ 3. plugin 자체 데이터인 version은 plugin root 기준 `package.json`에서 읽는다.
119
+
120
+ **주의**:
121
+ - plugin root는 `$CLAUDE_PLUGIN_ROOT` 값을 사용한다.
122
+ - `~/.claude/crew/plugin-root.json`은 사용자 데이터이므로 home 기준 경로에 저장한다.
123
+
124
+ ---
125
+
105
126
  ## Step 3 — Provider 설정
106
127
 
107
128
  에이전트별로 어떤 provider(claude/codex)와 model을 사용할지 설정한다.