@jjlabsio/claude-crew 0.1.39 → 0.1.41

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.39",
14
+ "version": "0.1.41",
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.39"
31
+ "version": "0.1.41"
32
32
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crew",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": {
6
6
  "name": "Jaejin Song",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlabsio/claude-crew",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": "Jaejin Song <wowlxx28@gmail.com>",
6
6
  "license": "MIT",
@@ -9,6 +9,10 @@ import {
9
9
  loadUserConfig
10
10
  } from "./lib/config.mjs";
11
11
  import { parseArgv } from "./lib/cli.mjs";
12
+ import {
13
+ persistCrewArtifact,
14
+ ArtifactPersistError
15
+ } from "./lib/artifacts.mjs";
12
16
  import {
13
17
  dispatch,
14
18
  DispatchError,
@@ -42,6 +46,10 @@ async function main(argv) {
42
46
  return dispatchCommand(flags);
43
47
  }
44
48
 
49
+ if (command === "persist-artifact") {
50
+ return persistArtifactCommand(flags);
51
+ }
52
+
45
53
  if (command === "render-followup") {
46
54
  return renderFollowupCommand(flags);
47
55
  }
@@ -224,6 +232,61 @@ function renderFollowupCommand(flags) {
224
232
  }
225
233
  }
226
234
 
235
+ async function persistArtifactCommand(flags) {
236
+ if (typeof flags.role !== "string" || flags.role.length === 0) {
237
+ console.error("Missing required --role <name>");
238
+ return 1;
239
+ }
240
+
241
+ if (
242
+ typeof flags["result-file"] !== "string" ||
243
+ flags["result-file"].length === 0
244
+ ) {
245
+ console.error("Missing required --result-file <path>");
246
+ return 1;
247
+ }
248
+
249
+ if (
250
+ typeof flags["request-file"] !== "string" ||
251
+ flags["request-file"].length === 0
252
+ ) {
253
+ console.error("Missing required --request-file <path>");
254
+ return 1;
255
+ }
256
+
257
+ try {
258
+ const contracts = loadContracts();
259
+ const contract = findContract(flags.role, contracts);
260
+ if (!contract) {
261
+ throw new Error(`Unknown role: ${flags.role}`);
262
+ }
263
+
264
+ const agentResult = JSON.parse(readFileSync(flags["result-file"], "utf8"));
265
+ const request = JSON.parse(readFileSync(flags["request-file"], "utf8"));
266
+
267
+ const savedPath = await persistCrewArtifact({
268
+ workspaceRoot: process.cwd(),
269
+ contract,
270
+ request,
271
+ agentResult
272
+ });
273
+
274
+ if (savedPath) {
275
+ process.stdout.write(`${JSON.stringify({ artifact_path: savedPath })}\n`);
276
+ } else {
277
+ process.stdout.write(`${JSON.stringify({ artifact_path: null })}\n`);
278
+ }
279
+ return 0;
280
+ } catch (error) {
281
+ if (error instanceof ArtifactPersistError) {
282
+ console.error(error.message);
283
+ return 1;
284
+ }
285
+ console.error(error.message);
286
+ return 1;
287
+ }
288
+ }
289
+
227
290
  async function dispatchCommand(flags) {
228
291
  if (typeof flags.role !== "string" || flags.role.length === 0) {
229
292
  console.error("Missing required --role <name>");
@@ -376,6 +439,9 @@ function usage() {
376
439
  console.error(
377
440
  " crew-agent-runner dispatch --role <name> --request-file <path> [--json] [--resume-handle <thread-id>]"
378
441
  );
442
+ console.error(
443
+ " crew-agent-runner persist-artifact --role <name> --result-file <path> --request-file <path>"
444
+ );
379
445
  }
380
446
 
381
447
  const exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,89 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, normalize, resolve, relative } from "node:path";
3
+
4
+ export class ArtifactPersistError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ArtifactPersistError";
8
+ }
9
+ }
10
+
11
+ export async function persistCrewArtifact({ workspaceRoot, contract, request, agentResult }) {
12
+ if (!shouldPersist(contract, agentResult)) {
13
+ return null;
14
+ }
15
+
16
+ const target = findArtifactTarget(contract);
17
+ if (!target) {
18
+ return null;
19
+ }
20
+
21
+ const resolvedTarget = replaceTaskId(target, request?.taskId);
22
+ const absolutePath = validateTargetPath(workspaceRoot, resolvedTarget);
23
+
24
+ await mkdir(dirname(absolutePath), { recursive: true });
25
+ await writeFile(absolutePath, agentResult.artifact, "utf8");
26
+
27
+ return absolutePath;
28
+ }
29
+
30
+ function shouldPersist(contract, agentResult) {
31
+ if (contract?.capabilities?.workspaceAccess !== "read-only") {
32
+ return false;
33
+ }
34
+
35
+ if (agentResult?.status !== "complete") {
36
+ return false;
37
+ }
38
+
39
+ if (typeof agentResult.artifact !== "string" || agentResult.artifact.length === 0) {
40
+ return false;
41
+ }
42
+
43
+ return true;
44
+ }
45
+
46
+ function findArtifactTarget(contract) {
47
+ const outputs = Array.isArray(contract?.outputs) ? contract.outputs : [];
48
+ for (const output of outputs) {
49
+ if (
50
+ output?.type === "artifact" &&
51
+ typeof output.target === "string" &&
52
+ output.target.startsWith(".crew/")
53
+ ) {
54
+ return output.target;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function replaceTaskId(target, taskId) {
61
+ if (typeof taskId === "string" && taskId.length > 0) {
62
+ return target.replace(/\{task-id\}/g, taskId);
63
+ }
64
+ return target;
65
+ }
66
+
67
+ function validateTargetPath(workspaceRoot, target) {
68
+ if (!workspaceRoot || typeof workspaceRoot !== "string") {
69
+ throw new ArtifactPersistError("workspaceRoot is required.");
70
+ }
71
+
72
+ const normalized = normalize(target);
73
+
74
+ if (normalized.startsWith("/") || normalized.startsWith("\\")) {
75
+ throw new ArtifactPersistError(`Absolute path rejected: ${target}`);
76
+ }
77
+
78
+ const crewBase = resolve(workspaceRoot, ".crew");
79
+ const absolutePath = resolve(workspaceRoot, normalized);
80
+ const rel = relative(crewBase, absolutePath);
81
+
82
+ if (rel.startsWith("..") || rel === "") {
83
+ throw new ArtifactPersistError(
84
+ `Target path escapes .crew/ directory: ${target}`
85
+ );
86
+ }
87
+
88
+ return absolutePath;
89
+ }
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
+ import { persistCrewArtifact, ArtifactPersistError } from "./artifacts.mjs";
7
8
  import { renderPrompt } from "./render.mjs";
8
9
 
9
10
  const DEFAULT_COMPANION = fileURLToPath(
@@ -84,6 +85,11 @@ export async function dispatch(input) {
84
85
  });
85
86
  }
86
87
 
88
+ const artifactPath = await persistArtifactSafe(input, agentResult);
89
+ if (artifactPath) {
90
+ return { ...agentResult, artifact_path: artifactPath };
91
+ }
92
+
87
93
  return agentResult;
88
94
  } finally {
89
95
  await rm(tmpDir, { recursive: true, force: true });
@@ -235,6 +241,24 @@ function resolveCompanion(input = {}) {
235
241
  };
236
242
  }
237
243
 
244
+ async function persistArtifactSafe(input, agentResult) {
245
+ try {
246
+ return await persistCrewArtifact({
247
+ workspaceRoot: process.cwd(),
248
+ contract: input.contract,
249
+ request: input.request,
250
+ agentResult
251
+ });
252
+ } catch (error) {
253
+ if (error instanceof ArtifactPersistError) {
254
+ throw new DispatchError(`Artifact persist failed: ${error.message}`, {
255
+ agentResult
256
+ });
257
+ }
258
+ throw error;
259
+ }
260
+ }
261
+
238
262
  function isNodeScript(value) {
239
263
  const name = basename(String(value));
240
264
  return name.endsWith(".mjs") || name.endsWith(".js");
@@ -8,7 +8,7 @@ export function renderPrompt(input) {
8
8
  section("Instructions", request.instruction, { required: true }),
9
9
  section("Success Gate", request.successGate),
10
10
  section("Failure Handling", request.failureHandling),
11
- section("AgentResult Contract", renderAgentResultContract())
11
+ section("AgentResult Contract", renderAgentResultContract(contract))
12
12
  ].filter(Boolean);
13
13
 
14
14
  return `${parts.join("\n\n")}\n`;
@@ -41,7 +41,7 @@ function renderCapability(contract) {
41
41
  `canAskUser: ${String(tools.includes("AskUserQuestion"))}`,
42
42
  `canRequestAgent: ${String(tools.includes("Agent"))}`,
43
43
  `canUseShell: ${String(tools.includes("Bash"))}`,
44
- `canWriteCrewFiles: ${String(canWriteCrewFiles(outputs))}`
44
+ `canReturnCrewArtifact: ${String(canReturnCrewArtifact(outputs, contract.capabilities?.workspaceAccess))}`
45
45
  ].join("\n");
46
46
  }
47
47
 
@@ -73,15 +73,19 @@ function renderJson(value) {
73
73
  return JSON.stringify(value, null, 2);
74
74
  }
75
75
 
76
- function renderAgentResultContract() {
77
- return [
76
+ function renderAgentResultContract(contract = {}) {
77
+ const outputs = Array.isArray(contract.outputs) ? contract.outputs : [];
78
+ const workspaceAccess = contract.capabilities?.workspaceAccess;
79
+ const returnArtifact = canReturnCrewArtifact(outputs, workspaceAccess);
80
+
81
+ const lines = [
78
82
  "Return exactly one final AgentResult JSON object wrapped in these tags:",
79
83
  "",
80
84
  "```text",
81
85
  "<crew-agent-result>",
82
86
  "{",
83
87
  ' "status": "complete | blocked_on_user | needs_agent | needs_tool | failed",',
84
- ' "artifact": null,',
88
+ ` "artifact": ${returnArtifact ? '"full Markdown content of the artifact"' : "null"},`,
85
89
  ' "questions": [],',
86
90
  ' "requests": [],',
87
91
  ' "summary": "short summary",',
@@ -97,7 +101,15 @@ function renderAgentResultContract() {
97
101
  "- Use blocked_on_user only with a non-empty questions array.",
98
102
  "- Use needs_agent or needs_tool only with a non-empty requests array.",
99
103
  "- Use failed with an error string when the task cannot continue."
100
- ].join("\n");
104
+ ];
105
+
106
+ if (returnArtifact) {
107
+ lines.push(
108
+ "- Do NOT write the artifact file yourself. Instead, put the full Markdown content into the artifact field as a string. The runner will validate and save it to the target path."
109
+ );
110
+ }
111
+
112
+ return lines.join("\n");
101
113
  }
102
114
 
103
115
  function normalizeBody(body) {
@@ -127,7 +139,11 @@ function titleCase(value) {
127
139
  .join("-");
128
140
  }
129
141
 
130
- function canWriteCrewFiles(outputs) {
142
+ export function canReturnCrewArtifact(outputs, workspaceAccess) {
143
+ if (workspaceAccess !== "read-only") {
144
+ return false;
145
+ }
146
+
131
147
  return outputs.some((output) => {
132
148
  return (
133
149
  output?.type === "artifact" &&
@@ -20,6 +20,7 @@ description: 유저 요구사항을 인터뷰하여 개발 가능한 수준의 s
20
20
  - 유저 승인 없이 crew-plan으로 전환하지 않는다.
21
21
  - 추측으로 비즈니스 결정을 채우지 않는다.
22
22
  - `git worktree add`를 직접 실행하지 않는다. 워크트리는 `EnterWorktree` 도구만 사용한다.
23
+ - `EnterWorktree` 호출 전에 프로젝트 디렉토리에 파일이나 디렉토리를 생성하지 않는다. 워크트리 진입 전 request-file이 필요하면 OS 임시 디렉토리(`$TMPDIR`)를 사용한다.
23
24
 
24
25
  ---
25
26
 
@@ -68,11 +69,11 @@ output:
68
69
  - 최초 요구사항 체크리스트 평가
69
70
 
70
71
  role instructions:
71
- - PM은 요청 내용에서 키워드를 추출하여 kebab-case task-id를 생성한다.
72
- - PM이 task-id를 반환하면 오케스트레이터가 `EnterWorktree(name: "crew/{task-id}")`를 호출하여 Claude 워크트리를 생성하고 진입한다. 이후 모든 파일 작업은 워크트리 안에서 수행된다.
72
+ - 오케스트레이터가 요청 내용에서 키워드를 추출하여 kebab-case task-id를 직접 생성한다 (에이전트 호출 불필요).
73
+ - 오케스트레이터가 `EnterWorktree(name: "crew/{task-id}")`를 호출하여 Claude 워크트리를 생성하고 진입한다. 이후 모든 파일 작업은 워크트리 안에서 수행된다.
73
74
  - 오케스트레이터가 유저 원본 요청을 `.crew/plans/{task-id}/brief.md`에 저장한다.
74
75
  - Explorer는 프로젝트 구조를 병렬로 파악하고, 결과를 파일로 저장하지 않고 인터뷰 컨텍스트로만 제공한다.
75
- - PM은 유저 요청과 Explorer 결과를 기반으로 체크리스트 5개 항목을 첫 평가한다.
76
+ - PM은 유저 요청과 Explorer 결과를 기반으로 체크리스트 5개 항목을 첫 평가한다 (워크트리 내부에서 실행).
76
77
 
77
78
  success gate:
78
79
  - Claude 워크트리 `crew/{task-id}`에 진입했다.
@@ -86,10 +87,12 @@ failure handling:
86
87
 
87
88
  **1b. task-id 생성 + 워크트리 진입 + brief.md 작성**
88
89
 
89
- 1. task-id는 요청 내용에서 키워드를 추출하여 kebab-case 생성한다.
90
+ 1. 오케스트레이터가 요청 내용에서 키워드를 추출하여 kebab-case task-id를 직접 생성한다. 에이전트 호출 없이 수행한다.
90
91
  2. `EnterWorktree(name: "crew/{task-id}")`를 호출하여 Claude 워크트리를 생성하고 진입한다. 이미 해당 워크트리 안이면 이 단계를 건너뛴다.
91
92
  3. 유저 요청 원문을 `.crew/plans/{task-id}/brief.md`에 저장한다.
92
93
 
94
+ 주의: 1~2단계에서 에이전트를 호출하거나 파일을 프로젝트 디렉토리에 생성하지 않는다. 모든 에이전트 호출(PM, Explorer)과 파일 작업은 워크트리 진입 후에 수행한다.
95
+
93
96
  **1c. Explorer 호출 (병렬, 워크트리 진입 후)**
94
97
 
95
98
  Explorer는 중앙 runner를 통해 병렬 실행되어 프로젝트 구조를 파악한다.