tspl-cli 0.0.0-stage → 0.1.1

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 (75) hide show
  1. package/README.md +127 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. package/src/workspace.js +250 -0
@@ -0,0 +1,708 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+ const { bindApproach } = require('../shared')
5
+ const { proposeApproaches, selectApproach, renderApproaches } = require('../task/approaches')
6
+ const Path = require('path')
7
+ const z = require('zod')
8
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js')
9
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js')
10
+
11
+ const { version } = require('../../package.json')
12
+ const { request } = require('../api-client')
13
+ const { findProject } = require('../workspace')
14
+ const { refreshIndex } = require('../analysis/analyzer')
15
+ const { readIndex, readTask, writeTask, readFeedback, writeFeedback } = require('../config-store')
16
+ const { createGraph } = require('../find/graph')
17
+ const { createSearch } = require('../find/search')
18
+ const { checkTask, taskText } = require('../task/check')
19
+ const { projectInstructionsText } = require('../project-config')
20
+ const { briefFrom, buildBrief, renderFiles, renderBrief } = require('../task/brief')
21
+ const { reviewPlan, renderChecklist, changesSince, withSnapshot } = require('../task/plan')
22
+ const { prepareBuild, describeProposal, blockers, runBuild, reviewBuild, describeReport, finishBuild, runPlanTests, runStyleChecks, mergeTests, renderBuildBrief, planRepos } = require('../task/build')
23
+ const { conventionsFor } = require('../task/conventions')
24
+ const { fetchDue, syncInBackground } = require('../commands/sync.command')
25
+
26
+ const PROJECT_TTL = 5 * 60 * 1000
27
+ const FEEDBACK_PER_FILE = 20
28
+
29
+ const INSTRUCTIONS = `tspl knows the code of every repo in this project (it read them without AI).
30
+ Tasks are prepared outside the AI with "tspl task new" (questions, done-when checks, files found; 0 tokens) and get a task id.
31
+ With a task id: call get_brief, read the files it lists, compare two or three approaches with propose_approaches, show one recommendation, wait for an explicit user selection and record it with select_approach, then submit_plan for the checklist the user approves (approve_plan) before any code changes.
32
+ Without a task id (free text only): check_task is the fallback that asks the questions here.`
33
+
34
+ const createContext = cwd => {
35
+ let found = null
36
+ let foundAt = 0
37
+ let state = null
38
+
39
+ const load = async () => {
40
+ if (!found || Date.now() - foundAt > PROJECT_TTL) {
41
+ found = await findProject(cwd)
42
+ foundAt = Date.now()
43
+ }
44
+ const { project, repos, offline } = found
45
+ const failed = []
46
+ const saved = state ? state.index : readIndex(project._id)
47
+ let changed = false
48
+ const index = offline
49
+ ? saved
50
+ : await refreshIndex(project, repos, (repo, repoIndex, err, isNew) => {
51
+ if (err) failed.push(`${repo.name}: ${err.message}`)
52
+ changed = changed || Boolean(isNew)
53
+ }, saved)
54
+ if (!offline && (changed || fetchDue(project._id))) syncInBackground(cwd)
55
+ if (!Object.keys(index.repos || {}).length) {
56
+ throw new Error('tspl has not read this project yet. Ask the user to run: tspl analyze')
57
+ }
58
+ const signature = Object.values(index.repos).map(repo => repo.commitSha).join()
59
+ if (!state || state.signature !== signature || state.feedbackChanged) {
60
+ const graph = createGraph(index, readFeedback(project._id))
61
+ state = { index, signature, graph, search: createSearch(graph), feedbackChanged: false }
62
+ }
63
+ const paths = new Map(repos.map(repo => [repo.name, repo.path]))
64
+ return { ...state, project, paths, offline, failed }
65
+ }
66
+
67
+ return {
68
+ load,
69
+ feedbackChanged() {
70
+ if (state) state.feedbackChanged = true
71
+ },
72
+ }
73
+ }
74
+
75
+ const shareTask = (projectId, task) =>
76
+ request('PUT', `/api/v1/projects/${projectId}/tasks/${task.id}`, {
77
+ card: task.card,
78
+ assumptions: task.assumptions || [],
79
+ found: task.found || [],
80
+ used: task.used || [],
81
+ plan: task.plan || null,
82
+ ...(task.brief ? { brief: task.brief } : {}),
83
+ })
84
+
85
+ const saveTask = async (projectId, task, wait = false) => {
86
+ writeTask(task)
87
+ const shared = shareTask(projectId, task).then(() => null, err => err)
88
+ return wait ? shared : null
89
+ }
90
+
91
+ /** The task on this computer, fetched from the server the first time (tasks created in the console). */
92
+ const localTask = async (loaded, taskId) => {
93
+ const saved = readTask(taskId)
94
+ if (saved.id && loaded.offline) return saved
95
+ if (loaded.offline) throw new Error(`task ${taskId} is not on this computer and the server cannot be reached`)
96
+ let remote
97
+ try {
98
+ remote = await request('GET', `/api/v1/projects/${loaded.project._id}/tasks/${encodeURIComponent(taskId)}`)
99
+ } catch (err) {
100
+ if (err.statusCode === 404) throw new Error(`task ${taskId} was not found in ${loaded.project.name} (it may have been deleted). Check the id in the console.`)
101
+ throw err
102
+ }
103
+ const card = { apps: [], doneWhen: [], ...(remote.card || {}) }
104
+ const task = {
105
+ ...saved,
106
+ id: remote.key,
107
+ card,
108
+ summary: taskText(card),
109
+ assumptions: remote.assumptions || [],
110
+ found: remote.found || [],
111
+ used: remote.used || [],
112
+ plan: remote.plan || null,
113
+ status: remote.status,
114
+ scope: remote.scope || null,
115
+ approaches: remote.approaches || null,
116
+ }
117
+ writeTask(task)
118
+ return task
119
+ }
120
+
121
+ const NEXT_STEP = {
122
+ 'pm-review': taskId =>
123
+ `Approved by the developer. The project needs PM approval before any code changes: the PM approves it in the tspl web console (Plans), or with "tspl plan approve ${taskId}" in their own terminal. Do not change any code yet; when the user says it is approved, call plan_status to check.`,
124
+ approved: taskId =>
125
+ `Approved. Call start_build to begin here, or, for a smaller context, the user runs "tspl build start ${taskId} --open" in a terminal to open a fresh AI session with only the checklist. Build exactly the checklist, follow how the code around it is written, run the tests and check every "done when" item. If you need to change files that are not on the checklist, stop and tell the user first.`,
126
+ 'changes-requested': () => 'Changes were asked for. Change the plan as asked and call submit_plan again.',
127
+ 'plan-review': taskId => `Waiting for the developer to approve the plan: "tspl plan approve ${taskId}" in their own terminal, or the tspl console. When they say it is done, call plan_status.`,
128
+ draft: () => 'Read the relevant code, propose approaches, wait for the user to choose, then submit_plan.',
129
+ building: () => 'Building. When the checklist is done, the tests ran and every "done when" item was checked, call finish_build.',
130
+ done: () => 'Done: the build was reported and passed.',
131
+ }
132
+
133
+ const text = value => ({ content: [{ type: 'text', text: value }] })
134
+
135
+ const failure = err => ({ content: [{ type: 'text', text: `tspl could not do this: ${err.message}` }], isError: true })
136
+
137
+ const describeQuestion = (question, i) => {
138
+ const lines = [`${i + 1}. [${question.header}] ${question.question}${question.multiSelect ? ' (choose one or more)' : ''} → card.${question.target || question.field}`]
139
+ question.options.forEach(option => lines.push(` - ${option.label}${option.description ? `: ${option.description}` : ''}`))
140
+ if (question.suggestOptions) lines.push(` ${question.suggestOptions}`)
141
+ if (!question.options.length && !question.suggestOptions) lines.push(' Free text: offer 2-3 likely answers if you can infer them, otherwise ask in plain text.')
142
+ return lines.join('\n')
143
+ }
144
+
145
+ const formatCheck = (result, taskId) => {
146
+ if (result.status === 'ready') {
147
+ return [
148
+ `Status: ready. Task id: ${taskId}`,
149
+ 'Show this summary to the user and ask them to confirm or correct it:',
150
+ '',
151
+ result.summary,
152
+ '',
153
+ result.suggestChecks ? 'Suggest 2-4 short, testable "done when" checks under it for the user to tick or edit.' : null,
154
+ `If they confirm unchanged, call find_code with taskId "${taskId}". If they change the checks or correct something, call check_task once more with the card and taskId "${taskId}" first.`,
155
+ ]
156
+ .filter(line => line !== null)
157
+ .join('\n')
158
+ }
159
+ return [
160
+ `Status: needs answers (round ${result.card.round} of 2).`,
161
+ 'Ask the user these questions in one go, with your multiple-choice question tool if you have one:',
162
+ '',
163
+ ...result.questions.map(describeQuestion),
164
+ '',
165
+ result.assumptions.length ? `Assumed so far: ${result.assumptions.join('; ')}` : null,
166
+ 'Put each answer in the card field shown after the arrow, then call check_task again with this card:',
167
+ JSON.stringify(result.card),
168
+ ]
169
+ .filter(line => line !== null)
170
+ .join('\n')
171
+ }
172
+
173
+ const TRUST = {
174
+ high: 'Start with these files, at the "look at" places. Then confirm with one quick search of your own (the names below and the task\'s words) for places tspl may have missed, such as copies in other repos.',
175
+ medium: 'Good starting point, likely not complete. Read these files first, then search for the names below and the task\'s words to find the other places before you plan.',
176
+ }
177
+
178
+ const searchFolders = result =>
179
+ [...new Set(result.files.slice(0, 6).map(file => `${file.repo}/${Path.dirname(Path.dirname(file.path))}`))].slice(0, 4)
180
+
181
+ const placeOptions = (result, areas) => {
182
+ const seen = new Set()
183
+ return [...(result.choices || []), ...areas]
184
+ .filter(option => option.files.length && !seen.has(option.files[0]) && seen.add(option.files[0]))
185
+ .slice(0, 4)
186
+ }
187
+
188
+ const lowConfidence = (result, areas, keywords) => {
189
+ if (!keywords.length) {
190
+ return [
191
+ 'tspl is not sure yet.',
192
+ 'First think how this code is probably named: function and variable names, labels on the screen, API paths, and other words for the user\'s words.',
193
+ 'Then call find_code again with the same taskId and those as keywords (5-15 short words or names).',
194
+ ]
195
+ }
196
+ const options = placeOptions(result, areas)
197
+ if (options.length > 1) {
198
+ return [
199
+ 'tspl found more than one place this could be.',
200
+ 'If the task does not make clear which one, ask the user, in one question with these as options (plus "Something else"):',
201
+ ...options.map((area, i) => ` ${String.fromCharCode(65 + i)}) ${area.name} (${area.repo}): ${area.files.slice(0, 2).join(', ')}`),
202
+ 'Then search the code yourself in that place, starting with the files below.',
203
+ ]
204
+ }
205
+ return [
206
+ 'tspl is still not sure: search the code yourself, starting in these folders and with these words:',
207
+ ` - folders: ${searchFolders(result).join(', ')}`,
208
+ ` - words: ${[...new Set([...result.searchSteps.map(step => step.pattern), ...keywords])].slice(0, 10).join(', ')}`,
209
+ ]
210
+ }
211
+
212
+ const recordUsed = (loaded, saved, words, files) => {
213
+ const known = new Set(loaded.graph.files.map(file => `${file.repo}/${file.path}`))
214
+ const keys = files.map(file => file.trim().replace(/^([^:/\s]+):\s*/, '$1/'))
215
+ const accepted = keys.filter(key => known.has(key))
216
+ const feedback = readFeedback(loaded.project._id)
217
+ accepted.forEach(key => {
218
+ const list = (feedback[key] || []).filter(item => item !== words)
219
+ feedback[key] = [words, ...list].slice(0, FEEDBACK_PER_FILE)
220
+ })
221
+ writeFeedback(loaded.project._id, feedback)
222
+ return { accepted, unknown: keys.filter(key => !known.has(key)) }
223
+ }
224
+
225
+ const formatFind = (result, context, scope, keywords = []) => {
226
+ const lines = []
227
+ const commits = Object.values(context.index.repos).map(repo => `${repo.name}@${repo.branch}`).join(', ')
228
+ const level = result.confidence.level
229
+ const areas = result.areas.filter(area => area.score >= result.areas[0].score * 0.5)
230
+ lines.push(`tspl searched ${context.graph.files.length} files in ${context.graph.repos.length} repos (0 AI tokens).`)
231
+ lines.push(`Confidence: ${level.toUpperCase()} (${result.confidence.reasons.join('; ')})`)
232
+ if (scope.length) lines.push(`Apps: ${scope.join(', ')} (plus code they connect to)`)
233
+ if (keywords.length) lines.push(`Keywords: ${keywords.join(', ')}`)
234
+ lines.push(...(level === 'low' ? lowConfidence(result, areas, keywords) : [TRUST[level]]))
235
+ lines.push('')
236
+ lines.push(level === 'low' ? 'Closest files so far (hints only):' : 'Files, most relevant first:')
237
+ lines.push(...renderFiles(result.files))
238
+ if (level === 'medium' && placeOptions(result, areas).length > 1) {
239
+ lines.push('')
240
+ lines.push('Two or more places match; if the files do not make clear which one, ask the user:')
241
+ placeOptions(result, areas).forEach((area, i) => lines.push(` ${String.fromCharCode(65 + i)}) ${area.name} (${area.repo}): ${area.files.slice(0, 2).join(', ')}`))
242
+ }
243
+ if (level !== 'low' && result.searchSteps.length) {
244
+ lines.push('')
245
+ lines.push('Names to search for:')
246
+ result.searchSteps.forEach(step => lines.push(` ${step.pattern} (${step.why})`))
247
+ }
248
+ if (result.chains.length) {
249
+ lines.push('')
250
+ lines.push('How they connect:')
251
+ result.chains.forEach(chain => {
252
+ lines.push(` ${chain.map((step, i) => `${i ? ` → ${step.detail ? `${step.detail} → ` : ''}` : ''}${step.repo}: ${step.path}`).join('')}`)
253
+ })
254
+ }
255
+ if (result.placement.length) {
256
+ lines.push('')
257
+ lines.push('New files, if needed, usually go:')
258
+ result.placement.forEach(place => lines.push(` ${place.repo}: ${place.pattern} (${place.count} like this)`))
259
+ }
260
+ lines.push('')
261
+ lines.push(`Based on the last fetched commit of: ${commits}. Local, uncommitted changes are not included.`)
262
+ if (context.failed.length) lines.push(`Not refreshed: ${context.failed.join('; ')}`)
263
+ lines.push('')
264
+ lines.push('When you know which files change, call report_files with them (repo: path), including any tspl missed.')
265
+ return lines.join('\n')
266
+ }
267
+
268
+ const formatBrief = (task, loaded, cwd) => {
269
+ const level = task.brief.confidence.level
270
+ const next = []
271
+ if (task.scope && !task.scope.confirmed) {
272
+ next.push(`The PM sent the scope back: "${task.scope.note || 'no note'}". Tell the user; they can change the task with: tspl task edit ${task.id}. Do not plan until the scope is confirmed.`)
273
+ } else if (task.plan && NEXT_STEP[task.status]) {
274
+ next.push(`A plan v${task.plan.version} exists already. ${NEXT_STEP[task.status](task.id)}`)
275
+ } else {
276
+ next.push(
277
+ `Read the files above at the "look at" places. ${level === 'high' ? 'Confirm with one quick search for the names above.' : 'Also do one short, targeted search for the names above and the task\'s words (grep, not whole repos).'}`,
278
+ `Compare approaches and ask the user to choose before calling submit_plan with taskId "${task.id}": files to edit, create and remove (with why), screen changes, and tests with the numbers of the "done when" checks they prove.`,
279
+ )
280
+ }
281
+ return [renderApproaches(task.approaches), '', renderBrief(task, { files: loaded.graph.files.length, repos: loaded.graph.repos.length }, projectInstructionsText(cwd)), '', ...next].join('\n')
282
+ }
283
+
284
+ const cardShape = {
285
+ ticket: z.string().optional().describe('Ticket id or link, if the user gave one'),
286
+ type: z.string().optional().describe('feature, change or bug'),
287
+ what: z.string().optional().describe('What should change, in the user\'s words'),
288
+ where: z.string().optional().describe('Where it is, as the user said it (app, screen, menu)'),
289
+ apps: z.array(z.string()).optional().describe('App/repo names chosen by the user'),
290
+ screen: z.string().optional().describe('Screen chosen by the user'),
291
+ who: z.string().optional().describe('Who uses it (role), if the user said'),
292
+ how: z.string().optional().describe('Features and changes: how it should work (steps or rules)'),
293
+ steps: z.string().optional().describe('Bugs: steps to make it happen'),
294
+ now: z.string().optional().describe('Bugs: what happens now. Changes: how it works today'),
295
+ expected: z.string().optional().describe('Bugs: what should happen'),
296
+ doneWhen: z.array(z.string()).optional().describe('Checks that must pass when done'),
297
+ details: z.string().optional().describe('Ticket description and acceptance criteria, text from screenshots or PDFs, and anything else the user said'),
298
+ skip: z.boolean().optional().describe('true if the user wants to skip further questions'),
299
+ round: z.number().optional().describe('Copy from the previous check_task answer'),
300
+ asked: z.record(z.string(), z.number()).optional().describe('Copy from the previous check_task answer'),
301
+ taskId: z.string().optional().describe('When correcting or confirming a task that is already ready, its task id'),
302
+ }
303
+
304
+ const PLAN_ITEM = z.union([z.string(), z.object({ file: z.string(), why: z.string().optional() })])
305
+
306
+ const createServer = (cwd, context = createContext(cwd)) => {
307
+ const server = new McpServer({ name: 'tspl', version }, { instructions: INSTRUCTIONS })
308
+
309
+ server.registerTool(
310
+ 'check_task',
311
+ {
312
+ title: 'Check a task is clear (fallback when there is no task id)',
313
+ description:
314
+ 'Only when the user gave free text and no task id. Fill the card ONLY with what the user said or showed; leave the rest empty. ' +
315
+ 'Returns either a few questions to ask the user (with options taken from the code) or "ready" with a task id and a summary to confirm. ' +
316
+ 'Tasks prepared with "tspl task new" already have an id: use get_brief instead.',
317
+ inputSchema: cardShape,
318
+ },
319
+ async ({ taskId: existing, ...card }) => {
320
+ try {
321
+ const loaded = await context.load()
322
+ const result = checkTask(card, loaded)
323
+ let taskId = null
324
+ if (result.status === 'ready') {
325
+ const saved = existing ? readTask(existing) : {}
326
+ taskId = saved.id || `t-${Date.now().toString(36)}-${Crypto.randomBytes(2).toString('hex')}`
327
+ await saveTask(loaded.project._id, {
328
+ ...saved,
329
+ id: taskId,
330
+ status: saved.status && saved.status !== 'stopped' ? saved.status : 'draft',
331
+ projectId: loaded.project._id,
332
+ card: result.card,
333
+ assumptions: result.assumptions,
334
+ summary: result.summary,
335
+ createdAt: saved.createdAt || new Date().toISOString(),
336
+ updatedAt: new Date().toISOString(),
337
+ })
338
+ }
339
+ return text(formatCheck(result, taskId))
340
+ } catch (err) {
341
+ return failure(err)
342
+ }
343
+ },
344
+ )
345
+
346
+ server.registerTool(
347
+ 'find_code',
348
+ {
349
+ title: 'Find the code for a task',
350
+ description:
351
+ 'Find the files a task is about across all repos of the project, with reasons, how they connect (screen → API → service → model), ' +
352
+ 'where new files usually go and a confidence level. Uses no AI tokens. Pass taskId from check_task, or a task text.',
353
+ inputSchema: {
354
+ taskId: z.string().optional(),
355
+ task: z.string().optional().describe('Task text, if there is no taskId'),
356
+ apps: z.array(z.string()).optional().describe('Limit to these apps/repos (and code they connect to)'),
357
+ limit: z.number().optional().describe('How many files (default 10)'),
358
+ keywords: z.array(z.string()).optional().describe('Words you expect in the code for this task: function or variable names, screen labels, API paths, and other words for the user\'s words'),
359
+ },
360
+ },
361
+ async ({ taskId, task, apps, limit, keywords }) => {
362
+ try {
363
+ const loaded = await context.load()
364
+ const saved = taskId ? await localTask(loaded, taskId) : null
365
+ const words = (keywords && keywords.length ? keywords : (saved && saved.keywords) || []).map(word => String(word).trim()).filter(Boolean).slice(0, 20)
366
+ const base = saved ? taskText(saved.card) : task
367
+ if (!base) throw new Error('pass taskId or task')
368
+ const query = [base, ...words].join(' ')
369
+ const known = new Set(loaded.graph.repos.map(repo => repo.name))
370
+ const scope = (apps || (saved && saved.card.apps) || []).filter(name => known.has(name))
371
+ const result = loaded.search.find(query, { limit: limit || 10, repos: scope, title: [saved ? saved.card.what : base, ...words].join(' ') })
372
+ if (saved) {
373
+ const basis = Object.fromEntries(Object.values(loaded.index.repos).map(repo => [repo.name, { branch: repo.branch, commit: repo.commitSha }]))
374
+ const brief = briefFrom(result, { keywords: words, basis, source: 'ai', refined: saved.brief ? saved.brief.refined : null })
375
+ await saveTask(loaded.project._id, { ...saved, keywords: words, basis, brief, found: result.files.map(file => `${file.repo}/${file.path}`), confidence: result.confidence.level })
376
+ }
377
+ return text(formatFind(result, loaded, scope, words))
378
+ } catch (err) {
379
+ return failure(err)
380
+ }
381
+ },
382
+ )
383
+
384
+ server.registerTool(
385
+ 'get_brief',
386
+ {
387
+ title: 'Get the brief of a prepared task',
388
+ description:
389
+ 'Call first when the user gives a tspl task id (t-…). Returns the task card, the "done when" checks, whether the PM confirmed the scope, ' +
390
+ 'and the files tspl found for it (no AI tokens) with where to look and how they connect. Read those files, then propose approaches and wait for the explicit selection before submit_plan.',
391
+ inputSchema: { taskId: z.string() },
392
+ },
393
+ async ({ taskId }) => {
394
+ try {
395
+ const loaded = await context.load()
396
+ const saved = await localTask(loaded, taskId)
397
+ const { brief } = buildBrief(loaded, saved, { keywords: saved.keywords || [], source: saved.brief ? saved.brief.source : 'wizard', refined: saved.brief ? saved.brief.refined : null })
398
+ const task = {
399
+ ...saved,
400
+ brief,
401
+ found: brief.files.map(file => `${file.repo}/${file.path}`),
402
+ confidence: brief.confidence.level,
403
+ basis: brief.basis,
404
+ }
405
+ writeTask(task)
406
+ return text(formatBrief(task, loaded, cwd))
407
+ } catch (err) {
408
+ return failure(err)
409
+ }
410
+ },
411
+ )
412
+
413
+ server.registerTool(
414
+ 'report_files',
415
+ {
416
+ title: 'Report the files you will change',
417
+ description:
418
+ 'After you have looked at the code, report the files you will change, including files tspl did not list. ' +
419
+ 'tspl learns from this so the next similar task finds them first.',
420
+ inputSchema: {
421
+ taskId: z.string().optional(),
422
+ task: z.string().optional().describe('Task text, if there is no taskId'),
423
+ files: z.array(z.string()).describe('Files you will change, as "repo: path" or "repo/path"'),
424
+ note: z.string().optional().describe('Anything tspl got wrong or missed'),
425
+ },
426
+ },
427
+ async ({ taskId, task, files, note }) => {
428
+ try {
429
+ const loaded = await context.load()
430
+ const saved = taskId ? readTask(taskId) : null
431
+ const words = saved && saved.card ? taskText(saved.card) : task
432
+ if (!words) throw new Error('pass taskId or task')
433
+ const { accepted, unknown } = recordUsed(loaded, saved, words, files)
434
+ context.feedbackChanged()
435
+ if (saved && saved.id) {
436
+ const found = new Set(saved.found || [])
437
+ await saveTask(loaded.project._id, { ...saved, used: accepted, missed: accepted.filter(key => !found.has(key)), note: note || null })
438
+ }
439
+ return text(
440
+ `Saved ${accepted.length} file(s); tspl will rank them higher for similar tasks.` +
441
+ (unknown.length ? ` Not in tspl's copy of the code (new or uncommitted files are fine): ${unknown.join(', ')}` : ''),
442
+ )
443
+ } catch (err) {
444
+ return failure(err)
445
+ }
446
+ },
447
+ )
448
+
449
+ const APPROACH_OPTION = z.object({
450
+ id: z.string().min(1).max(60), title: z.string().min(1).max(200), summary: z.string().min(1).max(2000),
451
+ benefits: z.array(z.string().min(1).max(1000)).min(1).max(10), drawbacks: z.array(z.string().min(1).max(1000)).min(1).max(10),
452
+ files: z.array(z.string().min(1).max(1000)).max(100).optional(),
453
+ })
454
+ server.registerTool('propose_approaches', {
455
+ title: 'Offer implementation approaches',
456
+ description: 'After reading relevant code, save two or three meaningful options and one recommendation. Show them to the user and wait for an explicit selection before planning.',
457
+ inputSchema: { taskId: z.string(), expectedVersion: z.number().int().min(0), options: z.array(APPROACH_OPTION).min(2).max(3), recommendedId: z.string(), reason: z.string().min(1).max(2000) },
458
+ }, async ({ taskId, ...proposal }) => {
459
+ try {
460
+ const loaded = await context.load()
461
+ if (loaded.offline) throw new Error('Connect to the server before saving approaches')
462
+ const task = await proposeApproaches(loaded.project._id, taskId, proposal)
463
+ return text(renderApproaches(task.approaches))
464
+ } catch (err) { return failure(err) }
465
+ })
466
+ server.registerTool('select_approach', {
467
+ title: "Record the user's approach selection",
468
+ description: 'Only after the user explicitly answers the options question, record their chosen option ID and approach version. Never choose the recommendation automatically. This does not approve a plan.',
469
+ inputSchema: { taskId: z.string(), version: z.number().int().min(1), optionId: z.string() },
470
+ }, async ({ taskId, ...selection }) => {
471
+ try {
472
+ const loaded = await context.load()
473
+ if (loaded.offline) throw new Error('Connect to the server before saving the selection')
474
+ const task = await selectApproach(loaded.project._id, taskId, { ...selection, via: 'mcp' })
475
+ return text(renderApproaches(task.approaches))
476
+ } catch (err) { return failure(err) }
477
+ })
478
+
479
+ server.registerTool(
480
+ 'submit_plan',
481
+ {
482
+ title: 'Submit the plan checklist',
483
+ description:
484
+ 'After reading the code and recording the explicit approach selection, submit the plan: files to edit, create and remove (with what changes in each), ' +
485
+ 'screen changes, and tests (with the numbers of the "done when" checks each test covers). tspl checks it against the code ' +
486
+ 'and returns the checklist to show the user for approval. Do not change any code before the user approves.',
487
+ inputSchema: {
488
+ taskId: z.string(),
489
+ approach: z.object({ version: z.number().int().min(1), optionId: z.string() }).optional().describe('The saved approach selection; required for plan submission'),
490
+ edit: z.array(PLAN_ITEM).optional().describe('Files to edit, as "repo/path" with why'),
491
+ create: z.array(PLAN_ITEM).optional().describe('New files, as "repo/path" with why'),
492
+ remove: z.array(PLAN_ITEM).optional().describe('Files to delete, as "repo/path" with why'),
493
+ screens: z.array(z.string()).optional().describe('What the user will see change on screen'),
494
+ tests: z.array(z.union([z.string(), z.object({ name: z.string(), file: z.string().optional(), checks: z.array(z.number()).optional(), command: z.string().optional() })])).optional()
495
+ .describe('Tests to add or run: name, file as "repo/path" (the test file to add or change), checks = numbers of the "done when" items the test proves, command = the shell command that runs it from the repo folder (tspl runs it at finish_build)'),
496
+ notes: z.string().optional().describe('Risks, open questions, things left out'),
497
+ testCases: z.array(z.object({ title: z.string(), steps: z.array(z.string()).optional(), expected: z.string().optional() })).optional()
498
+ .describe('Only when the task has no "done when" items: propose 2-6 test cases (title = what must be true, steps a person or test follows, expected result). They become TC1..TCn and your tests\' "checks" refer to their numbers; people approve them with the plan.'),
499
+ },
500
+ },
501
+ async ({ taskId, ...plan }) => {
502
+ try {
503
+ const loaded = await context.load()
504
+ const saved = await localTask(loaded, taskId)
505
+ if (loaded.offline) throw new Error('Connect to the server before planning')
506
+ const chosen = bindApproach(saved.approaches, saved.card, plan.approach)
507
+ plan.approach = chosen
508
+ const review = reviewPlan(plan, saved.card, loaded.graph)
509
+ if (review.testCases) {
510
+ saved.card = { ...saved.card, doneWhen: review.testCases.map(item => item.title), testCases: review.testCases.map(item => ({ steps: item.steps, expected: item.expected || item.title })) }
511
+ }
512
+ const version = ((saved.plan && saved.plan.version) || 0) + 1
513
+ const changes = changesSince(saved.plan, review)
514
+ const planned = ['edit', 'remove'].flatMap(kind => review[kind].map(item => item.file))
515
+ const { accepted } = recordUsed(loaded, saved, taskText(saved.card), planned)
516
+ context.feedbackChanged()
517
+ const found = new Set(saved.found || [])
518
+ const plannedTask = {
519
+ ...saved,
520
+ used: accepted,
521
+ missed: accepted.filter(key => !found.has(key)),
522
+ status: 'plan-review',
523
+ plans: saved.plan ? [...(saved.plans || []), saved.plan] : saved.plans || [],
524
+ plan: { ...withSnapshot(review, saved.card, cwd), approach: plan.approach, version, basis: saved.basis || null, submittedAt: new Date().toISOString() },
525
+ }
526
+ const shared = await shareTask(loaded.project._id, plannedTask)
527
+ writeTask({ ...plannedTask, status: shared.status, plan: shared.plan, approaches: shared.approaches })
528
+ return text([
529
+ `Selected approach: ${chosen.option.title} [${chosen.optionId}] (v${chosen.version})`,
530
+ renderChecklist(review, saved.card, `${taskId} v${version}`),
531
+ changes.length ? `\nCHANGES SINCE v${version - 1}\n${changes.map(line => ` ${line}`).join('\n')}` : null,
532
+ '',
533
+ review.warnings.length || review.companions.length
534
+ ? 'Fix only the tspl checks that are real problems and call submit_plan again; mention the others to the user.'
535
+ : null,
536
+ 'Show the whole checklist above to the user. A person approves it, not this chat: the user runs',
537
+ ` tspl plan approve ${taskId} (or: tspl plan reject ${taskId} --note "…")`,
538
+ 'in their own terminal, or decides in the tspl console. Do not change any code before plan_status says approved. When the user says they have decided, call plan_status.',
539
+ ].filter(line => line !== null).join('\n'))
540
+ } catch (err) {
541
+ return failure(err)
542
+ }
543
+ },
544
+ )
545
+
546
+ server.registerTool(
547
+ 'plan_status',
548
+ {
549
+ title: 'Check if a plan is approved',
550
+ description: 'Call after the user says they decided on the plan, before building, and whenever the user asks where a task stands: returns who approved the plan, how, and whether building may start.',
551
+ inputSchema: { taskId: z.string() },
552
+ },
553
+ async ({ taskId }) => {
554
+ try {
555
+ const loaded = await context.load()
556
+ const task = await request('GET', `/api/v1/projects/${loaded.project._id}/tasks/${taskId}`)
557
+ const saved = readTask(taskId)
558
+ if (saved.id) writeTask({ ...saved, status: task.status, decisions: task.decisions })
559
+ const decisions = (task.decisions || []).map(decision =>
560
+ `${decision.stage} ${decision.approved ? 'approved' : 'asked for changes'} v${decision.planVersion || '-'}${decision.userName ? ` (${decision.userName}${decision.via ? `, ${decision.via === 'web' ? 'console' : 'terminal'}` : ''})` : ''}${decision.note ? `: ${decision.note}` : ''}`)
561
+ return text([
562
+ `Task ${taskId} · plan v${task.plan ? task.plan.version : '-'} · status: ${task.status}`,
563
+ ...decisions.map(line => ` - ${line}`),
564
+ NEXT_STEP[task.status] ? NEXT_STEP[task.status](taskId) : '',
565
+ ].join('\n'))
566
+ } catch (err) {
567
+ return failure(err)
568
+ }
569
+ },
570
+ )
571
+
572
+ server.registerTool(
573
+ 'start_build',
574
+ {
575
+ title: 'Start building an approved plan',
576
+ description:
577
+ 'Call when the plan is approved. First call without confirm: tspl proposes the branch name and the branch each repo starts from, ' +
578
+ 'and shows uncommitted changes; ask the user to confirm or change them. Then call again with confirm true (and branch, bases or ' +
579
+ 'fromCurrent if they changed anything): tspl creates the branch in every repo on the plan and turns on the edit lock.',
580
+ inputSchema: {
581
+ taskId: z.string(),
582
+ confirm: z.boolean().optional(),
583
+ branch: z.string().optional().describe('Branch name, if the user changed it'),
584
+ bases: z.record(z.string(), z.string()).optional().describe('Branch each repo starts from, if the user changed it: { repo: branch }'),
585
+ fromCurrent: z.boolean().optional().describe('true to start from the branch each repo has checked out now'),
586
+ },
587
+ },
588
+ async ({ taskId, confirm, branch, bases, fromCurrent }) => {
589
+ try {
590
+ const loaded = await context.load()
591
+ const task = await request('GET', `/api/v1/projects/${loaded.project._id}/tasks/${taskId}`)
592
+ if (!['approved', 'building'].includes(task.status)) {
593
+ return text(`Not approved yet (status: ${task.status}). ${NEXT_STEP[task.status] ? NEXT_STEP[task.status](taskId) : ''}`)
594
+ }
595
+ const proposal = prepareBuild({ taskKey: taskId, task, project: loaded.project, paths: loaded.paths, branch, bases, fromCurrent })
596
+ const problems = blockers(proposal)
597
+ if (!confirm) {
598
+ return text([
599
+ describeProposal(proposal),
600
+ ...problems.map(problem => `Problem: ${problem}`),
601
+ '',
602
+ problems.length
603
+ ? 'Tell the user these must be fixed before building starts (for example commit or stash their changes), then call start_build again.'
604
+ : 'Ask the user to confirm this branch and where each repo starts from (or change them), then call start_build with confirm: true.',
605
+ ].join('\n'))
606
+ }
607
+ const result = await runBuild({ projectId: loaded.project._id, taskKey: taskId, task, proposal })
608
+ if (!result.started) {
609
+ return text(`Not started:\n${result.problems.map(problem => ` - ${problem}`).join('\n')}\nTell the user and ask how to go on.`)
610
+ }
611
+ return text([
612
+ ...result.repos.map(repo => `${repo.name}: on ${proposal.branch} from ${repo.base}@${repo.baseSha.slice(0, 7)}${repo.notes.length ? ` (${repo.notes.join('; ')})` : ''}`),
613
+ '',
614
+ renderBuildBrief({ ...task, key: taskId, build: { branch: proposal.branch, repos: result.repos } }, result.repos, conventionsFor(loaded.graph, loaded.index, { focus: planRepos(task.plan) }), projectInstructionsText(cwd)),
615
+ '',
616
+ 'The edit lock is on: in these repos only the files on the approved checklist can be changed, and only on this branch; git refuses commits and pushes with other files.',
617
+ 'Now build the checklist item by item, run the tests and check every "done when" item.',
618
+ 'When everything is done, do NOT commit or push: call finish_build with the result of each "done when" item and each test. tspl runs the test commands from the plan itself on the files as they are now; the developer commits and pushes later if they want.',
619
+ ].join('\n'))
620
+ } catch (err) {
621
+ return failure(err)
622
+ }
623
+ },
624
+ )
625
+
626
+ server.registerTool(
627
+ 'finish_build',
628
+ {
629
+ title: 'Report a finished build',
630
+ description:
631
+ 'Call when the approved checklist is built, the tests ran and every "done when" item was checked. tspl runs the test commands from the plan itself, plus lint and format on the changed files when the repo has them, ' +
632
+ 'compares the changes in git with the approved checklist, saves the report for the team, and the server marks the task done only when everything passed. ' +
633
+ 'Report honestly: a check or test that did not pass must be reported as passed: false.',
634
+ inputSchema: {
635
+ taskId: z.string(),
636
+ checks: z.array(z.object({
637
+ n: z.number().describe('Number of the "done when" item'),
638
+ passed: z.boolean(),
639
+ how: z.string().optional().describe('How you checked it, or what went wrong'),
640
+ })).describe('One entry per "done when" item'),
641
+ tests: z.array(z.object({
642
+ name: z.string(),
643
+ passed: z.boolean(),
644
+ how: z.string().optional().describe('Command you ran, or the failure'),
645
+ })).describe('Tests you ran and their result'),
646
+ summary: z.string().optional().describe('What changed, in two or three plain sentences'),
647
+ },
648
+ },
649
+ async ({ taskId, checks, tests, summary }) => {
650
+ try {
651
+ const loaded = await context.load()
652
+ const task = await request('GET', `/api/v1/projects/${loaded.project._id}/tasks/${taskId}`)
653
+ if (task.plan && task.plan.workflow) {
654
+ // Workflow tasks are finished by the same controller as `tspl task run`: the AI's own report counts for nothing.
655
+ const { runTask } = require('../workflow/controller')
656
+ const { controllerDeps, renderOutcome } = require('../workflow/runtime')
657
+ const result = await runTask({ cwd, taskId, options: { external: true } }, controllerDeps(cwd, { interactive: false }))
658
+ return text([
659
+ 'tspl does not take this report as proof. It checked the committed work itself:',
660
+ '',
661
+ renderOutcome(result),
662
+ '',
663
+ result.stage === 'ready'
664
+ ? 'The pull requests are ready for human review.'
665
+ : 'Tell the user exactly this state and the next step above. Do not say the task is done.',
666
+ ].join('\n'))
667
+ }
668
+ if (!['building', 'done'].includes(task.status) || !task.build) {
669
+ return text(`This task has no started build (status: ${task.status}). ${NEXT_STEP[task.status] ? NEXT_STEP[task.status](taskId) : ''}`)
670
+ }
671
+ const executed = [...runPlanTests({ task, paths: loaded.paths }), ...runStyleChecks({ task, paths: loaded.paths })]
672
+ const report = reviewBuild({ task, paths: loaded.paths, checks, tests: mergeTests(executed, tests) })
673
+ const saved = await finishBuild({ projectId: loaded.project._id, taskKey: taskId, task, report, summary })
674
+ const next = report.passed
675
+ ? [
676
+ 'Saved: the tests passed and the task is done. Nothing was committed or pushed.',
677
+ 'Show this report to the user. Committing, pushing and opening a pull request are their choice; if they want it:',
678
+ ...report.repos.filter(repo => !repo.missing).flatMap(repo => [
679
+ ` ${repo.name}: commit, then ${repo.pushed ? 'already pushed' : repo.push}`,
680
+ repo.pullRequest ? ` pull request: ${repo.pullRequest}` : ' open the pull request in your git host',
681
+ ]),
682
+ ]
683
+ : [
684
+ `Saved, but the task is not done (status: ${saved.status}); the edit lock stays on.`,
685
+ 'Show the problems to the user. Fix them (or, for files off the checklist, submit a new plan version for approval), then call finish_build again. Do not commit to make it pass.',
686
+ ]
687
+ return text([describeReport(report), '', ...next].join('\n'))
688
+ } catch (err) {
689
+ return failure(err)
690
+ }
691
+ },
692
+ )
693
+
694
+ return server
695
+ }
696
+
697
+ const startServer = async cwd => {
698
+ const server = createServer(cwd)
699
+ await server.connect(new StdioServerTransport())
700
+ }
701
+
702
+ module.exports = {
703
+ localTaskForTest: localTask,
704
+ createContext,
705
+ saveTask,
706
+ createServer,
707
+ startServer,
708
+ }