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,82 @@
1
+ 'use strict'
2
+
3
+ const { request } = require('../api-client')
4
+
5
+ class LeaseLostError extends Error {
6
+ constructor(message) {
7
+ super(message)
8
+ this.name = 'LeaseLostError'
9
+ }
10
+ }
11
+
12
+ const conflict = err => err && err.statusCode === 409
13
+
14
+ /**
15
+ * The server side of one task's workflow for one run. Calls are sent one at a time because every event carries the
16
+ * revision the previous one produced.
17
+ */
18
+ const workflowClient = (projectId, taskKey, deps = {}) => {
19
+ const call = deps.request || request
20
+ const base = `/api/v1/projects/${projectId}/tasks/${encodeURIComponent(taskKey)}/workflow`
21
+ let revision = 0
22
+ let runId = null
23
+ let queue = Promise.resolve()
24
+
25
+ const serial = work => {
26
+ const next = queue.then(work, work)
27
+ queue = next.catch(() => {})
28
+ return next
29
+ }
30
+
31
+ const adopt = result => {
32
+ revision = result.workflow ? result.workflow.revision : 0
33
+ return result
34
+ }
35
+
36
+ const client = {
37
+ get revision() {
38
+ return revision
39
+ },
40
+
41
+ async get() {
42
+ return adopt(await call('GET', base))
43
+ },
44
+
45
+ /** Takes the task for this run. A held lease (another run) is reported as an error with the server's words. */
46
+ async claim(id) {
47
+ runId = id
48
+ return serial(async () => {
49
+ const current = await call('GET', base)
50
+ return adopt(await call('POST', `${base}/claim`, { runId: id, expectedRevision: current.workflow ? current.workflow.revision : 0 }))
51
+ })
52
+ },
53
+
54
+ send(event) {
55
+ return serial(async () => {
56
+ try {
57
+ return adopt(await call('PUT', base, { runId, expectedRevision: revision, event }))
58
+ } catch (err) {
59
+ if (!conflict(err)) throw err
60
+ const current = await call('GET', base)
61
+ const owned = current.workflow && current.workflow.runId === runId && current.workflow.revision !== revision
62
+ if (!owned) {
63
+ if (/lease|own|no longer/i.test(err.message)) throw new LeaseLostError(err.message)
64
+ throw err
65
+ }
66
+ adopt(current)
67
+ return adopt(await call('PUT', base, { runId, expectedRevision: revision, event }))
68
+ }
69
+ })
70
+ },
71
+
72
+ stop: () => serial(async () => adopt(await call('POST', `${base}/stop`))),
73
+
74
+ verify: (criterion, note) => call('POST', `${base}/verify`, { criterion, actor: 'human', note: note || '' }).then(adopt),
75
+ }
76
+ return client
77
+ }
78
+
79
+ module.exports = {
80
+ LeaseLostError,
81
+ workflowClient,
82
+ }
@@ -0,0 +1,585 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+ const { spawnSync } = require('child_process')
5
+ const { TEST_FILE, completionProblems, RETRY_STAGES, workflowProgress } = require('../shared')
6
+
7
+ const { LeaseLostError } = require('./client')
8
+ const { AgentError } = require('./agents')
9
+ const { renderPrBody } = require('./pr-body')
10
+ const { resolveRequirements, pollCi } = require('../providers')
11
+
12
+ const HEARTBEAT_MS = 15 * 1000
13
+ const CI_POLL_MS = 30 * 1000
14
+ const MAX_STEPS = 60
15
+ const RESUME_AS_IS = ['repairs-exhausted', 'out-of-plan']
16
+
17
+ const git = (dir, args) => {
18
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
19
+ return { ok: result.status === 0, out: (result.stdout || '').trim(), err: (result.stderr || '').trim() }
20
+ }
21
+
22
+ /** Local Git operations the controller needs. Kept narrow: it commits named files and pushes one branch, nothing else. */
23
+ const gitOps = {
24
+ commit(dir, files, message) {
25
+ const added = git(dir, ['add', '--all', '--', ...files])
26
+ if (!added.ok) throw new Error(`git add failed: ${added.err}`)
27
+ const committed = git(dir, ['commit', '-q', '-m', message])
28
+ if (!committed.ok) throw new Error(`git commit failed: ${committed.err}`)
29
+ },
30
+ push(dir, branch) {
31
+ const pushed = git(dir, ['push', '-u', 'origin', branch])
32
+ if (!pushed.ok) throw new Error(`git push failed: ${pushed.err.split('\n')[0]}`)
33
+ },
34
+ remoteTip(dir, branch) {
35
+ const listed = git(dir, ['ls-remote', 'origin', `refs/heads/${branch}`])
36
+ return listed.ok && listed.out ? listed.out.split(/\s+/)[0] : null
37
+ },
38
+ }
39
+
40
+ const dirtyHint = names => `${names.join(', ')} has uncommitted changes (after tspl init, commit the new .tspl/ folder first)`
41
+
42
+ const commitMessage = task => `${task.card && task.card.ticket ? `${task.card.ticket}: ` : ''}${(task.card && task.card.what) || task.key}\n\ntspl task ${task.key || task.id}`
43
+
44
+ const sameDestinations = (authorization, destinations, planVersion) =>
45
+ Boolean(authorization) &&
46
+ authorization.planVersion === planVersion &&
47
+ destinations.length === (authorization.destinations || []).length &&
48
+ destinations.every(item => authorization.destinations.some(known => ['repo', 'repository', 'base', 'branch'].every(field => known[field] === item[field])))
49
+
50
+ const failureFindings = workflow => [
51
+ ...(workflow.evidence || [])
52
+ .filter(item => ['failed', 'timed-out'].includes(item.status))
53
+ .map(item => `Check ${item.checkId} ${item.status}:\n${String(item.output || '').split('\n').slice(-25).join('\n')}`),
54
+ ...((workflow.review && workflow.review.verdict === 'changes-requested' && workflow.review.findings) || []),
55
+ ]
56
+
57
+ /**
58
+ * Advances one task from wherever the server says it is. Every external step is preceded and followed by a server
59
+ * checkpoint, and on resume the actual repository and provider state is read again instead of repeating commands.
60
+ * Returns { stage, blockers, next, prs }.
61
+ */
62
+ const runTask = async ({ cwd, taskId, signal, options = {} }, deps) => {
63
+ if (options.stage && !RETRY_STAGES.includes(options.stage)) throw new Error(`Unknown retry stage: ${options.stage}. Use ${RETRY_STAGES.join(', ')}`)
64
+ const ctx = await deps.load({ cwd, taskId })
65
+ const taskKey = ctx.task.key || taskId
66
+ const warnings = []
67
+ const outcome = (stage, blockers = [], next = []) => ({ stage, blockers, next, warnings, prs: (state.workflow && state.workflow.prs) || [] })
68
+ const state = { task: ctx.task, workflow: ctx.task.workflow || null }
69
+ const again = `tspl task run ${taskKey}`
70
+
71
+ const status = ctx.task.status
72
+ if (ctx.task.scope && ctx.task.scope.confirmed === false) {
73
+ return outcome('planning', [`The PM sent the scope back: ${ctx.task.scope.note || 'no note'}`], [`Change the task in the console, then: ${again}`])
74
+ }
75
+ const flow = ctx.task.flow || null
76
+ if (flow && flow.scope !== false && !(ctx.task.scope && ctx.task.scope.confirmed) && ['draft', 'plan-review', 'changes-requested'].includes(status)) {
77
+ return outcome('planning', ['The PM has not confirmed the scope yet.'], ['The PM confirms it in the console (or: tspl task scope ' + ctx.task.key + ')', `Then: ${again}`])
78
+ }
79
+ if (!ctx.task.plan || !ctx.task.plan.workflow) {
80
+ if (!ctx.task.plan && deps.plan) return planStage()
81
+ return outcome('planning', [ctx.task.plan ? 'This is a legacy task: its plan has no workflow snapshot, so it cannot use the tested-PR workflow.' : 'There is no plan yet.'], [
82
+ ctx.task.plan ? `Plan it again inside an initialized project (tspl init), then: ${again}` : `Plan it first: /tspl ${taskKey} in your AI tool, or ${again} once a plan exists`,
83
+ ])
84
+ }
85
+ if (['plan-review', 'pm-review', 'changes-requested'].includes(status)) {
86
+ return outcome('awaiting-approval', [`The plan is waiting: ${status}.`], [status === 'changes-requested' ? 'Change the plan as asked and submit it again.' : `A person approves: tspl plan approve ${taskKey}`, `Then: ${again}`])
87
+ }
88
+ if (!['approved', 'building', 'done'].includes(status)) {
89
+ return outcome('planning', [`The task is ${status}.`], [`Prepare it with: tspl task show ${taskKey}`])
90
+ }
91
+ if (status === 'done' && state.workflow && state.workflow.stage === 'ready') return outcome('ready', [], ['Nothing left to do.'])
92
+
93
+ async function planStage() {
94
+ signal?.throwIfAborted()
95
+ const planned = await deps.plan({ ...ctx, signal })
96
+ signal?.throwIfAborted()
97
+ if (planned && planned.waitingForSelection) return outcome('planning', ['Choose an implementation approach before planning.'], [planned.note, again])
98
+ return outcome('awaiting-approval', [], [`A person approves the plan: tspl plan approve ${taskKey}`, `Then: ${again}`].concat(planned && planned.note ? [planned.note] : []))
99
+ }
100
+
101
+ if (state.workflow && state.workflow.stage === 'blocked' && state.workflow.blocker && RESUME_AS_IS.includes(state.workflow.blocker.reason) && (state.workflow.blocker.reason === 'out-of-plan' || !options.stage)) {
102
+ return outcome('blocked', [state.workflow.blocker.message || state.workflow.blocker.reason], [
103
+ state.workflow.blocker.reason === 'out-of-plan' ? `Amend the plan: tspl plan amend ${taskKey}` : `Fix and commit it yourself, then: ${again} --stage testing (or amend the plan: tspl plan amend ${taskKey})`,
104
+ ])
105
+ }
106
+
107
+ const run = deps.runs.read(taskKey)
108
+ const runId = run.runId || Crypto.randomUUID()
109
+ deps.runs.write(taskKey, { ...run, runId })
110
+
111
+ let lock
112
+ try {
113
+ lock = deps.claimCheckouts(ctx.paths, { runId, taskId: taskKey })
114
+ } catch (err) {
115
+ return outcome('blocked', [err.message], [`Stop the other task, then: ${again}`])
116
+ }
117
+
118
+ const client = deps.client(ctx.projectId, taskKey)
119
+ const abort = new AbortController()
120
+ if (signal) {
121
+ if (signal.aborted) abort.abort()
122
+ else signal.addEventListener('abort', () => abort.abort(), { once: true })
123
+ }
124
+ let leaseLost = false
125
+ let timer = null
126
+ let ciDeadline = null
127
+ const aborted = () => abort.signal.aborted
128
+
129
+ try {
130
+ try {
131
+ const claimed = await client.claim(runId)
132
+ state.task = claimed.task
133
+ state.workflow = claimed.workflow
134
+ } catch (err) {
135
+ return outcome('blocked', [err.message], [`Try again later: ${again}`])
136
+ }
137
+ timer = setInterval(() => {
138
+ client.send({ type: 'heartbeat' }).then(() => lock.renew()).catch(() => {
139
+ leaseLost = true
140
+ abort.abort()
141
+ })
142
+ }, deps.heartbeatMs || HEARTBEAT_MS)
143
+ if (timer.unref) timer.unref()
144
+
145
+ const emit = async event => {
146
+ if (leaseLost) throw new LeaseLostError('The task lease was lost')
147
+ const result = await client.send(event)
148
+ state.task = result.task
149
+ state.workflow = result.workflow
150
+ return result
151
+ }
152
+ const block = async (reason, message, next = [again]) => {
153
+ try {
154
+ await emit({ type: 'stage', stage: 'blocked', blocker: { reason, message } })
155
+ } catch (err) {
156
+ throw err
157
+ }
158
+ const stage = state.workflow.resumeStage || state.workflow.stage
159
+ const retry = stage === 'repairing' ? 'testing' : stage
160
+ return outcome('blocked', [message], [...next, ...(RETRY_STAGES.includes(retry) ? [`Retry this stage: ${again} --stage ${retry}`] : [])])
161
+ }
162
+
163
+ const plan = ctx.task.plan
164
+ const snapshot = plan.workflow
165
+ const configRepo = name => ctx.config.repos.find(repo => repo.name === name)
166
+ const buildOf = () => state.task.build
167
+ const heads = () => deps.captureHeads(ctx.paths, buildOf())
168
+ const changedHeads = list => list.filter(head => head.baseSha && head.headSha !== head.baseSha)
169
+ const sameHeads = (local, server) => local.length === server.length && local.every(head => server.some(item => item.name === head.name && item.headSha === head.headSha))
170
+ const dirtyOf = list => list.filter(head => !head.clean).map(head => head.name)
171
+ // The work happens only on the build branch chosen before coding; a person or the coding engine moving off it stops the run.
172
+ const offBranch = list => list.filter(head => head.branch && head.branch !== buildOf().branch)
173
+ const branchBlock = (list, who) => {
174
+ const off = offBranch(list)
175
+ if (!off.length) return null
176
+ const where = off.map(head => `${head.name} is on "${head.branch}"`).join(', ')
177
+ const fix = off.map(head => `git -C ${ctx.paths.get(head.name)} switch ${buildOf().branch}`)
178
+ return block('other', `${who}: ${where}, but this task works only on "${buildOf().branch}". Nothing was committed.`, [...fix.map(cmd => `Switch back: ${cmd}`), `Then: ${again}`])
179
+ }
180
+
181
+ // The branch each repo starts from and the new branch name are decided before any code is written:
182
+ // from --base/--branch, else asked in the terminal; scripts without either keep the project defaults.
183
+ const startBuild = async () => {
184
+ const prepare = choice => deps.prepareBuild({ taskKey, task: state.task, project: ctx.project, paths: ctx.paths, ...choice })
185
+ let proposal = prepare({ branch: options.branch, base: options.base })
186
+ if (!options.branch && !options.base && deps.chooseBranch) {
187
+ const choice = await deps.chooseBranch(proposal)
188
+ if (!choice) return { blocked: await block('stopped', 'No branch was chosen, so coding did not start.', [again]) }
189
+ proposal = prepare(choice)
190
+ }
191
+ const problems = deps.buildBlockers(proposal)
192
+ if (problems.length) return { blocked: await block('other', problems.join('; '), [`Fix that, then: ${again}`]) }
193
+ const result = await deps.startBuild({ projectId: ctx.projectId, taskKey, task: state.task, proposal })
194
+ if (!result.started) return { blocked: await block('other', result.problems.join('; '), [`Fix that, then: ${again}`]) }
195
+ state.task = await deps.refreshTask(ctx.projectId, taskKey)
196
+ return {}
197
+ }
198
+
199
+ const commitWork = async (result, label) => {
200
+ if (result.outOfPlan.length) {
201
+ return block('out-of-plan', `${label} changed files that are not in the approved plan: ${result.outOfPlan.join(', ')}`, [`Amend the plan: tspl plan amend ${taskKey}`])
202
+ }
203
+ if (!result.changes.length) return block('other', `${label} made no changes.`, [`Look at the branch yourself, then: ${again}`])
204
+ for (const change of result.changes) deps.git.commit(ctx.paths.get(change.repo), change.files, commitMessage(state.task))
205
+ const after = heads()
206
+ const dirty = dirtyOf(after)
207
+ if (dirty.length) return block('out-of-plan', `${dirty.join(', ')} still has uncommitted files outside the approved plan.`, [`Clean them up yourself, then: ${again}`])
208
+ await emit({ type: 'heads', heads: after })
209
+ return null
210
+ }
211
+
212
+ const resilientAgent = async work => {
213
+ try { return await work() } catch (err) {
214
+ const transient = err instanceof AgentError && (err.code === 'timeout' || err.code === 'failed' && /temporar|connection|ECONN|502|503/i.test(err.message))
215
+ if (!transient || aborted()) throw err
216
+ // One retry only; quota/auth failures and cancelled work require explicit recovery.
217
+ await new Promise(resolve => setTimeout(resolve, deps.retryDelayMs ?? 1000))
218
+ if (aborted()) throw err
219
+ return work()
220
+ }
221
+ }
222
+
223
+ const implement = async (label, findings) => {
224
+ const engine = deps.engineFor('build')
225
+ const before = heads()
226
+ const wrongStart = branchBlock(before, 'Before coding')
227
+ if (wrongStart) return wrongStart
228
+ let result
229
+ try {
230
+ result = await resilientAgent(() => deps.agents.implementTask({
231
+ engine: engine.name, model: engine.model, task: state.task, paths: ctx.paths, heads: before, instructions: ctx.instructions,
232
+ findings, branch: buildOf().branch, signal: abort.signal, timeoutMs: ctx.config.workflow.engineTimeoutMs,
233
+ }, deps.agentDeps))
234
+ } catch (err) {
235
+ if (err instanceof AgentError) return agentBlock(err)
236
+ throw err
237
+ }
238
+ deps.runs.write(taskKey, { ...deps.runs.read(taskKey), buildSessionId: result.sessionId })
239
+ const moved = branchBlock(heads(), `${label} left the task branch`)
240
+ if (moved) return moved
241
+ return commitWork(result, label)
242
+ }
243
+
244
+ const agentBlock = async err => {
245
+ if (err.code === 'cancelled') {
246
+ if (leaseLost) return outcome('blocked', ['The task lease was lost; the new owner was left running.'], [again])
247
+ return block('stopped', 'Cancelled. The stage was interrupted; resume when ready.', [again])
248
+ }
249
+ const reason = ['limit', 'auth', 'timeout', 'failed', 'invalid-output', 'unsupported'].includes(err.code) ? 'provider' : 'other'
250
+ return block(reason, err.message, [err.code === 'limit' ? `Resume later: ${again}` : `Fix that, then: ${again}`])
251
+ }
252
+
253
+ if (options.stage) {
254
+ if (!buildOf() && options.stage !== 'implementing') return block('other', 'Implementation has not started; set up the build first.', [`${again} --stage implementing`])
255
+ if (buildOf()) {
256
+ const local = heads()
257
+ if (dirtyOf(local).length) return block('other', `${dirtyHint(dirtyOf(local))}; commit the manual fix before retrying.`, [`Commit the fix, then: ${again} --stage ${options.stage}`])
258
+ if (local.length && !sameHeads(local, state.workflow.heads)) await emit({ type: 'heads', heads: local })
259
+ }
260
+ await emit({ type: 'retry', stage: options.stage })
261
+ }
262
+
263
+ for (let step = 0; step < MAX_STEPS; step++) {
264
+ if (leaseLost) return outcome('blocked', ['The task lease was lost; another run may own it now. Nothing more was changed.'], [`Check with: tspl task status ${taskKey}`])
265
+ if (aborted()) {
266
+ return block('stopped', 'Cancelled. The run was stopped and its lease released.', [again])
267
+ }
268
+ const workflow = state.workflow
269
+
270
+ switch (workflow.stage) {
271
+ case 'implementing': {
272
+ if (!buildOf()) {
273
+ const started = await startBuild()
274
+ if (started.blocked) return started.blocked
275
+ } else if (deps.refreshChecklist) {
276
+ deps.refreshChecklist(taskKey, state.task.plan)
277
+ }
278
+ const current = heads()
279
+ const wrongBranch = branchBlock(current, 'Before coding')
280
+ if (wrongBranch) return wrongBranch
281
+ const ahead = changedHeads(current)
282
+ if (ahead.length && !dirtyOf(current).length) {
283
+ await emit({ type: 'heads', heads: current })
284
+ await emit({ type: 'stage', stage: 'testing' })
285
+ break
286
+ }
287
+ if (dirtyOf(current).length && ahead.length === 0 && !options.external) {
288
+ // uncommitted work from a person: never stash or reset it
289
+ return block('other', `${dirtyHint(dirtyOf(current))}. Commit or stash them, then run again.`, [`Commit or stash, then: ${again}`])
290
+ }
291
+ if (options.external) {
292
+ return outcome('implementing', ['Commit your work on the build branch first; tspl checks committed work only.'], [`git commit, then: ${again}`])
293
+ }
294
+ const blocked = await implement('The coding engine', [])
295
+ if (blocked) return blocked
296
+ await emit({ type: 'stage', stage: 'testing' })
297
+ break
298
+ }
299
+
300
+ case 'repairing': {
301
+ const blocked = await implement('The repair', failureFindings(workflow))
302
+ if (blocked) return blocked
303
+ await emit({ type: 'stage', stage: 'testing' })
304
+ break
305
+ }
306
+
307
+ case 'testing': {
308
+ const local = heads()
309
+ if (dirtyOf(local).length) return block('other', `${dirtyHint(dirtyOf(local))}, so the tested commit would not be what ships.`, [`Commit them, then: ${again}`])
310
+ if (!sameHeads(local, workflow.heads)) {
311
+ await emit({ type: 'heads', heads: local })
312
+ break
313
+ }
314
+ const evidence = await deps.runChecks({ snapshot, heads: local, paths: ctx.paths, signal: abort.signal, build: buildOf() }, deps.evidenceDeps)
315
+ if (aborted()) break
316
+ const files = evidence.flatMap(item => (item.attachments || []).map(file => ({ ...file, checkId: item.checkId, headSha: item.headSha })))
317
+ const results = evidence.map(({ attachments, ...item }) => item)
318
+ if (files.length && deps.uploadArtifact) {
319
+ for (const file of files) {
320
+ try {
321
+ await deps.uploadArtifact(ctx.projectId, taskKey, file)
322
+ } catch (err) {
323
+ warnings.push(`Evidence upload failed for ${file.name || file.path}: ${err.message}. Screenshot remains on this computer: ${file.path}`)
324
+ }
325
+ }
326
+ }
327
+ evidence.splice(0, evidence.length, ...results)
328
+ if (evidence.length) await emit({ type: 'evidence', evidence })
329
+ const failed = evidence.filter(item => ['failed', 'timed-out'].includes(item.status))
330
+ const unusable = evidence.filter(item => ['missing', 'stale', 'cancelled', 'skipped'].includes(item.status))
331
+ if (unusable.length) {
332
+ return block('other', `These checks could not run: ${unusable.map(item => `${item.checkId} (${item.status}: ${String(item.output).split('\n')[0]})`).join('; ')}`, [`Fix the cause (see .tspl/project.json), then: ${again}`])
333
+ }
334
+ if (failed.length) {
335
+ try {
336
+ await emit({ type: 'stage', stage: 'repairing' })
337
+ } catch (err) {
338
+ if (err instanceof LeaseLostError || !/repair rounds/.test(err.message)) throw err
339
+ return block('repairs-exhausted', `${err.message}. Still failing: ${failed.map(item => item.checkId).join(', ')}`, [`Fix it on the branch yourself, then: ${again}`])
340
+ }
341
+ break
342
+ }
343
+ await emit({ type: 'stage', stage: 'reviewing' })
344
+ break
345
+ }
346
+
347
+ case 'reviewing': {
348
+ const local = heads()
349
+ if (dirtyOf(local).length) return block('other', `${dirtyHint(dirtyOf(local))}.`, [`Commit them, then: ${again}`])
350
+ if (!sameHeads(local, workflow.heads)) {
351
+ await emit({ type: 'heads', heads: local })
352
+ break
353
+ }
354
+ const options = snapshot.options || {}
355
+ const verifier = deps.engineFor('verify')
356
+ let review = workflow.review
357
+ if (options.review === false) review = { verdict: 'passed', findings: [] }
358
+ else if (!review) {
359
+ try {
360
+ review = await resilientAgent(() => deps.agents.reviewTask({
361
+ engine: verifier.name, model: verifier.model, task: state.task, paths: ctx.paths, heads: local, instructions: ctx.instructions,
362
+ evidence: workflow.evidence, buildSessionId: deps.runs.read(taskKey).buildSessionId, signal: abort.signal, timeoutMs: ctx.config.workflow.engineTimeoutMs,
363
+ }, deps.agentDeps))
364
+ } catch (err) {
365
+ if (err instanceof AgentError) return agentBlock(err)
366
+ throw err
367
+ }
368
+ await emit({ type: 'review', review })
369
+ }
370
+ if (review.verdict === 'changes-requested') {
371
+ try {
372
+ await emit({ type: 'stage', stage: 'repairing' })
373
+ } catch (err) {
374
+ if (err instanceof LeaseLostError || !/repair rounds/.test(err.message)) throw err
375
+ return block('repairs-exhausted', `${err.message}. The review still asks for changes: ${review.findings.map(item => item.message).join('; ')}`, [`Fix it on the branch yourself, then: ${again}`])
376
+ }
377
+ break
378
+ }
379
+ const problems = completionProblems({ plan: state.task.plan, workflow: state.workflow, upTo: 'review' })
380
+ if (problems.length) {
381
+ const manual = problems.filter(text => /Acceptance criterion/.test(text))
382
+ if (manual.length === problems.length) {
383
+ return outcome('reviewing', manual, snapshot.manualCriteria.map(n => `tspl task verify ${taskKey} --check ${n}`).concat([`Then: ${again}`]))
384
+ }
385
+ return block('other', problems.join(' '), [`Fix it, then: ${again}`])
386
+ }
387
+ if (options.pullRequest === false) {
388
+ try {
389
+ await emit({ type: 'stage', stage: 'ready' })
390
+ } catch (err) {
391
+ if (err instanceof LeaseLostError) throw err
392
+ return block('other', err.message, [`Fix what it says, then: ${again}`])
393
+ }
394
+ break
395
+ }
396
+ await emit({ type: 'stage', stage: 'awaiting-delivery' })
397
+ break
398
+ }
399
+
400
+ case 'awaiting-delivery': {
401
+ const changed = changedHeads(workflow.heads)
402
+ const build = buildOf()
403
+ const destinations = changed.map(head => ({
404
+ repo: head.name,
405
+ repository: configRepo(head.name).delivery.repository,
406
+ base: build.repos.find(repo => repo.name === head.name).base,
407
+ branch: build.branch,
408
+ }))
409
+ for (const destination of destinations) {
410
+ const repoConfig = configRepo(destination.repo)
411
+ const provider = deps.providerFor(repoConfig)
412
+ if (!provider.supported) return block('provider', provider.reason, [`Open the pull request yourself from ${build.branch}.`])
413
+ try {
414
+ await provider.authenticate()
415
+ const resolved = resolveRequirements(repoConfig, await provider.requiredChecks(destination.base))
416
+ if (!resolved.determined) return block('provider', resolved.problems.join(' '), [`Fix .tspl/project.json, then: ${again}`])
417
+ } catch (err) {
418
+ if (err.code) return block('provider', err.message, [`Fix that, then: ${again}`])
419
+ throw err
420
+ }
421
+ }
422
+ if (!sameDestinations(workflow.deliveryAuthorization, destinations, workflow.planVersion)) {
423
+ const summary = [
424
+ 'About to push and open draft pull requests:',
425
+ ...destinations.map(item => ` ${item.repo}: branch ${item.branch} → ${item.repository} (${item.base})`),
426
+ ` title: ${state.task.card.what}`,
427
+ ].join('\n')
428
+ const accepted = options.confirmDelivery || (deps.confirm ? await deps.confirm(summary) : false)
429
+ if (!accepted) return outcome('awaiting-delivery', [summary], [`Confirm and continue: ${again} --confirm-delivery`])
430
+ await emit({ type: 'delivery', authorization: { planVersion: workflow.planVersion, destinations } })
431
+ }
432
+ await emit({ type: 'stage', stage: 'creating-pr' })
433
+ break
434
+ }
435
+
436
+ case 'creating-pr': {
437
+ const build = buildOf()
438
+ const local = heads()
439
+ if (!sameHeads(local, workflow.heads)) {
440
+ await emit({ type: 'heads', heads: local })
441
+ break
442
+ }
443
+ for (const head of changedHeads(workflow.heads)) {
444
+ const existing = (workflow.prs || []).find(item => item.repo === head.name)
445
+ if (existing && existing.headSha === head.headSha) continue
446
+ const repoConfig = configRepo(head.name)
447
+ const provider = deps.providerFor(repoConfig)
448
+ const base = build.repos.find(repo => repo.name === head.name).base
449
+ const dir = ctx.paths.get(head.name)
450
+ try {
451
+ await provider.authenticate()
452
+ const resolved = resolveRequirements(repoConfig, await provider.requiredChecks(base))
453
+ if (!resolved.determined) return block('provider', resolved.problems.join(' '), [`Fix .tspl/project.json, then: ${again}`])
454
+ deps.git.push(dir, build.branch)
455
+ if (deps.git.remoteTip(dir, build.branch) !== head.headSha) {
456
+ return block('provider', `The remote branch ${build.branch} of ${head.name} is not at the tested commit ${head.headSha} after pushing. Someone else may have pushed to it.`, [`Look at the remote branch, then: ${again}`])
457
+ }
458
+ const body = renderPrBody({
459
+ task: state.task, summary: '', repo: head.name, head,
460
+ evidence: workflow.evidence.filter(item => item.repo === head.name),
461
+ criteria: ((state.task.card && state.task.card.doneWhen) || []).map((text, i) => ({ text, how: snapshot.checks.some(check => (check.criteria || []).includes(i + 1)) ? 'executed check' : 'human verification' })),
462
+ review: workflow.review,
463
+ limitations: ['Checks above were executed by the tspl CLI on the developer machine; required CI is reported separately by GitHub.'],
464
+ ci: resolved.confirmedNone ? 'This project confirmed that no CI is required; the PR relies on the local checks and the review above.' : null,
465
+ })
466
+ const pr = await provider.upsertDraft({ base, head: build.branch, title: commitMessage(state.task).split('\n')[0], body })
467
+ await emit({
468
+ type: 'pr',
469
+ pr: { repo: head.name, provider: provider.name, repository: repoConfig.delivery.repository, number: pr.number, url: pr.url, headSha: pr.headSha, base, draft: pr.draft, state: pr.state, requiredChecks: resolved.requirements, checks: [], observedAt: new Date().toISOString() },
470
+ })
471
+ } catch (err) {
472
+ if (err instanceof LeaseLostError) throw err
473
+ return block('provider', err.message, [`Resume without duplicates: ${again}`])
474
+ }
475
+ }
476
+ await emit({ type: 'stage', stage: 'awaiting-ci' })
477
+ break
478
+ }
479
+
480
+ case 'awaiting-ci': {
481
+ if (ciDeadline === null) ciDeadline = Date.now() + (deps.ciWaitMs || 0)
482
+ const local = heads()
483
+ if (!sameHeads(local, workflow.heads)) {
484
+ await emit({ type: 'heads', heads: local })
485
+ break
486
+ }
487
+ let pending = false
488
+ const prs = [...workflow.prs]
489
+ for (const pr of prs) {
490
+ const provider = deps.providerFor(configRepo(pr.repo))
491
+ let polled
492
+ try {
493
+ polled = await pollCi({ provider, number: pr.number, requirements: pr.requiredChecks, headSha: pr.headSha, maxMs: deps.ciPollMs ?? CI_POLL_MS }, deps.pollDeps)
494
+ } catch (err) {
495
+ return block('provider', err.message, [`Fix that, then: ${again}`])
496
+ }
497
+ await emit({ type: 'ci', repo: pr.repo, checks: polled.pr.checks, draft: polled.pr.draft, state: polled.pr.state, observedAt: polled.pr.observedAt })
498
+ if (polled.state === 'stale') return block('provider', `The pull request for ${pr.repo} is on ${polled.pr.headSha}, not the tested commit ${pr.headSha}. Someone pushed to the branch.`, [`Look at the branch, then: ${again}`])
499
+ if (polled.state === 'failed') {
500
+ const bad = polled.states.filter(item => item.state === 'failed').map(item => `${item.requirement.name}: ${item.reason}`)
501
+ return block('ci-failed', `Required CI failed for ${pr.repo}: ${bad.join('; ')}`, [`Fix the failure on the branch, then: ${again}`])
502
+ }
503
+ if (polled.state === 'pending') pending = true
504
+ }
505
+ if (pending) {
506
+ if (Date.now() < ciDeadline) {
507
+ await require('timers/promises').setTimeout(Math.min(deps.ciRetryDelayMs ?? 1000, Math.max(0, ciDeadline - Date.now())), undefined, { signal: abort.signal })
508
+ break
509
+ }
510
+ return outcome('awaiting-ci', [`Required CI has not finished: ${state.workflow.prs.map(item => item.url).join(', ')}`], [`Check again in a few minutes: ${again}`])
511
+ }
512
+ for (const pr of state.workflow.prs) {
513
+ const provider = deps.providerFor(configRepo(pr.repo))
514
+ let ready
515
+ try {
516
+ ready = await provider.markReady(pr.number, { headSha: pr.headSha })
517
+ } catch (err) {
518
+ return block('provider', err.message, [`Fix that, then: ${again}`])
519
+ }
520
+ await emit({ type: 'ci', repo: pr.repo, checks: pr.checks, draft: ready.draft, state: ready.state })
521
+ }
522
+ try {
523
+ await emit({ type: 'stage', stage: 'ready' })
524
+ } catch (err) {
525
+ if (err instanceof LeaseLostError) throw err
526
+ return outcome('awaiting-ci', [err.message], [`Fix what it says, then: ${again}`])
527
+ }
528
+ break
529
+ }
530
+
531
+ case 'ready': {
532
+ deps.finishLocal(taskKey)
533
+ if (!state.workflow.prs.length) {
534
+ const build = buildOf()
535
+ return outcome('ready', [], [`Done: tested${(snapshot.options || {}).review === false ? '' : ' and reviewed'} on branch ${build ? build.branch : 'the task branch'}. This task opens no pull request; push and merge it yourself.`])
536
+ }
537
+ return outcome('ready', [], state.workflow.prs.map(item => `Review the pull request: ${item.url}`))
538
+ }
539
+
540
+ default:
541
+ return outcome(workflow.stage, [`Unexpected stage ${workflow.stage}`], [`tspl task status ${taskKey}`])
542
+ }
543
+ }
544
+ return block('other', 'The workflow reached its step limit without finishing. Resume from the recorded checkpoint.', [again])
545
+ } catch (err) {
546
+ if (err instanceof LeaseLostError || leaseLost) {
547
+ return outcome('blocked', [`${err.message}. Another run may own the task now; nothing more was changed.`], [`Check with: tspl task status ${taskKey}`])
548
+ }
549
+ const message = aborted() ? 'Cancelled. The stage was interrupted; resume when ready.' : String(err.message || err).slice(0, 3500)
550
+ const stage = state.workflow?.resumeStage || state.workflow?.stage || 'implementing'
551
+ let persistence = ''
552
+ try {
553
+ const saved = await client.send({ type: 'stage', stage: 'blocked', blocker: { reason: aborted() ? 'stopped' : 'other', message } })
554
+ state.workflow = saved.workflow
555
+ } catch (saveError) { persistence = ` Could not save the failure: ${saveError.message}; an expired heartbeat will show the interruption.` }
556
+ return outcome('blocked', [message + persistence], [`Fix the cause, then: ${again} --stage ${stage === 'repairing' ? 'testing' : stage}`])
557
+ } finally {
558
+ clearInterval(timer)
559
+ if (lock) lock.release()
560
+ }
561
+ }
562
+
563
+ /** Plain-language state of a task's workflow, for `tspl task status`. */
564
+ const describeWorkflow = task => {
565
+ const workflow = task.workflow
566
+ if (!task.plan && task.approaches) return { lines: [`${task.key}: ${task.approaches.selection ? 'approach selected; ready to plan' : 'waiting for approach selection'} (v${task.approaches.version})`], next: [task.approaches.selection ? `tspl task run ${task.key}` : `tspl task choose ${task.key} <option-id> --version ${task.approaches.version}`] }
567
+ if (!task.plan || !task.plan.workflow) return { lines: [`${task.key}: legacy task (status ${task.status}). Its history is a build report, not a tested pull request.`], next: [] }
568
+ if (!workflow) return { lines: [`${task.key}: status ${task.status}; the workflow has not started.`], next: [task.status === 'approved' ? `tspl task run ${task.key}` : `tspl plan show ${task.key}`] }
569
+ const lines = [`${task.key}: stage ${workflow.stage} (revision ${workflow.revision}, repair rounds used ${workflow.attempts})`]
570
+ const progress = workflowProgress(task)
571
+ if (progress) lines.push(...progress.steps.map(step => ` ${step.label}: ${step.status} — ${step.reason}${['failed', 'interrupted', 'waiting'].includes(step.status) ? `\n ${step.command}` : ''}`))
572
+ if (workflow.blocker) lines.push(` blocked: ${workflow.blocker.reason}${workflow.blocker.message ? ` - ${workflow.blocker.message}` : ''}`)
573
+ lines.push(...(workflow.heads || []).map(head => ` ${head.name}: ${String(head.headSha).slice(0, 7)} on ${head.branch}${head.clean ? '' : ' (uncommitted changes)'}`))
574
+ lines.push(...(workflow.evidence || []).map(item => ` check ${item.checkId}: ${item.status}${item.testCount ? `, ${item.testCount} tests` : ''} on ${String(item.headSha).slice(0, 7)} [run by the CLI on a developer machine]`))
575
+ if (workflow.review) lines.push(` review: ${workflow.review.verdict} by ${workflow.review.engine || 'AI'} [separate session]`)
576
+ lines.push(...(workflow.prs || []).map(pr => ` PR ${pr.repo}: ${pr.url} ${pr.draft ? 'draft' : pr.state}, CI ${(pr.checks || []).length} observation(s) [reported by ${pr.provider}]`))
577
+ const next = workflow.stage === 'ready' ? [] : [`tspl task run ${task.key}`]
578
+ return { lines, next }
579
+ }
580
+
581
+ module.exports = {
582
+ gitOps,
583
+ runTask,
584
+ describeWorkflow,
585
+ }