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,67 @@
1
+ 'use strict'
2
+ const { completionProblems } = require('./workflow')
3
+ const STAGES = ['implementing', 'testing', 'reviewing', 'awaiting-delivery', 'creating-pr', 'awaiting-ci']
4
+ const LABELS = { implementing: 'Implementation', testing: 'Tests', reviewing: 'Review', 'awaiting-delivery': 'Push confirmation', 'creating-pr': 'Pull request', 'awaiting-ci': 'CI' }
5
+ const effectiveStage = workflow => {
6
+ const stage = workflow.stage === 'blocked' ? workflow.resumeStage || 'implementing' : workflow.stage
7
+ return stage === 'repairing' ? 'testing' : stage
8
+ }
9
+ const workflowProgress = (task, at = new Date()) => {
10
+ const wf = task.workflow
11
+ if (!wf) return null
12
+ const current = effectiveStage(wf), position = wf.stage === 'ready' ? STAGES.length : STAGES.indexOf(current)
13
+ const options = task.plan?.workflow?.options || {}
14
+ const steps = STAGES.map((stage, i) => {
15
+ const saved = wf.stageResults?.[stage] || {}
16
+ let status = i < position ? 'done' : i === position ? 'running' : 'pending'
17
+ let reason = status === 'done' ? `${LABELS[stage]} completed; workflow advanced.` : status === 'pending' ? `Waiting for ${LABELS[STAGES[i - 1]] || 'plan approval'} to complete.` : `${LABELS[stage]} is running.`
18
+ if (stage === 'reviewing' && options.review === false || ['awaiting-delivery', 'creating-pr', 'awaiting-ci'].includes(stage) && options.pullRequest === false) { status = 'skipped'; reason = 'Not required by this task’s approved workflow.' }
19
+ else if (i === position) {
20
+ if (wf.stage === 'blocked') { status = 'failed'; reason = wf.blocker?.message || wf.blocker?.reason || 'The runner stopped without a detailed error.' }
21
+ else if (stage === 'awaiting-delivery') { status = 'waiting'; reason = 'A person must confirm pushing the branch and opening the pull request.' }
22
+ else if (stage === 'awaiting-ci') { status = 'waiting'; reason = 'Waiting for required CI checks; rerun to poll their results.' }
23
+ else if (stage === 'reviewing' && wf.review?.verdict === 'passed') { status = 'waiting'; reason = 'Review passed; complete the outstanding acceptance checks before delivery.' }
24
+ else if (!wf.leaseExpiresAt || new Date(wf.leaseExpiresAt).getTime() <= new Date(at).getTime()) { status = 'interrupted'; reason = 'No active runner heartbeat. The process exited or its lease expired; resume this stage.' }
25
+ else if (wf.stage === 'repairing') reason = `Repairing failures (repair rounds used: ${wf.attempts || 0}). ${saved.reason || ''}`
26
+ }
27
+ if (status === 'pending' && saved.status === 'failed') { status = 'failed'; reason = saved.reason || `${LABELS[stage]} failed; waiting for the repair and recheck.` }
28
+ if (stage === 'testing' && status === 'done') reason = `${(wf.evidence || []).filter(item => item.status === 'passed').length} checks passed on the recorded commits.`
29
+ if (stage === 'testing' && status === 'done') {
30
+ const problems = completionProblems({ plan: task.plan, workflow: wf, upTo: 'review' }).filter(problem =>
31
+ problem !== 'There is no separate review result.' && !/^The (separate review|review head)/.test(problem) && !/^Acceptance criterion/.test(problem))
32
+ if (problems.length) { status = 'failed'; reason = problems.join(' ') }
33
+ }
34
+ if (stage === 'reviewing' && status === 'done' && (!wf.review || wf.review.verdict !== 'passed' || !(wf.heads || []).every(head => (wf.review.heads || []).some(item => item.name === head.name && item.headSha === head.headSha)))) {
35
+ status = 'failed'; reason = 'A passing separate review for the current commits is missing.'
36
+ }
37
+ if (stage === 'reviewing' && status === 'done' && wf.review) reason = `Separate review ${wf.review.verdict}${wf.review.engine ? ` (${wf.review.engine})` : ''}.`
38
+ if (status === 'running' && saved.reason && wf.stage !== 'repairing') reason = saved.reason
39
+ return { stage, label: LABELS[stage], status, reason, startedAt: saved.startedAt || null, finishedAt: saved.finishedAt || null, command: stage === 'awaiting-delivery' ? `tspl task run ${task.key} --stage awaiting-delivery --confirm-delivery` : `tspl task run ${task.key} --stage ${stage}` }
40
+ })
41
+ return { stage: current, steps, current: steps.find(step => step.stage === current) || null }
42
+ }
43
+ /** Retry real work only; forward jumps must satisfy the same evidence gates as normal progression. */
44
+ const retryWorkflow = (workflow, stage, plan) => {
45
+ if (!STAGES.includes(stage)) throw new Error(`Unknown retry stage: ${stage}`)
46
+ const current = effectiveStage(workflow)
47
+ if (workflow.stage === 'ready') throw new Error('This task is already ready; start a new task for further changes')
48
+ if (stage === 'implementing' && current !== 'implementing') throw new Error('Implementation has already completed; amend the plan for further code changes')
49
+ if (STAGES.indexOf(stage) > STAGES.indexOf(current)) {
50
+ const manualImplementation = current === 'implementing' && stage === 'testing' && workflow.heads?.some(head => head.baseSha && head.baseSha !== head.headSha) && workflow.heads.every(head => head.clean)
51
+ if (!manualImplementation) throw new Error(`Complete ${current} before retrying ${stage}; prerequisites cannot be skipped`)
52
+ }
53
+ if (stage === 'reviewing') {
54
+ const problems = completionProblems({ plan, workflow: { ...workflow, review: null }, upTo: 'review' }).filter(problem => problem !== 'There is no separate review result.' && !/^Acceptance criterion/.test(problem))
55
+ if (problems.length) throw new Error(`Run checks before review: ${problems.join(' ')}`)
56
+ }
57
+ if (['awaiting-delivery', 'creating-pr', 'awaiting-ci'].includes(stage)) {
58
+ const problems = completionProblems({ plan, workflow, upTo: 'review' })
59
+ if (problems.length) throw new Error(`Complete the required checks before delivery: ${problems.join(' ')}`)
60
+ }
61
+ const next = { ...workflow, stage, blocker: null, resumeStage: null }
62
+ if (stage === 'testing') { next.evidence = []; next.review = null; next.manualVerifications = [] }
63
+ if (stage === 'reviewing') next.review = null
64
+ if (stage === 'creating-pr' && !workflow.deliveryAuthorization) throw new Error('Confirm delivery before retrying pull request creation')
65
+ return next
66
+ }
67
+ module.exports = { workflowProgress, retryWorkflow, RETRY_STAGES: STAGES }
@@ -0,0 +1,381 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+
5
+ const SCHEMA_VERSION = 1
6
+
7
+ const CHECK_CATEGORIES = ['unit', 'integration', 'e2e', 'lint', 'typecheck', 'build']
8
+
9
+ const TEST_CATEGORIES = ['unit', 'integration', 'e2e']
10
+
11
+ const CHECK_PARSERS = ['node-tap', 'jest-json', 'playwright-json', 'custom', 'exit-code']
12
+
13
+ const CI_POLICIES = ['discover', 'explicit', 'none']
14
+
15
+ const WORKFLOW_STAGES = [
16
+ 'planning',
17
+ 'awaiting-approval',
18
+ 'implementing',
19
+ 'testing',
20
+ 'repairing',
21
+ 'reviewing',
22
+ 'awaiting-delivery',
23
+ 'creating-pr',
24
+ 'awaiting-ci',
25
+ 'ready',
26
+ 'blocked',
27
+ ]
28
+
29
+ /** Where each stage may go next. A blocked workflow resumes only at the stage it was blocked in. */
30
+ const STAGE_TRANSITIONS = {
31
+ planning: ['awaiting-approval', 'blocked'],
32
+ 'awaiting-approval': ['implementing', 'blocked'],
33
+ implementing: ['testing', 'blocked'],
34
+ testing: ['reviewing', 'repairing', 'blocked'],
35
+ repairing: ['testing', 'blocked'],
36
+ reviewing: ['awaiting-delivery', 'repairing', 'ready', 'blocked'],
37
+ 'awaiting-delivery': ['creating-pr', 'testing', 'blocked'],
38
+ 'creating-pr': ['awaiting-ci', 'testing', 'blocked'],
39
+ 'awaiting-ci': ['ready', 'creating-pr', 'testing', 'blocked'],
40
+ ready: [],
41
+ blocked: [],
42
+ }
43
+
44
+ const BLOCKER_REASONS = ['stopped', 'tests-failed', 'review-failed', 'repairs-exhausted', 'out-of-plan', 'lease-lost', 'provider', 'ci-failed', 'other']
45
+
46
+ /** Steps a task can switch off when it is created. Approvals can only be switched off by an owner, admin or PM. */
47
+ const TASK_OPTIONS = {
48
+ scope: true,
49
+ planApproval: true,
50
+ pmApproval: true,
51
+ review: true,
52
+ pullRequest: true,
53
+ }
54
+
55
+ const APPROVAL_OPTIONS = ['scope', 'planApproval', 'pmApproval']
56
+
57
+ const taskOptions = value => Object.fromEntries(Object.entries(TASK_OPTIONS).map(([key, fallback]) => [key, value && typeof value[key] === 'boolean' ? value[key] : fallback]))
58
+
59
+ const EVIDENCE_STATUS = ['passed', 'failed', 'skipped', 'missing', 'cancelled', 'timed-out', 'stale']
60
+
61
+ const WORKFLOW_DEFAULTS = { maxRepairRounds: 2, engineTimeoutMs: 120000, checkTimeoutMs: 600000 }
62
+
63
+ const canonical = value => {
64
+ if (Array.isArray(value)) return value.map(canonical)
65
+ if (value && typeof value === 'object') {
66
+ return Object.fromEntries(
67
+ Object.keys(value)
68
+ .sort()
69
+ .filter(key => value[key] !== undefined)
70
+ .map(key => [key, canonical(value[key])]),
71
+ )
72
+ }
73
+ return value
74
+ }
75
+
76
+ const hashValue = value => Crypto.createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
77
+
78
+ const checkHash = check =>
79
+ hashValue({
80
+ repo: check.repo,
81
+ cwd: check.cwd,
82
+ category: check.category,
83
+ command: check.command,
84
+ parser: check.parser,
85
+ timeoutMs: check.timeoutMs,
86
+ })
87
+
88
+ const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
89
+
90
+ const text = value => typeof value === 'string' && value.trim() !== ''
91
+
92
+ const relativeInside = value => {
93
+ if (!text(value) || value.includes('\0') || value.startsWith('/') || /^[a-z]:[\\/]/i.test(value) || value.startsWith('~')) return false
94
+ return !value.split(/[\\/]/).includes('..')
95
+ }
96
+
97
+ const validateRepo = (repo, index) => {
98
+ const at = `repos[${index}]`
99
+ if (!isObject(repo)) throw new Error(`${at} must be an object`)
100
+ ;['id', 'name'].forEach(field => {
101
+ if (!text(repo[field])) throw new Error(`${at}.${field} is required`)
102
+ })
103
+ if (!repo.fingerprint && !repo.remoteHash) throw new Error(`${at} needs a fingerprint or remoteHash`)
104
+ if (!relativeInside(repo.path)) throw new Error(`${at}.path must be relative to the workspace and stay inside it`)
105
+ const delivery = repo.delivery
106
+ if (!isObject(delivery)) throw new Error(`${at}.delivery is required`)
107
+ if (!text(delivery.provider)) throw new Error(`${at}.delivery.provider is required`)
108
+ if (!text(delivery.repository)) throw new Error(`${at}.delivery.repository is required`)
109
+ if (!CI_POLICIES.includes(delivery.ciPolicy)) throw new Error(`${at}.delivery.ciPolicy must be one of ${CI_POLICIES.join(', ')}`)
110
+ const required = delivery.requiredChecks === undefined ? [] : delivery.requiredChecks
111
+ if (!Array.isArray(required) || required.some(item => !isObject(item) || !text(item.name))) {
112
+ throw new Error(`${at}.delivery.requiredChecks must be a list of { name, appId? }`)
113
+ }
114
+ if (delivery.ciPolicy === 'explicit' && !required.length) throw new Error(`${at}: an explicit CI policy needs at least one required check`)
115
+ if (delivery.ciPolicy === 'none' && delivery.noCiConfirmed !== true) {
116
+ throw new Error(`${at}: a no-CI policy must be confirmed (noCiConfirmed: true) in the project file`)
117
+ }
118
+ return { ...repo, delivery: { ...delivery, requiredChecks: required } }
119
+ }
120
+
121
+ const validateCheck = (check, repoNames, at = 'check') => {
122
+ if (!isObject(check)) throw new Error(`${at} must be an object`)
123
+ if (!text(check.id)) throw new Error(`${at}.id is required`)
124
+ const label = `check ${check.id}`
125
+ if (!repoNames.includes(check.repo)) throw new Error(`${label}: repo "${check.repo}" is not one of the project repos`)
126
+ if (!relativeInside(check.cwd)) throw new Error(`${label}: cwd must be an explicit relative path inside the repo`)
127
+ if (!CHECK_CATEGORIES.includes(check.category)) throw new Error(`${label}: category must be one of ${CHECK_CATEGORIES.join(', ')}`)
128
+ if (!text(check.command)) throw new Error(`${label}: command is required`)
129
+ if (!Number.isInteger(check.timeoutMs) || check.timeoutMs <= 0) throw new Error(`${label}: timeoutMs must be a positive integer`)
130
+ if (typeof check.required !== 'boolean') throw new Error(`${label}: required must be true or false`)
131
+ if (!CHECK_PARSERS.includes(check.parser)) throw new Error(`${label}: parser must be one of ${CHECK_PARSERS.join(', ')}`)
132
+ const isTest = TEST_CATEGORIES.includes(check.category)
133
+ if (check.parser === 'exit-code' && isTest) {
134
+ throw new Error(`${label}: exit-code is only allowed for non-test checks; test checks need a result parser or an attested custom command`)
135
+ }
136
+ if (check.parser === 'custom' && isTest && check.attested !== true) {
137
+ throw new Error(`${label}: a custom test command must be attested (attested: true) because tspl cannot count its results`)
138
+ }
139
+ if (check.artifacts !== undefined && check.artifacts !== null && !relativeInside(check.artifacts)) {
140
+ throw new Error(`${label}: artifacts must be a folder inside the check's working directory`)
141
+ }
142
+ const criteria = check.criteria === undefined ? [] : check.criteria
143
+ if (!Array.isArray(criteria) || criteria.some(n => !Number.isInteger(n) || n < 1)) {
144
+ throw new Error(`${label}: criteria must be a list of done-when numbers`)
145
+ }
146
+ return { ...check, attested: check.attested === true, criteria }
147
+ }
148
+
149
+ const validateProjectConfig = value => {
150
+ if (!isObject(value)) throw new Error('Project config must be an object')
151
+ if (value.schemaVersion === undefined) throw new Error('Project config has no schemaVersion')
152
+ if (value.schemaVersion > SCHEMA_VERSION) {
153
+ throw new Error(`Project config schemaVersion ${value.schemaVersion} is newer than this tspl understands (${SCHEMA_VERSION}). Update tspl.`)
154
+ }
155
+ if (value.schemaVersion !== SCHEMA_VERSION) throw new Error(`Unsupported project config schemaVersion ${value.schemaVersion}`)
156
+ if (!text(value.organization)) throw new Error('Project config needs an organization')
157
+ if (!isObject(value.project) || !text(value.project.id) || !text(value.project.slug)) {
158
+ throw new Error('Project config needs project.id and project.slug')
159
+ }
160
+ if (!Array.isArray(value.repos) || !value.repos.length) throw new Error('Project config needs at least one repo')
161
+ const repos = value.repos.map(validateRepo)
162
+ ;['id', 'name', 'path'].forEach(field => {
163
+ const seen = new Set()
164
+ repos.forEach(repo => {
165
+ if (seen.has(repo[field])) throw new Error(`Duplicate repo ${field} "${repo[field]}"`)
166
+ seen.add(repo[field])
167
+ })
168
+ })
169
+ const names = repos.map(repo => repo.name)
170
+ const rawChecks = value.checks === undefined ? [] : value.checks
171
+ if (!Array.isArray(rawChecks)) throw new Error('checks must be a list')
172
+ const checks = rawChecks.map((check, i) => validateCheck(check, names, `checks[${i}]`))
173
+ const ids = new Set()
174
+ checks.forEach(check => {
175
+ if (ids.has(check.id)) throw new Error(`Duplicate check id "${check.id}"`)
176
+ ids.add(check.id)
177
+ })
178
+ const workflow = { ...WORKFLOW_DEFAULTS, ...(value.workflow || {}) }
179
+ ;['maxRepairRounds', 'engineTimeoutMs', 'checkTimeoutMs'].forEach(field => {
180
+ if (!Number.isInteger(workflow[field]) || workflow[field] < 0) throw new Error(`workflow.${field} must be a non-negative integer`)
181
+ })
182
+ const instructions = value.instructions === undefined ? [] : value.instructions
183
+ if (!Array.isArray(instructions) || instructions.some(file => !relativeInside(file))) {
184
+ throw new Error('instructions must be a list of relative paths inside the project')
185
+ }
186
+ if (value.home !== undefined && (!relativeInside(value.home) || /[\\/]/.test(value.home))) {
187
+ throw new Error('home must be the name of the repository (inside the workspace folder) that holds the .tspl files')
188
+ }
189
+ return {
190
+ schemaVersion: SCHEMA_VERSION,
191
+ ...(value.home ? { home: value.home } : {}),
192
+ organization: value.organization,
193
+ project: { id: value.project.id, slug: value.project.slug },
194
+ repos,
195
+ checks,
196
+ workflow,
197
+ instructions,
198
+ }
199
+ }
200
+
201
+ /** Shape check for a plan's frozen snapshot; the API refuses a plan whose snapshot is incomplete. */
202
+ const validateSnapshot = snapshot => {
203
+ if (!isObject(snapshot) || snapshot.version !== 1) throw new Error('The workflow snapshot must be an object with version 1')
204
+ if (!text(snapshot.configHash) || !text(snapshot.instructionsHash)) throw new Error('The workflow snapshot needs configHash and instructionsHash')
205
+ if (!Array.isArray(snapshot.repos) || !snapshot.repos.length) throw new Error('The workflow snapshot needs the project repos')
206
+ const repos = snapshot.repos.map(validateRepo)
207
+ const names = repos.map(repo => repo.name)
208
+ if (!Array.isArray(snapshot.checks)) throw new Error('The workflow snapshot needs a list of checks')
209
+ const checks = snapshot.checks.map((check, i) => validateCheck(check, names, `checks[${i}]`))
210
+ const ids = new Set()
211
+ checks.forEach(check => {
212
+ if (ids.has(check.id)) throw new Error(`Duplicate check id "${check.id}"`)
213
+ ids.add(check.id)
214
+ })
215
+ if (!checks.some(check => check.required && TEST_CATEGORIES.includes(check.category))) {
216
+ throw new Error('The project has no required test check; add one to .tspl/project.json before planning a tested pull request')
217
+ }
218
+ const acceptance = snapshot.acceptance === undefined ? 0 : snapshot.acceptance
219
+ const manualCriteria = snapshot.manualCriteria === undefined ? [] : snapshot.manualCriteria
220
+ if (!Number.isInteger(acceptance) || acceptance < 0) throw new Error('acceptance must be a count')
221
+ if (!Array.isArray(manualCriteria) || manualCriteria.some(n => !Number.isInteger(n) || n < 1 || n > acceptance)) {
222
+ throw new Error('manualCriteria must be done-when numbers within the acceptance count')
223
+ }
224
+ const options = taskOptions(snapshot.options)
225
+ const maxRepairRounds = snapshot.maxRepairRounds === undefined ? WORKFLOW_DEFAULTS.maxRepairRounds : snapshot.maxRepairRounds
226
+ if (!Number.isInteger(maxRepairRounds) || maxRepairRounds < 0) throw new Error('maxRepairRounds must be a non-negative integer')
227
+ return { ...snapshot, repos, checks, acceptance, manualCriteria, maxRepairRounds, options }
228
+ }
229
+
230
+ const isTestCheck = check => TEST_CATEGORIES.includes(check.category)
231
+
232
+ const requirementMet = (requirement, observations, headSha) =>
233
+ observations.some(
234
+ observation =>
235
+ observation.name === requirement.name &&
236
+ (requirement.appId === undefined || requirement.appId === null || observation.appId === requirement.appId) &&
237
+ observation.headSha === headSha &&
238
+ observation.status === 'completed' &&
239
+ observation.conclusion === 'success',
240
+ )
241
+
242
+ const sameHeads = (recorded, heads) =>
243
+ Array.isArray(recorded) &&
244
+ heads.every(head => recorded.some(item => item.name === head.name && item.headSha === head.headSha))
245
+
246
+ const evidenceProblems = (snapshot, workflow, headOf) => {
247
+ const problems = []
248
+ const evidence = Array.isArray(workflow.evidence) ? workflow.evidence : []
249
+ snapshot.checks
250
+ .filter(check => check.required)
251
+ .forEach(check => {
252
+ const found = evidence.filter(item => item.checkId === check.id)
253
+ if (!found.length) {
254
+ problems.push(`Required check ${check.id} is missing: it has no executed result.`)
255
+ return
256
+ }
257
+ if (found.length > 1) {
258
+ problems.push(`Required check ${check.id} has duplicate results.`)
259
+ return
260
+ }
261
+ const [item] = found
262
+ const head = headOf(check.repo)
263
+ if (!head) problems.push(`Required check ${check.id}: no recorded head for repo ${check.repo}.`)
264
+ else if (item.headSha !== head.headSha) problems.push(`Check ${check.id} ran on head ${item.headSha}, not the current head ${head.headSha} of ${check.repo}.`)
265
+ if (item.configHash !== snapshot.configHash) problems.push(`Check ${check.id} ran under a different approved config.`)
266
+ if (item.commandHash !== checkHash(check)) problems.push(`Check ${check.id} ran a different command than the approved one.`)
267
+ if (item.executed !== true) problems.push(`Check ${check.id} was not executed.`)
268
+ if (item.status !== 'passed') problems.push(`Check ${check.id} did not pass (status ${item.status}).`)
269
+ if (isTestCheck(check) && check.parser !== 'custom' && !(item.testCount > 0)) {
270
+ problems.push(`Check ${check.id} ran zero tests; a test count above zero is required.`)
271
+ }
272
+ })
273
+ const known = new Set(snapshot.checks.map(check => check.id))
274
+ evidence.filter(item => !known.has(item.checkId)).forEach(item => problems.push(`Result for unknown check ${item.checkId}.`))
275
+ return problems
276
+ }
277
+
278
+ const criteriaProblems = (snapshot, workflow, heads) => {
279
+ const problems = []
280
+ const covered = new Set(snapshot.checks.filter(check => check.required).flatMap(check => check.criteria || []))
281
+ const manual = new Set(snapshot.manualCriteria || [])
282
+ const verifications = Array.isArray(workflow.manualVerifications) ? workflow.manualVerifications : []
283
+ for (let n = 1; n <= (snapshot.acceptance || 0); n++) {
284
+ if (covered.has(n)) continue
285
+ if (manual.has(n) && verifications.some(item => item.criterion === n && item.userId && sameHeads(item.heads, heads))) continue
286
+ problems.push(`Acceptance criterion ${n} has no passing check and no human verification on the tested commits.`)
287
+ }
288
+ return problems
289
+ }
290
+
291
+ const deliveryProblems = (snapshot, workflow, heads) => {
292
+ const problems = []
293
+ const prs = Array.isArray(workflow.prs) ? workflow.prs : []
294
+ heads
295
+ .filter(head => head.baseSha !== head.headSha)
296
+ .forEach(head => {
297
+ const repo = snapshot.repos.find(item => item.name === head.name)
298
+ const found = prs.filter(item => item.repo === head.name)
299
+ if (!found.length) {
300
+ problems.push(`There is no pull request for ${head.name}.`)
301
+ return
302
+ }
303
+ if (found.length > 1) {
304
+ problems.push(`There are several pull request records for ${head.name}.`)
305
+ return
306
+ }
307
+ const [pr] = found
308
+ if (pr.headSha !== head.headSha) problems.push(`The pull request head ${pr.headSha} for ${head.name} is not the tested head ${head.headSha}.`)
309
+ if (pr.draft) problems.push(`The pull request for ${head.name} is still a draft.`)
310
+ if (pr.state !== 'open') problems.push(`The pull request for ${head.name} is ${pr.state}, not open.`)
311
+ const delivery = repo && repo.delivery
312
+ if (delivery && delivery.ciPolicy === 'none' && delivery.noCiConfirmed === true) return
313
+ const required = Array.isArray(pr.requiredChecks) ? pr.requiredChecks : []
314
+ if (!required.length) {
315
+ problems.push(`The required CI checks for ${head.name} were not determined; an empty set is not success.`)
316
+ return
317
+ }
318
+ const configured = (delivery && delivery.requiredChecks) || []
319
+ configured
320
+ .filter(item => !required.some(r => r.name === item.name && (item.appId === undefined || r.appId === item.appId)))
321
+ .forEach(item => problems.push(`Configured required CI check ${item.name} is missing from the pull request for ${head.name}.`))
322
+ required
323
+ .filter(item => !requirementMet(item, pr.checks || [], head.headSha))
324
+ .forEach(item => problems.push(`Required CI check ${item.name} has not succeeded on ${head.headSha} for ${head.name}.`))
325
+ })
326
+ return problems
327
+ }
328
+
329
+ /** upTo: 'review' skips the pull request conditions (used before delivery starts). */
330
+ const completionProblems = ({ plan, workflow, upTo = 'ready' }) => {
331
+ const snapshot = plan && plan.workflow
332
+ if (!isObject(snapshot) || !Array.isArray(snapshot.checks)) return ['The approved plan has no workflow snapshot.']
333
+ if (!isObject(workflow)) return ['The task has no workflow record.']
334
+ const problems = []
335
+ if (workflow.configHash !== snapshot.configHash) problems.push('The workflow config does not match the approved config hash.')
336
+ const heads = Array.isArray(workflow.heads) ? workflow.heads : []
337
+ if (!heads.length) {
338
+ problems.push('There are no recorded heads to test.')
339
+ return problems
340
+ }
341
+ heads.filter(head => head.clean !== true).forEach(head => problems.push(`${head.name} has uncommitted changes; checks must run on a clean committed branch.`))
342
+ const headOf = name => heads.find(head => head.name === name)
343
+ problems.push(...evidenceProblems(snapshot, workflow, headOf))
344
+ problems.push(...criteriaProblems(snapshot, workflow, heads))
345
+ const options = taskOptions(snapshot.options)
346
+ const review = workflow.review
347
+ if (!options.review) {
348
+ // the task was created without a separate review
349
+ } else if (!isObject(review)) problems.push('There is no separate review result.')
350
+ else {
351
+ if (review.verdict !== 'passed') problems.push('The separate review did not pass.')
352
+ heads.forEach(head => {
353
+ const seen = (review.heads || []).find(item => item.name === head.name)
354
+ if (!seen || seen.headSha !== head.headSha) problems.push(`The review head for ${head.name} (${seen ? seen.headSha : 'none'}) is not the tested head ${head.headSha}.`)
355
+ })
356
+ }
357
+ if (upTo === 'ready' && options.pullRequest) problems.push(...deliveryProblems(snapshot, workflow, heads))
358
+ return problems
359
+ }
360
+
361
+ module.exports = {
362
+ SCHEMA_VERSION,
363
+ CHECK_CATEGORIES,
364
+ TEST_CATEGORIES,
365
+ CHECK_PARSERS,
366
+ CI_POLICIES,
367
+ WORKFLOW_STAGES,
368
+ STAGE_TRANSITIONS,
369
+ TASK_OPTIONS,
370
+ APPROVAL_OPTIONS,
371
+ taskOptions,
372
+ BLOCKER_REASONS,
373
+ EVIDENCE_STATUS,
374
+ WORKFLOW_DEFAULTS,
375
+ hashValue,
376
+ checkHash,
377
+ validateProjectConfig,
378
+ validateCheck,
379
+ validateSnapshot,
380
+ completionProblems,
381
+ }
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+
6
+ const readPackage = dir => {
7
+ try {
8
+ return JSON.parse(Fs.readFileSync(Path.join(dir, 'package.json'), 'utf8'))
9
+ } catch (err) {
10
+ return null
11
+ }
12
+ }
13
+
14
+ const PYTHON_MARKERS = ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']
15
+
16
+ const isPython = dir =>
17
+ PYTHON_MARKERS.some(file => Fs.existsSync(Path.join(dir, file))) ||
18
+ Fs.readdirSync(dir).some(file => file.endsWith('.py'))
19
+
20
+ const detectStacks = dir => {
21
+ const pkg = readPackage(dir)
22
+ if (!pkg) {
23
+ return isPython(dir) ? ['python'] : []
24
+ }
25
+
26
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
27
+ const has = name => Object.prototype.hasOwnProperty.call(deps, name)
28
+ const stacks = []
29
+
30
+ if (has('@hapi/hapi') || has('hapi')) {
31
+ stacks.push('node-hapi-api')
32
+ }
33
+ if (has('@angular/core')) {
34
+ stacks.push('angular-web')
35
+ }
36
+ if (has('react-native') || has('expo')) {
37
+ stacks.push('react-native')
38
+ } else if (has('react')) {
39
+ stacks.push('react-web')
40
+ }
41
+
42
+ return stacks.length ? stacks : ['node']
43
+ }
44
+
45
+ module.exports = {
46
+ detectStacks,
47
+ }
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+ const { request } = require('../api-client')
3
+ const { bindApproach, approachBasis } = require('../shared')
4
+ const taskUrl = (projectId, key) => `/api/v1/projects/${projectId}/tasks/${encodeURIComponent(key)}`
5
+ const fetchApproaches = (projectId, key) => request('GET', taskUrl(projectId, key))
6
+ const proposeApproaches = (projectId, key, proposal) => request('POST', `${taskUrl(projectId, key)}/approaches`, proposal)
7
+ const selectApproach = (projectId, key, selection) => request('POST', `${taskUrl(projectId, key)}/approaches/selection`, selection)
8
+ const renderApproaches = approaches => {
9
+ if (!approaches) return 'Read the relevant code, then call propose_approaches with two or three options and one recommendation.'
10
+ if (approaches.selection) {
11
+ const option = approaches.options.find(item => item.id === approaches.selection.optionId)
12
+ if (!option) throw new Error('Selected approach not found; refresh the task')
13
+ return [
14
+ `IMPLEMENTATION APPROACHES v${approaches.version}`,
15
+ `Selected: ${option.id} — ${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''}`,
16
+ option.summary, `Benefits: ${option.benefits.join('; ')}`, `Drawbacks: ${option.drawbacks.join('; ')}`,
17
+ ...(option.files.length ? [`Files: ${option.files.join(', ')}`] : []),
18
+ `Create the plan from this choice using approach {version: ${approaches.version}, optionId: "${option.id}"}. Existing plan approvals still apply.`,
19
+ ].join('\n')
20
+ }
21
+ const lines = [`IMPLEMENTATION APPROACHES v${approaches.version}`]
22
+ approaches.options.forEach((option, i) => lines.push(
23
+ `${i + 1}. ${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''} [${option.id}]`,
24
+ ` ${option.summary}`, ` Benefits: ${option.benefits.join('; ')}`, ` Drawbacks: ${option.drawbacks.join('; ')}`,
25
+ ...(option.files.length ? [` Files: ${option.files.join(', ')}`] : []),
26
+ ))
27
+ lines.push(`Recommendation: ${approaches.reason}`)
28
+ lines.push('Show these options using your question tool, or a numbered chat list. Wait for an explicit user answer; never select automatically. Then call select_approach with the version and chosen option ID.')
29
+ return lines.join('\n')
30
+ }
31
+ /** Refresh, persist and ask before invoking the final planner. Dependencies keep terminal input injectable. */
32
+ const chooseForPlanning = async (projectId, key, { generate, choose, fetch = fetchApproaches, propose = proposeApproaches, select = selectApproach, signal }) => {
33
+ signal?.throwIfAborted()
34
+ let task = await fetch(projectId, key)
35
+ signal?.throwIfAborted()
36
+ const approaches = task.approaches
37
+ if (!approaches || approaches.basis !== approachBasis(task.card)) {
38
+ const proposal = await generate(task)
39
+ signal?.throwIfAborted()
40
+ task = await propose(projectId, key, { ...proposal, expectedVersion: approaches ? approaches.version : 0 })
41
+ signal?.throwIfAborted()
42
+ }
43
+ if (!task.approaches.selection) {
44
+ if (!choose) return { waitingForSelection: true, task, note: `${renderApproaches(task.approaches)}\nChoose explicitly: tspl task choose ${key} <option-id> --version ${task.approaches.version}` }
45
+ const optionId = await choose(task.approaches)
46
+ signal?.throwIfAborted()
47
+ task = await select(projectId, key, { version: task.approaches.version, optionId, via: 'cli' })
48
+ }
49
+ signal?.throwIfAborted()
50
+ bindApproach(task.approaches, task.card, { version: task.approaches.version, optionId: task.approaches.selection.optionId })
51
+ return { task }
52
+ }
53
+ module.exports = { fetchApproaches, proposeApproaches, selectApproach, renderApproaches, chooseForPlanning }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const { execFileSync } = require('child_process')
4
+
5
+ const FETCH_TIMEOUT = 60 * 1000
6
+
7
+ const tryGit = (dir, args, timeout) => {
8
+ try {
9
+ return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout }).trim()
10
+ } catch (err) {
11
+ return null
12
+ }
13
+ }
14
+
15
+ const validBranch = name => tryGit('.', ['check-ref-format', '--branch', name]) !== null
16
+
17
+ /** Local and origin branches of a repo, one entry per name, after a fetch so new remote branches show up. */
18
+ const listBranches = dir => {
19
+ tryGit(dir, ['fetch', 'origin', '--prune'], FETCH_TIMEOUT)
20
+ const refs = (tryGit(dir, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin']) || '').split('\n').filter(Boolean)
21
+ const names = refs
22
+ .map(ref => ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, ''))
23
+ .filter(name => name && name !== 'HEAD' && !name.startsWith('refs/'))
24
+ return [...new Set(names)].sort()
25
+ }
26
+
27
+ /** Picks the base branch from the repo's branches; typing filters the list. The suggested base comes first. */
28
+ const pickBase = (repo, search) => {
29
+ const branches = listBranches(repo.path)
30
+ const ordered = [repo.base, repo.current, ...branches].filter((name, i, all) => name && branches.includes(name) && all.indexOf(name) === i)
31
+ const label = name => `${name}${name === repo.base ? ' (project base branch)' : ''}${name === repo.current ? ' (checked out now)' : ''}`
32
+ return search({
33
+ message: `${repo.name}: start from which branch? (type to filter)`,
34
+ pageSize: 12,
35
+ source: async term => {
36
+ const wanted = String(term || '').trim().toLowerCase()
37
+ const matches = ordered.filter(name => name.toLowerCase().includes(wanted)).map(name => ({ name: label(name), value: name }))
38
+ return matches.length ? matches : [{ name: `No branch matches "${term}"`, value: null, disabled: true }]
39
+ },
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Asks, before any code is written, which branch each repo starts from and the name of the new branch.
45
+ * Returns { bases, branch }, or null when the person answers no at the end.
46
+ */
47
+ const askBranchChoice = async (proposal, { input, confirm, search }) => {
48
+ console.log('\nBefore coding starts, choose the branches.')
49
+ const bases = {}
50
+ for (const repo of proposal.repos) {
51
+ bases[repo.name] = await pickBase(repo, search)
52
+ }
53
+ const branch = await input({
54
+ message: 'Name of the new branch to create for this task:',
55
+ default: proposal.branch,
56
+ validate: value => {
57
+ const name = value.trim()
58
+ if (!name) return 'Give a branch name'
59
+ if (!validBranch(name)) return `"${name}" is not a valid branch name`
60
+ if (Object.values(bases).includes(name)) return 'The new branch must be different from the branch it starts from'
61
+ return true
62
+ },
63
+ }).then(value => value.trim())
64
+ const summary = proposal.repos.map(repo => ` ${repo.name}: ${bases[repo.name]} → ${branch}`).join('\n')
65
+ if (!(await confirm({ message: `Create these branches and start coding?\n${summary}\n`, default: true }))) return null
66
+ return { bases, branch }
67
+ }
68
+
69
+ module.exports = { askBranchChoice, listBranches, validBranch }