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,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
|
+
}
|