@jjlabsio/claude-crew 0.1.32 → 0.1.34

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +8 -7
  3. package/README.md +22 -0
  4. package/agents/code-reviewer.md +7 -0
  5. package/agents/dev.md +7 -0
  6. package/agents/explorer.md +7 -0
  7. package/agents/plan-evaluator.md +7 -0
  8. package/agents/planner.md +7 -0
  9. package/agents/pm.md +7 -0
  10. package/agents/qa.md +8 -1
  11. package/agents/researcher.md +7 -0
  12. package/agents/techlead.md +7 -0
  13. package/data/agent-contracts.json +350 -0
  14. package/data/agent-instructions/code-reviewer.md +47 -0
  15. package/data/agent-instructions/dev.md +48 -0
  16. package/data/agent-instructions/explorer.md +14 -0
  17. package/data/agent-instructions/plan-evaluator.md +68 -0
  18. package/data/agent-instructions/planner.md +73 -0
  19. package/data/agent-instructions/pm.md +47 -0
  20. package/data/agent-instructions/qa.md +65 -0
  21. package/data/agent-instructions/researcher.md +15 -0
  22. package/data/agent-instructions/techlead.md +66 -0
  23. package/package.json +8 -3
  24. package/scripts/crew-agent-runner.mjs +323 -0
  25. package/scripts/lib/build.mjs +213 -0
  26. package/scripts/lib/cli.mjs +30 -0
  27. package/scripts/lib/config.mjs +33 -0
  28. package/scripts/lib/contracts.mjs +146 -0
  29. package/scripts/lib/dispatch.mjs +241 -0
  30. package/scripts/lib/installHooks.mjs +136 -0
  31. package/scripts/lib/pluginRoot.mjs +10 -0
  32. package/scripts/lib/render.mjs +110 -0
  33. package/scripts/lib/renderFollowup.mjs +51 -0
  34. package/scripts/lib/resolve.mjs +72 -0
  35. package/scripts/lib/validate.mjs +100 -0
  36. package/skills/crew-agent-runner/SKILL.md +115 -0
  37. package/skills/crew-dev/SKILL.md +162 -780
  38. package/skills/crew-interview/SKILL.md +135 -44
  39. package/skills/crew-plan/SKILL.md +217 -414
  40. package/skills/crew-setup/SKILL.md +32 -19
@@ -0,0 +1,136 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+
5
+ export const MANAGED_BLOCK = [
6
+ "# >>> crew-agent-runner managed >>>",
7
+ "node scripts/crew-agent-runner.mjs validate || {",
8
+ " echo \"crew-agent-runner: validate failed. Run 'node scripts/crew-agent-runner.mjs build' to fix drift.\" >&2",
9
+ " exit 1",
10
+ "}",
11
+ "# <<< crew-agent-runner managed <<<"
12
+ ].join("\n");
13
+
14
+ const START_MARKER = "# >>> crew-agent-runner managed >>>";
15
+ const END_MARKER = "# <<< crew-agent-runner managed <<<";
16
+ const BASH_SHEBANG = "#!/usr/bin/env bash";
17
+
18
+ export async function installHooks({ root = process.cwd() } = {}) {
19
+ const projectRoot = resolve(root);
20
+ if (!(await isPluginSourceRepo(projectRoot))) {
21
+ throw new Error(
22
+ "install-hooks is for claude-crew plugin developers only. " +
23
+ "End users do not need this command (build/validate are dev tools). " +
24
+ "If you are developing claude-crew, run from the plugin source repo root."
25
+ );
26
+ }
27
+
28
+ const hooksDir = await resolveHooksDir(projectRoot);
29
+ const hookPath = join(hooksDir, "pre-commit");
30
+ await mkdir(hooksDir, { recursive: true });
31
+
32
+ const existing = await readUtf8OrNull(hookPath);
33
+ const base = ensureShebang(existing ?? "");
34
+ const next = upsertManagedBlock(base, MANAGED_BLOCK);
35
+
36
+ if (next !== existing) {
37
+ await writeFile(hookPath, next, "utf8");
38
+ }
39
+ await chmod(hookPath, 0o755);
40
+
41
+ return { hookPath };
42
+ }
43
+
44
+ async function isPluginSourceRepo(root) {
45
+ try {
46
+ const pluginJsonPath = join(root, ".claude-plugin", "plugin.json");
47
+ const packageJsonPath = join(root, "package.json");
48
+ const pluginJsonStat = await stat(pluginJsonPath);
49
+ const packageJsonStat = await stat(packageJsonPath);
50
+ if (!pluginJsonStat.isFile() || !packageJsonStat.isFile()) {
51
+ return false;
52
+ }
53
+
54
+ const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
55
+ return pkg.name === "@jjlabsio/claude-crew";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export function ensureShebang(content) {
62
+ if (content.startsWith("#!")) {
63
+ return content;
64
+ }
65
+
66
+ return `${BASH_SHEBANG}\n${content}`;
67
+ }
68
+
69
+ export function upsertManagedBlock(content, block) {
70
+ const start = content.indexOf(START_MARKER);
71
+ if (start !== -1) {
72
+ const end = content.indexOf(END_MARKER, start + START_MARKER.length);
73
+ if (end !== -1) {
74
+ const afterEnd = end + END_MARKER.length;
75
+ return `${content.slice(0, start)}${block}${content.slice(afterEnd)}`;
76
+ }
77
+ }
78
+
79
+ const separator = content.length === 0 || content.endsWith("\n") ? "" : "\n";
80
+ return `${content}${separator}${block}\n`;
81
+ }
82
+
83
+ async function resolveHooksDir(projectRoot) {
84
+ const configuredHooksPath = readConfiguredHooksPath(projectRoot);
85
+ if (configuredHooksPath) {
86
+ return isAbsolute(configuredHooksPath)
87
+ ? configuredHooksPath
88
+ : resolve(projectRoot, configuredHooksPath);
89
+ }
90
+
91
+ const dotGit = join(projectRoot, ".git");
92
+ try {
93
+ const dotGitStat = await stat(dotGit);
94
+ if (dotGitStat.isDirectory()) {
95
+ return join(dotGit, "hooks");
96
+ }
97
+ } catch (error) {
98
+ if (error?.code === "ENOENT") {
99
+ return join(dotGit, "hooks");
100
+ }
101
+ throw error;
102
+ }
103
+
104
+ const gitFile = await readFile(dotGit, "utf8");
105
+ const match = /^gitdir:\s*(.+)\s*$/m.exec(gitFile);
106
+ if (!match) {
107
+ throw new Error(".git is not a directory or gitdir file");
108
+ }
109
+
110
+ const gitDir = match[1];
111
+ return join(isAbsolute(gitDir) ? gitDir : resolve(dirname(dotGit), gitDir), "hooks");
112
+ }
113
+
114
+ function readConfiguredHooksPath(projectRoot) {
115
+ try {
116
+ const hooksPath = execFileSync(
117
+ "git",
118
+ ["-C", projectRoot, "config", "--get", "core.hooksPath"],
119
+ { encoding: "utf8" }
120
+ ).trim();
121
+ return hooksPath || null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ async function readUtf8OrNull(path) {
128
+ try {
129
+ return await readFile(path, "utf8");
130
+ } catch (error) {
131
+ if (error?.code === "ENOENT") {
132
+ return null;
133
+ }
134
+ throw error;
135
+ }
136
+ }
@@ -0,0 +1,10 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const HERE = dirname(fileURLToPath(import.meta.url));
5
+
6
+ export const PLUGIN_ROOT = resolve(HERE, "..", "..");
7
+
8
+ export function pluginPath(...segments) {
9
+ return resolve(PLUGIN_ROOT, ...segments);
10
+ }
@@ -0,0 +1,110 @@
1
+ export function renderPrompt(input) {
2
+ const { role, request = {}, contract = {} } = input;
3
+ const parts = [
4
+ `# ${titleCase(role)}`,
5
+ section("Capability", renderCapability(contract)),
6
+ section("Inputs", renderInputs(request.inputs, contract.inputs?.denied)),
7
+ section("Outputs", renderJson(contract.outputs)),
8
+ section("Instructions", request.instruction, { required: true }),
9
+ section("Success Gate", request.successGate),
10
+ section("Failure Handling", request.failureHandling)
11
+ ].filter(Boolean);
12
+
13
+ return `${parts.join("\n\n")}\n`;
14
+ }
15
+
16
+ export function section(title, body, options = {}) {
17
+ const renderedBody = normalizeBody(body);
18
+ if (renderedBody.length === 0) {
19
+ if (options.required) {
20
+ return `## ${title}`;
21
+ }
22
+ return "";
23
+ }
24
+
25
+ return `## ${title}\n${renderedBody}`;
26
+ }
27
+
28
+ export function fenceBlock(body, fence = "---") {
29
+ return `${fence}\n${normalizeBlockBody(body)}\n${fence}`;
30
+ }
31
+
32
+ function renderCapability(contract) {
33
+ const tools = Array.isArray(contract.claudeSubagent?.tools)
34
+ ? contract.claudeSubagent.tools
35
+ : [];
36
+ const outputs = Array.isArray(contract.outputs) ? contract.outputs : [];
37
+
38
+ return [
39
+ `workspaceAccess: ${contract.capabilities?.workspaceAccess ?? "unknown"}`,
40
+ `canAskUser: ${String(tools.includes("AskUserQuestion"))}`,
41
+ `canRequestAgent: ${String(tools.includes("Agent"))}`,
42
+ `canUseShell: ${String(tools.includes("Bash"))}`,
43
+ `canWriteCrewFiles: ${String(canWriteCrewFiles(outputs))}`
44
+ ].join("\n");
45
+ }
46
+
47
+ function renderInputs(inputs, denied) {
48
+ const lines = [];
49
+
50
+ if (Array.isArray(inputs) && inputs.length > 0) {
51
+ for (const item of inputs) {
52
+ lines.push(`### ${item.path}`);
53
+ lines.push(item.content ?? "");
54
+ }
55
+ }
56
+
57
+ if (Array.isArray(denied) && denied.length > 0) {
58
+ lines.push("### Denied Inputs");
59
+ for (const item of denied) {
60
+ lines.push(`- ${item}`);
61
+ }
62
+ }
63
+
64
+ return lines.join("\n");
65
+ }
66
+
67
+ function renderJson(value) {
68
+ if (value === undefined || value === null) {
69
+ return "";
70
+ }
71
+
72
+ return JSON.stringify(value, null, 2);
73
+ }
74
+
75
+ function normalizeBody(body) {
76
+ if (body === undefined || body === null) {
77
+ return "";
78
+ }
79
+
80
+ return String(body).trim();
81
+ }
82
+
83
+ function normalizeBlockBody(body) {
84
+ if (body === undefined || body === null) {
85
+ return "";
86
+ }
87
+
88
+ return String(body)
89
+ .replace(/^\uFEFF/, "")
90
+ .replace(/\r\n?/g, "\n")
91
+ .replace(/\n+$/g, "");
92
+ }
93
+
94
+ function titleCase(value) {
95
+ return String(value)
96
+ .split("-")
97
+ .filter(Boolean)
98
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
99
+ .join("-");
100
+ }
101
+
102
+ function canWriteCrewFiles(outputs) {
103
+ return outputs.some((output) => {
104
+ return (
105
+ output?.type === "artifact" &&
106
+ typeof output.target === "string" &&
107
+ output.target.startsWith(".crew/")
108
+ );
109
+ });
110
+ }
@@ -0,0 +1,51 @@
1
+ import { fenceBlock } from "./render.mjs";
2
+
3
+ export function renderFollowup({ previousResult, newInput } = {}) {
4
+ return [
5
+ "## 이전 결과",
6
+ `status: ${normalizeInline(previousResult?.status)}`,
7
+ `summary: ${normalizeInline(previousResult?.summary)}`,
8
+ "artifact:",
9
+ fenceBlock(serializeArtifact(previousResult?.artifact)),
10
+ "",
11
+ "## 추가 입력",
12
+ normalizeBlock(newInput),
13
+ "",
14
+ "## 지시",
15
+ "계속 진행해라."
16
+ ].join("\n") + "\n";
17
+ }
18
+
19
+ function serializeArtifact(artifact) {
20
+ if (artifact === undefined || artifact === null) {
21
+ return "";
22
+ }
23
+
24
+ if (typeof artifact === "string") {
25
+ return artifact;
26
+ }
27
+
28
+ return JSON.stringify(artifact, null, 2);
29
+ }
30
+
31
+ function normalizeInline(value) {
32
+ if (value === undefined || value === null) {
33
+ return "";
34
+ }
35
+
36
+ return String(value)
37
+ .replace(/^\uFEFF/, "")
38
+ .replace(/\r\n?/g, "\n")
39
+ .replace(/\n/g, " ");
40
+ }
41
+
42
+ function normalizeBlock(value) {
43
+ if (value === undefined || value === null) {
44
+ return "";
45
+ }
46
+
47
+ return String(value)
48
+ .replace(/^\uFEFF/, "")
49
+ .replace(/\r\n?/g, "\n")
50
+ .replace(/\n+$/g, "");
51
+ }
@@ -0,0 +1,72 @@
1
+ const PROVIDERS = new Set(["claude", "codex"]);
2
+
3
+ export function resolveRole(input) {
4
+ const { role, catalog, userConfig = {}, projectConfig = {}, contracts } = input;
5
+ const contract = findContract(role, contracts);
6
+
7
+ if (!contract) {
8
+ throw new Error(`Unknown role: ${role}`);
9
+ }
10
+
11
+ const defaults = cascadeRoleConfig(role, catalog, userConfig, projectConfig);
12
+ const provider = defaults.provider;
13
+ if (!PROVIDERS.has(provider)) {
14
+ throw new Error(`Unknown provider for role ${role}: ${provider}`);
15
+ }
16
+
17
+ const model = defaults.model;
18
+ if (typeof model !== "string" || model.length === 0) {
19
+ throw new Error(`Missing model for role ${role}`);
20
+ }
21
+
22
+ const codexSandbox = catalog?.agent_runtime?.[role]?.codex_sandbox;
23
+ if (!["read-only", "workspace-write"].includes(codexSandbox)) {
24
+ throw new Error(`Missing codex_sandbox for role ${role}`);
25
+ }
26
+
27
+ const warnings = [];
28
+ const workspaceAccess = contract?.capabilities?.workspaceAccess;
29
+ if (codexSandbox !== workspaceAccess) {
30
+ warnings.push(
31
+ `${role}: codex_sandbox ${codexSandbox} does not match contract capabilities.workspaceAccess ${workspaceAccess}`
32
+ );
33
+ }
34
+
35
+ return {
36
+ role,
37
+ provider,
38
+ model,
39
+ reasoning: provider === "codex" ? defaults.reasoning ?? null : null,
40
+ codex_sandbox: codexSandbox,
41
+ contract,
42
+ dispatch: {
43
+ path: provider === "codex" ? "codex" : "claude",
44
+ write: codexSandbox === "workspace-write"
45
+ },
46
+ warnings
47
+ };
48
+ }
49
+
50
+ function cascadeRoleConfig(role, ...configs) {
51
+ return configs.reduce((merged, config) => {
52
+ return {
53
+ ...merged,
54
+ ...roleConfig(config, role)
55
+ };
56
+ }, {});
57
+ }
58
+
59
+ function roleConfig(config, role) {
60
+ return {
61
+ ...(config?.agent_defaults?.[role] ?? {}),
62
+ ...(config?.providers?.[role] ?? {})
63
+ };
64
+ }
65
+
66
+ function findContract(role, contracts) {
67
+ if (Array.isArray(contracts?.roles)) {
68
+ return contracts.roles.find((contract) => contract.role === role);
69
+ }
70
+
71
+ return contracts?.[role];
72
+ }
@@ -0,0 +1,100 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+
4
+ import { deriveBuildOutput, resolveBuildInputs } from "./build.mjs";
5
+ import { loadContracts } from "./contracts.mjs";
6
+
7
+ export async function validate({ root = process.cwd() } = {}) {
8
+ const projectRoot = resolve(root);
9
+ const inputs = resolveBuildInputs(projectRoot);
10
+ const errors = [];
11
+
12
+ const contracts = loadContracts(inputs.contractsPath);
13
+ const catalog = JSON.parse(await readFile(inputs.catalogPath, "utf8"));
14
+ const derived = await deriveBuildOutput({
15
+ root: projectRoot,
16
+ contracts,
17
+ instructionsDir: inputs.instructionsDir,
18
+ pluginPath: inputs.pluginPath
19
+ });
20
+
21
+ errors.push(
22
+ ...(await compareDerived({
23
+ root: projectRoot,
24
+ pluginPath: inputs.pluginPath,
25
+ derived
26
+ }))
27
+ );
28
+ errors.push(...compareSandboxConsistency({ contracts, catalog }));
29
+
30
+ return { ok: errors.length === 0, errors };
31
+ }
32
+
33
+ export async function compareDerived({ root, pluginPath, derived }) {
34
+ const errors = [];
35
+
36
+ for (const [role, expected] of derived.agents.entries()) {
37
+ const relPath = `agents/${role}.md`;
38
+ const actual = await readUtf8OrNull(join(root, relPath));
39
+ if (actual !== expected) {
40
+ errors.push(`drift: ${relPath}`);
41
+ }
42
+ }
43
+
44
+ const actualPlugin = await readUtf8OrNull(pluginPath);
45
+ if (actualPlugin !== derived.pluginJson) {
46
+ errors.push(await pluginDriftMessage({ root, pluginPath, actualPlugin }));
47
+ }
48
+
49
+ return errors;
50
+ }
51
+
52
+ export function compareSandboxConsistency({ contracts, catalog }) {
53
+ const errors = [];
54
+
55
+ for (const contract of contracts.roles) {
56
+ const role = contract.role;
57
+ const workspaceAccess = contract.capabilities?.workspaceAccess;
58
+ const codexSandbox = catalog.agent_runtime?.[role]?.codex_sandbox;
59
+ if (workspaceAccess !== codexSandbox) {
60
+ errors.push(
61
+ `mismatch: ${role} workspaceAccess=${workspaceAccess} but codex_sandbox=${codexSandbox}`
62
+ );
63
+ }
64
+ }
65
+
66
+ return errors;
67
+ }
68
+
69
+ async function pluginDriftMessage({ root, pluginPath, actualPlugin }) {
70
+ const label = pluginLabel(root, pluginPath);
71
+ if (actualPlugin === null) {
72
+ return `drift: ${label}`;
73
+ }
74
+
75
+ try {
76
+ const plugin = JSON.parse(actualPlugin);
77
+ if (!Array.isArray(plugin.agents)) {
78
+ return `drift: ${label} agents`;
79
+ }
80
+ return `drift: ${label} agents`;
81
+ } catch {
82
+ return `drift: ${label}`;
83
+ }
84
+ }
85
+
86
+ function pluginLabel(root, pluginPath) {
87
+ const relPath = relative(root, pluginPath);
88
+ return relPath === ".claude-plugin/plugin.json" ? "plugin.json" : relPath;
89
+ }
90
+
91
+ async function readUtf8OrNull(path) {
92
+ try {
93
+ return await readFile(path, "utf8");
94
+ } catch (error) {
95
+ if (error?.code === "ENOENT") {
96
+ return null;
97
+ }
98
+ throw error;
99
+ }
100
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: crew-agent-runner
3
+ description: 모든 crew 에이전트 dispatch의 중앙 규약 — provider별 호출법 캡슐화
4
+ ---
5
+
6
+ # crew-agent-runner
7
+
8
+ crew 업무 스킬은 에이전트 provider별 호출 세부사항을 직접 구현하지 않고 이 중앙 규약을 따른다. 본 스킬은 resolve, dispatch, resume, followup 주입, retry/fallback/escalate 판단의 공통 표면을 정의한다.
9
+
10
+ 설치 후 drift 차단용 pre-commit hook은 `node scripts/crew-agent-runner.mjs install-hooks`로 설치한다.
11
+ (plugin 개발자 전용 — 사용자는 호출하지 않습니다. build/validate는 plugin source repo의 drift 차단 도구입니다.)
12
+
13
+ ## Dispatch 절차
14
+
15
+ 업무 스킬(crew-plan/crew-interview/crew-dev)이 role을 실행해야 할 때 본 절차를 따른다.
16
+
17
+ ### 1. resolve
18
+
19
+ 오케스트레이터는 먼저 `node scripts/crew-agent-runner.mjs resolve --role <role> --json`을 실행하여 provider/model/contract 통합 표를 받는다.
20
+
21
+ ### 2. request 객체 작성
22
+
23
+ `{ role, inputs (path+content), instruction, successGate, failureHandling, taskId }` 형태의 임시 JSON 파일을 작성한다.
24
+
25
+ ### 3a. Codex 경로
26
+
27
+ `provider == codex`이면 `node scripts/crew-agent-runner.mjs dispatch --role <role> --request-file <path> --json`을 실행한다. 이 명령은 AgentResult JSON을 즉시 반환한다.
28
+ dispatch CLI는 codex provider role에만 사용. claude provider role은 render + Agent tool 경로를 사용.
29
+
30
+ ### 3b. Claude 경로
31
+
32
+ `provider == claude`이면 다음 순서로 실행한다.
33
+
34
+ 1. `node scripts/crew-agent-runner.mjs render --role <role> --request-file <path>`를 실행하여 prompt 문자열을 받는다.
35
+ 2. 메인 오케스트레이터(Claude conversation)가 `Agent(subagent_type=<role>, model=<model>, prompt=<rendered>)`를 호출한다.
36
+ 3. sub-agent 결과를 AgentResult JSON 형식으로 정규화한다.
37
+
38
+ ## AgentResult 상태 처리
39
+
40
+ AgentResult 5상태:
41
+
42
+ ### complete
43
+
44
+ `artifact`를 `outputs.target`에 저장한다. 이후 다음 phase로 진행한다.
45
+
46
+ ### blocked_on_user
47
+
48
+ `questions`를 메인 오케스트레이터의 사용자 질문 도구로 사용자에게 전달한다. 답변 수신 후 followup을 주입한다. 절차는 아래 Resume 섹션을 따른다.
49
+
50
+ ### needs_agent
51
+
52
+ `requests`의 `role`을 새 dispatch 사이클로 실행한다. 결과를 followup으로 원래 에이전트에 주입한다.
53
+
54
+ ### needs_tool
55
+
56
+ capability를 넘어선 도구 요청이다. 오케스트레이터가 `contract.policy`에 따라 직접 도구 실행 후 결과를 주입하거나, 실행할 수 없으면 `failed`로 escalate한다.
57
+
58
+ ### failed
59
+
60
+ `contract.policy`의 `maxAttempts`, `fallbackProvider`, `escalateAfterAttempts`, `consecutiveSameFailureLimit`에 따라 처리한다.
61
+
62
+ - retry: `maxAttempts` 미만이면 재시도한다.
63
+ - fallbackProvider 전환: fallback provider가 있으면 provider를 전환한다.
64
+ - 사용자 escalate: 한도에 도달했거나 같은 사유가 반복되면 사용자에게 에스컬레이션한다.
65
+
66
+ ## Resume
67
+
68
+ `needs_agent` / `blocked_on_user` 상태에서 같은 sub-agent context를 이어가는 표준 절차.
69
+
70
+ ### Codex 경로
71
+
72
+ 1. `node scripts/crew-agent-runner.mjs render-followup --previous-result <file> --new-input <file>` 실행 → followup prompt 문자열 → 임시 파일에 저장.
73
+ 2. `node scripts/crew-agent-runner.mjs dispatch --role <role> --request-file <new-request-with-followup-prompt> --resume-handle <agent_handle> --json` 실행.
74
+ - 내부적으로 runner가 `crew-codex-companion.mjs task-resume-candidate`로 thread 일치 검증 후 `task --resume-last`를 호출하고 AgentResult를 정규화한다.
75
+ 3. AgentResult JSON을 받아 다음 상태 처리.
76
+
77
+ 주의: 직접 `crew-codex-companion.mjs task --resume-last`를 호출하지 말 것. runner의 candidate guard와 AgentResult 정규화를 건너뛴다.
78
+
79
+ ### Claude 경로
80
+
81
+ 1. `Agent` spawn으로 받은 sub-agent handle을 보존한다.
82
+ 2. `render-followup`을 사용하여 followup prompt를 생성한다.
83
+ 3. 메인 conversation에서 `SendMessage(to: <agent-handle>, message: <followup-prompt>)`로 같은 sub-agent에 후속 turn을 전달한다.
84
+
85
+ ## Followup 주입
86
+
87
+ Followup prompt 형식은 양 provider에서 동일하다.
88
+
89
+ ```markdown
90
+ ## 이전 결과
91
+ status: <status>
92
+ summary: <summary>
93
+ artifact:
94
+ ---
95
+ <artifact>
96
+ ---
97
+
98
+ ## 추가 입력
99
+ <new-input>
100
+
101
+ ## 지시
102
+ 계속 진행해라.
103
+ ```
104
+
105
+ 이 형식은 `runner.mjs render-followup`이 결정론적으로 생성한다.
106
+ artifact가 객체/배열인 경우 runner가 JSON으로 직렬화하여 fence 블록 안에 삽입한다 (정보 손실 방지).
107
+
108
+ ## Retry / Fallback / Escalate
109
+
110
+ `contract.policy`:
111
+
112
+ - `maxAttempts`: retry 횟수 한도
113
+ - `fallbackProvider`: `claude`/`codex` 전환
114
+ - `escalateAfterAttempts`: 한도 도달 시 사용자 escalate
115
+ - `consecutiveSameFailureLimit`: 같은 사유 연속 fail 한도