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.
- package/README.md +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- 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
|
+
}
|