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,475 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+ const Fs = require('fs')
5
+ const { checkbox, confirm, editor, input, select } = require('@inquirer/prompts')
6
+
7
+ const { selectApproach, renderApproaches } = require('../task/approaches')
8
+ const { request } = require('../api-client')
9
+ const { listTasks, readTask } = require('../config-store')
10
+ const { ENGINES } = require('../engines')
11
+ const { createContext, saveTask } = require('../mcp/server')
12
+ const { findProject } = require('../workspace')
13
+ const { buildBrief, renderBrief } = require('../task/brief')
14
+ const { checkTask, taskText } = require('../task/check')
15
+ const { engineFor, refineTask } = require('../task/refine')
16
+ const { runTask, describeWorkflow } = require('../workflow/controller')
17
+ const { runUntilDone } = require('../workflow/wait')
18
+ const { controllerDeps, renderOutcome, taskUrl } = require('../workflow/runtime')
19
+
20
+ const TICKET = /^[A-Za-z][A-Za-z0-9]+-\d+$/
21
+ const OTHER = '__other__'
22
+ const EDITABLE = [
23
+ ['what', 'What should change'],
24
+ ['ticket', 'Ticket'],
25
+ ['apps', 'Apps / repos'],
26
+ ['who', 'Who uses it'],
27
+ ['where', 'Where (screen, menu)'],
28
+ ['how', 'How it should work'],
29
+ ['steps', 'Steps to reproduce'],
30
+ ['now', 'What happens now'],
31
+ ['expected', 'What should happen'],
32
+ ['details', 'Ticket text / details'],
33
+ ['doneWhen', 'Done-when checks'],
34
+ ]
35
+
36
+ const newId = () => `t-${Date.now().toString(36)}-${Crypto.randomBytes(2).toString('hex')}`
37
+
38
+ const asList = value => String(value || '').split(',').map(item => item.trim()).filter(Boolean)
39
+
40
+ const counts = loaded => ({ files: loaded.graph.files.length, repos: loaded.graph.repos.length })
41
+
42
+ const askOptions = async (question, message) => {
43
+ const choices = [
44
+ ...question.options.map(option => ({
45
+ name: option.description ? `${option.label} — ${option.description}` : option.label,
46
+ value: option.value === undefined ? option.label : option.value,
47
+ })),
48
+ { name: 'Something else', value: OTHER },
49
+ ]
50
+ if (question.multiSelect) {
51
+ const picked = await checkbox({ message, choices, required: true })
52
+ const values = picked.filter(value => value !== OTHER)
53
+ if (picked.includes(OTHER)) values.push(...asList(await input({ message: 'Which? (comma separated)' })))
54
+ return values
55
+ }
56
+ const picked = await select({ message, choices })
57
+ return picked === OTHER ? input({ message: 'Which?' }) : picked
58
+ }
59
+
60
+ const answer = async (card, question) => {
61
+ const field = question.target || question.field
62
+ const message = `${question.header ? `${question.header}: ` : ''}${question.question}`
63
+ if (field === 'now, expected') {
64
+ card.now = await input({ message: 'Bug: what happens now?' })
65
+ card.expected = await input({ message: 'Bug: what should happen instead?' })
66
+ return
67
+ }
68
+ const value = question.options && question.options.length ? await askOptions(question, message) : await input({ message })
69
+ if (field === 'apps') card.apps = Array.isArray(value) ? value : asList(value)
70
+ else if (field === 'details') card.details = [card.details, value].filter(Boolean).join('\n')
71
+ else card[field] = Array.isArray(value) ? value.join(', ') : value
72
+ }
73
+
74
+ const clarify = async (card, loaded) => {
75
+ let current = card
76
+ for (;;) {
77
+ const result = checkTask(current, loaded)
78
+ if (result.status === 'ready') return result
79
+ current = result.card
80
+ for (const question of result.questions) await answer(current, question)
81
+ }
82
+ }
83
+
84
+ const askChecks = async card => {
85
+ if (card.doneWhen.length) return
86
+ console.log('\nDone when: the checks that must pass when this is finished. One per line; leave empty to finish.')
87
+ for (;;) {
88
+ const check = (await input({ message: `Check ${card.doneWhen.length + 1}:` })).trim()
89
+ if (!check) return
90
+ card.doneWhen.push(check)
91
+ }
92
+ }
93
+
94
+ const startCard = async (words, options) => {
95
+ const text = words.join(' ').trim()
96
+ const card = { ticket: options.ticket || '' }
97
+ if (TICKET.test(text)) card.ticket = text
98
+ else card.what = text
99
+ card.ticket = card.ticket.toUpperCase()
100
+ if (!card.what) {
101
+ card.what = await input({ message: card.ticket ? `${card.ticket}: what should change? (the ticket title, or one sentence)` : 'What should change? One or two sentences' })
102
+ }
103
+ if (options.file) {
104
+ card.details = Fs.readFileSync(options.file, 'utf8').trim()
105
+ } else if (await confirm({ message: 'Paste the ticket description or acceptance criteria? (opens your editor)', default: false })) {
106
+ card.details = (await editor({ message: 'Ticket text', postfix: '.md' })).trim()
107
+ }
108
+ return card
109
+ }
110
+
111
+ const engineLabel = () => {
112
+ try {
113
+ const { name, model } = engineFor('plan')
114
+ return `${ENGINES[name].label}${model ? ` ${model}` : ''}`
115
+ } catch (err) {
116
+ return null
117
+ }
118
+ }
119
+
120
+ const refresh = (loaded, task, extra = {}) => {
121
+ const { brief } = buildBrief(loaded, task, { keywords: task.brief ? task.brief.keywords : [], refined: task.brief ? task.brief.refined : null, ...extra })
122
+ return { ...task, brief }
123
+ }
124
+
125
+ const editField = async (task, loaded) => {
126
+ const field = await select({ message: 'Which field?', choices: EDITABLE.map(([value, name]) => ({ name, value })) })
127
+ const card = { ...task.card }
128
+ if (field === 'doneWhen') {
129
+ card.doneWhen = []
130
+ await askChecks(card)
131
+ } else if (field === 'apps') {
132
+ card.apps = await checkbox({
133
+ message: 'Which apps / repos?',
134
+ choices: loaded.graph.repos.map(repo => ({ name: repo.name, value: repo.name, checked: card.apps.includes(repo.name) })),
135
+ })
136
+ } else {
137
+ card[field] = await input({ message: EDITABLE.find(([value]) => value === field)[1], default: card[field] || '' })
138
+ if (field === 'ticket') card.ticket = card.ticket.toUpperCase()
139
+ }
140
+ const result = await clarify(card, loaded)
141
+ return refresh(loaded, { ...task, card: result.card, assumptions: result.assumptions, summary: result.summary })
142
+ }
143
+
144
+ const refine = async (task, loaded) => {
145
+ const label = engineLabel()
146
+ if (!label) throw new Error('No AI engine is set up on this computer. Run: tspl engines')
147
+ process.stdout.write(`Asking ${label} once (no files are opened)…`)
148
+ const found = await refineTask(task, task.brief, { cwd: process.cwd() })
149
+ process.stdout.write('\r')
150
+ const tokens = found.usage ? ` · ${found.usage.inputTokens + found.usage.outputTokens} tokens` : ''
151
+ console.log(`${label} answered in ${(found.ms / 1000).toFixed(1)}s${tokens}`)
152
+ if (found.note) console.log(` ${found.note}`)
153
+ found.questions.forEach(question => console.log(` Question for you: ${question}`))
154
+ let card = task.card
155
+ if (found.doneWhen.length && !card.doneWhen.length) {
156
+ const picked = await checkbox({
157
+ message: 'Suggested "done when" checks: keep which?',
158
+ choices: found.doneWhen.map(check => ({ name: check, value: check, checked: true })),
159
+ })
160
+ card = { ...card, doneWhen: picked }
161
+ }
162
+ const keywords = found.keywords.length
163
+ ? await checkbox({
164
+ message: 'Words the code probably uses: search with which?',
165
+ choices: found.keywords.map(word => ({ name: word, value: word, checked: true })),
166
+ })
167
+ : []
168
+ const result = await clarify(card, loaded)
169
+ const refined = { engine: found.engine, model: found.model, at: found.at, note: found.note, usage: found.usage }
170
+ return refresh(loaded, { ...task, card: result.card, assumptions: result.assumptions, summary: result.summary }, { keywords, refined })
171
+ }
172
+
173
+ const review = async (task, loaded) => {
174
+ let current = task
175
+ for (;;) {
176
+ console.log(`\n${renderBrief(current, counts(loaded))}`)
177
+ if (current.assumptions.length) {
178
+ console.log('\nAssumed (say if wrong):')
179
+ current.assumptions.forEach(line => console.log(` - ${line}`))
180
+ }
181
+ const label = engineLabel()
182
+ const action = await select({
183
+ message: 'What next?',
184
+ choices: [
185
+ { name: 'Looks right, save it', value: 'save' },
186
+ { name: 'Edit a field', value: 'edit' },
187
+ { name: 'Add words the code probably uses, then search again', value: 'keywords' },
188
+ ...(label ? [{ name: `Ask ${label} once to suggest words and checks`, value: 'refine' }] : []),
189
+ { name: 'Cancel (nothing is saved)', value: 'cancel' },
190
+ ],
191
+ })
192
+ if (action === 'save') return current
193
+ if (action === 'cancel') return null
194
+ if (action === 'edit') current = await editField(current, loaded)
195
+ if (action === 'keywords') {
196
+ const words = asList(await input({ message: 'Words or names (comma separated):', default: current.brief.keywords.join(', ') }))
197
+ current = refresh(loaded, current, { keywords: words })
198
+ }
199
+ if (action === 'refine') {
200
+ try {
201
+ current = await refine(current, loaded)
202
+ } catch (err) {
203
+ console.log(err.message)
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ const persist = async (loaded, task) => {
210
+ const now = new Date().toISOString()
211
+ const saved = {
212
+ ...task,
213
+ status: task.status || 'draft',
214
+ projectId: loaded.project._id,
215
+ keywords: task.brief.keywords,
216
+ basis: task.brief.basis,
217
+ found: task.brief.files.map(file => `${file.repo}/${file.path}`),
218
+ confidence: task.brief.confidence.level,
219
+ createdAt: task.createdAt || now,
220
+ updatedAt: now,
221
+ }
222
+ const shareError = await saveTask(loaded.project._id, saved, true)
223
+ const refined = task.brief.refined
224
+ if (!shareError && refined && refined.usage) {
225
+ await request('POST', `/api/v1/projects/${loaded.project._id}/tasks/${saved.id}/usage`, {
226
+ sessionId: `refine-${saved.id}-${refined.at}`,
227
+ step: 'intake',
228
+ engine: refined.engine,
229
+ model: refined.model,
230
+ source: 'tspl task refine',
231
+ input: refined.usage.inputTokens || 0,
232
+ cacheRead: refined.usage.cacheReadTokens || 0,
233
+ output: refined.usage.outputTokens || 0,
234
+ }).catch(() => null)
235
+ }
236
+ console.log(`\nSaved task ${saved.id}.`)
237
+ if (shareError) console.log(`It is on this computer only: the team could not be reached (${shareError.message}). It is shared the next time tspl talks to the API.`)
238
+ else console.log('The PM can see it in the tspl console under Plans → New tasks and confirm the scope.')
239
+ console.log(`\nNext, in your AI tool, in this project folder: /tspl ${saved.id}`)
240
+ console.log(`Change it later: tspl task edit ${saved.id} · See it: tspl task show ${saved.id}`)
241
+ }
242
+
243
+ const create = async (words, options) => {
244
+ const loaded = await createContext(process.cwd()).load()
245
+ const card = await startCard(words, options)
246
+ const result = await clarify(card, loaded)
247
+ await askChecks(result.card)
248
+ const ready = await clarify(result.card, loaded)
249
+ const task = refresh(loaded, { id: newId(), card: ready.card, assumptions: ready.assumptions, summary: ready.summary })
250
+ if (options.yes) console.log(`\n${renderBrief(task, counts(loaded))}`)
251
+ const final = options.yes ? task : await review(task, loaded)
252
+ if (!final) {
253
+ console.log('Nothing saved.')
254
+ return
255
+ }
256
+ await persist(loaded, final)
257
+ }
258
+
259
+ const localTask = id => {
260
+ const task = readTask(id)
261
+ if (!task.id) throw new Error(`Task ${id} is not on this computer. See it with: tspl task show ${id}`)
262
+ return task
263
+ }
264
+
265
+ const edit = async id => {
266
+ const loaded = await createContext(process.cwd()).load()
267
+ const task = refresh(loaded, localTask(id))
268
+ const final = await review(task, loaded)
269
+ if (!final) {
270
+ console.log('Nothing changed.')
271
+ return
272
+ }
273
+ await persist(loaded, final)
274
+ }
275
+
276
+ const show = async id => {
277
+ const local = readTask(id)
278
+ if (local.id) {
279
+ console.log(renderBrief(local))
280
+ return
281
+ }
282
+ const { project } = await findProject(process.cwd())
283
+ const remote = await request('GET', `/api/v1/projects/${project._id}/tasks/${encodeURIComponent(id)}`)
284
+ console.log(renderBrief({ ...remote, id: remote.key, summary: taskText(remote.card || {}) }))
285
+ }
286
+
287
+ const scope = async (id, options) => {
288
+ const { project } = await findProject(process.cwd())
289
+ const confirmed = !options.back
290
+ if (!confirmed && !options.note) throw new Error('Say what is missing or out of scope: --note "…"')
291
+ const task = await request('POST', `/api/v1/projects/${project._id}/tasks/${encodeURIComponent(id)}/scope`, { confirmed, note: options.note || '' })
292
+ console.log(confirmed ? `Scope confirmed for ${task.key}. The developer can plan it now: /tspl ${task.key}` : `Scope sent back for ${task.key} with your note.`)
293
+ }
294
+
295
+ const runWorkflow = async (id, options) => {
296
+ const controller = new AbortController()
297
+ const onSignal = () => controller.abort()
298
+ process.once('SIGINT', onSignal)
299
+ process.once('SIGTERM', onSignal)
300
+ try {
301
+ let stage = options.stage
302
+ const run = () => {
303
+ const once = runTask({ cwd: process.cwd(), taskId: id, signal: controller.signal, options: { confirmDelivery: Boolean(options.confirmDelivery), stage, branch: options.branch, base: options.base } }, controllerDeps(process.cwd()))
304
+ stage = undefined // a retried stage is retried once; later rounds just continue
305
+ return once
306
+ }
307
+ let result
308
+ if (options.wait) {
309
+ const { project } = await findProject(process.cwd())
310
+ result = await runUntilDone({
311
+ run,
312
+ fetchTask: () => request('GET', taskUrl(project._id, id)),
313
+ report: outcome => console.log(renderOutcome(outcome)),
314
+ log: line => console.log(`\n${line}`),
315
+ signal: controller.signal,
316
+ })
317
+ } else {
318
+ result = await run()
319
+ console.log(renderOutcome(result))
320
+ }
321
+ process.exitCode = result.stage === 'blocked' ? 1 : 0
322
+ } catch (err) {
323
+ if (!controller.signal.aborted) throw err
324
+ console.log('\nStopped waiting. Run it again any time; it continues where it was.')
325
+ } finally {
326
+ process.off('SIGINT', onSignal)
327
+ process.off('SIGTERM', onSignal)
328
+ }
329
+ }
330
+
331
+ const workflowStatus = async id => {
332
+ const { project } = await findProject(process.cwd())
333
+ const task = await request('GET', taskUrl(project._id, id))
334
+ const { lines, next } = describeWorkflow(task)
335
+ console.log(lines.join('\n'))
336
+ if (next.length) console.log(`Next: ${next.join(' | ')}`)
337
+ }
338
+
339
+ const verify = async (id, options) => {
340
+ const { project } = await findProject(process.cwd())
341
+ const criterion = Number(options.check)
342
+ if (!Number.isInteger(criterion) || criterion < 1) throw new Error('--check needs the number of a done-when item')
343
+ const task = await request('GET', taskUrl(project._id, id))
344
+ const text = ((task.card && task.card.doneWhen) || [])[criterion - 1]
345
+ if (!text) throw new Error(`${id} has no done-when item ${criterion}`)
346
+ if (!(await confirm({ message: `You checked yourself, on the tested commits, that: "${text}"?`, default: false }))) {
347
+ console.log('Nothing recorded.')
348
+ return
349
+ }
350
+ const { workflow } = await request('POST', `${taskUrl(project._id, id)}/workflow/verify`, { criterion, actor: 'human', note: options.note || '' })
351
+ for (const file of options.file || []) {
352
+ const type = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4', '.txt': 'text/plain' }[require('path').extname(file).toLowerCase()]
353
+ if (!type) throw new Error(`${file}: only png, jpg, webp, webm, mp4 or txt files can be attached`)
354
+ await request('POST', `${taskUrl(project._id, id)}/artifacts`, {
355
+ name: require('path').basename(file),
356
+ contentType: type,
357
+ data: Fs.readFileSync(file).toString('base64'),
358
+ criterion,
359
+ headSha: workflow.heads[0] ? workflow.heads[0].headSha : null,
360
+ caption: options.note || null,
361
+ source: 'person',
362
+ })
363
+ console.log(`Attached ${file} to test case ${criterion}.`)
364
+ }
365
+ console.log(`Recorded: done-when ${criterion} checked by you on ${workflow.heads.map(head => `${head.name}@${String(head.headSha).slice(0, 7)}`).join(', ')}. Continue: tspl task run ${id}`)
366
+ }
367
+
368
+ const remove = async (id, options) => {
369
+ const { project } = await findProject(process.cwd())
370
+ if (!options.yes && !(await confirm({ message: `Delete task ${id} for everyone? It disappears from the console and from every list.`, default: false }))) {
371
+ console.log('Nothing deleted.')
372
+ return
373
+ }
374
+ try {
375
+ await request('DELETE', taskUrl(project._id, id), { reason: options.reason || '' })
376
+ } catch (err) {
377
+ if (err.statusCode !== 404) throw err
378
+ console.log(`${id} was already deleted or never shared; removing the copy on this computer.`)
379
+ }
380
+ const { stopBuild } = require('../task/build')
381
+ stopBuild(id)
382
+ require('../config-store').removeTask(id)
383
+ console.log(`Deleted ${id}. Its branches stay in Git; delete them yourself if you no longer need them.`)
384
+ }
385
+
386
+ const list = () => {
387
+ const tasks = listTasks().sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
388
+ if (!tasks.length) {
389
+ console.log('No tasks on this computer yet. Start one: tspl task new "<what should change>"')
390
+ return
391
+ }
392
+ tasks.forEach(task => {
393
+ const card = task.card || {}
394
+ console.log(`${task.id.padEnd(18)} ${(task.status || 'draft').padEnd(18)} ${card.ticket ? `${card.ticket} · ` : ''}${card.what || ''}`)
395
+ })
396
+ }
397
+
398
+ const chooseApproach = async (taskId, optionId, options) => {
399
+ const version = Number(options.version)
400
+ if (!Number.isSafeInteger(version) || version < 1) throw new Error('--version must be a positive integer from the displayed options')
401
+ const { project } = await findProject(process.cwd())
402
+ const task = await selectApproach(project._id, taskId, { version, optionId, via: 'cli' })
403
+ console.log(renderApproaches(task.approaches))
404
+ console.log(`Continue: tspl task run ${taskId}`)
405
+ }
406
+
407
+ module.exports = {
408
+ runWorkflow,
409
+ register(program) {
410
+ const task = program.command('task').description('Prepare a task before any AI work: questions, done-when checks and the code it touches (no AI tokens)')
411
+ task.command('choose <task> <option>')
412
+ .description('Choose a saved implementation approach before planning')
413
+ .requiredOption('--version <n>', 'version of the displayed options')
414
+ .action(chooseApproach)
415
+ task
416
+ .command('new [text...]')
417
+ .description('Start a task from a sentence or a ticket id; saves it for the team')
418
+ .option('--ticket <id>', 'ticket id, if the text is not one')
419
+ .option('--file <path>', 'read the ticket text from a file')
420
+ .option('-y, --yes', 'save without the review menu (scripts)')
421
+ .action(create)
422
+ task
423
+ .command('edit <task>')
424
+ .description('Change a task on this computer and save it again')
425
+ .action(edit)
426
+ task
427
+ .command('show <task>')
428
+ .description('Show a task brief: card, done-when checks, files tspl found')
429
+ .action(show)
430
+ task
431
+ .command('list')
432
+ .description('Tasks on this computer')
433
+ .action(list)
434
+ task
435
+ .command('delete <task>')
436
+ .alias('rm')
437
+ .description('Delete a task for everyone (you created it, or you are an owner, admin or PM)')
438
+ .option('--reason <text>', 'why, kept in the task history')
439
+ .option('-y, --yes', 'do not ask to confirm')
440
+ .action(remove)
441
+ task
442
+ .command('run <task>')
443
+ .description('Continue an approved task: code, run the checks, repair, review separately, open draft pull requests, wait for CI. Run it again to resume.')
444
+ .option('--stage <stage>', 'retry implementing, testing, reviewing, awaiting-delivery, creating-pr or awaiting-ci, then continue')
445
+ .option('--confirm-delivery', 'confirm pushing the branch and opening draft pull requests (otherwise you are asked)')
446
+ .option('--base <branch>', 'branch every repo starts from, e.g. master (otherwise you are asked before coding)')
447
+ .option('--branch <name>', 'name of the new branch to create, e.g. feature/contact-page (otherwise you are asked)')
448
+ .option('-w, --wait', 'when it stops for an approval, a manual check or CI, wait and continue by itself')
449
+ .action(runWorkflow)
450
+ task
451
+ .command('watch <task>')
452
+ .description('Same as run --wait: keep going through approvals, manual checks and CI until the task is ready or needs you here')
453
+ .option('--confirm-delivery', 'confirm pushing the branch and opening draft pull requests (otherwise you are asked)')
454
+ .option('--base <branch>', 'branch every repo starts from, e.g. master (otherwise you are asked before coding)')
455
+ .option('--branch <name>', 'name of the new branch to create, e.g. feature/contact-page (otherwise you are asked)')
456
+ .action((id, options) => runWorkflow(id, { ...options, wait: true }))
457
+ task
458
+ .command('status <task>')
459
+ .description('Where a task is: stage, checks, review, pull requests, and the exact next command')
460
+ .action(workflowStatus)
461
+ task
462
+ .command('verify <task>')
463
+ .description('Record that you checked a done-when item yourself (only for items no executed check covers)')
464
+ .requiredOption('--check <n>', 'number of the done-when item')
465
+ .option('--note <text>', 'what you looked at')
466
+ .option('--file <paths...>', 'screenshots or a screen recording that show it (png, jpg, webp, webm, mp4)')
467
+ .action(verify)
468
+ task
469
+ .command('scope <task>')
470
+ .description('Confirm a task scope as PM, or send it back with --back --note "…"')
471
+ .option('--back', 'send it back instead of confirming')
472
+ .option('--note <text>', 'what is missing or out of scope')
473
+ .action(scope)
474
+ },
475
+ }
@@ -0,0 +1,105 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+
6
+ const { request } = require('../api-client')
7
+ const { readLinks, readActive, listTasks } = require('../config-store')
8
+
9
+ const PLANNING = ['draft', 'plan-review', 'pm-review', 'approved', 'changes-requested']
10
+ const RECENT_HOURS = 24
11
+
12
+ const readStdin = () =>
13
+ new Promise(resolve => {
14
+ let data = ''
15
+ process.stdin.setEncoding('utf8')
16
+ process.stdin.on('data', chunk => { data += chunk })
17
+ process.stdin.on('end', () => resolve(data))
18
+ process.stdin.on('error', () => resolve(data))
19
+ })
20
+
21
+ const real = file => {
22
+ try {
23
+ return Fs.realpathSync(file)
24
+ } catch (err) {
25
+ return file
26
+ }
27
+ }
28
+
29
+ const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
30
+
31
+ const parseTranscript = file => {
32
+ const byMessage = new Map()
33
+ let model = null
34
+ Fs.readFileSync(file, 'utf8').split('\n').forEach(line => {
35
+ if (!line.trim()) return
36
+ let entry
37
+ try {
38
+ entry = JSON.parse(line)
39
+ } catch (err) {
40
+ return
41
+ }
42
+ const message = entry && entry.type === 'assistant' ? entry.message : null
43
+ if (!message || !message.usage) return
44
+ byMessage.set(message.id || entry.uuid, message.usage)
45
+ if (message.model) model = message.model
46
+ })
47
+ const totals = { calls: byMessage.size, input: 0, cacheWrite: 0, cacheRead: 0, output: 0, model }
48
+ byMessage.forEach(usage => {
49
+ totals.input += usage.input_tokens || 0
50
+ totals.cacheWrite += usage.cache_creation_input_tokens || 0
51
+ totals.cacheRead += usage.cache_read_input_tokens || 0
52
+ totals.output += usage.output_tokens || 0
53
+ })
54
+ return totals
55
+ }
56
+
57
+ const pickTask = cwd => {
58
+ const dir = real(cwd)
59
+ const building = Object.entries(readActive()).find(([, build]) => build.repos.some(repo => inside(dir, real(repo.path))))
60
+ if (building) return { projectId: building[1].projectId, taskId: building[0], step: 'build' }
61
+ const link = Object.values(readLinks()).find(entry => inside(dir, real(entry.folder)) || Object.values(entry.repos || {}).some(repo => inside(dir, real(repo.path))))
62
+ if (!link) return null
63
+ const since = Date.now() - RECENT_HOURS * 3600 * 1000
64
+ const task = listTasks()
65
+ .filter(item => String(item.projectId) === String(link.projectId) && PLANNING.includes(item.status) && new Date(item.updatedAt || 0).getTime() > since)
66
+ .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))[0]
67
+ return task ? { projectId: link.projectId, taskId: task.id, step: 'plan' } : null
68
+ }
69
+
70
+ const record = async () => {
71
+ let input
72
+ try {
73
+ input = JSON.parse((await readStdin()) || '{}')
74
+ } catch (err) {
75
+ return
76
+ }
77
+ if (!input.transcript_path || !Fs.existsSync(input.transcript_path)) return
78
+ const totals = parseTranscript(input.transcript_path)
79
+ if (!totals.calls) return
80
+ const target = pickTask(input.cwd || process.cwd())
81
+ if (!target) return
82
+ try {
83
+ await request('POST', `/api/v1/projects/${target.projectId}/tasks/${target.taskId}/usage`, {
84
+ sessionId: input.session_id || Path.basename(input.transcript_path, '.jsonl'),
85
+ step: target.step,
86
+ engine: 'claude',
87
+ model: totals.model,
88
+ source: 'claude-code',
89
+ input: totals.input,
90
+ cacheWrite: totals.cacheWrite,
91
+ cacheRead: totals.cacheRead,
92
+ output: totals.output,
93
+ })
94
+ } catch (err) {
95
+ if (process.env.TSPL_DEBUG) process.stderr.write(`tspl usage: ${err.message}\n`)
96
+ }
97
+ }
98
+
99
+ module.exports = {
100
+ parseTranscript,
101
+ pickTask,
102
+ register(program) {
103
+ program.command('usage', { hidden: true }).description('Record AI usage for the current task (called by the AI tool when a turn ends)').action(record)
104
+ },
105
+ }