@straiffi/archon 1.0.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 (124) hide show
  1. package/README.md +224 -0
  2. package/dist/cli.js +216 -0
  3. package/dist/client/assets/index-8_-boBBA.css +2 -0
  4. package/dist/client/assets/index-s_jjeqha.js +176 -0
  5. package/dist/client/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  6. package/dist/client/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  7. package/dist/client/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  8. package/dist/client/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  9. package/dist/client/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  10. package/dist/client/favicon.svg +62 -0
  11. package/dist/client/icons.svg +24 -0
  12. package/dist/client/index.html +14 -0
  13. package/dist/server/db.js +764 -0
  14. package/dist/server/db.js.map +1 -0
  15. package/dist/server/index.js +5134 -0
  16. package/dist/server/index.js.map +1 -0
  17. package/dist/server/lib/agent.js +1302 -0
  18. package/dist/server/lib/agent.js.map +1 -0
  19. package/dist/server/lib/buildChains.js +2 -0
  20. package/dist/server/lib/buildChains.js.map +1 -0
  21. package/dist/server/lib/buildFlow.js +59 -0
  22. package/dist/server/lib/buildFlow.js.map +1 -0
  23. package/dist/server/lib/buildSequences.js +599 -0
  24. package/dist/server/lib/buildSequences.js.map +1 -0
  25. package/dist/server/lib/bundleActivity.js +95 -0
  26. package/dist/server/lib/bundleActivity.js.map +1 -0
  27. package/dist/server/lib/bundlePullRequests.js +126 -0
  28. package/dist/server/lib/bundlePullRequests.js.map +1 -0
  29. package/dist/server/lib/chatMessages.js +60 -0
  30. package/dist/server/lib/chatMessages.js.map +1 -0
  31. package/dist/server/lib/chatTargets.js +123 -0
  32. package/dist/server/lib/chatTargets.js.map +1 -0
  33. package/dist/server/lib/chatTicketProposals.js +180 -0
  34. package/dist/server/lib/chatTicketProposals.js.map +1 -0
  35. package/dist/server/lib/chats.js +279 -0
  36. package/dist/server/lib/chats.js.map +1 -0
  37. package/dist/server/lib/config.js +3 -0
  38. package/dist/server/lib/config.js.map +1 -0
  39. package/dist/server/lib/cors.js +30 -0
  40. package/dist/server/lib/cors.js.map +1 -0
  41. package/dist/server/lib/directoryPicker.js +174 -0
  42. package/dist/server/lib/directoryPicker.js.map +1 -0
  43. package/dist/server/lib/git.js +1284 -0
  44. package/dist/server/lib/git.js.map +1 -0
  45. package/dist/server/lib/integrations/github.js +511 -0
  46. package/dist/server/lib/integrations/github.js.map +1 -0
  47. package/dist/server/lib/integrations/index.js +162 -0
  48. package/dist/server/lib/integrations/index.js.map +1 -0
  49. package/dist/server/lib/integrations/jira.js +283 -0
  50. package/dist/server/lib/integrations/jira.js.map +1 -0
  51. package/dist/server/lib/integrations/planning.js +27 -0
  52. package/dist/server/lib/integrations/planning.js.map +1 -0
  53. package/dist/server/lib/integrations/types.js +2 -0
  54. package/dist/server/lib/integrations/types.js.map +1 -0
  55. package/dist/server/lib/lightweightPrompt.js +88 -0
  56. package/dist/server/lib/lightweightPrompt.js.map +1 -0
  57. package/dist/server/lib/models.js +219 -0
  58. package/dist/server/lib/models.js.map +1 -0
  59. package/dist/server/lib/preview.js +377 -0
  60. package/dist/server/lib/preview.js.map +1 -0
  61. package/dist/server/lib/previewProxy.js +659 -0
  62. package/dist/server/lib/previewProxy.js.map +1 -0
  63. package/dist/server/lib/projectAutoConfig.js +682 -0
  64. package/dist/server/lib/projectAutoConfig.js.map +1 -0
  65. package/dist/server/lib/projectFileSuggestions.js +133 -0
  66. package/dist/server/lib/projectFileSuggestions.js.map +1 -0
  67. package/dist/server/lib/projectMemory.js +1519 -0
  68. package/dist/server/lib/projectMemory.js.map +1 -0
  69. package/dist/server/lib/projectMemoryPrompt.js +390 -0
  70. package/dist/server/lib/projectMemoryPrompt.js.map +1 -0
  71. package/dist/server/lib/projectMemoryScan.js +681 -0
  72. package/dist/server/lib/projectMemoryScan.js.map +1 -0
  73. package/dist/server/lib/projectMemorySuggestions.js +166 -0
  74. package/dist/server/lib/projectMemorySuggestions.js.map +1 -0
  75. package/dist/server/lib/projectMemoryTransfer.js +958 -0
  76. package/dist/server/lib/projectMemoryTransfer.js.map +1 -0
  77. package/dist/server/lib/projects.js +569 -0
  78. package/dist/server/lib/projects.js.map +1 -0
  79. package/dist/server/lib/promptSkills.js +28 -0
  80. package/dist/server/lib/promptSkills.js.map +1 -0
  81. package/dist/server/lib/queue.js +15 -0
  82. package/dist/server/lib/queue.js.map +1 -0
  83. package/dist/server/lib/reviewFindings.js +390 -0
  84. package/dist/server/lib/reviewFindings.js.map +1 -0
  85. package/dist/server/lib/run.js +416 -0
  86. package/dist/server/lib/run.js.map +1 -0
  87. package/dist/server/lib/runtimePaths.js +93 -0
  88. package/dist/server/lib/runtimePaths.js.map +1 -0
  89. package/dist/server/lib/shell.js +27 -0
  90. package/dist/server/lib/shell.js.map +1 -0
  91. package/dist/server/lib/skills.js +124 -0
  92. package/dist/server/lib/skills.js.map +1 -0
  93. package/dist/server/lib/startDev.js +18 -0
  94. package/dist/server/lib/startDev.js.map +1 -0
  95. package/dist/server/lib/staticClient.js +80 -0
  96. package/dist/server/lib/staticClient.js.map +1 -0
  97. package/dist/server/lib/terminal.js +366 -0
  98. package/dist/server/lib/terminal.js.map +1 -0
  99. package/dist/server/lib/ticketDependencies.js +174 -0
  100. package/dist/server/lib/ticketDependencies.js.map +1 -0
  101. package/dist/server/lib/ticketMessages.js +65 -0
  102. package/dist/server/lib/ticketMessages.js.map +1 -0
  103. package/dist/server/lib/ticketOpenQuestions.js +128 -0
  104. package/dist/server/lib/ticketOpenQuestions.js.map +1 -0
  105. package/dist/server/lib/ticketUndo.js +549 -0
  106. package/dist/server/lib/ticketUndo.js.map +1 -0
  107. package/dist/server/lib/tickets.js +981 -0
  108. package/dist/server/lib/tickets.js.map +1 -0
  109. package/dist/server/lib/types.js +2 -0
  110. package/dist/server/lib/types.js.map +1 -0
  111. package/dist/server/package.json +3 -0
  112. package/dist/server/workers/build.js +229 -0
  113. package/dist/server/workers/build.js.map +1 -0
  114. package/dist/server/workers/chat.js +190 -0
  115. package/dist/server/workers/chat.js.map +1 -0
  116. package/dist/server/workers/followUp.js +204 -0
  117. package/dist/server/workers/followUp.js.map +1 -0
  118. package/dist/server/workers/plan.js +1130 -0
  119. package/dist/server/workers/plan.js.map +1 -0
  120. package/dist/server/workers/planFollowUp.js +360 -0
  121. package/dist/server/workers/planFollowUp.js.map +1 -0
  122. package/dist/server/workers/review.js +167 -0
  123. package/dist/server/workers/review.js.map +1 -0
  124. package/package.json +40 -0
@@ -0,0 +1,1130 @@
1
+ import { spawn } from 'child_process';
2
+ import { randomUUID } from 'crypto';
3
+ import Database from 'better-sqlite3';
4
+ import db from '../db.js';
5
+ import config from '../lib/config.js';
6
+ import { JiraApiError } from '../lib/integrations/jira.js';
7
+ import { JiraPlanningReferenceError, resolveJiraPlanningDescription } from '../lib/integrations/planning.js';
8
+ import { resolveExistingWorktreePath } from '../lib/git.js';
9
+ import { normalizeModelId } from '../lib/models.js';
10
+ import { persistProjectMemorySuggestions } from '../lib/projectMemory.js';
11
+ import { getProjectMemoryPromptBlock } from '../lib/projectMemoryPrompt.js';
12
+ import { normalizeProjectMemorySuggestions, PROJECT_MEMORY_SUGGESTION_COMMON_RULE_LINES } from '../lib/projectMemorySuggestions.js';
13
+ import { prependSkillPromptBlock } from '../lib/promptSkills.js';
14
+ import { registerBundleActivity, unregisterBundleActivity } from '../lib/bundleActivity.js';
15
+ import { enqueue, isRunning } from '../lib/queue.js';
16
+ import { getEffectiveProject, getProjectById, listLinkedProjects } from '../lib/projects.js';
17
+ import { resolveOpencodeDbPath } from '../lib/runtimePaths.js';
18
+ import { createTicketMessage, upsertTicketDescriptionMessage } from '../lib/ticketMessages.js';
19
+ import { getTicket, resolveTicketBranch, resolveTicketTool, resolveTicketWorkerKey } from '../lib/tickets.js';
20
+ const extractLeadingJsonObject = (line) => {
21
+ if (!line.startsWith('{')) {
22
+ return null;
23
+ }
24
+ let depth = 0;
25
+ let inString = false;
26
+ let escaping = false;
27
+ for (let index = 0; index < line.length; index += 1) {
28
+ const char = line[index];
29
+ if (inString) {
30
+ if (escaping) {
31
+ escaping = false;
32
+ continue;
33
+ }
34
+ if (char === '\\') {
35
+ escaping = true;
36
+ continue;
37
+ }
38
+ if (char === '"') {
39
+ inString = false;
40
+ }
41
+ continue;
42
+ }
43
+ if (char === '"') {
44
+ inString = true;
45
+ continue;
46
+ }
47
+ if (char === '{') {
48
+ depth += 1;
49
+ continue;
50
+ }
51
+ if (char === '}') {
52
+ depth -= 1;
53
+ if (depth === 0) {
54
+ return line.slice(0, index + 1);
55
+ }
56
+ }
57
+ }
58
+ return null;
59
+ };
60
+ const formatPlannerToolCall = (name, input) => {
61
+ const value = {
62
+ Read: input?.file_path,
63
+ Write: input?.file_path,
64
+ Edit: input?.file_path,
65
+ Bash: input?.command,
66
+ Glob: input?.pattern,
67
+ Grep: input?.pattern,
68
+ WebFetch: input?.url,
69
+ WebSearch: input?.query,
70
+ Task: input?.description,
71
+ NotebookEdit: input?.notebook_path,
72
+ }[name] ?? Object.values(input ?? {}).find(entry => typeof entry === 'string');
73
+ if (typeof value !== 'string' || value.length === 0) {
74
+ return `_${name}_`;
75
+ }
76
+ const display = value.length > 100 ? value.slice(0, 100) + '…' : value;
77
+ return `_${name}_ \`${display}\``;
78
+ };
79
+ const parseClaudePlannerStreamJsonLine = (line) => {
80
+ const trimmedLine = line.trim();
81
+ if (!trimmedLine) {
82
+ return { logFragment: null, responseFragment: null };
83
+ }
84
+ let event;
85
+ try {
86
+ event = JSON.parse(trimmedLine);
87
+ }
88
+ catch {
89
+ return { logFragment: trimmedLine, responseFragment: trimmedLine };
90
+ }
91
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
92
+ const logParts = [];
93
+ const responseParts = [];
94
+ for (const block of event.message.content) {
95
+ if (block.type === 'text' && block.text) {
96
+ const text = block.text.trim();
97
+ if (text) {
98
+ logParts.push(text);
99
+ responseParts.push(text);
100
+ }
101
+ continue;
102
+ }
103
+ if (block.type === 'tool_use' && block.name) {
104
+ logParts.push(formatPlannerToolCall(block.name, block.input));
105
+ }
106
+ }
107
+ return {
108
+ logFragment: logParts.length > 0 ? logParts.join('\n\n') : null,
109
+ responseFragment: responseParts.length > 0 ? responseParts.join('\n\n') : null,
110
+ };
111
+ }
112
+ return { logFragment: null, responseFragment: null };
113
+ };
114
+ const formatPlannerOpencodeToolUse = (event) => {
115
+ const toolName = event.part?.tool;
116
+ if (!toolName) {
117
+ return null;
118
+ }
119
+ return formatPlannerToolCall(toolName, event.part?.state?.input);
120
+ };
121
+ const formatPlannerStepTitle = (event) => {
122
+ const title = event.part?.title?.trim();
123
+ return title || null;
124
+ };
125
+ const parseOpencodePlannerJsonLine = (line) => {
126
+ const trimmedLine = line.trim();
127
+ if (!trimmedLine) {
128
+ return { logFragment: null, responseFragment: null };
129
+ }
130
+ const jsonLine = extractLeadingJsonObject(trimmedLine);
131
+ if (!jsonLine) {
132
+ return { logFragment: trimmedLine, responseFragment: trimmedLine };
133
+ }
134
+ let event;
135
+ try {
136
+ event = JSON.parse(jsonLine);
137
+ }
138
+ catch {
139
+ return { logFragment: trimmedLine, responseFragment: trimmedLine };
140
+ }
141
+ if (event.type === 'text' && event.part?.type === 'text' && event.part.text) {
142
+ const text = event.part.text.trim();
143
+ const phase = event.part.metadata?.openai?.phase;
144
+ return {
145
+ logFragment: text || null,
146
+ responseFragment: text && (!phase || phase === 'final_answer') ? text : null,
147
+ };
148
+ }
149
+ if (event.type === 'reasoning' && event.part?.type === 'reasoning') {
150
+ const text = event.part.text?.trim() ?? '';
151
+ return {
152
+ logFragment: text || null,
153
+ responseFragment: null,
154
+ };
155
+ }
156
+ if (event.type === 'tool_use') {
157
+ return {
158
+ logFragment: formatPlannerOpencodeToolUse(event),
159
+ responseFragment: null,
160
+ };
161
+ }
162
+ if (event.type === 'step_start') {
163
+ return {
164
+ logFragment: formatPlannerStepTitle(event),
165
+ responseFragment: null,
166
+ };
167
+ }
168
+ return { logFragment: null, responseFragment: null };
169
+ };
170
+ const stripSystemReminderBlocks = (output) => {
171
+ return output.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
172
+ };
173
+ export const IMPLEMENTATION_READY_DESCRIPTION_SECTION_HEADINGS = [
174
+ 'Problem Statement',
175
+ 'Acceptance Criteria',
176
+ 'Implementation Approach',
177
+ 'Edge Cases and Error Handling',
178
+ 'Constraints and Things to Avoid',
179
+ ];
180
+ const IMPLEMENTATION_READY_DESCRIPTION_REQUIREMENTS = [
181
+ '- A clear problem statement or goal',
182
+ '- Specific acceptance criteria as a checklist',
183
+ '- Concrete implementation approach (files to touch, patterns to follow, APIs to use, etc.)',
184
+ '- Edge cases and error handling to consider',
185
+ '- Any relevant constraints or things to avoid',
186
+ ].join('\n');
187
+ const IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE = [
188
+ 'Use exactly these markdown sections, in order:',
189
+ ...IMPLEMENTATION_READY_DESCRIPTION_SECTION_HEADINGS.map(heading => `- ## ${heading}`),
190
+ ].join('\n');
191
+ const INDENTED_IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE = IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE
192
+ .split('\n')
193
+ .map(line => ` ${line}`)
194
+ .join('\n');
195
+ const MULTI_TICKET_MAX_COUNT = 6;
196
+ const OUTLINE_DETAIL_REQUIREMENTS = [
197
+ '- `summary`: 1-3 sentences describing scope and why the ticket exists',
198
+ '- `goal`: one sentence describing the intended end state',
199
+ '- `repo_context`: the most relevant existing area, component, module, API, or workflow the implementer should inspect first',
200
+ '- `likely_files`: 1-5 likely file paths when they can be inferred; otherwise an empty array',
201
+ '- `test_scope`: concrete tests, checks, or verification the implementer should run or update',
202
+ '- `constraints`: 2-5 short strings describing important limits, things to preserve, or things to avoid',
203
+ ].join('\n');
204
+ const IMPLEMENTATION_DETAIL_RULES = [
205
+ '- Include at least 3 checklist items in `## Acceptance Criteria`.',
206
+ '- Include at least 2 concrete implementation bullets or numbered steps in `## Implementation Approach`.',
207
+ '- Mention specific files, components, modules, or APIs when they can be inferred from the repository.',
208
+ '- Include explicit testing or verification guidance, not just generic quality-check reminders.',
209
+ '- Carry forward the outline ticket\'s goal, repo context, likely files, test scope, and constraints into the final description.',
210
+ ].join('\n');
211
+ const PLANNING_QUESTIONS_RULES = [
212
+ '- Always write the best complete implementation-ready description first, using your strongest reasonable assumptions.',
213
+ '- If a material assumption affects scope, implementation details, constraints, or acceptance criteria, also surface it as an item in `open_questions`.',
214
+ '- Use `open_questions` only for unresolved ticket-local refinements that would improve the plan further.',
215
+ '- Every `open_questions` entry must be written as a direct question ending with `?`.',
216
+ '- Keep `open_questions` to 0-3 short strings.',
217
+ '- Do not use `open_questions` for issues that would require changing the ticket split or dependency graph. In those cases, make the strongest reasonable assumption in the plan text and continue.',
218
+ '- Return an empty `open_questions` array when the ticket is already clear enough to implement.',
219
+ ].join('\n');
220
+ const MEMORY_SUGGESTION_RULES = [
221
+ ...PROJECT_MEMORY_SUGGESTION_COMMON_RULE_LINES,
222
+ '- Do not suggest planner assumptions as project memory.',
223
+ '- Use the exact shape `{ "kind": "decision", "title": "...", "scope": "optional scope", "decision": "...", "rationale": "optional why", "implications": ["optional implication"] }` for decisions.',
224
+ '- Use the exact shape `{ "kind": "convention", "title": "...", "scope": "relevant area", "instruction": "...", "rationale": "optional why", "stages": ["build"], "priority": "normal" }` for conventions.',
225
+ ].join('\n');
226
+ const ENGLISH_OUTPUT_RULES = [
227
+ '- Always write the output in English regardless of the language used in the source description, ticket, or linked materials.',
228
+ '- Translate or restate non-English input into clear natural English for titles, summaries, descriptions, constraints, acceptance criteria, implementation steps, and open questions.',
229
+ ].join('\n');
230
+ const EXPANDED_TICKET_EXAMPLE = [
231
+ '{',
232
+ ' "key": "header-hello-world",',
233
+ ' "title": "Add hello world header label",',
234
+ ' "description": "## Problem Statement\\nThe header currently lacks the requested label, so implementers need to add it without disturbing the existing header layout.\\n\\n## Acceptance Criteria\\n- [ ] A \\\"Hello World\\\" label is visible next to the existing header title.\\n- [ ] The label uses the requested pink Tailwind styling and remains readable in the current theme.\\n- [ ] The change does not regress the existing project controls or header layout on smaller screens.\\n\\n## Implementation Approach\\n1. Update \\`client/src/App.tsx\\` where the header title is rendered so the new label sits beside the existing title block.\\n2. Reuse the current Tailwind header styling patterns for spacing and typography instead of introducing custom CSS.\\n3. Verify the header still wraps cleanly and that the existing project controls remain unchanged.\\n\\n## Edge Cases and Error Handling\\n- Ensure the added label does not wrap awkwardly or overlap adjacent header controls on narrow screens.\\n- Preserve the existing title and control rendering when projects are absent or the layout wraps.\\n\\n## Constraints and Things to Avoid\\n- Do not add a new dependency or a separate feature-specific component unless reuse is clearly justified.\\n- Do not change unrelated header behavior or styling while adding the new label."',
235
+ '}',
236
+ ].join('\n');
237
+ const MAX_LINKED_RESEARCH_REQUESTS = 2;
238
+ const normalizeLinkedResearchConfidence = (value) => {
239
+ return value === 'low' || value === 'medium' || value === 'high' ? value : 'medium';
240
+ };
241
+ const buildLinkedProjectSelectionPrompt = (taskDescription, project, linkedProjects) => {
242
+ const linkedProjectList = linkedProjects
243
+ .map(linkedProject => `- ${linkedProject.name} (id: ${linkedProject.id})`)
244
+ .join('\n');
245
+ return `You are deciding whether planning this task requires read-only research in linked repositories.
246
+
247
+ Primary implementation project: ${project.name}
248
+
249
+ Linked projects are token-expensive to inspect, so only request linked-project research when the user explicitly asks to inspect, read, or check a linked project, or when planning the current project would otherwise require guessing about external contracts.
250
+
251
+ Return ONLY a JSON object with this exact shape:
252
+ {
253
+ "requests": [
254
+ {
255
+ "project_id": "linked-project-id",
256
+ "question": "Specific research question",
257
+ "rationale": "Why this linked repo must be inspected"
258
+ }
259
+ ]
260
+ }
261
+
262
+ Rules:
263
+ - Return an empty requests array when the current project repo is sufficient.
264
+ - Request at most ${MAX_LINKED_RESEARCH_REQUESTS} linked-project investigations.
265
+ - Only use project ids from the linked project list below.
266
+ - Honor explicit user requests to inspect, read, or check a linked project when the request can be turned into a concrete contract question.
267
+ - Only request research for concrete contract questions such as API payloads, auth expectations, stored data shape, events, or shared type definitions.
268
+ - Do not request implementation guidance from linked repos.
269
+ - Do not request broad repository exploration.
270
+ - Do not include any text before or after the JSON object.
271
+
272
+ Linked projects:
273
+ ${linkedProjectList}
274
+
275
+ Task to plan:
276
+ ${taskDescription}`;
277
+ };
278
+ const buildLinkedProjectResearchPrompt = (primaryProject, linkedProject, request) => {
279
+ const question = typeof request.question === 'string' ? request.question.trim() : '';
280
+ const rationale = typeof request.rationale === 'string' ? request.rationale.trim() : '';
281
+ return `You are performing narrow, read-only repository research for planning work in another repo.
282
+
283
+ Primary implementation project: ${primaryProject.name}
284
+ Reference project: ${linkedProject.name}
285
+
286
+ Research question:
287
+ ${question}
288
+
289
+ Why this linked repo was selected:
290
+ ${rationale || 'Answer the question only if this repo contains the relevant contract details.'}
291
+
292
+ Return ONLY a JSON object with this exact shape:
293
+ {
294
+ "summary": "1-2 sentence answer",
295
+ "files_checked": ["path/to/file.ts"],
296
+ "findings": ["Concrete finding", "Concrete finding"],
297
+ "implementation_details": ["Exact external fact the downstream implementer must follow"],
298
+ "exact_snippet": "Optional short verbatim snippet or example payload",
299
+ "confidence": "low"
300
+ }
301
+
302
+ Rules:
303
+ - This is read-only research. Do not edit files or propose making changes in this repo.
304
+ - Keep the investigation narrow and directly tied to the question.
305
+ - Check only the files needed to answer the question.
306
+ - Assume downstream build agents will not inspect this linked repo.
307
+ - Put the exact external facts the downstream implementer must receive into \`implementation_details\`.
308
+ - If the answer depends on exact field names, field ordering, example payloads, schemas, or text and the relevant content is short enough, copy it verbatim into \`exact_snippet\`.
309
+ - Use confidence values low, medium, or high.
310
+ - If the answer is uncertain, say so in the summary or findings.
311
+ - Do not include any text before or after the JSON object.`;
312
+ };
313
+ export const buildLinkedResearchContext = (project, linkedResearch) => {
314
+ if (linkedResearch.length === 0) {
315
+ return `
316
+
317
+ Primary implementation scope:
318
+ - The active implementation target is project \`${project.name}\` only.
319
+ - Do not plan changes in any other repository unless those changes are split into separate tickets owned by their own projects.`;
320
+ }
321
+ const researchBlocks = linkedResearch.map(result => {
322
+ const filesChecked = result.filesChecked.length > 0
323
+ ? result.filesChecked.map(filePath => ` - ${filePath}`).join('\n')
324
+ : ' - None recorded';
325
+ const findings = result.findings.length > 0
326
+ ? result.findings.map(finding => ` - ${finding}`).join('\n')
327
+ : ' - No concrete findings recorded';
328
+ const implementationDetails = result.implementationDetails.length > 0
329
+ ? result.implementationDetails.map(detail => ` - ${detail}`).join('\n')
330
+ : ' - No implementation details recorded';
331
+ const exactSnippet = result.exactSnippet
332
+ ? [' - Exact snippet:', '```', result.exactSnippet, '```'].join('\n')
333
+ : ' - Exact snippet: None provided';
334
+ return [
335
+ `- Linked project: ${result.projectName}`,
336
+ ` - Question: ${result.question}`,
337
+ ` - Why requested: ${result.rationale}`,
338
+ ` - Summary: ${result.summary}`,
339
+ ` - Confidence: ${result.confidence}`,
340
+ ' - Files checked:',
341
+ filesChecked,
342
+ ' - Findings:',
343
+ findings,
344
+ ' - Implementation details to copy into the ticket:',
345
+ implementationDetails,
346
+ exactSnippet,
347
+ ].join('\n');
348
+ }).join('\n');
349
+ return `
350
+
351
+ Primary implementation scope:
352
+ - The active implementation target is project \`${project.name}\` only.
353
+ - The linked-project findings below are read-only reference material.
354
+ - Do not create tickets or implementation steps that modify linked repositories as part of this planning output.
355
+ - Use linked findings only to describe contracts, constraints, or behaviors the current project must follow.
356
+ - Assume downstream build agents cannot inspect linked repositories.
357
+ - If linked-project findings are needed, copy all required external facts directly into the ticket description.
358
+ - Do not tell implementers to inspect linked projects or linked files.
359
+ - When linked research includes exact snippets, schemas, payloads, or field ordering, embed those details directly in the ticket instead of paraphrasing them away.
360
+
361
+ Linked project findings:
362
+ ${researchBlocks}`;
363
+ };
364
+ const buildPlanPrompt = (title, description, linkedResearchContext = '') => `You are helping plan a software development ticket. Based on the title and brief description below, write a detailed ticket description that is specific enough for a separate AI agent to implement the feature end-to-end without needing any further context or clarification.
365
+
366
+ Include:
367
+ ${IMPLEMENTATION_READY_DESCRIPTION_REQUIREMENTS}
368
+
369
+ ${IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE}
370
+
371
+ Title: ${title}
372
+ Brief description: ${description}
373
+ ${linkedResearchContext}
374
+
375
+ Additional rules:
376
+ - Language rules:
377
+ ${ENGLISH_OUTPUT_RULES}
378
+ - The downstream build agent will only work in the active project repo.
379
+ - If linked-project findings are relevant, embed the required external contract details directly in the ticket.
380
+ - Do not instruct the implementer to inspect linked projects or linked files.
381
+ - Preserve any user-provided URLs, Figma links, docs, tickets, or other external resources that are needed during implementation.
382
+ - Include those resources verbatim in the final ticket description instead of replacing them with vague summaries.
383
+
384
+ Write only the expanded description in markdown, formatted to be readable by both humans and agents. No preamble or meta-commentary.`;
385
+ const buildSingleTicketPrompt = (title, description, linkedResearchContext = '', projectMemoryBlock = '') => `You are helping plan a single software development ticket.
386
+
387
+ This is a planning-only task.
388
+ - You may inspect the repository and use read-only tools if helpful.
389
+ - Do not edit files or make changes.
390
+ - Return a short natural-language planning update first, then the final JSON object on the last line.
391
+
392
+ Return ONLY a JSON object with this exact shape:
393
+ {
394
+ "tickets": [
395
+ {
396
+ "key": "main-ticket",
397
+ "title": "Ticket title",
398
+ "description": "Detailed markdown description",
399
+ "open_questions": ["Optional question"],
400
+ "memory_suggestions": []
401
+ }
402
+ ],
403
+ "dependencies": []
404
+ }
405
+
406
+ Rules:
407
+ - Return exactly one ticket.
408
+ - Use the key \`main-ticket\`.
409
+ - Use the provided title exactly.
410
+ - Return an empty dependencies array.
411
+ - Language rules:
412
+ ${ENGLISH_OUTPUT_RULES}
413
+ - The description must be valid markdown and include:
414
+ ${IMPLEMENTATION_READY_DESCRIPTION_REQUIREMENTS}
415
+ ${INDENTED_IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE}
416
+ - Additional detail requirements:
417
+ ${IMPLEMENTATION_DETAIL_RULES}
418
+ - Preserve any user-provided URLs, Figma links, docs, tickets, or other external resources that are needed during implementation.
419
+ - Include those resources verbatim in the final ticket description instead of replacing them with vague summaries.
420
+ - Assume the downstream build agent cannot inspect linked repositories.
421
+ - If linked-project findings are needed, copy the required facts directly into the ticket description.
422
+ - Do not tell implementers to inspect linked projects or linked files.
423
+ - When linked research includes exact snippets, payloads, schemas, or field ordering, embed those exact details directly in the ticket description when relevant.
424
+ - Clarification handling rules:
425
+ ${PLANNING_QUESTIONS_RULES}
426
+ - Memory suggestion rules:
427
+ ${MEMORY_SUGGESTION_RULES}
428
+ - Do not wrap the JSON in markdown fences.
429
+ - Write the natural-language update in English.
430
+ - Keep the natural-language update to 1-3 short sentences focused on what changed in the plan.
431
+ - Put the JSON object after the natural-language update.
432
+ - The final line must be the JSON object.
433
+
434
+ Title: ${title}
435
+ Brief description: ${description}
436
+ ${projectMemoryBlock ? `${projectMemoryBlock}
437
+
438
+ ` : ''}
439
+ ${linkedResearchContext}`;
440
+ const buildPlanningAgentOutlinePrompt = (description, linkedResearchContext = '', projectMemoryBlock = '') => `You are helping break down a software development initiative into implementation tickets for a kanban board.
441
+
442
+ This is pass 1 of 2. Your job is to decide the best ticket split and dependency graph, not to fully expand implementation details yet.
443
+
444
+ This is a planning-only task.
445
+ - You may inspect the repository and use read-only tools if helpful.
446
+ - Do not edit files or make changes.
447
+ - Return only the final JSON object.
448
+
449
+ Return ONLY a JSON object with this exact shape:
450
+ {
451
+ "tickets": [
452
+ {
453
+ "key": "short-kebab-key",
454
+ "title": "Ticket title",
455
+ "summary": "Short scope summary",
456
+ "goal": "Intended end state",
457
+ "repo_context": "Relevant existing area to inspect first",
458
+ "likely_files": ["path/to/file.ts"],
459
+ "test_scope": "Concrete tests and checks to run or update",
460
+ "constraints": ["Constraint one", "Constraint two"]
461
+ }
462
+ ],
463
+ "dependencies": [
464
+ {
465
+ "blocker_key": "ticket-key",
466
+ "dependent_key": "other-ticket-key"
467
+ }
468
+ ]
469
+ }
470
+
471
+ Rules:
472
+ - Create between 1 and ${MULTI_TICKET_MAX_COUNT} tickets.
473
+ - Each ticket should be independently actionable and belong in the plan column.
474
+ - Prefer the smallest useful number of tickets that still separates major implementation concerns cleanly.
475
+ - Do not write full implementation-ready descriptions yet.
476
+ - Language rules:
477
+ ${ENGLISH_OUTPUT_RULES}
478
+ - For each ticket, include:
479
+ ${OUTLINE_DETAIL_REQUIREMENTS}
480
+ - Use stable unique keys in kebab-case.
481
+ - Dependencies must include only direct blockers. Do not include transitive dependencies.
482
+ - Every dependency must reference one of the generated ticket keys.
483
+ - If no dependencies are needed, return an empty dependencies array.
484
+ - Do not wrap the JSON in markdown fences.
485
+ - Do not include any text before or after the JSON object.
486
+ - Every generated ticket must stay scoped to the active implementation project, not the linked reference repos.
487
+ - If linked-project findings are relevant, capture them as self-contained facts in ticket summaries or constraints instead of telling implementers to inspect linked repos.
488
+ - Preserve any user-provided URLs, Figma links, docs, tickets, or other external resources when they are needed during implementation.
489
+ - If a resource is relevant to a generated ticket, carry it into the summary, test scope, or constraints so it survives the expansion pass.
490
+
491
+ Work to break down:
492
+ ${description}
493
+ ${projectMemoryBlock ? `${projectMemoryBlock}
494
+
495
+ ` : ''}
496
+ ${linkedResearchContext}`;
497
+ const buildPlanningAgentExpansionPrompt = (description, outline, linkedResearchContext = '', projectMemoryBlock = '') => `You are helping expand a previously decomposed software development initiative into implementation-ready tickets for a kanban board.
498
+
499
+ Each generated ticket will later be implemented by a separate AI agent, so every ticket description must be self-contained and implementation-ready without needing any further context or clarification.
500
+
501
+ This is pass 2 of 2. The ticket split and dependency graph are already decided.
502
+
503
+ This is a planning-only task.
504
+ - You may inspect the repository and use read-only tools if helpful.
505
+ - Do not edit files or make changes.
506
+ - Return only the final JSON object.
507
+
508
+ Return ONLY a JSON object with this exact shape:
509
+ {
510
+ "tickets": [
511
+ {
512
+ "key": "short-kebab-key",
513
+ "title": "Ticket title",
514
+ "description": "Detailed markdown description",
515
+ "open_questions": ["Optional question"],
516
+ "memory_suggestions": []
517
+ }
518
+ ],
519
+ "dependencies": [
520
+ {
521
+ "blocker_key": "ticket-key",
522
+ "dependent_key": "other-ticket-key"
523
+ }
524
+ ]
525
+ }
526
+
527
+ ${projectMemoryBlock ? `${projectMemoryBlock}
528
+
529
+ ` : ''}
530
+
531
+ Rules:
532
+ - Preserve every ticket key exactly as provided.
533
+ - Preserve every ticket title exactly as provided.
534
+ - Preserve the dependency list exactly as provided.
535
+ - Language rules:
536
+ ${ENGLISH_OUTPUT_RULES}
537
+ - Each description must be valid markdown and include:
538
+ ${IMPLEMENTATION_READY_DESCRIPTION_REQUIREMENTS}
539
+ ${INDENTED_IMPLEMENTATION_READY_DESCRIPTION_SECTION_TEMPLATE}
540
+ - Additional detail requirements:
541
+ ${IMPLEMENTATION_DETAIL_RULES}
542
+ - Do not wrap the JSON in markdown fences.
543
+ - Do not include any text before or after the JSON object.
544
+ - Assume the downstream build agent cannot inspect linked repositories.
545
+ - If linked-project findings are needed, copy the required facts directly into the ticket description.
546
+ - Do not tell implementers to inspect linked projects or linked files.
547
+ - When linked research includes exact snippets, payloads, schemas, or field ordering, embed those exact details directly in the ticket description when relevant.
548
+ - Preserve any user-provided URLs, Figma links, docs, tickets, or other external resources that are needed during implementation.
549
+ - Include those resources verbatim in the final ticket description instead of replacing them with vague summaries.
550
+ - Clarification handling rules:
551
+ ${PLANNING_QUESTIONS_RULES}
552
+ - Memory suggestion rules:
553
+ ${MEMORY_SUGGESTION_RULES}
554
+
555
+ Example of the expected depth and structure for a single expanded ticket:
556
+ ${EXPANDED_TICKET_EXAMPLE}
557
+
558
+ Original initiative:
559
+ ${description}
560
+ ${linkedResearchContext}
561
+
562
+ Outline to expand:
563
+ ${JSON.stringify(outline, null, 2)}`;
564
+ const resolvePlannerCwd = (project, branch) => {
565
+ if (!project) {
566
+ return undefined;
567
+ }
568
+ if (!branch) {
569
+ return project.repo_path;
570
+ }
571
+ return resolveExistingWorktreePath(branch, project) ?? project.repo_path;
572
+ };
573
+ const updatePlanningSessionId = (ticketId, sessionId, io) => {
574
+ db.prepare('UPDATE tickets SET planning_session_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId, ticketId);
575
+ const ticket = getTicket(ticketId);
576
+ if (ticket) {
577
+ io.emit('ticket:updated', ticket);
578
+ }
579
+ };
580
+ export const runPlannerPrompt = (tool, model, variant, prompt, cwd, options = {}) => {
581
+ const resolvedModel = normalizeModelId(model);
582
+ const sessionTracking = options.sessionTracking;
583
+ let sessionId = null;
584
+ let spawnTimestamp = null;
585
+ let sessionLookupTimer = null;
586
+ let hasResolvedSessionId = Boolean(sessionTracking?.resumeSessionId);
587
+ const [cmd, args] = tool === 'claude'
588
+ ? (() => {
589
+ if (!sessionTracking) {
590
+ return ['claude', ['--dangerously-skip-permissions', '--print', '--verbose', '--output-format', 'stream-json', prompt]];
591
+ }
592
+ sessionId = sessionTracking.resumeSessionId || randomUUID();
593
+ return [
594
+ 'claude',
595
+ [
596
+ '--dangerously-skip-permissions',
597
+ '--print',
598
+ '--verbose',
599
+ '--output-format',
600
+ 'stream-json',
601
+ sessionTracking.resumeSessionId ? '--resume' : '--session-id',
602
+ sessionId,
603
+ prompt,
604
+ ],
605
+ ];
606
+ })()
607
+ : [
608
+ 'opencode',
609
+ [
610
+ 'run',
611
+ '--agent',
612
+ 'plan',
613
+ '--format',
614
+ 'json',
615
+ ...(sessionTracking?.resumeSessionId ? ['--session', sessionTracking.resumeSessionId] : []),
616
+ ...(resolvedModel ? ['--model', resolvedModel] : []),
617
+ ...(variant ? ['--variant', variant] : []),
618
+ prompt,
619
+ ],
620
+ ];
621
+ return new Promise((resolve, reject) => {
622
+ const attemptOpencodeSessionLookup = () => {
623
+ if (!sessionTracking || tool !== 'opencode' || hasResolvedSessionId) {
624
+ return false;
625
+ }
626
+ try {
627
+ const opencodeDb = new Database(resolveOpencodeDbPath(), { readonly: true });
628
+ const row = opencodeDb.prepare('SELECT id FROM session WHERE time_created > ? ORDER BY time_created DESC LIMIT 1').get(spawnTimestamp ?? 0);
629
+ if (row?.id) {
630
+ hasResolvedSessionId = true;
631
+ updatePlanningSessionId(sessionTracking.ticketId, row.id, sessionTracking.io);
632
+ }
633
+ opencodeDb.close();
634
+ }
635
+ catch {
636
+ // Planning session metadata is best-effort.
637
+ }
638
+ return hasResolvedSessionId;
639
+ };
640
+ const child = spawn(cmd, args, {
641
+ stdio: ['ignore', 'pipe', 'pipe'],
642
+ env: process.env,
643
+ ...(cwd ? { cwd } : {}),
644
+ });
645
+ if (sessionTracking && tool === 'claude' && sessionId) {
646
+ hasResolvedSessionId = true;
647
+ updatePlanningSessionId(sessionTracking.ticketId, sessionId, sessionTracking.io);
648
+ }
649
+ if (sessionTracking && tool === 'opencode') {
650
+ spawnTimestamp = Date.now();
651
+ if (!sessionTracking.resumeSessionId) {
652
+ sessionLookupTimer = setTimeout(() => {
653
+ attemptOpencodeSessionLookup();
654
+ sessionLookupTimer = null;
655
+ }, 1500);
656
+ }
657
+ }
658
+ const stdoutChunks = [];
659
+ const stderrChunks = [];
660
+ const logLines = [];
661
+ const responseLines = [];
662
+ let remainder = '';
663
+ const emitProgress = () => {
664
+ options.onProgress?.({
665
+ log: logLines.length > 0 ? logLines.join('\n\n') : null,
666
+ response: responseLines.length > 0 ? responseLines.join('\n\n') : null,
667
+ });
668
+ };
669
+ child.stdout?.on('data', (chunk) => {
670
+ stdoutChunks.push(chunk);
671
+ const text = remainder + chunk.toString();
672
+ const lines = text.split('\n');
673
+ remainder = lines.pop() ?? '';
674
+ for (const line of lines) {
675
+ const { logFragment, responseFragment } = tool === 'claude'
676
+ ? parseClaudePlannerStreamJsonLine(line)
677
+ : parseOpencodePlannerJsonLine(line);
678
+ if (logFragment !== null) {
679
+ logLines.push(logFragment);
680
+ }
681
+ if (responseFragment !== null) {
682
+ responseLines.push(responseFragment);
683
+ }
684
+ if (logFragment !== null || responseFragment !== null) {
685
+ emitProgress();
686
+ }
687
+ }
688
+ });
689
+ child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
690
+ child.on('close', code => {
691
+ const resolvedSessionOnClose = attemptOpencodeSessionLookup();
692
+ if (sessionLookupTimer && (resolvedSessionOnClose || hasResolvedSessionId)) {
693
+ clearTimeout(sessionLookupTimer);
694
+ sessionLookupTimer = null;
695
+ }
696
+ if (remainder.trim()) {
697
+ const { logFragment, responseFragment } = tool === 'claude'
698
+ ? parseClaudePlannerStreamJsonLine(remainder)
699
+ : parseOpencodePlannerJsonLine(remainder);
700
+ if (logFragment !== null) {
701
+ logLines.push(logFragment);
702
+ }
703
+ if (responseFragment !== null) {
704
+ responseLines.push(responseFragment);
705
+ }
706
+ }
707
+ const rawOutput = Buffer.concat(stdoutChunks).toString().trim();
708
+ const output = tool === 'claude' && !rawOutput.startsWith('{')
709
+ ? rawOutput
710
+ : responseLines.join('\n\n').trim() || rawOutput;
711
+ const errorOutput = Buffer.concat(stderrChunks).toString().trim();
712
+ if (code === 0 && output) {
713
+ resolve(output);
714
+ return;
715
+ }
716
+ const fallbackError = code === 0
717
+ ? 'Planner did not return a usable plan.'
718
+ : `Process exited with code ${code}`;
719
+ reject(new Error(output || errorOutput || fallbackError));
720
+ });
721
+ child.on('error', err => {
722
+ if (sessionLookupTimer && hasResolvedSessionId) {
723
+ clearTimeout(sessionLookupTimer);
724
+ sessionLookupTimer = null;
725
+ }
726
+ reject(err);
727
+ });
728
+ });
729
+ };
730
+ const stripMarkdownCodeFence = (output) => {
731
+ const trimmedOutput = output.trim();
732
+ const fencedMatch = trimmedOutput.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
733
+ return fencedMatch ? fencedMatch[1].trim() : trimmedOutput;
734
+ };
735
+ const extractFirstJsonObjectWithLeadingText = (output) => {
736
+ const normalizedOutput = stripMarkdownCodeFence(stripSystemReminderBlocks(output));
737
+ try {
738
+ return {
739
+ parsed: JSON.parse(normalizedOutput),
740
+ before: '',
741
+ };
742
+ }
743
+ catch {
744
+ // Fall back to scanning for the first balanced JSON object.
745
+ }
746
+ for (let index = 0; index < normalizedOutput.length; index += 1) {
747
+ if (normalizedOutput[index] !== '{') {
748
+ continue;
749
+ }
750
+ let depth = 0;
751
+ let inString = false;
752
+ let escaping = false;
753
+ for (let endIndex = index; endIndex < normalizedOutput.length; endIndex += 1) {
754
+ const char = normalizedOutput[endIndex];
755
+ if (inString) {
756
+ if (escaping) {
757
+ escaping = false;
758
+ continue;
759
+ }
760
+ if (char === '\\') {
761
+ escaping = true;
762
+ continue;
763
+ }
764
+ if (char === '"') {
765
+ inString = false;
766
+ }
767
+ continue;
768
+ }
769
+ if (char === '"') {
770
+ inString = true;
771
+ continue;
772
+ }
773
+ if (char === '{') {
774
+ depth += 1;
775
+ continue;
776
+ }
777
+ if (char === '}') {
778
+ depth -= 1;
779
+ if (depth === 0) {
780
+ try {
781
+ return {
782
+ parsed: JSON.parse(normalizedOutput.slice(index, endIndex + 1)),
783
+ before: normalizedOutput.slice(0, index).trim(),
784
+ };
785
+ }
786
+ catch {
787
+ break;
788
+ }
789
+ }
790
+ }
791
+ }
792
+ }
793
+ throw new Error('Planner did not return a valid JSON ticket breakdown.');
794
+ };
795
+ const extractFirstJsonObject = (output) => {
796
+ const normalizedOutput = stripMarkdownCodeFence(stripSystemReminderBlocks(output));
797
+ try {
798
+ return JSON.parse(normalizedOutput);
799
+ }
800
+ catch {
801
+ // Fall back to scanning for the first balanced JSON object.
802
+ }
803
+ for (let index = 0; index < normalizedOutput.length; index += 1) {
804
+ if (normalizedOutput[index] !== '{') {
805
+ continue;
806
+ }
807
+ let depth = 0;
808
+ let inString = false;
809
+ let escaping = false;
810
+ for (let endIndex = index; endIndex < normalizedOutput.length; endIndex += 1) {
811
+ const char = normalizedOutput[endIndex];
812
+ if (inString) {
813
+ if (escaping) {
814
+ escaping = false;
815
+ continue;
816
+ }
817
+ if (char === '\\') {
818
+ escaping = true;
819
+ continue;
820
+ }
821
+ if (char === '"') {
822
+ inString = false;
823
+ }
824
+ continue;
825
+ }
826
+ if (char === '"') {
827
+ inString = true;
828
+ continue;
829
+ }
830
+ if (char === '{') {
831
+ depth += 1;
832
+ continue;
833
+ }
834
+ if (char === '}') {
835
+ depth -= 1;
836
+ if (depth === 0) {
837
+ try {
838
+ return JSON.parse(normalizedOutput.slice(index, endIndex + 1));
839
+ }
840
+ catch {
841
+ break;
842
+ }
843
+ }
844
+ }
845
+ }
846
+ }
847
+ throw new Error('Planner did not return a valid JSON ticket breakdown.');
848
+ };
849
+ const normalizeOpenQuestions = (value) => {
850
+ if (!Array.isArray(value)) {
851
+ return [];
852
+ }
853
+ return value
854
+ .filter((entry) => typeof entry === 'string')
855
+ .map(entry => entry.trim())
856
+ .filter(Boolean)
857
+ .filter((entry, index, values) => values.indexOf(entry) === index)
858
+ .slice(0, 3);
859
+ };
860
+ const validateOpenQuestions = (questions) => {
861
+ for (const question of questions) {
862
+ if (!question.endsWith('?')) {
863
+ throw new Error(`Planner returned an invalid open question: "${question}"`);
864
+ }
865
+ }
866
+ return questions;
867
+ };
868
+ const normalizePlanningMemorySuggestions = (value) => {
869
+ return normalizeProjectMemorySuggestions(value);
870
+ };
871
+ const createPlanningContext = ({ sourceKind, initiativePrompt, relatedTicketIds, openQuestions, }) => ({
872
+ source_kind: sourceKind,
873
+ initiative_prompt: initiativePrompt,
874
+ related_ticket_ids: relatedTicketIds,
875
+ open_questions: openQuestions,
876
+ });
877
+ const buildPlanningAssistantMessage = ({ response, openQuestions, }) => {
878
+ const trimmedResponse = response.trim();
879
+ const questionsBlock = openQuestions.length > 0
880
+ ? `Open questions:\n${openQuestions.map((question, index) => `${index + 1}. ${question}`).join('\n')}`
881
+ : '';
882
+ if (trimmedResponse && questionsBlock) {
883
+ return `${trimmedResponse}\n\n${questionsBlock}`;
884
+ }
885
+ if (trimmedResponse) {
886
+ return trimmedResponse;
887
+ }
888
+ if (questionsBlock) {
889
+ return questionsBlock;
890
+ }
891
+ return 'I prepared the implementation-ready plan.';
892
+ };
893
+ const normalizePlanningDraftTicket = (ticket) => {
894
+ return {
895
+ ...ticket,
896
+ open_questions: validateOpenQuestions(normalizeOpenQuestions(ticket.open_questions)),
897
+ memory_suggestions: normalizePlanningMemorySuggestions(ticket.memory_suggestions),
898
+ };
899
+ };
900
+ const normalizeLinkedResearchRequests = (decision, linkedProjects) => {
901
+ const linkedProjectIds = new Set(linkedProjects.map(project => project.id));
902
+ const requests = Array.isArray(decision.requests) ? decision.requests : [];
903
+ const seenKeys = new Set();
904
+ const validRequests = requests.filter((request) => {
905
+ return typeof request.project_id === 'string' && typeof request.question === 'string' && typeof request.rationale === 'string';
906
+ });
907
+ return validRequests
908
+ .map(request => ({
909
+ project_id: request.project_id.trim(),
910
+ question: request.question.trim(),
911
+ rationale: request.rationale.trim(),
912
+ }))
913
+ .filter(request => request.project_id !== '' && request.question !== '' && request.rationale !== '' && linkedProjectIds.has(request.project_id))
914
+ .filter(request => {
915
+ const key = `${request.project_id}:${request.question.toLowerCase()}`;
916
+ if (seenKeys.has(key)) {
917
+ return false;
918
+ }
919
+ seenKeys.add(key);
920
+ return true;
921
+ })
922
+ .slice(0, MAX_LINKED_RESEARCH_REQUESTS);
923
+ };
924
+ const normalizeLinkedResearchResponse = (response, linkedProject, request) => {
925
+ const summary = typeof response.summary === 'string' ? response.summary.trim() : '';
926
+ const filesChecked = Array.isArray(response.files_checked)
927
+ ? response.files_checked.filter(filePath => typeof filePath === 'string' && filePath.trim() !== '').map(filePath => filePath.trim())
928
+ : [];
929
+ const findings = Array.isArray(response.findings)
930
+ ? response.findings.filter(finding => typeof finding === 'string' && finding.trim() !== '').map(finding => finding.trim())
931
+ : [];
932
+ const implementationDetails = Array.isArray(response.implementation_details)
933
+ ? response.implementation_details.filter(detail => typeof detail === 'string' && detail.trim() !== '').map(detail => detail.trim())
934
+ : [];
935
+ const exactSnippet = typeof response.exact_snippet === 'string' && response.exact_snippet.trim() !== ''
936
+ ? response.exact_snippet.trim()
937
+ : null;
938
+ if (summary === '') {
939
+ throw new Error(`Linked project research for ${linkedProject.name} did not return a summary`);
940
+ }
941
+ return {
942
+ projectId: linkedProject.id,
943
+ projectName: linkedProject.name,
944
+ rationale: request.rationale,
945
+ question: request.question,
946
+ summary,
947
+ filesChecked,
948
+ findings,
949
+ implementationDetails,
950
+ exactSnippet,
951
+ confidence: normalizeLinkedResearchConfidence(response.confidence),
952
+ };
953
+ };
954
+ export const prepareLinkedPlanningResearch = async (taskDescription, options = {}) => {
955
+ const project = options.projectId ? getProjectById(options.projectId) : null;
956
+ const cwd = resolvePlannerCwd(project, options.branch);
957
+ if (!project || !cwd) {
958
+ return [];
959
+ }
960
+ const linkedProjects = listLinkedProjects(project.id);
961
+ if (linkedProjects.length === 0) {
962
+ return [];
963
+ }
964
+ const tool = options.tool ?? config.tool ?? 'opencode';
965
+ let decision;
966
+ try {
967
+ const output = await runPlannerPrompt(tool, options.model ?? null, options.variant ?? null, buildLinkedProjectSelectionPrompt(taskDescription, project, linkedProjects), cwd);
968
+ decision = extractFirstJsonObject(output);
969
+ }
970
+ catch {
971
+ return [];
972
+ }
973
+ const requests = normalizeLinkedResearchRequests(decision, linkedProjects);
974
+ if (requests.length === 0) {
975
+ return [];
976
+ }
977
+ const linkedProjectsById = new Map(linkedProjects.map(linkedProject => [linkedProject.id, linkedProject]));
978
+ const linkedResearch = await Promise.all(requests.map(async (request) => {
979
+ const linkedProject = linkedProjectsById.get(request.project_id);
980
+ if (!linkedProject) {
981
+ throw new Error(`Linked project ${request.project_id} is no longer available`);
982
+ }
983
+ const output = await runPlannerPrompt(tool, options.model ?? null, options.variant ?? null, prependSkillPromptBlock(buildLinkedProjectResearchPrompt(project, linkedProject, request), options.skills ?? []), linkedProject.repo_path);
984
+ return normalizeLinkedResearchResponse(extractFirstJsonObject(output), linkedProject, request);
985
+ }));
986
+ return linkedResearch;
987
+ };
988
+ export const generatePlanText = (title, description, options = {}) => {
989
+ const tool = options.tool ?? config.tool ?? 'opencode';
990
+ const project = options.projectId ? getProjectById(options.projectId) : null;
991
+ return (async () => {
992
+ const linkedResearch = options.linkedResearch ?? await prepareLinkedPlanningResearch(`${title}\n\n${description}`, options);
993
+ const linkedResearchContext = project ? buildLinkedResearchContext(project, linkedResearch) : '';
994
+ const projectMemoryBlock = getProjectMemoryPromptBlock(project?.id, {
995
+ stage: 'plan',
996
+ title,
997
+ description,
998
+ });
999
+ const expanded = extractFirstJsonObjectWithLeadingText(await runPlannerPrompt(tool, options.model ?? null, options.variant ?? null, prependSkillPromptBlock(buildSingleTicketPrompt(title, description, linkedResearchContext, projectMemoryBlock), options.skills ?? []), resolvePlannerCwd(project, options.branch), {
1000
+ onProgress: options.onProgress,
1001
+ ...(options.ticketId && options.io
1002
+ ? {
1003
+ sessionTracking: {
1004
+ ticketId: options.ticketId,
1005
+ io: options.io,
1006
+ resumeSessionId: options.planningSessionId,
1007
+ },
1008
+ }
1009
+ : {}),
1010
+ }));
1011
+ const expandedTicket = expanded.parsed.tickets.find(ticket => ticket.key === 'main-ticket');
1012
+ if (!expandedTicket?.description?.trim()) {
1013
+ throw new Error('Planner did not return a usable plan.');
1014
+ }
1015
+ return {
1016
+ ...normalizePlanningDraftTicket(expandedTicket),
1017
+ key: 'main-ticket',
1018
+ title: title.trim(),
1019
+ description: expandedTicket.description.trim(),
1020
+ response: expanded.before.trim(),
1021
+ };
1022
+ })();
1023
+ };
1024
+ export const generatePlanningTicketOutline = async (description, options = {}) => {
1025
+ const tool = options.tool ?? config.tool ?? 'opencode';
1026
+ const project = options.projectId ? getProjectById(options.projectId) : null;
1027
+ const linkedResearch = options.linkedResearch ?? await prepareLinkedPlanningResearch(description, options);
1028
+ const linkedResearchContext = project ? buildLinkedResearchContext(project, linkedResearch) : '';
1029
+ const projectMemoryBlock = getProjectMemoryPromptBlock(project?.id, {
1030
+ stage: 'plan',
1031
+ title: description,
1032
+ description,
1033
+ });
1034
+ const output = await runPlannerPrompt(tool, options.model ?? null, options.variant ?? null, prependSkillPromptBlock(buildPlanningAgentOutlinePrompt(description, linkedResearchContext, projectMemoryBlock), options.skills ?? []), resolvePlannerCwd(project, options.branch));
1035
+ return extractFirstJsonObject(output);
1036
+ };
1037
+ export const expandPlanningTicketOutline = async (description, outline, options = {}) => {
1038
+ const tool = options.tool ?? config.tool ?? 'opencode';
1039
+ const project = options.projectId ? getProjectById(options.projectId) : null;
1040
+ const linkedResearch = options.linkedResearch ?? await prepareLinkedPlanningResearch(description, options);
1041
+ const linkedResearchContext = project ? buildLinkedResearchContext(project, linkedResearch) : '';
1042
+ const projectMemoryBlock = getProjectMemoryPromptBlock(project?.id, {
1043
+ stage: 'plan',
1044
+ title: description,
1045
+ description,
1046
+ });
1047
+ const output = await runPlannerPrompt(tool, options.model ?? null, options.variant ?? null, prependSkillPromptBlock(buildPlanningAgentExpansionPrompt(description, outline, linkedResearchContext, projectMemoryBlock), options.skills ?? []), resolvePlannerCwd(project, options.branch));
1048
+ return extractFirstJsonObject(output);
1049
+ };
1050
+ const emitTicketUpdate = (ticketId, io) => {
1051
+ const updated = getTicket(ticketId);
1052
+ if (updated) {
1053
+ io.emit('ticket:updated', updated);
1054
+ }
1055
+ };
1056
+ const updatePlanningProgress = (ticketId, log, response, io) => {
1057
+ db.prepare('UPDATE tickets SET agent_status = ?, agent_log = ?, streaming_response = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run('running', log ?? null, response ?? null, ticketId);
1058
+ emitTicketUpdate(ticketId, io);
1059
+ };
1060
+ export const startPlan = (ticket, io, options = {}) => {
1061
+ const jobKey = resolveTicketWorkerKey(ticket, 'plan');
1062
+ if (!jobKey || isRunning(jobKey)) {
1063
+ return;
1064
+ }
1065
+ enqueue(jobKey, async () => {
1066
+ registerBundleActivity(ticket, 'plan');
1067
+ db.prepare('UPDATE tickets SET agent_status = ?, agent_log = NULL, streaming_response = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run('running', ticket.id);
1068
+ emitTicketUpdate(ticket.id, io);
1069
+ try {
1070
+ const project = getEffectiveProject(ticket);
1071
+ const branch = options.branch === undefined ? resolveTicketBranch(ticket) : options.branch;
1072
+ const planningDescription = await resolveJiraPlanningDescription(project?.id ?? ticket.project_id, ticket.description);
1073
+ const initiativePrompt = ticket.planning_context?.initiative_prompt?.trim() || planningDescription;
1074
+ const planned = await generatePlanText(ticket.title, planningDescription, {
1075
+ tool: resolveTicketTool(ticket, config.tool ?? 'opencode'),
1076
+ model: ticket.model,
1077
+ variant: ticket.variant,
1078
+ skills: options.skills ?? ticket.skills ?? [],
1079
+ projectId: project?.id ?? ticket.project_id,
1080
+ branch,
1081
+ planningSessionId: ticket.planning_session_id,
1082
+ ticketId: ticket.id,
1083
+ io,
1084
+ onProgress: progress => {
1085
+ updatePlanningProgress(ticket.id, progress.log, progress.response, io);
1086
+ },
1087
+ });
1088
+ const planningContext = createPlanningContext({
1089
+ sourceKind: 'single',
1090
+ initiativePrompt,
1091
+ relatedTicketIds: [ticket.id],
1092
+ openQuestions: planned.open_questions,
1093
+ });
1094
+ db.prepare('UPDATE tickets SET description = ?, agent_status = ?, agent_log = NULL, streaming_response = NULL, planning_context_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(planned.description, 'done', JSON.stringify(planningContext), ticket.id);
1095
+ const suggestionProjectId = project?.id ?? ticket.project_id;
1096
+ if (suggestionProjectId && Boolean(project?.memory_enabled)) {
1097
+ persistProjectMemorySuggestions(suggestionProjectId, ticket.id, 'plan', planned.memory_suggestions);
1098
+ }
1099
+ upsertTicketDescriptionMessage(ticket.id, planned.description);
1100
+ const assistantMessage = buildPlanningAssistantMessage({
1101
+ response: planned.response,
1102
+ openQuestions: planned.open_questions,
1103
+ });
1104
+ if (assistantMessage) {
1105
+ createTicketMessage({
1106
+ ticketId: ticket.id,
1107
+ role: 'assistant',
1108
+ kind: 'planning_response',
1109
+ content: assistantMessage,
1110
+ });
1111
+ }
1112
+ emitTicketUpdate(ticket.id, io);
1113
+ }
1114
+ catch (err) {
1115
+ const message = err instanceof JiraPlanningReferenceError
1116
+ ? err.message
1117
+ : err instanceof JiraApiError
1118
+ ? err.publicMessage
1119
+ : err instanceof Error
1120
+ ? err.message
1121
+ : String(err);
1122
+ db.prepare('UPDATE tickets SET agent_status = ?, agent_log = ?, streaming_response = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run('error', message, ticket.id);
1123
+ emitTicketUpdate(ticket.id, io);
1124
+ }
1125
+ finally {
1126
+ unregisterBundleActivity(ticket, 'plan');
1127
+ }
1128
+ });
1129
+ };
1130
+ //# sourceMappingURL=plan.js.map