cairn-ai 0.2.5 → 0.2.7

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 (69) hide show
  1. package/.agents/workflows/cairn-memory.md +2 -2
  2. package/.agents/workflows/cairn-plan.md +7 -3
  3. package/.agents/workflows/cairn-review.md +2 -2
  4. package/.agents/workflows/cairn-work.md +7 -3
  5. package/.claude/agents/explorer.md +2 -2
  6. package/.claude/agents/worker.md +2 -2
  7. package/.claude/commands/cairn-memory.md +1 -1
  8. package/.claude/commands/cairn-plan.md +1 -1
  9. package/.claude/commands/cairn-review.md +1 -1
  10. package/.claude/commands/cairn-work.md +1 -1
  11. package/.codex-plugin/plugin.json +4 -4
  12. package/README.de.md +3 -2
  13. package/README.es.md +3 -2
  14. package/README.fr.md +3 -2
  15. package/README.ja.md +3 -2
  16. package/README.ko.md +8 -5
  17. package/README.md +10 -7
  18. package/README.pt.md +3 -2
  19. package/README.zh.md +3 -2
  20. package/agents/explorer.md +2 -2
  21. package/agents/worker.md +6 -2
  22. package/commands/cairn-install.md +2 -1
  23. package/commands/cairn-memory.md +2 -2
  24. package/commands/cairn-plan.md +8 -4
  25. package/commands/cairn-review.md +2 -2
  26. package/commands/cairn-uninstall.md +1 -1
  27. package/commands/cairn-work.md +7 -3
  28. package/docs/model-guidance/README.md +16 -4
  29. package/docs/model-guidance/claude.md +2 -0
  30. package/docs/model-guidance/codex.md +4 -2
  31. package/package.json +5 -3
  32. package/scripts/cairn-cleanup.mjs +67 -0
  33. package/scripts/cairn-goal-cli.mjs +233 -0
  34. package/scripts/cairn-goal-evidence.mjs +179 -0
  35. package/scripts/cairn-goal-operations-evidence.mjs +124 -0
  36. package/scripts/cairn-goal-operations-mutations.mjs +63 -0
  37. package/scripts/cairn-goal-operations.mjs +177 -0
  38. package/scripts/cairn-goal-policy.mjs +33 -0
  39. package/scripts/cairn-goal-state-validation.mjs +199 -0
  40. package/scripts/cairn-goal-state.mjs +89 -0
  41. package/scripts/cairn-goal.mjs +34 -961
  42. package/scripts/cairn-lifecycle-commands.mjs +200 -0
  43. package/scripts/cairn-lifecycle-config.mjs +149 -0
  44. package/scripts/cairn-lifecycle-integrity.mjs +218 -0
  45. package/scripts/cairn-lifecycle-legacy.mjs +142 -0
  46. package/scripts/cairn-lifecycle-lock.mjs +205 -0
  47. package/scripts/cairn-lifecycle-mirror.mjs +91 -0
  48. package/scripts/cairn-lifecycle-staging.mjs +152 -0
  49. package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
  50. package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
  51. package/scripts/cairn-lifecycle-transaction.mjs +12 -0
  52. package/scripts/cairn-lifecycle.mjs +103 -1185
  53. package/scripts/cairn-paths.mjs +82 -6
  54. package/scripts/cairn-safe-fs.mjs +61 -35
  55. package/scripts/cairn-state-context.mjs +148 -0
  56. package/scripts/cairn-state-hook.mjs +24 -0
  57. package/scripts/cairn-state-init.mjs +137 -0
  58. package/scripts/cairn-state.mjs +15 -269
  59. package/scripts/cairn-toolcheck-detect.mjs +229 -0
  60. package/scripts/cairn-toolcheck-runtime.mjs +178 -0
  61. package/scripts/cairn-toolcheck.mjs +79 -424
  62. package/scripts/cairn.mjs +49 -27
  63. package/skills/cairn-memory/SKILL.md +4 -4
  64. package/skills/cairn-plan/SKILL.md +29 -10
  65. package/skills/cairn-review/SKILL.md +5 -5
  66. package/skills/cairn-work/SKILL.md +35 -22
  67. package/templates/PLAN.md +3 -1
  68. package/templates/work-plan.md +21 -0
  69. package/scripts/cairn-state.sh +0 -167
@@ -1,126 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
- import { join } from "node:path";
5
3
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { evaluateStop, isGoalOwnedBySession, readGoalState } from "./cairn-goal.mjs";
4
+ import { contextResult, foreignSessionContextResult, prependContext } from "./cairn-state-context.mjs";
5
+ import { continuationReason, stopAllowedMessage, stopHookOutput } from "./cairn-state-hook.mjs";
6
+ import { initializeProject } from "./cairn-state-init.mjs";
7
+ import { evaluateStop, isGoalOwnedBySession, reconcileWorktreeState } from "./cairn-goal.mjs";
7
8
  import { resolveRepoRoot } from "./cairn-paths.mjs";
8
- import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./cairn-safe-fs.mjs";
9
9
 
10
- const memoryTemplate = `# MEMORY
11
-
12
- This file is a short index of persistent repository knowledge.
13
-
14
- ## Domain Knowledge
15
-
16
- - Link detailed notes under \`docs/memory/\`.
17
-
18
- ## Policy
19
-
20
- - Prefer precise repository exploration before implementation.
21
- - Preserve proper nouns, file names, variable names, service names, alert names, MCP tool names, and agent names exactly as written.
22
- - Read only the detailed memory files needed for the current task.
23
- - Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
24
-
25
- ## Update Rules
26
-
27
- - Keep root files short.
28
- - Move detailed domain knowledge to \`docs/memory/<domain>.md\`.
29
- - Record facts with source paths, commands, and observed behavior.
30
- `;
31
-
32
- const memoryTemplateKo = `# MEMORY
33
-
34
- 이 파일은 지속적으로 필요한 저장소 지식의 짧은 색인입니다.
35
-
36
- ## Domain Knowledge
37
-
38
- - 자세한 기록은 \`docs/memory/\` 아래에 연결합니다.
39
-
40
- ## Policy
41
-
42
- - 구현 전에는 저장소를 정확히 탐색합니다.
43
- - 고유명사, 파일 이름, 변수 이름, 서비스 이름, 알림 이름, MCP 도구 이름, 에이전트 이름은 쓰인 그대로 보존합니다.
44
- - 현재 작업에 필요한 상세 메모리 파일만 읽습니다.
45
- - 사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.
46
-
47
- ## Update Rules
48
-
49
- - 루트 파일은 짧게 유지합니다.
50
- - 자세한 도메인 지식은 \`docs/memory/<domain>.md\`로 옮깁니다.
51
- - 사실은 출처 경로, 명령, 관찰된 동작과 함께 기록합니다.
52
- `;
53
-
54
- const planTemplate = `# PLAN
55
-
56
- This file is a short index of active and completed work plans.
57
-
58
- ## Active Plans
59
-
60
- - Link detailed plans under \`docs/plan/\`.
61
-
62
- ## Completed Plans
63
-
64
- - Move completed topics here with evidence links.
65
-
66
- ## Planning Rules
67
-
68
- - Every agent must start assigned work by reading the project-root \`MEMORY.md\` for domain knowledge and repository policy.
69
- - For implementation or continued execution, first write an initial plan with \`triage-plan\` active, synchronize it to native UI plan/goal tools and the repository goal before exploration, then update it after triage.
70
- - Plans must be decision-complete before implementation.
71
- - Run complexity triage before applying agent, plugin, or delegated workflow guidance.
72
- - Record the selected Light Path or Heavy Path and the checked Heavy Path signals in \`docs/plan/<topic>.md\`.
73
- - Split implementation into small module tasks.
74
- - Detect repository stack and required LSP/check tools before implementation.
75
- - Record missing required tools and suggested commands. Run only pinned, supported installation steps after explicit user approval.
76
- - Each task normally passes exactly two gates.
77
- - Module acceptance verification.
78
- - Surface integration verification.
79
- - Run dry-run or check mode before external-state mutation when available.
80
- - Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
81
- - Use at most two verification passes per task by default.
82
- - If a gate fails, diagnose once, shrink the task or split it into sub-tasks, and rerun both gates.
83
- - After two failed passes, record the blocker in \`docs/plan/<topic>.md\`.
84
- `;
85
-
86
- const planTemplateKo = `# PLAN
87
-
88
- 이 파일은 진행 중이거나 완료된 작업 계획의 짧은 색인입니다.
89
-
90
- ## Active Plans
91
-
92
- - 상세 계획은 \`docs/plan/\` 아래에 연결합니다.
93
-
94
- ## Completed Plans
95
-
96
- - 완료된 주제는 증거 링크와 함께 이곳으로 옮깁니다.
97
-
98
- ## Planning Rules
99
-
100
- - 모든 에이전트는 배정된 작업을 시작할 때 도메인 지식과 저장소 정책을 위해 프로젝트 루트 \`MEMORY.md\`를 먼저 읽어야 합니다.
101
- - 구현 또는 계속 실행 요청은 먼저 \`triage-plan\`이 active인 초기 계획을 쓰고, 탐색 전에 UI plan/goal과 저장소 goal을 동기화한 뒤 트리아지 결과로 계획을 갱신합니다.
102
- - 계획은 구현 전에 의사결정이 완료된 상태여야 합니다.
103
- - 에이전트, 플러그인, 위임 워크플로 지침을 적용하기 전에 복잡도 트리아지를 실행합니다.
104
- - 선택한 Light Path 또는 Heavy Path와 확인한 Heavy Path 신호를 \`docs/plan/<topic>.md\`에 기록합니다.
105
- - 구현은 작은 모듈 작업으로 나눕니다.
106
- - 구현 전에 저장소 스택과 필요한 LSP/check 도구를 감지합니다.
107
- - 필요한 도구가 없으면 누락 상태와 제안 명령을 기록합니다. 명시적 사용자 승인 뒤에만 고정된 지원 설치 단계를 실행합니다.
108
- - 각 작업은 보통 정확히 두 게이트를 통과합니다.
109
- - 모듈 수용 검증.
110
- - 표면 통합 검증.
111
- - 외부 상태 변경 전에는 가능한 경우 dry-run 또는 check mode를 실행합니다.
112
- - 사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.
113
- - 기본적으로 작업당 검증은 최대 두 번만 수행합니다.
114
- - 게이트가 실패하면 한 번 진단하고, 작업을 줄이거나 sub-task로 나눈 뒤 두 게이트를 다시 실행합니다.
115
- - 두 번 실패한 뒤에는 blocker를 \`docs/plan/<topic>.md\`에 기록합니다.
116
- `;
117
-
118
- const contextLimits = {
119
- goalTitle: 64,
120
- roadmapTaskId: 24,
121
- taskTitle: 40,
122
- roadmapWindow: 3,
123
- };
10
+ const recoverableGoalStatuses = new Set(["active", "paused", "blocked"]);
124
11
 
125
12
  if (isCliEntry()) {
126
13
  const event = process.argv[2] ?? "manual";
@@ -157,11 +44,17 @@ export async function runStateResult(event = "manual", {
157
44
  }
158
45
 
159
46
  if (event === "session-start" || event === "user-prompt-submit") {
160
- const state = await readGoalState({ root: resolvedRoot });
161
- if (state?.goal?.status === "active" && !isGoalOwnedBySession(state, payload?.session_id)) {
47
+ const reconciled = await reconcileWorktreeState({ root: resolvedRoot });
48
+ const state = reconciled.state;
49
+ if (recoverableGoalStatuses.has(state?.goal?.status) && !isGoalOwnedBySession(state, payload?.session_id)) {
162
50
  return foreignSessionContextResult({ ko, event });
163
51
  }
164
- return contextResult({ ko, state, event });
52
+ const result = contextResult({ ko, state, event });
53
+ if (!reconciled.removed) return result;
54
+ const notice = ko
55
+ ? "Cairn worktree 점검: 현재 worktree에 속하지 않는 stale Cairn 상태를 제거했습니다."
56
+ : "Cairn worktree check: removed stale Cairn state that did not belong to this worktree.";
57
+ return prependContext(result, notice);
165
58
  }
166
59
 
167
60
  if (event === "post-tool-use") {
@@ -181,7 +74,7 @@ export async function runStateResult(event = "manual", {
181
74
  }
182
75
 
183
76
  if (event === "stop" || event === "subagent-stop") {
184
- const state = await readGoalState({ root: resolvedRoot });
77
+ const { state } = await reconcileWorktreeState({ root: resolvedRoot });
185
78
  if (!isGoalOwnedBySession(state, payload?.session_id)) return { status: 0, message: "", hookOutput: {} };
186
79
  const gate = evaluateStop(state, {
187
80
  subagent: event === "subagent-stop",
@@ -201,153 +94,6 @@ export async function runStateResult(event = "manual", {
201
94
  };
202
95
  }
203
96
 
204
- async function initializeProject(root, ko) {
205
- await safeMkdir(root, "docs/memory");
206
- await safeMkdir(root, "docs/plan");
207
- await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
208
- await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
209
- }
210
-
211
- async function writeIfMissing(root, path, content) {
212
- await assertNoSymlinkComponents(root, path);
213
- try {
214
- await readFile(path, "utf8");
215
- } catch (error) {
216
- if (error?.code !== "ENOENT") throw error;
217
- try {
218
- await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
219
- } catch (writeError) {
220
- if (writeError?.code !== "EEXIST") throw writeError;
221
- await assertNoSymlinkComponents(root, path, { allowMissing: false });
222
- }
223
- }
224
- }
225
-
226
- function contextResult({ ko, state, event }) {
227
- const base = ko ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요." : "Cairn kernel: read root MEMORY.md first.";
228
- const idlePolicy = event === "user-prompt-submit"
229
- ? (ko
230
- ? " 구현/계속 실행이면 cairn-plan을 로드하고 active plan과 current task를 복원하거나 생성하세요. 상담·설명·계획 전용은 goal 없이 처리하세요."
231
- : " For implementation/continue, load cairn-plan and restore or create the active plan and current task. Keep consultation, explanation, and plan-only requests goal-free.")
232
- : (ko
233
- ? " 중요 작업 전에 cairn-plan을 로드하고 active plan과 current task를 복원하세요."
234
- : " Before non-trivial work, load cairn-plan and restore the active plan and current task.");
235
- const failClosed = ko
236
- ? " state, skill, plan 또는 required reference가 없거나 읽을 수 없거나 일치하지 않으면 수정·위임·완료하지 말고 보고하거나 차단하세요."
237
- : " If state, skill, plan, or a required ref is missing, unreadable, or inconsistent, do not edit, delegate, or complete; report/block.";
238
- if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
239
- const task = state.tasks.find((item) => item.status === "active")
240
- ?? state.tasks.find((item) => item.status === "pending")
241
- ?? state.tasks.find((item) => item.status === "blocked");
242
- const planId = state.goal.planId;
243
- const phase = task ? "cairn-work" : "cairn-review";
244
- const activeHeader = ko
245
- ? `${base} ${phase}를 로드하고 정확한 plan ${planId}${task ? "과 아래 current task" : "의 완료 기준"}를 복원하세요. 압축·재시작·handoff·위임 뒤 다시 읽으세요.${failClosed}`
246
- : `${base} Load ${phase}; restore exact plan ${planId}${task ? " and the current task below" : " completion criteria"}. Re-read after compaction/restart/handoff/delegation.${failClosed}`;
247
- if (!task) {
248
- return contextHookResult({
249
- event,
250
- message: [activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"),
251
- });
252
- }
253
- const taskId = task.id;
254
- const taskTitle = clipText(task.title, contextLimits.taskTitle);
255
- const continuation = taskId.length > 48
256
- ? (ko
257
- ? `정확한 current task: ${taskId}. 바인딩된 증거 뒤에만 완료하세요.`
258
- : `Exact task: ${taskId}. Complete after bound evidence.`)
259
- : (ko
260
- ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
261
- : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`);
262
- const resume = event === "user-prompt-submit"
263
- ? (taskId.length > 48
264
- ? (ko
265
- ? "곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 위의 정확한 task를 재개하세요."
266
- : "Resume that exact task after side questions unless paused, stopped, or switched.")
267
- : (ko
268
- ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
269
- : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`))
270
- : "";
271
- return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
272
- }
273
-
274
- function foreignSessionContextResult({ ko, event }) {
275
- const message = ko
276
- ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요. 저장소 goal을 다른 session이 소유합니다. cairn goal status로 상태를 확인하고 소유권이 해결될 때까지 Cairn 작업을 시작·수정·위임·완료하지 마세요. 이 capsule은 goal, plan, task 상세를 노출하지 않습니다."
277
- : "Cairn kernel: read root MEMORY.md first. A repository goal is owned by another session. Inspect cairn goal status; until ownership is resolved, do not start, edit, delegate, or complete Cairn work. This capsule does not expose goal, plan, or task details.";
278
- return contextHookResult({ event, message });
279
- }
280
-
281
- function taskRoadmap(ko, state) {
282
- const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
283
- if (state.tasks.length <= contextLimits.roadmapWindow) {
284
- return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
285
- }
286
-
287
- const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
288
- const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
289
- const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
290
- const omitted = state.tasks.length - 1;
291
- const header = ko
292
- ? `작업 단계 (${state.tasks.length}개; ${countSummary})`
293
- : `Work steps (${state.tasks.length}; ${countSummary})`;
294
- const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
295
- const currentPosition = currentIndex + 1;
296
- return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}`;
297
- }
298
-
299
- function contextHookResult({ event, message }) {
300
- return {
301
- status: 0,
302
- message,
303
- hookOutput: {
304
- hookSpecificOutput: {
305
- hookEventName: event === "session-start" ? "SessionStart" : "UserPromptSubmit",
306
- additionalContext: message,
307
- },
308
- },
309
- };
310
- }
311
-
312
- function continuationReason(reason, payload) {
313
- const continuation = payload?.stop_hook_active ? " This turn was already continued; the active goal remains persisted for the next session." : "";
314
- return `${reason}${continuation}`;
315
- }
316
-
317
- function stopHookOutput(gate, payload) {
318
- if (!gate.block) return { continue: true };
319
- const reason = continuationReason(gate.reason, payload);
320
- if (payload?.stop_hook_active) {
321
- return { continue: true, systemMessage: reason };
322
- }
323
- return { decision: "block", reason };
324
- }
325
-
326
- function stopAllowedMessage({ ko, state, event }) {
327
- if (state?.goal?.status) {
328
- return ko
329
- ? `Cairn 종료 게이트: 목표 상태가 ${state.goal.status}이므로 종료를 허용합니다.`
330
- : `Cairn stop gate: goal status is ${state.goal.status}; allowing stop.`;
331
- }
332
- return ko
333
- ? `${event === "subagent-stop" ? "서브에이전트" : "작업"} 종료 게이트: 활성 Cairn 목표가 없어 종료를 허용합니다.`
334
- : `${event === "subagent-stop" ? "Subagent" : "Turn"} stop gate: no active Cairn goal; allowing stop.`;
335
- }
336
-
337
- function activeGoalCompletionMessage(ko, state) {
338
- const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
339
- return ko
340
- ? `활성 목표 "${goalTitle}"의 모든 task가 완료되었습니다. 완료 기준을 확인하고 명시적으로 complete 상태로 전이하세요.`
341
- : `All tasks for active goal "${goalTitle}" are complete. Verify criteria and explicitly transition the goal to completed.`;
342
- }
343
-
344
- const TASK_STATUS_NAMES = ["active", "pending", "blocked", "completed"];
345
-
346
- function clipText(value, maxLength) {
347
- const text = String(value ?? "");
348
- return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
349
- }
350
-
351
97
  function resolveRoot(root, payload) {
352
98
  return resolveRepoRoot({
353
99
  explicitRoot: root ?? null,
@@ -0,0 +1,229 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { commandOk } from "./cairn-toolcheck-runtime.mjs";
4
+
5
+ export async function collectEntries(base) {
6
+ const output = [];
7
+ async function walk(dir) {
8
+ for (const entry of await readdir(dir)) {
9
+ if ([".git", ".cairn", "node_modules", "vendor", "target", ".venv", "__pycache__"].includes(entry)) continue;
10
+ const path = join(dir, entry);
11
+ const relativePath = normalizePath(relative(base, path));
12
+ if (relativePath === "test/fixtures" || relativePath.startsWith("test/fixtures/")) continue;
13
+ const info = await lstat(path);
14
+ if (info.isSymbolicLink()) continue;
15
+ if (info.isDirectory()) await walk(path);
16
+ else output.push(relativePath);
17
+ }
18
+ }
19
+ await walk(base);
20
+ return output;
21
+ }
22
+
23
+ export function detectStacks(paths) {
24
+ const stacks = new Set();
25
+ if (hasBasename(paths, "package.json")) stacks.add("javascript");
26
+ if (hasBasename(paths, "tsconfig.json") || paths.some((path) => path.endsWith(".ts") || path.endsWith(".tsx"))) stacks.add("typescript");
27
+ if (hasBasename(paths, "pyproject.toml") || paths.some((path) => path.endsWith(".py"))) stacks.add("python");
28
+ if (hasBasename(paths, "composer.json") || paths.some((path) => path.endsWith(".php"))) stacks.add("php");
29
+ if (hasBasename(paths, "pom.xml", "build.gradle", "build.gradle.kts") || paths.some((path) => path.endsWith(".java"))) stacks.add("java");
30
+ if (paths.some((path) => path.endsWith(".kt") || path.endsWith(".kts"))) stacks.add("kotlin");
31
+ if (hasBasename(paths, "Package.swift") || paths.some((path) => hasPathSegmentWithSuffix(path, ".xcodeproj", ".xcworkspace") || path.endsWith(".swift"))) stacks.add("swift");
32
+ if (hasBasename(paths, "go.mod") || paths.some((path) => path.endsWith(".go"))) stacks.add("go");
33
+ if (hasBasename(paths, "Cargo.toml") || paths.some((path) => path.endsWith(".rs"))) stacks.add("rust");
34
+ return [...stacks];
35
+ }
36
+
37
+ export function buildRequirements(stacks, entries = [], _commandAvailable = commandOk, platform = process.platform) {
38
+ const requirements = [];
39
+ if (stacks.includes("javascript")) {
40
+ requirements.push(unsafeReq("node", ["--version"], "JavaScript runtime for package scripts and CLI checks", "automatic runtime installation is unavailable"));
41
+ const manager = packageManager(entries);
42
+ requirements.push(unsafeReq(manager, ["--version"], `JavaScript package manager selected from ${packageManagerSource(entries)}`, "automatic package-manager installation is unavailable"));
43
+ }
44
+ if (stacks.includes("typescript")) {
45
+ requirements.push(unsafeReq("typescript-language-server", ["--version"], "TypeScript LSP server", "automatic package installation is disabled without pinned package versions"));
46
+ requirements.push(unsafeReq("tsc", ["--version"], "TypeScript compiler verification", "automatic package installation is disabled without pinned package versions"));
47
+ }
48
+ if (stacks.includes("python")) {
49
+ requirements.push(unsafeReq("basedpyright", ["--version"], "Python LSP/type checker", "automatic Python package installation is disabled without pinned package versions"));
50
+ requirements.push(unsafeReq("ruff", ["--version"], "Python lint and format checker", "automatic Python package installation is disabled without pinned package versions"));
51
+ }
52
+ if (stacks.includes("php")) {
53
+ const composerProject = hasBasename(entries, "composer.json");
54
+ requirements.push(req("php", ["--version"], "PHP runtime for Composer scripts and CLI checks"));
55
+ if (composerProject) requirements.push(req("composer", ["--version"], "PHP package manager for project-local tool installation"));
56
+ requirements.push(unsafeReq("phpactor", ["--version"], "PHP LSP server", "automatic Composer package installation is disabled without pinned package versions"));
57
+ requirements.push(unsafeReq("phpstan", ["--version"], "PHP static analysis verification", "automatic Composer package installation is disabled without pinned package versions"));
58
+ requirements.push(unsafeReq("php-cs-fixer", ["--version"], "PHP formatting verification", "automatic Composer package installation is disabled without pinned package versions"));
59
+ }
60
+ if (stacks.includes("java")) {
61
+ addJvmRequirements(requirements);
62
+ requirements.push(unsafeReq("jdtls", ["--version"], "Java LSP server", "checksum-free JDTLS latest downloads are disabled"));
63
+ addJvmBuildToolRequirements(requirements, entries);
64
+ }
65
+ if (stacks.includes("kotlin")) {
66
+ addJvmRequirements(requirements);
67
+ requirements.push(req("kotlin-lsp.sh", ["--help"], "Kotlin LSP server"));
68
+ requirements.push(req("kotlinc", ["-version"], "Kotlin compiler verification"));
69
+ addJvmBuildToolRequirements(requirements, entries);
70
+ }
71
+ if (stacks.includes("swift")) {
72
+ requirements.push(req("swift", ["--version"], "Swift toolchain for build and test commands"));
73
+ requirements.push(req("sourcekit-lsp", ["--version"], "Swift LSP server"));
74
+ if (entries.some((path) => hasPathSegmentWithSuffix(path, ".xcodeproj", ".xcworkspace"))) {
75
+ requirements.push(req("xcodebuild", ["-version"], "Xcode build verification"));
76
+ }
77
+ }
78
+ if (stacks.includes("go")) {
79
+ requirements.push(unsafeReq("gopls", ["version"], "Go LSP server", "unpinned go install targets are disabled"));
80
+ requirements.push(unsafeReq("golangci-lint", ["--version"], "Go lint verification", "unpinned go install targets are disabled"));
81
+ }
82
+ if (stacks.includes("rust")) {
83
+ requirements.push(unsafeReq("rust-analyzer", ["--version"], "Rust LSP server", "automatic rustup changes are disabled without a pinned toolchain manifest"));
84
+ requirements.push(unsafeReq("cargo", ["clippy", "--version"], "Rust clippy verification", "automatic rustup changes are disabled without a pinned toolchain manifest"));
85
+ }
86
+ return requirements;
87
+ }
88
+
89
+ export function addJvmRequirements(requirements) {
90
+ if (!requirements.some((requirement) => requirement.name === "java")) {
91
+ requirements.push(req("java", ["--version"], "JVM runtime for build and test commands"));
92
+ }
93
+ if (!requirements.some((requirement) => requirement.name === "javac")) {
94
+ requirements.push(req("javac", ["--version"], "JVM compiler verification"));
95
+ }
96
+ }
97
+
98
+ export function addJvmBuildToolRequirements(requirements, entries = []) {
99
+ const gradleWindows = findBasename(entries, "gradlew.bat");
100
+ const gradleUnix = findBasename(entries, "gradlew");
101
+ if (gradleWindows) pushUnique(requirements, req(repositoryCommand(gradleWindows), ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
102
+ else if (gradleUnix) pushUnique(requirements, req(repositoryCommand(gradleUnix), ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
103
+ else if (hasBasename(entries, "build.gradle", "build.gradle.kts")) pushUnique(requirements, req("gradle", ["--version"], "Gradle availability"));
104
+ const mavenWindows = findBasename(entries, "mvnw.cmd", "mvnw.bat");
105
+ const mavenUnix = findBasename(entries, "mvnw");
106
+ if (mavenWindows) pushUnique(requirements, req(repositoryCommand(mavenWindows), ["--version"], "Maven wrapper availability", null, { localOnly: true }));
107
+ else if (mavenUnix) pushUnique(requirements, req(repositoryCommand(mavenUnix), ["--version"], "Maven wrapper availability", null, { localOnly: true }));
108
+ else if (hasBasename(entries, "pom.xml")) pushUnique(requirements, req("mvn", ["--version"], "Maven availability"));
109
+ }
110
+
111
+ export function pushUnique(requirements, requirement) {
112
+ if (!requirements.some((candidate) => candidate.name === requirement.name)) requirements.push(requirement);
113
+ }
114
+
115
+ // Kept for API compatibility. Callers may show these commands, but buildRequirements
116
+ // no longer attaches an unpinned command to an automatically executable requirement.
117
+ export function composerInstall(packages) {
118
+ return { command: "composer", args: ["require", "--dev", ...packages] };
119
+ }
120
+
121
+ export function pythonInstall(uv, project, tool, platform = process.platform) {
122
+ if (uv && project) return { command: "uv", args: ["add", "--dev", tool] };
123
+ if (uv) return { command: "uv", args: ["tool", "install", tool] };
124
+ if (platform === "win32") return { command: "py", args: ["-3", "-m", "pip", "install", "--user", tool] };
125
+ return { command: "python3", args: ["-m", "pip", "install", "--user", tool] };
126
+ }
127
+
128
+ export function jdtlsInstall(_platform = process.platform) {
129
+ return null;
130
+ }
131
+
132
+ export function req(command, args, reason, installCommand = null, options = {}) {
133
+ return {
134
+ name: command,
135
+ command,
136
+ args,
137
+ reason,
138
+ install: installCommand,
139
+ installUnavailableReason: options.installUnavailableReason ?? null,
140
+ localOnly: options.localOnly ?? false,
141
+ };
142
+ }
143
+
144
+ export function unsafeReq(command, args, reason, installUnavailableReason) {
145
+ return req(command, args, reason, null, { installUnavailableReason });
146
+ }
147
+
148
+ export function packageManager(entries = []) {
149
+ return selectPackageManager(entries).manager;
150
+ }
151
+
152
+ export function packageManagerSource(entries = []) {
153
+ const selected = selectPackageManager(entries);
154
+ return selected.path ?? "the npm default (no lockfile found)";
155
+ }
156
+
157
+ export function installDev(pm, packages) {
158
+ if (pm === "bun") return { command: "bun", args: ["add", "-d", ...packages] };
159
+ if (pm === "pnpm") return { command: "pnpm", args: ["add", "-D", ...packages] };
160
+ if (pm === "yarn") return { command: "yarn", args: ["add", "-D", ...packages] };
161
+ return { command: "npm", args: ["install", "--save-dev", ...packages] };
162
+ }
163
+
164
+ function normalizePath(path) {
165
+ return path.split(sep).join("/");
166
+ }
167
+
168
+ function pathBasename(path) {
169
+ const normalized = normalizePath(path);
170
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
171
+ }
172
+
173
+ function hasBasename(paths, ...names) {
174
+ const expected = new Set(names);
175
+ return paths.some((path) => expected.has(pathBasename(path)));
176
+ }
177
+
178
+ function findBasename(paths, ...names) {
179
+ const expected = new Set(names);
180
+ return [...paths]
181
+ .filter((path) => expected.has(pathBasename(path)))
182
+ .sort(compareRepositoryPaths)[0] ?? null;
183
+ }
184
+
185
+ function hasPathSegmentWithSuffix(path, ...suffixes) {
186
+ return normalizePath(path)
187
+ .split("/")
188
+ .some((segment) => suffixes.some((suffix) => segment.endsWith(suffix)));
189
+ }
190
+
191
+ function repositoryCommand(path) {
192
+ const normalized = normalizePath(path);
193
+ return normalized.startsWith("./") ? normalized : `./${normalized}`;
194
+ }
195
+
196
+ function selectPackageManager(entries) {
197
+ const lockfiles = [
198
+ { manager: "bun", names: new Set(["bun.lock", "bun.lockb"]), priority: 0 },
199
+ { manager: "pnpm", names: new Set(["pnpm-lock.yaml"]), priority: 1 },
200
+ { manager: "yarn", names: new Set(["yarn.lock"]), priority: 2 },
201
+ { manager: "npm", names: new Set(["package-lock.json", "npm-shrinkwrap.json"]), priority: 3 },
202
+ ];
203
+ const candidates = [];
204
+
205
+ for (const path of entries) {
206
+ const normalized = normalizePath(path);
207
+ const name = pathBasename(normalized);
208
+ const match = lockfiles.find(({ names }) => names.has(name));
209
+ if (!match) continue;
210
+ candidates.push({
211
+ manager: match.manager,
212
+ path: normalized,
213
+ depth: normalized.split("/").length - 1,
214
+ priority: match.priority,
215
+ });
216
+ }
217
+
218
+ candidates.sort((left, right) => left.depth - right.depth
219
+ || left.priority - right.priority
220
+ || left.path.localeCompare(right.path));
221
+ return candidates[0] ?? { manager: "npm", path: null };
222
+ }
223
+
224
+ function compareRepositoryPaths(left, right) {
225
+ const leftPath = normalizePath(left);
226
+ const rightPath = normalizePath(right);
227
+ const depth = leftPath.split("/").length - rightPath.split("/").length;
228
+ return depth || leftPath.localeCompare(rightPath);
229
+ }