@tea-agent/loop-agent 0.8.0 → 0.10.0-alpha.0

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 (188) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +101 -1
  3. package/README.md +69 -5
  4. package/dist/application/dag/args.js +13 -16
  5. package/dist/application/dag/generate-task-dag.js +32 -2
  6. package/dist/application/dag/run-dag.js +1 -27
  7. package/dist/application/dag/validate-dag.js +2 -2
  8. package/dist/application/loop/run-action.js +0 -4
  9. package/dist/cli/command-definitions.js +7 -11
  10. package/dist/cli/program.js +9 -21
  11. package/dist/commands/cursor-prompt.js +42 -82
  12. package/dist/commands/dag-approve.js +36 -0
  13. package/dist/commands/dag-reconcile-run.js +118 -0
  14. package/dist/commands/delegate.js +75 -77
  15. package/dist/commands/doctor.js +0 -18
  16. package/dist/commands/init.js +60 -40
  17. package/dist/commands/instructions.js +7 -10
  18. package/dist/commands/loop.js +4 -20
  19. package/dist/executors/config-core.js +0 -51
  20. package/dist/executors/dag-pi-executor.js +1 -1
  21. package/dist/executors/dag.js +0 -1
  22. package/dist/executors/index.js +0 -2
  23. package/dist/executors/model-routing.js +9 -9
  24. package/dist/executors/shell-executor.js +75 -9
  25. package/dist/governance/checks.js +6 -3
  26. package/dist/governance/manifest-types.js +33 -2
  27. package/dist/infrastructure/harness/loop-action-store.js +0 -3
  28. package/dist/records/harvest.js +2 -23
  29. package/dist/records/one-shot-runs.js +1 -1
  30. package/dist/shared/artifacts-core.js +24 -5
  31. package/dist/shared/output-truncation.js +37 -0
  32. package/dist/shared/package-metadata.js +353 -0
  33. package/dist/shared/reference-context.js +48 -22
  34. package/dist/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
  35. package/dist/sidecars/cursor-prompt/index.js +3 -0
  36. package/dist/sidecars/cursor-prompt/stream.js +121 -0
  37. package/dist/task/config-types.js +30 -13
  38. package/dist/task/delegate.js +9 -21
  39. package/dist/task/runtime.js +2 -3
  40. package/dist/worker/cli.js +243 -0
  41. package/dist/worker/closeout/apply.js +73 -0
  42. package/dist/worker/closeout/preview.js +30 -0
  43. package/dist/worker/delivery/final-verification.js +194 -0
  44. package/dist/worker/delivery/git-transaction.js +354 -0
  45. package/dist/worker/delivery/package.js +502 -0
  46. package/dist/worker/feature/decision-loader.js +68 -0
  47. package/dist/worker/feature/discover.js +14 -0
  48. package/dist/worker/feature/next-action.js +74 -0
  49. package/dist/worker/feature/reducer.js +133 -0
  50. package/dist/worker/feature/review.js +502 -0
  51. package/dist/worker/feature/run.js +365 -0
  52. package/dist/worker/feature/types.js +1 -0
  53. package/dist/worker/follow-up/approve.js +270 -0
  54. package/dist/worker/follow-up/factory.js +234 -0
  55. package/dist/worker/follow-up/paths.js +25 -0
  56. package/dist/worker/follow-up/policy.js +26 -0
  57. package/dist/worker/follow-up/schema.js +93 -0
  58. package/dist/worker/follow-up/store.js +96 -0
  59. package/dist/worker/loop-agent/loop-agent-client.js +345 -101
  60. package/dist/worker/metrics/projector.js +139 -0
  61. package/dist/worker/observability/read-model.js +282 -15
  62. package/dist/worker/observe/paths.js +17 -5
  63. package/dist/worker/observe/routes.js +78 -20
  64. package/dist/worker/observe/server.js +8 -6
  65. package/dist/worker/observe/static/app.js +1045 -177
  66. package/dist/worker/observe/static/index.html +70 -43
  67. package/dist/worker/observe/static/styles.css +553 -610
  68. package/dist/worker/pool/run-store.js +14 -2
  69. package/dist/worker/pool/validation.js +59 -0
  70. package/dist/worker/preflight.js +49 -1
  71. package/dist/worker/report/morning-report.js +41 -6
  72. package/dist/worker/run-task/run-task.js +23 -13
  73. package/dist/worker/runner/run-ready.js +89 -11
  74. package/dist/worker/task-spec/schema.js +0 -1
  75. package/dist/workflows/dag/convergence/controller.js +1 -1
  76. package/dist/workflows/dag/executor-registry.js +0 -2
  77. package/dist/workflows/dag/governance-profile.js +10 -0
  78. package/dist/workflows/dag/init-hybrid.js +601 -26
  79. package/dist/workflows/dag/lifecycle.js +146 -0
  80. package/dist/workflows/dag/node-execution.js +64 -7
  81. package/dist/workflows/dag/prompt.js +16 -0
  82. package/dist/workflows/dag/report.js +2 -0
  83. package/dist/workflows/dag/runner.js +176 -119
  84. package/dist/workflows/dag/scheduler.js +7 -2
  85. package/dist/workflows/dag/skill-snapshot.js +527 -0
  86. package/dist/workflows/dag/types.js +45 -9
  87. package/dist/workflows/dag/validate.js +5 -8
  88. package/dist/workflows/loop/actions/dag-action.js +0 -2
  89. package/dist/workflows/loop/actions/shared.js +1 -1
  90. package/dist/workflows/loop/actions.js +14 -31
  91. package/dist/workflows/loop/benchmark.js +1 -1
  92. package/dist/workflows/loop/index.js +1 -1
  93. package/dist/workflows/loop/policy/auto-policy.js +22 -14
  94. package/dist/workflows/loop/policy/path-patterns.js +13 -0
  95. package/docs/README.md +35 -12
  96. package/docs/agent-dag-recovery-playbook.md +1 -1
  97. package/docs/architecture/README.md +26 -0
  98. package/docs/architecture/dag-execution.md +134 -0
  99. package/docs/architecture/evolution.md +52 -0
  100. package/docs/architecture/facts-and-state.md +58 -0
  101. package/docs/architecture/runtime-boundaries.md +41 -15
  102. package/docs/architecture/system-overview.md +93 -0
  103. package/docs/architecture/worker-and-feature.md +81 -0
  104. package/docs/cursor-prompt-sidecar.md +36 -0
  105. package/docs/decisions/README.md +13 -1
  106. package/docs/design/README.md +39 -13
  107. package/docs/development-principles.md +1 -1
  108. package/docs/exec-plans/active/README.md +2 -2
  109. package/docs/exec-plans/completed/README.md +21 -0
  110. package/docs/feature-workflow.md +44 -4
  111. package/docs/init-surface.manifest.json +63 -1
  112. package/docs/loop-agent-harness.md +65 -3
  113. package/docs/progress/README.md +27 -0
  114. package/docs/reports/README.md +74 -5
  115. package/docs/skills/README.md +2 -1
  116. package/docs/skills/vetted-skill-registry.md +2 -1
  117. package/docs/templates/agent-dag-report.schema.json +4 -2
  118. package/docs/templates/agent-dag.base.json +0 -5
  119. package/docs/templates/agent-dag.final-verification.json +0 -5
  120. package/docs/templates/agent-dag.schema.json +1 -2
  121. package/docs/templates/agent-dag.supervised-implementation.json +1 -6
  122. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +131 -0
  123. package/docs/templates/backend-test-dag.json +213 -0
  124. package/docs/templates/backend-test-dag.retrospect.prompt.md +128 -0
  125. package/docs/templates/backend-test-dag.review-cases.prompt.md +85 -0
  126. package/docs/templates/frontend-design-contract.md +33 -0
  127. package/docs/templates/frontend-task-constraints.md +25 -0
  128. package/docs/templates/frontend-task-requirement.md +61 -0
  129. package/docs/templates/harness.schema.json +8 -5
  130. package/docs/templates/hybrid-dag.json +1 -6
  131. package/docs/templates/init-evolution-review.md +4 -2
  132. package/docs/templates/interactive-ui-round2-experiment.md +1 -1
  133. package/docs/templates/product-line/task.yaml +0 -1
  134. package/docs/templates/worker-dogfood-evidence.md +28 -0
  135. package/docs/templates/worker-dogfood-setup.md +20 -0
  136. package/docs/verification-matrix.md +17 -0
  137. package/examples/decision-gate-agent-dag.json +87 -33
  138. package/examples/example-dag.json +0 -5
  139. package/examples/hybrid-loop-agent-dag.json +0 -5
  140. package/harness.json +6 -11
  141. package/package.json +22 -44
  142. package/scripts/check-product-line-docs.sh +10 -3
  143. package/scripts/check-task-pool-root.sh +1 -1
  144. package/skills/agent-worker/SKILL.md +37 -0
  145. package/skills/agent-worker/references/agent-worker-operator.md +43 -0
  146. package/skills/frontend-design-review/SKILL.md +59 -0
  147. package/skills/frontend-design-review/references/review-checklist.md +37 -0
  148. package/skills/frontend-implementation/SKILL.md +48 -0
  149. package/skills/frontend-implementation/references/code-standards.md +34 -0
  150. package/skills/frontend-implementation/references/design-spec.md +46 -0
  151. package/skills/frontend-implementation/references/node-contracts.md +32 -0
  152. package/skills/frontend-review/SKILL.md +53 -0
  153. package/skills/frontend-review/references/review-findings.md +42 -0
  154. package/skills/frontend-verification/SKILL.md +40 -0
  155. package/skills/frontend-verification/references/verification-checklist.md +56 -0
  156. package/skills/grill-me/SKILL.md +10 -0
  157. package/skills/grill-with-docs/SKILL.md +88 -0
  158. package/skills/grill-with-docs/adr-format.md +47 -0
  159. package/skills/grill-with-docs/context-format.md +60 -0
  160. package/skills/init-capability-evolution/SKILL.md +1 -0
  161. package/skills/loop-agent/SKILL.md +11 -9
  162. package/skills/loop-agent/references/command-reference.md +28 -15
  163. package/skills/loop-agent/references/docs-converge.md +126 -0
  164. package/skills/loop-agent/references/harness-policy.md +7 -7
  165. package/skills/loop-agent/references/hybrid-dag.md +13 -15
  166. package/skills/loop-agent/references/long-running-loop.md +4 -6
  167. package/skills/loop-agent/references/multi-worktree.md +6 -6
  168. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  169. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
  170. package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
  171. package/skills/using-git-worktrees/SKILL.md +215 -0
  172. package/dist/commands/cursor-worker.js +0 -43
  173. package/dist/cursor-worker-entry.js +0 -8
  174. package/dist/executors/cursor-artifacts.js +0 -33
  175. package/dist/executors/cursor-execution-log.js +0 -81
  176. package/dist/executors/cursor-executor-artifacts.js +0 -134
  177. package/dist/executors/cursor-run.js +0 -115
  178. package/dist/executors/cursor-tool.js +0 -94
  179. package/dist/executors/cursor-worker-client.js +0 -223
  180. package/dist/executors/cursor-worker-protocol.js +0 -18
  181. package/dist/executors/cursor-worker-server.js +0 -54
  182. package/dist/executors/cursor-worker.js +0 -3
  183. package/dist/executors/cursor.js +0 -6
  184. package/dist/executors/dag-cursor-executor.js +0 -87
  185. package/dist/workflows/loop/actions/cursor-fix.js +0 -191
  186. package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
  187. package/docs/cursor-executor-usage.md +0 -25
  188. package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
@@ -1,12 +1,10 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { loadHarnessManifest } from '../governance/harness.js';
5
5
  import { resolveWorktreePath } from '../task/worktree.js';
6
6
  import { getTaskPaths, getTaskStatus, loadTaskConfig } from '../task/runtime.js';
7
7
  import { validateDiffPathGuard, listDiffPaths } from '../governance/path-guard.js';
8
- import { validateCursorArtifacts } from '../executors/cursor-artifacts.js';
9
- import { checkTaskHandoff } from '../governance/checks.js';
10
8
  import { archiveTaskDirFromWorktree } from '../task/worktree-cleanup.js';
11
9
  export { copyDir } from '../shared/copy-dir.js';
12
10
  /**
@@ -154,8 +152,7 @@ async function runHarvestGates(repoRoot, taskId, taskRoot, baseBranch, branch) {
154
152
  catch {
155
153
  return;
156
154
  }
157
- const shouldRunPathGuard = taskConfig.executor === 'cursor'
158
- || (taskConfig.allowedPaths ?? []).length > 0
155
+ const shouldRunPathGuard = (taskConfig.allowedPaths ?? []).length > 0
159
156
  || (taskConfig.forbiddenPaths ?? []).length > 0;
160
157
  if (shouldRunPathGuard) {
161
158
  const changedPaths = await listDiffPaths(repoRoot, baseBranch, branch);
@@ -165,24 +162,6 @@ async function runHarvestGates(repoRoot, taskId, taskRoot, baseBranch, branch) {
165
162
  'Worktree preserved for manual review.');
166
163
  }
167
164
  }
168
- if (taskConfig.executor !== 'cursor') {
169
- return;
170
- }
171
- const paths = getTaskPaths(taskRoot, taskId);
172
- const modifyLog = await readFile(path.join(paths.taskDir, 'artifacts', '修改记录.md'), 'utf-8');
173
- const verifyResult = await readFile(path.join(paths.taskDir, 'artifacts', '验证结果.md'), 'utf-8');
174
- const artifactIssues = validateCursorArtifacts(modifyLog, verifyResult);
175
- if (artifactIssues.length > 0) {
176
- throw new Error(`harvest artifact validation failed for cursor task "${taskId}": ${artifactIssues.join('; ')}. ` +
177
- 'Worktree preserved for manual review.');
178
- }
179
- const handoff = await checkTaskHandoff(taskRoot, taskId);
180
- const taskHandoff = handoff.tasks.find((task) => task.taskId === taskId);
181
- const blockingIssues = (taskHandoff?.issues ?? []).filter((issue) => issue.severity === 'error');
182
- if (blockingIssues.length > 0) {
183
- throw new Error(`harvest handoff check failed for task "${taskId}": ${blockingIssues.map((issue) => issue.message).join('; ')}. ` +
184
- 'Worktree preserved for manual review.');
185
- }
186
165
  }
187
166
  // ---------------------------------------------------------------------------
188
167
  // Helpers
@@ -1,6 +1,6 @@
1
1
  import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { truncateOutput } from '../executors/cursor-executor.js';
3
+ import { truncateOutput } from '../shared/output-truncation.js';
4
4
  import { createOneShotActiveRunDir, ensureOneShotRunDirs, finalizeLegacyRunLogToCompleted, transferOneShotRunToCompleted, transferOneShotRunToFailed, writeOneShotActiveLogAtPath, writeOneShotRunMarkdown, writeOneShotRunMeta, } from '../infrastructure/harness/one-shot-run-store.js';
5
5
  /** Maximum chars for result summary in the run log. */
6
6
  export const RUN_LOG_RESULT_MAX = 4_000;
@@ -2,13 +2,25 @@ import path from 'node:path';
2
2
  import { resolveTaskContextFromDir, writeTaskArtifactFile, } from '../infrastructure/harness/task-store.js';
3
3
  export const ARTIFACTS_DIR = 'artifacts';
4
4
  const SPEC_ARTIFACT_STEPS = ['analyze', 'plan'];
5
- const DEFAULT_ARTIFACTS = {
6
- '分析报告.md': '# 分析报告\n\n## 1. 任务目标\n\n## 2. 当前现状\n\n## 3. 受影响模块与文件\n\n## 4. 关键约束与不变式\n\n## 5. 硬约束摘要\n\n## 6. 允许修改范围 / 禁止修改范围\n\n## 7. 风险点\n\n## 8. 待确认问题\n',
7
- '实现计划.md': '# 实现计划\n\n## 1. 修改目标\n\n## 2. 修改范围\n\n## 3. 允许修改文件 / 目录\n\n## 4. 明确不做的内容\n\n## 5. 关键决策\n\n## 6. 不变式\n\n## 7. 风险与回滚点\n\n## 8. 测试与验证方案\n\n## 9. 不在本次范围内的事项\n\n## 10. 待确认事项\n',
5
+ /** Promote-run / closeout bridge templates seeded by new-task. */
6
+ const SEEDED_ARTIFACTS = {
8
7
  '修改记录.md': '# 修改记录\n\n## 1. 修改文件清单\n\n## 2. 关键改动说明\n',
9
8
  '验证结果.md': '# 验证结果\n\n## 1. 执行的命令\n\n## 2. 通过项\n\n## 3. 失败项\n',
9
+ };
10
+ /**
11
+ * Legacy L1 analyze/plan/retrospective templates.
12
+ * Kept only so validateArtifactContent can recognize unchanged historical files.
13
+ * new-task must not seed these.
14
+ */
15
+ const LEGACY_ARTIFACT_TEMPLATES = {
16
+ '分析报告.md': '# 分析报告\n\n## 1. 任务目标\n\n## 2. 当前现状\n\n## 3. 受影响模块与文件\n\n## 4. 关键约束与不变式\n\n## 5. 硬约束摘要\n\n## 6. 允许修改范围 / 禁止修改范围\n\n## 7. 风险点\n\n## 8. 待确认问题\n',
17
+ '实现计划.md': '# 实现计划\n\n## 1. 修改目标\n\n## 2. 修改范围\n\n## 3. 允许修改文件 / 目录\n\n## 4. 明确不做的内容\n\n## 5. 关键决策\n\n## 6. 不变式\n\n## 7. 风险与回滚点\n\n## 8. 测试与验证方案\n\n## 9. 不在本次范围内的事项\n\n## 10. 待确认事项\n',
10
18
  '复盘报告.md': '# 复盘报告\n\n## 1. 质量复盘\n\n## 2. 流程问题\n\n## 3. 下次建议\n',
11
19
  };
20
+ const ARTIFACT_TEMPLATES = {
21
+ ...LEGACY_ARTIFACT_TEMPLATES,
22
+ ...SEEDED_ARTIFACTS,
23
+ };
12
24
  const ARTIFACT_NAME_BY_STEP = {
13
25
  analyze: '分析报告.md',
14
26
  plan: '实现计划.md',
@@ -16,6 +28,8 @@ const ARTIFACT_NAME_BY_STEP = {
16
28
  verify: '验证结果.md',
17
29
  retrospective: '复盘报告.md',
18
30
  };
31
+ /** Steps whose artifact files are seeded by new-task (promote/closeout bridge). */
32
+ export const SEEDED_ARTIFACT_STEPS = ['implement', 'verify'];
19
33
  export function getArtifactFileName(step) {
20
34
  return ARTIFACT_NAME_BY_STEP[step];
21
35
  }
@@ -35,6 +49,9 @@ export function formatSpecArtifactRelativePaths() {
35
49
  export function formatSpecArtifactDisplayNames() {
36
50
  return SPEC_ARTIFACT_STEPS.map((step) => getArtifactDisplayName(step)).join(' + ');
37
51
  }
52
+ export function listSeededArtifactFileNames() {
53
+ return Object.keys(SEEDED_ARTIFACTS);
54
+ }
38
55
  const REQUIRED_HEADINGS = {
39
56
  analyze: ['# 分析报告', '## 1. 任务目标'],
40
57
  plan: ['# 实现计划', '## 1. 修改目标'],
@@ -43,7 +60,7 @@ const REQUIRED_HEADINGS = {
43
60
  };
44
61
  export async function initializeArtifacts(taskDir) {
45
62
  const { repoRoot, taskId } = resolveTaskContextFromDir(taskDir);
46
- await Promise.all(Object.entries(DEFAULT_ARTIFACTS).map(([name, content]) => writeTaskArtifactFile(repoRoot, taskId, name, content)));
63
+ await Promise.all(Object.entries(SEEDED_ARTIFACTS).map(([name, content]) => writeTaskArtifactFile(repoRoot, taskId, name, content)));
47
64
  }
48
65
  export function getArtifactPath(taskDir, step) {
49
66
  return path.join(taskDir, ARTIFACTS_DIR, getArtifactFileName(step));
@@ -57,7 +74,9 @@ function normalizeArtifactContent(content) {
57
74
  }
58
75
  function isUnchangedNewTaskTemplate(step, content) {
59
76
  const artifactName = ARTIFACT_NAME_BY_STEP[step];
60
- const template = DEFAULT_ARTIFACTS[artifactName];
77
+ const template = ARTIFACT_TEMPLATES[artifactName];
78
+ if (!template)
79
+ return false;
61
80
  return normalizeArtifactContent(content) === normalizeArtifactContent(template);
62
81
  }
63
82
  function firstRequiredSectionIsEmpty(step, content) {
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+ export function truncateOutput(text, maxBytes) {
3
+ if (!text)
4
+ return { text, truncated: false };
5
+ if (maxBytes <= 0) {
6
+ return { text: "\n...[truncated]", truncated: true };
7
+ }
8
+ if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
9
+ return { text, truncated: false };
10
+ }
11
+ let low = 0;
12
+ let high = text.length;
13
+ while (low < high) {
14
+ const mid = Math.ceil((low + high) / 2);
15
+ if (Buffer.byteLength(text.slice(0, mid), "utf-8") <= maxBytes) {
16
+ low = mid;
17
+ }
18
+ else {
19
+ high = mid - 1;
20
+ }
21
+ }
22
+ let end = low;
23
+ // Avoid ending the prefix on a dangling UTF-16 high surrogate.
24
+ if (end > 0) {
25
+ const code = text.charCodeAt(end - 1);
26
+ if (code >= 0xd800 && code <= 0xdbff)
27
+ end -= 1;
28
+ }
29
+ return {
30
+ text: `${text.slice(0, end)}\n...[truncated]`,
31
+ truncated: true,
32
+ };
33
+ }
34
+ export function redactPromptForLog(prompt) {
35
+ const hash = createHash("sha256").update(prompt).digest("hex").slice(0, 12);
36
+ return `<prompt sha256=${hash} len=${prompt.length}>`;
37
+ }
@@ -0,0 +1,353 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { platform } from "node:os";
6
+ // ---------------------------------------------------------------------------
7
+ // Public helpers
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Read the `version` field from a package.json file.
11
+ *
12
+ * Returns `undefined` when the file is missing, unreadable, or lacks a
13
+ * string `version` field — callers must treat `undefined` as a hard failure
14
+ * for identity construction.
15
+ */
16
+ export function readPackageVersion(packageDir) {
17
+ try {
18
+ const raw = readFileSync(path.join(packageDir, "package.json"), "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ if (typeof parsed.version === "string")
21
+ return parsed.version;
22
+ }
23
+ catch {
24
+ // file missing or unreadable
25
+ }
26
+ return undefined;
27
+ }
28
+ /**
29
+ * Read the `name` field from a package.json file.
30
+ *
31
+ * Returns `undefined` when the file is missing, unreadable, or lacks a
32
+ * string `name` field.
33
+ */
34
+ export function readPackageName(packageDir) {
35
+ try {
36
+ const raw = readFileSync(path.join(packageDir, "package.json"), "utf-8");
37
+ const parsed = JSON.parse(raw);
38
+ if (typeof parsed.name === "string")
39
+ return parsed.name;
40
+ }
41
+ catch {
42
+ // file missing or unreadable
43
+ }
44
+ return undefined;
45
+ }
46
+ /**
47
+ * Compute the SHA-256 hash of a file on disk.
48
+ *
49
+ * Returns `undefined` when the file is unreadable — callers must treat this
50
+ * as a hard failure for identity construction.
51
+ */
52
+ export function computeBinarySha256(filePath) {
53
+ try {
54
+ const buffer = readFileSync(filePath);
55
+ return createHash("sha256").update(buffer).digest("hex");
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * Compute a portable, deterministic content fingerprint for the installed
63
+ * package rooted at `packageRoot`.
64
+ *
65
+ * The fingerprint covers four scopes (package.json, bin/**, dist/**,
66
+ * skills/**) and is independent of the absolute installation path.
67
+ *
68
+ * Sorting uses code-point order (UTF-8 byte comparison), NOT localeCompare,
69
+ * to ensure deterministic behaviour across locales.
70
+ */
71
+ export function computePackageFingerprint(packageRoot) {
72
+ const scope = [
73
+ "package.json",
74
+ "bin/**",
75
+ "dist/**",
76
+ "skills/**",
77
+ ];
78
+ // Collecting entries ---------------------------------------------------
79
+ const entries = [];
80
+ // 1) package.json (required single file)
81
+ addRequiredFile(entries, path.join(packageRoot, "package.json"), "");
82
+ // 2) bin/**, dist/**, skills/** via recursive walk
83
+ for (const dir of ["bin", "dist", "skills"]) {
84
+ const absoluteDir = path.join(packageRoot, dir);
85
+ collectTree(entries, absoluteDir, dir);
86
+ }
87
+ // 3) Deterministic sort by relative path (code-point / byte order)
88
+ entries.sort((a, b) => {
89
+ const aBuf = Buffer.from(a.relativePath, "utf-8");
90
+ const bBuf = Buffer.from(b.relativePath, "utf-8");
91
+ const minLen = Math.min(aBuf.length, bBuf.length);
92
+ for (let i = 0; i < minLen; i++) {
93
+ if (aBuf[i] !== bBuf[i])
94
+ return aBuf[i] - bBuf[i];
95
+ }
96
+ return aBuf.length - bBuf.length;
97
+ });
98
+ // 4) Cumulative hash: hash of (<relativePath> + "\t" + <contentHash>) lines
99
+ const cumulative = createHash("sha256");
100
+ for (const entry of entries) {
101
+ cumulative.update(`${entry.relativePath}\t${entry.contentHash}\n`);
102
+ }
103
+ return {
104
+ algorithm: "sha256",
105
+ scopeVersion: 1,
106
+ scope,
107
+ value: `sha256:${cumulative.digest("hex")}`,
108
+ fileCount: entries.length,
109
+ };
110
+ }
111
+ /**
112
+ * Resolve a requested bin name or path to the absolute executable.
113
+ *
114
+ * Resolution strategy:
115
+ * 1. If `requested` is already an absolute path, use it directly.
116
+ * 2. If `requested` contains a path separator (/, \\), resolve against cwd.
117
+ * 3. Otherwise, search PATH directories (with optional PATHEXT on Windows).
118
+ * PATH misses fail closed; a bare command name is never invented relative to
119
+ * cwd when it was not actually found.
120
+ *
121
+ * After this call, the result must be treated as frozen — subsequent PATH
122
+ * mutations must not trigger a new resolution.
123
+ */
124
+ export function resolveBinPaths(requested, resolvedAt, inputs) {
125
+ const cwd = inputs?.cwd ?? process.cwd();
126
+ const env = inputs?.env ?? process.env;
127
+ const osPlatform = inputs?.platform ?? platform();
128
+ if (path.isAbsolute(requested)) {
129
+ const entry = path.resolve(requested);
130
+ assertRegularFile(entry, requested);
131
+ return { requested, entry, realEntry: realpathSync(entry), resolvedAt };
132
+ }
133
+ // Path-like relative (contains / or \)
134
+ if (requested.includes("/") || requested.includes("\\")) {
135
+ const resolved = path.resolve(cwd, requested);
136
+ assertRegularFile(resolved, requested);
137
+ return { requested, entry: resolved, realEntry: realpathSync(resolved), resolvedAt };
138
+ }
139
+ // Search PATH
140
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
141
+ const candidates = resolveFromPath(requested, pathEnv, osPlatform, env.PATHEXT ?? env.Pathext ?? env.pathext, cwd);
142
+ if (candidates.length > 0) {
143
+ const entry = candidates[0];
144
+ assertRegularFile(entry, requested);
145
+ return { requested, entry, realEntry: realpathSync(entry), resolvedAt };
146
+ }
147
+ throw new Error(`controller identity: cannot resolve executable ${JSON.stringify(requested)}`);
148
+ }
149
+ /**
150
+ * Resolve an npm-generated Windows `.cmd` shim to the referenced JavaScript
151
+ * bin entry within the same package.
152
+ *
153
+ * On Windows, npm creates `.cmd` wrappers like:
154
+ * ```
155
+ * @IF EXIST "%~dp0\node.exe" (
156
+ * "%~dp0\node.exe" "%~dp0\..\package\bin\cli.js" %*
157
+ * ) ELSE (
158
+ * ...
159
+ * )
160
+ * ```
161
+ *
162
+ * This function parses the `.cmd` shim, extracts the JS script reference, and
163
+ * returns the resolved absolute path. Returns `undefined` if the shim cannot
164
+ * be parsed or the referenced script does not exist.
165
+ *
166
+ * Pure function: does not require a Windows host to execute.
167
+ */
168
+ export function resolveCmdShim(shimPath) {
169
+ try {
170
+ const content = readFileSync(shimPath, "utf-8");
171
+ const matches = content.matchAll(/"(?:%dp0%|%~dp0)([^"]+\.(?:js|mjs|cjs))"/gi);
172
+ for (const match of matches) {
173
+ if (!match[1])
174
+ continue;
175
+ const windowsRelative = match[1].replace(/^[/\\]+/, "");
176
+ const scriptPath = path.resolve(path.dirname(shimPath), ...windowsRelative.split(/[\\/]+/));
177
+ try {
178
+ if (statSync(scriptPath).isFile())
179
+ return realpathSync(scriptPath);
180
+ }
181
+ catch {
182
+ // Try the next JavaScript reference in the shim.
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+ catch {
188
+ return undefined;
189
+ }
190
+ }
191
+ /** Check whether a file is a JavaScript script (node shebang or JS extension). */
192
+ export function isJavaScriptScript(filePath) {
193
+ try {
194
+ const header = readFileSync(filePath).subarray(0, 256).toString("utf-8").trimStart();
195
+ if (header.startsWith("#!/usr/bin/env node") || header.startsWith("#!/usr/bin/node") || header.startsWith("#!node")) {
196
+ return true;
197
+ }
198
+ }
199
+ catch {
200
+ // can't read — check by extension only
201
+ }
202
+ const ext = path.extname(filePath).toLowerCase();
203
+ return ext === ".js" || ext === ".mjs" || ext === ".cjs" || ext === ".ts";
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Package root resolution
207
+ // ---------------------------------------------------------------------------
208
+ /**
209
+ * Find the nearest package root by walking upward from `startDir` until a
210
+ * `package.json` is found.
211
+ *
212
+ * Returns `undefined` if no package.json was found at any ancestor (up to 100
213
+ * levels). Callers must treat `undefined` as a hard failure for identity
214
+ * construction.
215
+ */
216
+ export function findPackageRoot(startDir) {
217
+ let current = path.resolve(startDir);
218
+ for (let i = 0; i < 100; i++) {
219
+ try {
220
+ statSync(path.join(current, "package.json"));
221
+ return current;
222
+ }
223
+ catch {
224
+ const parent = path.dirname(current);
225
+ if (parent === current)
226
+ break;
227
+ current = parent;
228
+ }
229
+ }
230
+ return undefined;
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // PATHEXT resolution
234
+ // ---------------------------------------------------------------------------
235
+ /**
236
+ * Expand a Windows executable name into candidate entries using PATHEXT.
237
+ *
238
+ * Example: on a system with `PATHEXT=.COM;.EXE;.BAT;.CMD;.VBS;.JS`,
239
+ * resolving "loop-agent" produces:
240
+ * ["loop-agent.COM", "loop-agent.EXE", "loop-agent.BAT", "loop-agent.CMD", ...]
241
+ *
242
+ * If the name already has an extension matching a PATHEXT entry, no expansion
243
+ * is performed and the name is used as-is.
244
+ *
245
+ * Pure function for testability.
246
+ */
247
+ export function expandPathext(bin, pathext) {
248
+ if (!pathext)
249
+ return [bin];
250
+ const extensions = pathext
251
+ .split(";")
252
+ .map((ext) => ext.trim())
253
+ .filter(Boolean);
254
+ if (extensions.length === 0)
255
+ return [bin];
256
+ const binLower = bin.toLowerCase();
257
+ const alreadyHasExt = extensions.some((ext) => binLower.endsWith(ext.toLowerCase()));
258
+ if (alreadyHasExt)
259
+ return [bin];
260
+ return extensions.map((ext) => bin + ext);
261
+ }
262
+ // ---------------------------------------------------------------------------
263
+ // Internal helpers
264
+ // ---------------------------------------------------------------------------
265
+ function addRequiredFile(entries, absolutePath, relativeDir) {
266
+ const buffer = readFileSync(absolutePath);
267
+ const contentHash = createHash("sha256").update(buffer).digest("hex");
268
+ const relativePath = relativeDir
269
+ ? path.posix.join(relativeDir, path.basename(absolutePath))
270
+ : path.basename(absolutePath);
271
+ entries.push({ relativePath, contentHash });
272
+ }
273
+ function collectTree(entries, absoluteDir, relativeDir) {
274
+ let stats;
275
+ try {
276
+ stats = statSync(absoluteDir);
277
+ }
278
+ catch (error) {
279
+ if (isNotFound(error))
280
+ return; // optional scope directory does not exist
281
+ throw error;
282
+ }
283
+ if (!stats.isDirectory())
284
+ return;
285
+ const dirents = readdirSync(absoluteDir, { withFileTypes: true });
286
+ for (const dirent of dirents) {
287
+ const childAbs = path.join(absoluteDir, dirent.name);
288
+ const childRel = path.posix.join(relativeDir, dirent.name);
289
+ if (dirent.isFile()) {
290
+ addRequiredFile(entries, childAbs, relativeDir);
291
+ }
292
+ else if (dirent.isDirectory()) {
293
+ collectTree(entries, childAbs, childRel);
294
+ }
295
+ // Ignore symlinks, sockets, etc.
296
+ }
297
+ }
298
+ function isNotFound(error) {
299
+ return Boolean(error
300
+ && typeof error === "object"
301
+ && "code" in error
302
+ && error.code === "ENOENT");
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Internal: PATH resolution (synchronous, no external dependencies)
306
+ // ---------------------------------------------------------------------------
307
+ function resolveFromPath(bin, pathEnv, osPlatform, pathext, cwd) {
308
+ const results = [];
309
+ const dirs = pathEnv.split(osPlatform === "win32" ? ";" : path.delimiter);
310
+ // On Windows, expand with PATHEXT
311
+ const isWindows = osPlatform === "win32";
312
+ for (const rawDir of dirs) {
313
+ const dir = rawDir || cwd;
314
+ if (isWindows) {
315
+ // Get PATHEXT from a well-known env var — in tests, passed via the
316
+ // merged env through ResolutionInputs. Here we fall back to process.env.
317
+ const candidates = expandPathext(bin, pathext ?? ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC");
318
+ for (const candidate of candidates) {
319
+ const filePath = path.join(dir, candidate);
320
+ try {
321
+ if (!statSync(filePath).isFile())
322
+ continue;
323
+ results.push(filePath);
324
+ }
325
+ catch {
326
+ // not found in this PATH entry
327
+ }
328
+ }
329
+ }
330
+ else {
331
+ const candidate = path.join(dir, bin);
332
+ try {
333
+ if (!statSync(candidate).isFile())
334
+ continue;
335
+ results.push(candidate);
336
+ }
337
+ catch {
338
+ // not found in this PATH entry
339
+ }
340
+ }
341
+ }
342
+ return results;
343
+ }
344
+ function assertRegularFile(filePath, requested) {
345
+ try {
346
+ if (statSync(filePath).isFile())
347
+ return;
348
+ }
349
+ catch {
350
+ // Fall through to the stable error below.
351
+ }
352
+ throw new Error(`controller identity: resolved executable for ${JSON.stringify(requested)} is not a readable file: ${filePath}`);
353
+ }
@@ -7,6 +7,17 @@ import path from 'node:path';
7
7
  const DEFAULT_MAX_FILES_PER_REPO = 24;
8
8
  const DEFAULT_MAX_TOTAL_FILES = 48;
9
9
  const DEFAULT_MAX_FILE_BYTES = 512_000;
10
+ const MAX_REFERENCE_ENTRIES_PER_REPO = 1_024;
11
+ const SKIPPED_REFERENCE_DIRECTORIES = new Set([
12
+ '.git',
13
+ '.hg',
14
+ '.svn',
15
+ '.venv',
16
+ 'build',
17
+ 'dist',
18
+ 'node_modules',
19
+ 'target',
20
+ ]);
10
21
  export function isFeatureStudyTask(taskConfig) {
11
22
  return taskConfig.taskKind === 'feature-study';
12
23
  }
@@ -102,8 +113,8 @@ function uniqueKeepOrder(items) {
102
113
  return result;
103
114
  }
104
115
  async function collectRepoFiles(root, focusGlobs, maxFiles) {
105
- const candidates = await listFilesRecursive(root);
106
- const matched = candidates
116
+ const candidates = await listFilesRecursive(root, MAX_REFERENCE_ENTRIES_PER_REPO);
117
+ const matched = candidates.files
107
118
  .map((absolutePath) => ({
108
119
  absolutePath,
109
120
  relativePath: path.relative(root, absolutePath).replace(/\\/g, '/'),
@@ -111,7 +122,7 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
111
122
  .filter((entry) => focusGlobs.some((pattern) => matchGlob(pattern, entry.relativePath)))
112
123
  .sort((a, b) => a.relativePath.localeCompare(b.relativePath));
113
124
  const files = [];
114
- let truncated = false;
125
+ let truncated = candidates.truncated;
115
126
  for (const entry of matched) {
116
127
  if (files.length >= maxFiles) {
117
128
  truncated = true;
@@ -122,10 +133,6 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
122
133
  continue;
123
134
  if (fileStat.size > DEFAULT_MAX_FILE_BYTES)
124
135
  continue;
125
- const lower = entry.relativePath.toLowerCase();
126
- if (lower.includes('/target/') || lower.includes('/node_modules/') || lower.includes('/.git/')) {
127
- continue;
128
- }
129
136
  files.push({
130
137
  relativePath: entry.relativePath,
131
138
  absolutePath: entry.absolutePath,
@@ -134,21 +141,40 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
134
141
  }
135
142
  return { files, truncated };
136
143
  }
137
- async function listFilesRecursive(dir) {
138
- let entries;
139
- try {
140
- entries = await readdir(dir, { withFileTypes: true });
141
- }
142
- catch {
143
- return [];
144
- }
145
- const nested = await Promise.all(entries.map(async (entry) => {
146
- const target = path.join(dir, entry.name);
147
- if (entry.isDirectory())
148
- return listFilesRecursive(target);
149
- return [target];
150
- }));
151
- return nested.flat();
144
+ async function listFilesRecursive(dir, maxEntries) {
145
+ const files = [];
146
+ let visited = 0;
147
+ let truncated = false;
148
+ const walk = async (current) => {
149
+ let entries;
150
+ try {
151
+ entries = await readdir(current, { withFileTypes: true });
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ for (const entry of entries) {
157
+ if (visited >= maxEntries) {
158
+ truncated = true;
159
+ return;
160
+ }
161
+ visited += 1;
162
+ if (entry.isDirectory() && SKIPPED_REFERENCE_DIRECTORIES.has(entry.name.toLowerCase())) {
163
+ continue;
164
+ }
165
+ const target = path.join(current, entry.name);
166
+ if (entry.isDirectory()) {
167
+ await walk(target);
168
+ if (truncated)
169
+ return;
170
+ }
171
+ else {
172
+ files.push(target);
173
+ }
174
+ }
175
+ };
176
+ await walk(dir);
177
+ return { files, truncated };
152
178
  }
153
179
  export function matchGlob(pattern, relativePath) {
154
180
  const normalizedPattern = pattern.replace(/\\/g, '/');
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { referencePathJoin } from "../shared/path-refs.js";
1
+ import { referencePathJoin } from "../../shared/path-refs.js";
2
+ import { truncateOutput, redactPromptForLog } from "../../shared/output-truncation.js";
3
3
  /** Cursor SDK account default (user → team → system). Override per call when needed. */
4
4
  export const DEFAULT_CURSOR_MODEL = "default";
5
5
  export const DEFAULT_CURSOR_TIMEOUT_MS = 1_800_000;
@@ -48,47 +48,11 @@ export function buildCursorPrompt(userTask, artifactOptions) {
48
48
  userTask,
49
49
  ].join("\n");
50
50
  }
51
- export function truncateOutput(text, maxBytes) {
52
- if (!text)
53
- return { text, truncated: false };
54
- if (maxBytes <= 0) {
55
- return { text: "\n...[truncated]", truncated: true };
56
- }
57
- if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
58
- return { text, truncated: false };
59
- }
60
- let low = 0;
61
- let high = text.length;
62
- while (low < high) {
63
- const mid = Math.ceil((low + high) / 2);
64
- if (Buffer.byteLength(text.slice(0, mid), "utf-8") <= maxBytes) {
65
- low = mid;
66
- }
67
- else {
68
- high = mid - 1;
69
- }
70
- }
71
- let end = low;
72
- // Avoid ending the prefix on a dangling UTF-16 high surrogate.
73
- if (end > 0) {
74
- const code = text.charCodeAt(end - 1);
75
- if (code >= 0xd800 && code <= 0xdbff)
76
- end -= 1;
77
- }
78
- return {
79
- text: `${text.slice(0, end)}\n...[truncated]`,
80
- truncated: true,
81
- };
82
- }
83
51
  export function redactApiKey(text, apiKey) {
84
52
  if (!apiKey)
85
53
  return text;
86
54
  return text.split(apiKey).join("<redacted-api-key>");
87
55
  }
88
- export function redactPromptForLog(prompt) {
89
- const hash = createHash("sha256").update(prompt).digest("hex").slice(0, 12);
90
- return `<prompt sha256=${hash} len=${prompt.length}>`;
91
- }
92
56
  export function buildRedactedExecutorRecord(input) {
93
57
  const apiKey = input.apiKey ?? process.env.CURSOR_API_KEY;
94
58
  const stdout = truncateOutput(redactApiKey(input.result.stdout, apiKey), 2_000);
@@ -239,10 +203,6 @@ function applyTokenUsageToResult(result, usage) {
239
203
  }
240
204
  }
241
205
  export async function executeCursorTask(options) {
242
- const { shouldUseCursorWorker, executeCursorTaskViaWorker } = await import("./cursor-worker-client.js");
243
- if (shouldUseCursorWorker(options)) {
244
- return executeCursorTaskViaWorker(options);
245
- }
246
206
  const model = options.model ?? DEFAULT_CURSOR_MODEL;
247
207
  const timeoutMs = options.timeoutMs ?? DEFAULT_CURSOR_TIMEOUT_MS;
248
208
  const apiKey = options.apiKey ?? process.env.CURSOR_API_KEY;