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,249 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Os = require('os')
|
|
5
|
+
const Path = require('path')
|
|
6
|
+
|
|
7
|
+
const { runProcess } = require('../workflow/process')
|
|
8
|
+
const { mergeBody } = require('../workflow/pr-body')
|
|
9
|
+
|
|
10
|
+
const TIMEOUT = 60 * 1000
|
|
11
|
+
const REPOSITORY = /^[\w.-]+\/[\w.-]+$/
|
|
12
|
+
const PR_FIELDS = 'number,url,headRefOid,headRefName,baseRefName,isDraft,state,body,title,headRepositoryOwner'
|
|
13
|
+
|
|
14
|
+
class ProviderError extends Error {
|
|
15
|
+
/** code: missing-cli, auth, permission, not-found, rate-limit, unsupported, ambiguous, closed-history, invalid, failed */
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.code = code
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const classify = (result, what) => {
|
|
23
|
+
const text = `${result.stderr}\n${result.stdout}`
|
|
24
|
+
if (result.error && /ENOENT/.test(result.error)) return new ProviderError('missing-cli', 'The GitHub CLI (gh) is not installed. Install it from https://cli.github.com and run: gh auth login')
|
|
25
|
+
if (/gh auth login|not logged in|authentication (failed|required)|bad credentials|HTTP 401/i.test(text)) return new ProviderError('auth', 'The GitHub CLI is not logged in. Run: gh auth login')
|
|
26
|
+
if (/HTTP 403|Resource not accessible|must have admin|forbidden/i.test(text)) return new ProviderError('permission', `GitHub refused ${what}: your account lacks permission (${firstLine(text)})`)
|
|
27
|
+
if (/rate limit/i.test(text)) return new ProviderError('rate-limit', 'GitHub rate limit reached; try again later')
|
|
28
|
+
if (/HTTP 404|Not Found|Could not resolve/i.test(text)) return new ProviderError('not-found', `GitHub could not find it while ${what}: ${firstLine(text)}`)
|
|
29
|
+
return new ProviderError('failed', `${what} failed: ${firstLine(text) || `exit code ${result.exitCode}`}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const firstLine = text => String(text || '').trim().split('\n').find(Boolean) || ''
|
|
33
|
+
|
|
34
|
+
/** Splits `gh api --paginate` output (several JSON documents back to back) into values. */
|
|
35
|
+
const parseJsonStream = text => {
|
|
36
|
+
const values = []
|
|
37
|
+
const source = String(text || '').trim()
|
|
38
|
+
let index = 0
|
|
39
|
+
while (index < source.length) {
|
|
40
|
+
while (index < source.length && /\s/.test(source[index])) index++
|
|
41
|
+
if (index >= source.length) break
|
|
42
|
+
const open = source[index]
|
|
43
|
+
if (open !== '{' && open !== '[') throw new ProviderError('failed', 'GitHub returned something that is not JSON')
|
|
44
|
+
let depth = 0
|
|
45
|
+
let inString = false
|
|
46
|
+
let end = -1
|
|
47
|
+
for (let i = index; i < source.length; i++) {
|
|
48
|
+
const char = source[i]
|
|
49
|
+
if (inString) {
|
|
50
|
+
if (char === '\\') i++
|
|
51
|
+
else if (char === '"') inString = false
|
|
52
|
+
} else if (char === '"') inString = true
|
|
53
|
+
else if (char === '{' || char === '[') depth++
|
|
54
|
+
else if ((char === '}' || char === ']') && --depth === 0) { end = i; break }
|
|
55
|
+
}
|
|
56
|
+
if (end === -1) throw new ProviderError('failed', 'GitHub returned cut-off JSON')
|
|
57
|
+
values.push(JSON.parse(source.slice(index, end + 1)))
|
|
58
|
+
index = end + 1
|
|
59
|
+
}
|
|
60
|
+
return values
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const normalizePr = (pr, repository) => ({
|
|
64
|
+
number: pr.number,
|
|
65
|
+
url: pr.url,
|
|
66
|
+
headSha: pr.headRefOid,
|
|
67
|
+
head: pr.headRefName,
|
|
68
|
+
base: pr.baseRefName,
|
|
69
|
+
draft: Boolean(pr.isDraft),
|
|
70
|
+
state: String(pr.state || '').toLowerCase(),
|
|
71
|
+
body: pr.body || '',
|
|
72
|
+
title: pr.title || '',
|
|
73
|
+
repository,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const encode = branch => branch.split('/').map(encodeURIComponent).join('%2F')
|
|
77
|
+
|
|
78
|
+
const CHECK_RUN_FINAL = new Set(['completed'])
|
|
79
|
+
|
|
80
|
+
const fromCheckRun = run => ({
|
|
81
|
+
name: run.name,
|
|
82
|
+
appId: run.app && run.app.id ? run.app.id : null,
|
|
83
|
+
headSha: run.head_sha,
|
|
84
|
+
status: run.status,
|
|
85
|
+
conclusion: run.conclusion || null,
|
|
86
|
+
url: run.html_url || null,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const fromStatus = (status, sha) => ({
|
|
90
|
+
name: status.context,
|
|
91
|
+
appId: null,
|
|
92
|
+
headSha: sha,
|
|
93
|
+
status: status.state === 'pending' ? 'in_progress' : 'completed',
|
|
94
|
+
conclusion: status.state === 'success' ? 'success' : status.state === 'pending' ? null : 'failure',
|
|
95
|
+
url: status.target_url || null,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const createGitHubProvider = (repoConfig, deps = {}) => {
|
|
99
|
+
const delivery = repoConfig.delivery || {}
|
|
100
|
+
const repository = delivery.repository
|
|
101
|
+
if (delivery.provider !== 'github') throw new ProviderError('unsupported', `Automatic pull requests support GitHub only, not "${delivery.provider}".`)
|
|
102
|
+
if (!REPOSITORY.test(repository || '')) throw new ProviderError('invalid', `"${repository}" is not an owner/name GitHub repository`)
|
|
103
|
+
const run = deps.run || runProcess
|
|
104
|
+
const owner = repository.split('/')[0]
|
|
105
|
+
|
|
106
|
+
const gh = async (args, what, { allow } = {}) => {
|
|
107
|
+
const result = await run('gh', args, { timeoutMs: TIMEOUT, cwd: deps.cwd, env: { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' } })
|
|
108
|
+
if (result.exitCode === 0 && !result.error) return result
|
|
109
|
+
if (allow && allow(result)) return result
|
|
110
|
+
throw classify(result, what)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const api = async (path, what, options) => {
|
|
114
|
+
const result = await gh(['api', '--paginate', `repos/${repository}/${path}`], what, options)
|
|
115
|
+
return result.exitCode === 0 ? parseJsonStream(result.stdout) : { failed: result }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const view = async number => {
|
|
119
|
+
const result = await gh(['pr', 'view', String(number), '--repo', repository, '--json', PR_FIELDS], `reading pull request #${number}`)
|
|
120
|
+
return normalizePr(JSON.parse(result.stdout), repository)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const provider = {
|
|
124
|
+
name: 'github',
|
|
125
|
+
supported: true,
|
|
126
|
+
repository,
|
|
127
|
+
|
|
128
|
+
async authenticate() {
|
|
129
|
+
const result = await gh(['auth', 'status', '--hostname', 'github.com'], 'checking GitHub login')
|
|
130
|
+
return { ok: true, detail: firstLine(result.stdout || result.stderr) }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async findPr({ base, head }) {
|
|
134
|
+
const result = await gh(
|
|
135
|
+
['pr', 'list', '--repo', repository, '--head', head, '--base', base, '--state', 'all', '--limit', '100', '--json', PR_FIELDS],
|
|
136
|
+
'looking for an existing pull request',
|
|
137
|
+
)
|
|
138
|
+
const all = JSON.parse(result.stdout || '[]')
|
|
139
|
+
.filter(pr => pr.headRefName === head && pr.baseRefName === base)
|
|
140
|
+
.filter(pr => !pr.headRepositoryOwner || !pr.headRepositoryOwner.login || pr.headRepositoryOwner.login.toLowerCase() === owner.toLowerCase())
|
|
141
|
+
.map(pr => normalizePr(pr, repository))
|
|
142
|
+
if (!all.length) return null
|
|
143
|
+
const open = all.filter(pr => pr.state === 'open')
|
|
144
|
+
if (open.length > 1) throw new ProviderError('ambiguous', `Several open pull requests exist for ${head} → ${base}: ${open.map(pr => `#${pr.number}`).join(', ')}. Close the extra ones.`)
|
|
145
|
+
if (!open.length) {
|
|
146
|
+
throw new ProviderError('closed-history', `Pull request #${all[0].number} for ${head} → ${base} is ${all[0].state}. tspl will not reopen it; use a new branch or reopen it yourself.`)
|
|
147
|
+
}
|
|
148
|
+
return open[0]
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async upsertDraft({ base, head, title, body }) {
|
|
152
|
+
const dir = Fs.mkdtempSync(Path.join(Os.tmpdir(), 'tspl-pr-'))
|
|
153
|
+
try {
|
|
154
|
+
let existing = await provider.findPr({ base, head })
|
|
155
|
+
if (!existing) {
|
|
156
|
+
const file = Path.join(dir, 'body.md')
|
|
157
|
+
Fs.writeFileSync(file, body, 'utf8')
|
|
158
|
+
const created = await gh(
|
|
159
|
+
['pr', 'create', '--repo', repository, '--base', base, '--head', head, '--draft', '--title', title, '--body-file', file],
|
|
160
|
+
'creating the pull request',
|
|
161
|
+
{ allow: result => /already exists/i.test(`${result.stderr}${result.stdout}`) },
|
|
162
|
+
)
|
|
163
|
+
if (created.exitCode === 0) {
|
|
164
|
+
const number = Number((created.stdout.match(/\/pull\/(\d+)/) || [])[1])
|
|
165
|
+
if (!number) throw new ProviderError('failed', `GitHub created the pull request but returned no number: ${firstLine(created.stdout)}`)
|
|
166
|
+
return { ...(await view(number)), created: true }
|
|
167
|
+
}
|
|
168
|
+
existing = await provider.findPr({ base, head })
|
|
169
|
+
if (!existing) throw new ProviderError('failed', 'GitHub says the pull request exists but it could not be found')
|
|
170
|
+
}
|
|
171
|
+
const merged = mergeBody(existing.body, body)
|
|
172
|
+
if (merged !== existing.body) {
|
|
173
|
+
const file = Path.join(dir, 'body.md')
|
|
174
|
+
Fs.writeFileSync(file, merged, 'utf8')
|
|
175
|
+
await gh(['pr', 'edit', String(existing.number), '--repo', repository, '--body-file', file], 'updating the pull request description')
|
|
176
|
+
}
|
|
177
|
+
return { ...(await view(existing.number)), created: false }
|
|
178
|
+
} finally {
|
|
179
|
+
Fs.rmSync(dir, { recursive: true, force: true })
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Required status checks of the base branch from rulesets and classic protection. `determined` is false when
|
|
185
|
+
* GitHub would not tell us (permissions), which is different from "nothing is required".
|
|
186
|
+
*/
|
|
187
|
+
async requiredChecks(base) {
|
|
188
|
+
const requirements = []
|
|
189
|
+
const problems = []
|
|
190
|
+
const add = (name, appId) => {
|
|
191
|
+
if (name && !requirements.some(item => item.name === name && item.appId === (appId || null))) requirements.push({ name, appId: appId || null })
|
|
192
|
+
}
|
|
193
|
+
let seen = 0
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const pages = await api(`rules/branches/${encode(base)}`, 'reading branch rules')
|
|
197
|
+
seen++
|
|
198
|
+
pages.flat().filter(rule => rule.type === 'required_status_checks').forEach(rule => {
|
|
199
|
+
;((rule.parameters && rule.parameters.required_status_checks) || []).forEach(item => add(item.context, item.integration_id))
|
|
200
|
+
})
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (!['permission', 'not-found'].includes(err.code)) throw err
|
|
203
|
+
problems.push(`Branch rules for ${base} could not be read (${err.message})`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const classic = await api(`branches/${encode(base)}/protection/required_status_checks`, 'reading branch protection', {
|
|
208
|
+
allow: result => /HTTP 404/.test(`${result.stderr}${result.stdout}`) && /Branch not protected|Required status checks not enabled/i.test(`${result.stderr}${result.stdout}`),
|
|
209
|
+
})
|
|
210
|
+
seen++
|
|
211
|
+
if (!classic.failed) {
|
|
212
|
+
classic.forEach(entry => {
|
|
213
|
+
;(entry.checks || []).forEach(item => add(item.context, item.app_id > 0 ? item.app_id : null))
|
|
214
|
+
;(entry.contexts || []).filter(name => !(entry.checks || []).some(item => item.context === name)).forEach(name => add(name, null))
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!['permission', 'not-found'].includes(err.code)) throw err
|
|
219
|
+
problems.push(`Branch protection for ${base} could not be read (${err.message})`)
|
|
220
|
+
}
|
|
221
|
+
return { requirements, determined: problems.length === 0 && seen === 2, problems }
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async observePr(number) {
|
|
225
|
+
const pr = await view(number)
|
|
226
|
+
const runs = (await api(`commits/${pr.headSha}/check-runs`, 'reading check runs')).flatMap(page => page.check_runs || []).map(fromCheckRun)
|
|
227
|
+
const statuses = (await api(`commits/${pr.headSha}/status`, 'reading commit statuses')).flatMap(page => page.statuses || []).map(item => fromStatus(item, pr.headSha))
|
|
228
|
+
return { ...pr, checks: [...runs, ...statuses], observedAt: new Date().toISOString() }
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async markReady(number, { headSha }) {
|
|
232
|
+
const before = await view(number)
|
|
233
|
+
if (before.headSha !== headSha) throw new ProviderError('invalid', `The pull request head moved to ${before.headSha}; it is no longer the tested commit ${headSha}.`)
|
|
234
|
+
if (before.state !== 'open') throw new ProviderError('closed-history', `Pull request #${number} is ${before.state}`)
|
|
235
|
+
if (before.draft) await gh(['pr', 'ready', String(number), '--repo', repository], 'marking the pull request ready')
|
|
236
|
+
const after = await view(number)
|
|
237
|
+
if (after.draft || after.headSha !== headSha) throw new ProviderError('failed', 'GitHub did not mark the pull request ready on the tested commit')
|
|
238
|
+
return after
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
return provider
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
ProviderError,
|
|
246
|
+
CHECK_RUN_FINAL,
|
|
247
|
+
parseJsonStream,
|
|
248
|
+
createGitHubProvider,
|
|
249
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createGitHubProvider, ProviderError } = require('./github')
|
|
4
|
+
|
|
5
|
+
const unsupported = repoConfig => {
|
|
6
|
+
const reason = `Automatic pull requests support GitHub only, not "${repoConfig.delivery.provider}". tspl can still print a compare link, but that cannot make a task ready.`
|
|
7
|
+
const refuse = async () => { throw new ProviderError('unsupported', reason) }
|
|
8
|
+
return { name: repoConfig.delivery.provider, supported: false, reason, authenticate: refuse, findPr: refuse, upsertDraft: refuse, requiredChecks: refuse, observePr: refuse, markReady: refuse }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const createProvider = (repoConfig, deps = {}) => (repoConfig.delivery.provider === 'github' ? createGitHubProvider(repoConfig, deps) : unsupported(repoConfig))
|
|
12
|
+
|
|
13
|
+
const same = (a, b) => a.name === b.name && (a.appId || null) === (b.appId || null)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The CI requirements for a repo: the project's configured checks plus whatever the provider reports. An empty set is
|
|
17
|
+
* never "no CI needed" unless the project confirmed that policy.
|
|
18
|
+
*/
|
|
19
|
+
const resolveRequirements = (repoConfig, discovered) => {
|
|
20
|
+
const delivery = repoConfig.delivery
|
|
21
|
+
if (delivery.ciPolicy === 'none' && delivery.noCiConfirmed === true) {
|
|
22
|
+
return { requirements: [], determined: true, confirmedNone: true, problems: [] }
|
|
23
|
+
}
|
|
24
|
+
const requirements = [...(delivery.requiredChecks || []).map(item => ({ name: item.name, appId: item.appId || null }))]
|
|
25
|
+
;(discovered.requirements || []).forEach(item => {
|
|
26
|
+
if (!requirements.some(existing => same(existing, item))) requirements.push(item)
|
|
27
|
+
})
|
|
28
|
+
const configured = delivery.requiredChecks || []
|
|
29
|
+
const determined = requirements.length > 0 && (Boolean(discovered.determined) || configured.length > 0)
|
|
30
|
+
const problems = []
|
|
31
|
+
if (!determined) {
|
|
32
|
+
problems.push(...(discovered.problems || []))
|
|
33
|
+
problems.push(
|
|
34
|
+
requirements.length
|
|
35
|
+
? 'GitHub would not show the branch requirements; add delivery.requiredChecks to .tspl/project.json.'
|
|
36
|
+
: discovered.determined
|
|
37
|
+
? 'No required CI checks are configured on the branch or in the project file. Add delivery.requiredChecks to .tspl/project.json, or confirm that no CI is required.'
|
|
38
|
+
: 'GitHub would not show the branch requirements and none are configured in the project file; add delivery.requiredChecks.',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return { requirements, determined, confirmedNone: false, problems }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FAILED = new Set(['failure', 'cancelled', 'timed_out', 'skipped', 'neutral', 'action_required', 'stale', 'startup_failure'])
|
|
45
|
+
|
|
46
|
+
/** One requirement against the provider's observations of the tested commit: success, pending or failed. */
|
|
47
|
+
const requirementState = (requirement, observations, headSha) => {
|
|
48
|
+
const matching = observations.filter(
|
|
49
|
+
item => item.name === requirement.name && (!requirement.appId || item.appId === requirement.appId) && item.headSha === headSha,
|
|
50
|
+
)
|
|
51
|
+
if (!matching.length) return { state: 'pending', reason: 'not reported yet' }
|
|
52
|
+
const failed = matching.find(item => item.status === 'completed' && FAILED.has(item.conclusion))
|
|
53
|
+
if (failed) return { state: 'failed', reason: `${failed.conclusion}${failed.url ? ` (${failed.url})` : ''}` }
|
|
54
|
+
if (matching.every(item => item.status === 'completed' && item.conclusion === 'success')) return { state: 'success' }
|
|
55
|
+
return { state: 'pending', reason: 'still running' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const evaluateCi = (requirements, observations, headSha) => {
|
|
59
|
+
const states = requirements.map(requirement => ({ requirement, ...requirementState(requirement, observations, headSha) }))
|
|
60
|
+
const state = states.some(item => item.state === 'failed') ? 'failed' : states.some(item => item.state === 'pending') || !states.length ? 'pending' : 'success'
|
|
61
|
+
return { state, states }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Polls for at most maxMs, then returns pending so the run can resume later. */
|
|
65
|
+
const pollCi = async ({ provider, number, requirements, headSha, maxMs = 30000, intervalMs = 5000 }, deps = {}) => {
|
|
66
|
+
const sleep = deps.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)))
|
|
67
|
+
const clock = deps.now || Date.now
|
|
68
|
+
const deadline = clock() + maxMs
|
|
69
|
+
for (;;) {
|
|
70
|
+
const pr = await provider.observePr(number)
|
|
71
|
+
const result = evaluateCi(requirements, pr.checks, headSha)
|
|
72
|
+
if (pr.headSha !== headSha) return { pr, ...result, state: 'stale' }
|
|
73
|
+
if (result.state !== 'pending' || clock() + intervalMs > deadline) return { pr, ...result }
|
|
74
|
+
await sleep(intervalMs)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
createProvider,
|
|
80
|
+
resolveRequirements,
|
|
81
|
+
evaluateCi,
|
|
82
|
+
pollCi,
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const MIN_NODE_MAJOR = 22
|
|
4
|
+
|
|
5
|
+
const nodeVersion = () => process.env.TSPL_TEST_NODE_VERSION || process.versions.node
|
|
6
|
+
|
|
7
|
+
const checkRuntime = (version = nodeVersion()) => {
|
|
8
|
+
const major = Number.parseInt(String(version).split('.')[0], 10)
|
|
9
|
+
if (!(major >= MIN_NODE_MAJOR)) {
|
|
10
|
+
return `tspl needs Node ${MIN_NODE_MAJOR} or newer, but this is Node ${version}. Install a newer Node (https://nodejs.org) and run tspl again.`
|
|
11
|
+
}
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
MIN_NODE_MAJOR,
|
|
17
|
+
checkRuntime,
|
|
18
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { ENGINE_STEPS } = require('./shared')
|
|
4
|
+
|
|
5
|
+
const { checkRuntime } = require('./runtime-check')
|
|
6
|
+
|
|
7
|
+
const REQUIRED_FAILURES = new Set(['failed', 'missing', 'manual'])
|
|
8
|
+
|
|
9
|
+
const step = (id, label, status, detail, extra = {}) => ({ id, label, status, detail, required: true, ...extra })
|
|
10
|
+
|
|
11
|
+
const classify = err => (err && err.statusCode === 0 ? 'unreachable' : err && err.statusCode === 401 ? 'expired' : 'error')
|
|
12
|
+
|
|
13
|
+
const checkApi = async (deps, options, steps, next) => {
|
|
14
|
+
const config = deps.config.read()
|
|
15
|
+
const credentials = deps.credentials.read()
|
|
16
|
+
let apiUrl = config.apiUrl
|
|
17
|
+
let loggedIn = false
|
|
18
|
+
let note = ''
|
|
19
|
+
|
|
20
|
+
if (credentials.accessToken) {
|
|
21
|
+
try {
|
|
22
|
+
const me = await deps.request('GET', '/api/v1/auth/me')
|
|
23
|
+
loggedIn = true
|
|
24
|
+
note = `${me.user.email} (${me.role}) in ${me.organization.name} at ${apiUrl}`
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const kind = classify(err)
|
|
27
|
+
if (kind === 'unreachable') {
|
|
28
|
+
steps.push(step('login', 'TSPL API login', 'failed', `${err.message}. Your saved login was kept; try again when the API is reachable.`))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
note = kind === 'expired' ? 'Your saved login has expired.' : err.message
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let change = Boolean(options.api && options.api.replace(/\/+$/, '') !== apiUrl)
|
|
36
|
+
if (loggedIn && !change && !options.yes) {
|
|
37
|
+
change = await deps.prompts.confirm({ message: `Logged in: ${note}. Change the API address or account?`, default: false })
|
|
38
|
+
}
|
|
39
|
+
if (loggedIn && !change) {
|
|
40
|
+
steps.push(step('login', 'TSPL API login', 'ok', note))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (options.yes && !options.api && !options.email) {
|
|
44
|
+
steps.push(step('login', 'TSPL API login', 'missing', note || 'Not logged in.'))
|
|
45
|
+
next.push('Log in: tspl login')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const wanted = options.api || (await deps.prompts.input({ message: 'TSPL API address:', default: apiUrl }))
|
|
50
|
+
const target = wanted.replace(/\/+$/, '')
|
|
51
|
+
if (deps.originOf(target) !== deps.originOf(apiUrl)) {
|
|
52
|
+
deps.credentials.clear()
|
|
53
|
+
}
|
|
54
|
+
deps.config.write({ ...config, apiUrl: target })
|
|
55
|
+
apiUrl = target
|
|
56
|
+
|
|
57
|
+
const email = options.email || (await deps.prompts.input({ message: 'Email:' }))
|
|
58
|
+
const secret = options.password || (await deps.prompts.password({ message: 'Password:', mask: '*' }))
|
|
59
|
+
try {
|
|
60
|
+
const result = await deps.login({ api: target, email, password: secret, org: options.org })
|
|
61
|
+
steps.push(step('login', 'TSPL API login', 'ok', `${result.user.email} (${result.role}) in ${result.organization.name} at ${apiUrl}`))
|
|
62
|
+
} catch (err) {
|
|
63
|
+
steps.push(step('login', 'TSPL API login', 'failed', `${err.message} (API ${apiUrl}). Run: tspl login`))
|
|
64
|
+
next.push('Log in: tspl login')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const chooseEngines = async (deps, options, steps) => {
|
|
69
|
+
const installed = deps.engines.detectAll()
|
|
70
|
+
const names = Object.keys(installed).filter(name => installed[name].installed)
|
|
71
|
+
if (!names.length) {
|
|
72
|
+
steps.push(step('engines', 'AI engine', 'missing', 'No AI engine is installed. Install one: npm install -g @anthropic-ai/claude-code (or codex, gemini).'))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const saved = deps.engines.read()
|
|
76
|
+
const keep = saved.assignments && Object.values(saved.assignments).every(name => names.includes(name))
|
|
77
|
+
let assignments = keep ? saved.assignments : null
|
|
78
|
+
let models = saved.models || {}
|
|
79
|
+
|
|
80
|
+
if (assignments && !options.yes) {
|
|
81
|
+
const change = await deps.prompts.confirm({ message: `Engines are set (${Object.entries(assignments).map(([k, v]) => `${k}: ${v}`).join(', ')}). Change them?`, default: false })
|
|
82
|
+
if (change) assignments = null
|
|
83
|
+
}
|
|
84
|
+
if (!assignments) {
|
|
85
|
+
const defaults = deps.engines.defaultAssignments(installed)
|
|
86
|
+
let main = defaults.build
|
|
87
|
+
if (!options.yes && names.length > 1) {
|
|
88
|
+
main = await deps.prompts.select({ message: 'Which AI engine should write code?', choices: names.map(name => ({ name, value: name })), default: main })
|
|
89
|
+
}
|
|
90
|
+
assignments = { plan: main, build: main, verify: main, bulk: main }
|
|
91
|
+
const others = names.filter(name => name !== main && name !== 'ollama')
|
|
92
|
+
if (!options.yes && others.length) {
|
|
93
|
+
const separate = await deps.prompts.confirm({ message: `Review the code with a different engine than ${main}? (otherwise ${main} reviews in a fresh session)`, default: false })
|
|
94
|
+
if (separate) assignments.verify = await deps.prompts.select({ message: 'Which engine should review?', choices: others.map(name => ({ name, value: name })) })
|
|
95
|
+
}
|
|
96
|
+
if (!options.yes && (await deps.prompts.confirm({ message: 'Choose the engine and model for each step yourself?', default: false }))) {
|
|
97
|
+
models = {}
|
|
98
|
+
for (const key of Object.keys(ENGINE_STEPS)) {
|
|
99
|
+
assignments[key] = await deps.prompts.select({ message: `Engine for ${key} (${ENGINE_STEPS[key]}):`, choices: names.map(name => ({ name, value: name })), default: assignments[key] })
|
|
100
|
+
const model = await deps.prompts.input({ message: `Model for ${key} (blank for the engine's default):` })
|
|
101
|
+
if (model.trim()) models[key] = model.trim()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
deps.engines.write({ ...saved, assignments, models })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const separate = assignments.build !== assignments.verify
|
|
108
|
+
const verifyNote = separate
|
|
109
|
+
? `review uses ${assignments.verify}, a different engine from ${assignments.build}`
|
|
110
|
+
: `review uses ${assignments.verify} in a fresh session; it is the same provider as the coding engine, not an independent one`
|
|
111
|
+
steps.push(step('engines', 'AI engines', 'ok', `code: ${assignments.build}; ${verifyNote}`))
|
|
112
|
+
return assignments
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const probeEngines = async (deps, options, assignments, steps) => {
|
|
116
|
+
if (!assignments || options.yes) return
|
|
117
|
+
const ok = await deps.prompts.confirm({ message: 'Send one tiny test prompt to each engine you use? This makes a real AI request.', default: false })
|
|
118
|
+
if (!ok) {
|
|
119
|
+
steps.push(step('probe', 'Engine check', 'skipped', 'Not run. Installed is not the same as logged in; run: tspl engines test', { required: false }))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const models = deps.engines.read().models || {}
|
|
123
|
+
const targets = new Map(Object.entries(assignments).map(([key, name]) => [name, models[key]]))
|
|
124
|
+
const failures = []
|
|
125
|
+
for (const [name, model] of targets) {
|
|
126
|
+
const result = await deps.engines.run(name, 'Reply with just the word OK.', { model, timeoutMs: 90000 })
|
|
127
|
+
if (!result.ok) failures.push(`${name}: ${result.limitHit ? 'usage limit reached' : result.error}`)
|
|
128
|
+
}
|
|
129
|
+
steps.push(failures.length ? step('probe', 'Engine check', 'failed', failures.join('; ')) : step('probe', 'Engine check', 'ok', 'Every engine answered.'))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const connectTools = async (deps, options, steps, next) => {
|
|
133
|
+
const tools = options.connect || (options.yes ? [] : await deps.prompts.checkbox({
|
|
134
|
+
message: 'Add tspl to which AI tools?',
|
|
135
|
+
choices: Object.entries(deps.tools).filter(([key]) => key !== 'agy').map(([value, name]) => ({ name, value })),
|
|
136
|
+
}))
|
|
137
|
+
if (!tools.length) {
|
|
138
|
+
steps.push(step('connect', 'AI tool integrations', 'skipped', 'None selected. Add one later: tspl connect <tool>', { required: false }))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
for (const tool of tools) {
|
|
142
|
+
const result = await deps.connect(tool, {})
|
|
143
|
+
if (result.error) steps.push(step(`connect:${tool}`, `Connect ${tool}`, 'failed', result.error))
|
|
144
|
+
else if (!result.configured) {
|
|
145
|
+
steps.push(step(`connect:${tool}`, `Connect ${tool}`, 'manual', 'Could not be configured automatically.'))
|
|
146
|
+
next.push(...result.manualSteps)
|
|
147
|
+
} else {
|
|
148
|
+
steps.push(step(`connect:${tool}`, `Connect ${tool}`, 'ok', 'Configured.'))
|
|
149
|
+
next.push(...result.manualSteps)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Configures this machine as a TSPL developer client. It never looks at projects: that is `tspl init`.
|
|
156
|
+
* Returns { ready, steps, next }; `ready` is false while any required step failed or needs a manual action.
|
|
157
|
+
*/
|
|
158
|
+
const configureMachine = async (options = {}, deps) => {
|
|
159
|
+
const steps = []
|
|
160
|
+
const next = []
|
|
161
|
+
|
|
162
|
+
const runtime = checkRuntime(deps.nodeVersion)
|
|
163
|
+
steps.push(runtime ? step('node', 'Node.js', 'failed', runtime) : step('node', 'Node.js', 'ok', `Node ${deps.nodeVersion || process.versions.node}`))
|
|
164
|
+
const git = deps.gitVersion()
|
|
165
|
+
steps.push(git ? step('git', 'Git', 'ok', git) : step('git', 'Git', 'failed', 'Git was not found. Install Git, then run tspl setup again.'))
|
|
166
|
+
|
|
167
|
+
await checkApi(deps, options, steps, next)
|
|
168
|
+
const assignments = await chooseEngines(deps, options, steps)
|
|
169
|
+
await probeEngines(deps, options, assignments, steps)
|
|
170
|
+
await connectTools(deps, options, steps, next)
|
|
171
|
+
|
|
172
|
+
const ready = !steps.some(item => item.required && REQUIRED_FAILURES.has(item.status))
|
|
173
|
+
if (ready) next.unshift('Run tspl init inside a project folder.')
|
|
174
|
+
else next.push('Fix the items above and run tspl setup again.')
|
|
175
|
+
return { ready, steps, next }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
configureMachine,
|
|
180
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { hashValue } = require('./workflow')
|
|
4
|
+
const string = (value, name, max) => {
|
|
5
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) throw new Error(`${name} must be nonempty and at most ${max} characters`)
|
|
6
|
+
return value.trim()
|
|
7
|
+
}
|
|
8
|
+
const strings = (value, name, maxItems, maxLength, min = 1) => {
|
|
9
|
+
if (!Array.isArray(value) || value.length < min || value.length > maxItems) throw new Error(`${name} must contain ${min}-${maxItems} items`)
|
|
10
|
+
return value.map(item => string(item, name, maxLength))
|
|
11
|
+
}
|
|
12
|
+
const validateApproachProposal = value => {
|
|
13
|
+
if (!value || !Array.isArray(value.options) || value.options.length < 2 || value.options.length > 3) throw new Error('Offer two or three implementation approaches')
|
|
14
|
+
const options = value.options.map(item => ({
|
|
15
|
+
id: string(item.id, 'Option ID', 60), title: string(item.title, 'Title', 200),
|
|
16
|
+
summary: string(item.summary, 'Summary', 2000), benefits: strings(item.benefits, 'Benefits', 10, 1000),
|
|
17
|
+
drawbacks: strings(item.drawbacks, 'Drawbacks', 10, 1000), files: strings(item.files || [], 'Files', 100, 1000, 0),
|
|
18
|
+
}))
|
|
19
|
+
const ids = new Set(options.map(item => item.id))
|
|
20
|
+
if (ids.size !== options.length) throw new Error('Option IDs must be unique')
|
|
21
|
+
if (!ids.has(value.recommendedId)) throw new Error('Recommend exactly one of the offered options')
|
|
22
|
+
const notes = value.codeNotes || []
|
|
23
|
+
if (!Array.isArray(notes) || notes.length > 12) throw new Error('Code notes must contain at most 12 items')
|
|
24
|
+
const codeNotes = notes.map(item => ({ file: string(item && item.file, 'Code note file', 1000), lines: item.lines ? string(item.lines, 'Code note lines', 40) : null, note: string(item.note, 'Code note', 600) }))
|
|
25
|
+
return { options, recommendedId: value.recommendedId, reason: string(value.reason, 'Recommendation reason', 2000), ...(codeNotes.length ? { codeNotes } : {}) }
|
|
26
|
+
}
|
|
27
|
+
const approachBasis = card => {
|
|
28
|
+
const value = card || {}
|
|
29
|
+
const doneWhen = value.doneWhen || []
|
|
30
|
+
return hashValue({ ...value, apps: value.apps || [], doneWhen, testCases: doneWhen.map((text, i) => {
|
|
31
|
+
const item = (value.testCases || [])[i] || {}
|
|
32
|
+
return { steps: item.steps || [], expected: item.expected || text }
|
|
33
|
+
}) })
|
|
34
|
+
}
|
|
35
|
+
const bindApproach = (approaches, card, reference) => {
|
|
36
|
+
if (!approaches || !approaches.selection) throw new Error('Choose an implementation approach before submitting a plan')
|
|
37
|
+
if (approaches.basis !== approachBasis(card)) throw new Error('Requirements changed; propose approaches again and ask the user to choose')
|
|
38
|
+
const selection = approaches.selection
|
|
39
|
+
if (!reference || reference.version !== approaches.version || reference.optionId !== selection.optionId || selection.version !== approaches.version) throw new Error('The approach selection is stale or does not match this plan; refresh the task')
|
|
40
|
+
const option = approaches.options.find(item => item.id === selection.optionId)
|
|
41
|
+
if (!option) throw new Error('Selected approach not found')
|
|
42
|
+
return { version: approaches.version, optionId: option.id, option, recommendedId: approaches.recommendedId, reason: approaches.reason, selection, basis: approaches.basis }
|
|
43
|
+
}
|
|
44
|
+
module.exports = { validateApproachProposal, approachBasis, bindApproach }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const ROLES = {
|
|
4
|
+
OWNER: 'owner',
|
|
5
|
+
ADMIN: 'admin',
|
|
6
|
+
PM: 'pm',
|
|
7
|
+
DEVELOPER: 'developer',
|
|
8
|
+
VIEWER: 'viewer',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PROJECT_MANAGER_ROLES = [ROLES.OWNER, ROLES.ADMIN, ROLES.PM]
|
|
12
|
+
|
|
13
|
+
const ORG_WIDE_ROLES = [ROLES.OWNER, ROLES.ADMIN]
|
|
14
|
+
|
|
15
|
+
const PROJECT_CONTRIBUTOR_ROLES = [
|
|
16
|
+
ROLES.OWNER,
|
|
17
|
+
ROLES.ADMIN,
|
|
18
|
+
ROLES.PM,
|
|
19
|
+
ROLES.DEVELOPER,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const ENGINE_STEPS = {
|
|
23
|
+
plan: 'questions and plans',
|
|
24
|
+
build: 'writing code',
|
|
25
|
+
verify: 'checking the work (should differ from build)',
|
|
26
|
+
bulk: 'bulk reading during analysis',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CLIENT_TYPES = {
|
|
30
|
+
CLI: 'cli',
|
|
31
|
+
WEB: 'web',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const USER_STATUS = {
|
|
35
|
+
ACTIVE: 'active',
|
|
36
|
+
DISABLED: 'disabled',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MEMBERSHIP_STATUS = {
|
|
40
|
+
INVITED: 'invited',
|
|
41
|
+
ACTIVE: 'active',
|
|
42
|
+
REMOVED: 'removed',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ORG_STATUS = {
|
|
46
|
+
ACTIVE: 'active',
|
|
47
|
+
SUSPENDED: 'suspended',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PROJECT_STATUS = {
|
|
51
|
+
ACTIVE: 'active',
|
|
52
|
+
ARCHIVED: 'archived',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TASK_STATUS = {
|
|
56
|
+
DRAFT: 'draft',
|
|
57
|
+
PLAN_REVIEW: 'plan-review',
|
|
58
|
+
PM_REVIEW: 'pm-review',
|
|
59
|
+
APPROVED: 'approved',
|
|
60
|
+
CHANGES_REQUESTED: 'changes-requested',
|
|
61
|
+
BUILDING: 'building',
|
|
62
|
+
DONE: 'done',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const APPROVAL_STAGES = {
|
|
66
|
+
DEVELOPER: 'developer',
|
|
67
|
+
PM: 'pm',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const USAGE_STEPS = ['intake', 'plan', 'build', 'verify', 'other']
|
|
71
|
+
|
|
72
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(spec|test)\.[^/]+$/
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
ROLES,
|
|
76
|
+
PROJECT_MANAGER_ROLES,
|
|
77
|
+
ORG_WIDE_ROLES,
|
|
78
|
+
PROJECT_CONTRIBUTOR_ROLES,
|
|
79
|
+
ENGINE_STEPS,
|
|
80
|
+
CLIENT_TYPES,
|
|
81
|
+
USER_STATUS,
|
|
82
|
+
MEMBERSHIP_STATUS,
|
|
83
|
+
ORG_STATUS,
|
|
84
|
+
PROJECT_STATUS,
|
|
85
|
+
TASK_STATUS,
|
|
86
|
+
APPROVAL_STAGES,
|
|
87
|
+
USAGE_STEPS,
|
|
88
|
+
TEST_FILE,
|
|
89
|
+
}
|