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,148 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const { confirm, input, search, select } = require('@inquirer/prompts')
5
+
6
+ const { request } = require('../api-client')
7
+ const { readActive, writeActive, readLinks, readCredentials, readRun, writeRun, readTask, writeTask } = require('../config-store')
8
+ const { findProject } = require('../workspace')
9
+ const { findProjectConfig, resolveRepoPaths, readProjectInstructions } = require('../project-config')
10
+ const { prepareBuild, blockers, runBuild, refreshChecklist } = require('../task/build')
11
+ const { askBranchChoice } = require('../task/branch-choice')
12
+ const { removeHooks } = require('../task/hooks')
13
+ const { engineFor } = require('../task/refine')
14
+ const { reviewPlan, withSnapshot } = require('../task/plan')
15
+ const { createProvider } = require('../providers')
16
+ const { runChecks, captureHeads } = require('./evidence')
17
+ const { implementTask, planTask, proposeTaskApproaches } = require('./agents')
18
+ const { planningContext, planningBrief } = require('../task/planning-context')
19
+ const { chooseForPlanning, renderApproaches } = require('../task/approaches')
20
+ const { saveDraft, takeDraft } = require('../task/plan-draft')
21
+ const { reviewTask } = require('./review')
22
+ const { claimCheckouts } = require('./checkout-lock')
23
+ const { workflowClient } = require('./client')
24
+ const { gitOps } = require('./controller')
25
+
26
+ const taskUrl = (projectId, key) => `/api/v1/projects/${projectId}/tasks/${encodeURIComponent(key)}`
27
+
28
+ const load = async ({ cwd, taskId }) => {
29
+ const found = findProjectConfig(cwd)
30
+ if (!found) throw new Error('This folder has no .tspl/project.json. Run: tspl init')
31
+ const { project, repos } = await findProject(cwd)
32
+ if (project._id !== found.config.project.id) {
33
+ throw new Error(`The project file is for project ${found.config.project.slug} (${found.config.project.id}), but this folder is linked to ${project.slug}. Check .tspl/project.json.`)
34
+ }
35
+ const links = readLinks()[`${readCredentials().organization}/${project.slug}`]
36
+ const paths = resolveRepoPaths(found.root, found.config, links)
37
+ const missing = found.config.repos.filter(repo => !paths.has(repo.name)).map(repo => repo.name)
38
+ if (missing.length) throw new Error(`These repos are not cloned on this computer: ${missing.join(', ')}. Clone them, then run: tspl init`)
39
+ void repos
40
+ const task = await request('GET', taskUrl(project._id, taskId))
41
+ return {
42
+ projectId: project._id,
43
+ project,
44
+ root: found.root,
45
+ config: found.config,
46
+ paths,
47
+ instructions: readProjectInstructions(found.root, found.config).text,
48
+ task,
49
+ }
50
+ }
51
+
52
+ const finishLocal = taskKey => {
53
+ const active = readActive()
54
+ ;((active[taskKey] || {}).repos || []).forEach(repo => removeHooks(repo.path))
55
+ delete active[taskKey]
56
+ writeActive(active)
57
+ const local = readTask(taskKey)
58
+ if (local.id) writeTask({ ...local, status: 'done' })
59
+ }
60
+
61
+ /** Plans a task with the planning engine, checks the plan against the code and submits it for approval. */
62
+ const plan = async (cwd, ctx, { interactive = Boolean(process.stdin.isTTY) } = {}) => {
63
+ const { createContext } = require('../mcp/server')
64
+ const loaded = await createContext(cwd).load()
65
+ const engine = engineFor('plan')
66
+ const choice = await chooseForPlanning(ctx.projectId, ctx.task.key, {
67
+ signal: ctx.signal,
68
+ generate: async task => {
69
+ const brief = planningContext(loaded, task, { paths: ctx.paths })
70
+ const { draftPlan, ...proposal } = await proposeTaskApproaches({ engine: engine.name, model: engine.model, task, brief, paths: ctx.paths, instructions: ctx.instructions, timeoutMs: ctx.config.workflow.engineTimeoutMs, signal: ctx.signal, withPlan: true })
71
+ if (draftPlan) saveDraft(ctx.projectId, task, proposal, draftPlan, brief)
72
+ return proposal
73
+ },
74
+ choose: interactive ? async approaches => {
75
+ console.log(renderApproaches(approaches))
76
+ return select({ message: 'Which implementation approach should the plan use?', choices: approaches.options.map(option => ({ value: option.id, name: `${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''}` })) }, { signal: ctx.signal })
77
+ } : null,
78
+ })
79
+ if (choice.waitingForSelection) return choice
80
+ ctx.task = choice.task
81
+ const { text: brief, files: briefFiles } = planningBrief(loaded, ctx.task, { paths: ctx.paths })
82
+ const proposed = await planTask({ engine: engine.name, model: engine.model, task: ctx.task, brief, draft: takeDraft(ctx.projectId, ctx.task, brief), paths: ctx.paths, instructions: ctx.instructions, timeoutMs: ctx.config.workflow.engineTimeoutMs, signal: ctx.signal })
83
+ const review = reviewPlan(proposed, ctx.task.card, loaded.graph, { briefFiles })
84
+ const version = ((ctx.task.plan && ctx.task.plan.version) || 0) + 1
85
+ ctx.signal?.throwIfAborted()
86
+ await request('PUT', taskUrl(ctx.projectId, ctx.task.key), {
87
+ card: ctx.task.card,
88
+ assumptions: ctx.task.assumptions || [],
89
+ found: ctx.task.found || [],
90
+ used: ctx.task.used || [],
91
+ plan: { ...withSnapshot(review, ctx.task.card, cwd), approach: proposed.approach, version, basis: null, submittedAt: new Date().toISOString() },
92
+ })
93
+ return { note: review.warnings.length ? `${review.warnings.length} plan warning(s): see tspl plan show ${ctx.task.key}` : null }
94
+ }
95
+
96
+ /** Sends one screenshot or video to the task, tied to its check, commit and test case. */
97
+ const uploadArtifact = (projectId, key, file) =>
98
+ request('POST', `${taskUrl(projectId, key)}/artifacts`, {
99
+ name: file.name || require('path').basename(file.path),
100
+ contentType: file.contentType,
101
+ data: Fs.readFileSync(file.path).toString('base64'),
102
+ criterion: file.criterion || null,
103
+ checkId: file.checkId || null,
104
+ step: file.step ? String(file.step).slice(0, 500) : null,
105
+ headSha: file.headSha || null,
106
+ source: 'cli',
107
+ })
108
+
109
+ const controllerDeps = (cwd, { interactive = Boolean(process.stdin.isTTY) } = {}) => ({
110
+ uploadArtifact,
111
+ load,
112
+ plan: ctx => plan(cwd, ctx, { interactive }),
113
+ client: (projectId, key) => workflowClient(projectId, key),
114
+ agents: { implementTask, reviewTask },
115
+ runChecks,
116
+ captureHeads,
117
+ engineFor,
118
+ ciWaitMs: 10 * 60 * 1000,
119
+ prepareBuild,
120
+ buildBlockers: blockers,
121
+ startBuild: runBuild,
122
+ refreshChecklist,
123
+ refreshTask: (projectId, key) => request('GET', taskUrl(projectId, key)),
124
+ git: gitOps,
125
+ providerFor: repoConfig => createProvider(repoConfig),
126
+ confirm: interactive ? message => confirm({ message: `${message}\nContinue?`, default: false }) : null,
127
+ chooseBranch: interactive ? proposal => askBranchChoice(proposal, { input, confirm, search }) : null,
128
+ claimCheckouts,
129
+ runs: { read: readRun, write: writeRun },
130
+ finishLocal,
131
+ })
132
+
133
+ const renderOutcome = result => {
134
+ const lines = [`Stage: ${result.stage}`]
135
+ result.blockers.forEach(line => lines.push(` ! ${line}`))
136
+ ;(result.warnings || []).forEach(line => lines.push(` ! ${line}`))
137
+ ;(result.prs || []).forEach(pr => lines.push(` PR ${pr.repo}: ${pr.url} (${pr.draft ? 'draft' : pr.state})`))
138
+ if (result.next.length) lines.push('Next:', ...result.next.map(line => ` ${line}`))
139
+ return lines.join('\n')
140
+ }
141
+
142
+ module.exports = {
143
+ controllerDeps,
144
+ renderOutcome,
145
+ finishLocal,
146
+ taskUrl,
147
+ uploadArtifact,
148
+ }
@@ -0,0 +1,58 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Stages where the run stops for a person somewhere else (the console or another terminal): approving a plan,
5
+ * confirming scope, recording a manual check, or CI. Anything else needs the developer at this keyboard.
6
+ */
7
+ const waitsForOthers = result => {
8
+ if (['awaiting-approval', 'awaiting-ci'].includes(result.stage)) return true
9
+ if (result.stage === 'reviewing') return true
10
+ if (result.stage === 'planning') return result.blockers.some(line => /has not confirmed the scope/.test(line))
11
+ return false
12
+ }
13
+
14
+ /** What a person can change while we wait; when any of it moves, the run is worth trying again. */
15
+ const fingerprint = task => {
16
+ const workflow = task.workflow || {}
17
+ return JSON.stringify([
18
+ task.status,
19
+ task.scope ? task.scope.confirmed : null,
20
+ task.plan ? task.plan.version : null,
21
+ (task.decisions || []).length,
22
+ workflow.revision || 0,
23
+ (workflow.manualVerifications || []).length,
24
+ (workflow.prs || []).map(pr => [pr.headSha, (pr.checks || []).map(check => `${check.status}:${check.conclusion}`)]),
25
+ ])
26
+ }
27
+
28
+ const sleep = (ms, signal) =>
29
+ new Promise((resolve, reject) => {
30
+ if (signal && signal.aborted) return reject(signal.reason || new Error('aborted'))
31
+ const timer = setTimeout(resolve, ms)
32
+ if (signal) signal.addEventListener('abort', () => (clearTimeout(timer), reject(signal.reason || new Error('aborted'))), { once: true })
33
+ })
34
+
35
+ /**
36
+ * Runs the task, and whenever it stops for someone else, checks the server every `intervalMs` and runs it again
37
+ * as soon as something changed. CI is rechecked on every tick because its state lives with the git host.
38
+ * Returns the last outcome: ready, or the first stop that needs the developer.
39
+ */
40
+ const runUntilDone = async ({ run, fetchTask, report, log, signal, intervalMs = 15000, waitMs = sleep }) => {
41
+ for (;;) {
42
+ const result = await run()
43
+ report(result)
44
+ if (result.stage === 'ready' || !waitsForOthers(result)) return result
45
+ let seen = fingerprint(await fetchTask())
46
+ log(`Waiting for ${result.stage === 'awaiting-ci' ? 'CI' : 'a person'}; checking every ${Math.round(intervalMs / 1000)}s and continuing by itself. Ctrl+C stops waiting.`)
47
+ for (;;) {
48
+ await waitMs(intervalMs, signal)
49
+ if (result.stage === 'awaiting-ci') break
50
+ const now = fingerprint(await fetchTask())
51
+ if (now !== seen) break
52
+ seen = now
53
+ }
54
+ log('Something changed; continuing.')
55
+ }
56
+ }
57
+
58
+ module.exports = { runUntilDone, waitsForOthers, fingerprint }
@@ -0,0 +1,250 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+ const { checkbox, confirm, input, select } = require('@inquirer/prompts')
6
+
7
+ const { request } = require('./api-client')
8
+ const { readCredentials, readLinks, writeLinks } = require('./config-store')
9
+ const { findRepos, defaultBranch, identify } = require('./git-helper')
10
+ const { detectStacks } = require('./stack-detector')
11
+
12
+ const scan = folder =>
13
+ findRepos(folder)
14
+ .map(dir => ({
15
+ dir,
16
+ name: Path.basename(dir),
17
+ branch: defaultBranch(dir),
18
+ stacks: detectStacks(dir),
19
+ ...identify(dir),
20
+ }))
21
+ .filter(repo => repo.fingerprint || repo.remoteHash)
22
+
23
+ const sameRepo = (projectRepo, localRepo) =>
24
+ Boolean(
25
+ (projectRepo.fingerprint && projectRepo.fingerprint === localRepo.fingerprint) ||
26
+ (projectRepo.remoteHash && projectRepo.remoteHash === localRepo.remoteHash),
27
+ )
28
+
29
+ const toPayload = repo => ({
30
+ name: repo.name,
31
+ fingerprint: repo.fingerprint,
32
+ remoteHash: repo.remoteHash,
33
+ defaultBaseBranch: repo.branch || undefined,
34
+ })
35
+
36
+ const linkKey = project => `${readCredentials().organization}/${project.slug}`
37
+
38
+ const pickRepos = (message, repos, options) => {
39
+ if (options.yes) {
40
+ return repos
41
+ }
42
+ return checkbox({
43
+ message,
44
+ choices: repos.map(repo => ({ name: repo.name, value: repo, checked: true })),
45
+ })
46
+ }
47
+
48
+ const createProject = async (folder, local, options) => {
49
+ const suggested = options.name || Path.basename(folder)
50
+ if (!options.yes) {
51
+ const create = await confirm({
52
+ message: `These ${local.length} repos are not in any project yet. Create a project for them?`,
53
+ default: true,
54
+ })
55
+ if (!create) {
56
+ return {}
57
+ }
58
+ }
59
+
60
+ const name = options.yes
61
+ ? suggested
62
+ : await input({ message: 'Project name:', default: suggested })
63
+ const repos = await pickRepos('Include which repos?', local, options)
64
+
65
+ try {
66
+ const project = await request('POST', '/api/v1/projects', {
67
+ name,
68
+ repos: repos.map(toPayload),
69
+ })
70
+ return { project, excluded: local.filter(repo => !repos.includes(repo)) }
71
+ } catch (err) {
72
+ if (err.statusCode === 403) {
73
+ throw new Error(
74
+ 'No project uses these repos yet. Ask an owner, admin or PM to create one.',
75
+ )
76
+ }
77
+ throw err
78
+ }
79
+ }
80
+
81
+ const NEW_PROJECT = '__new__'
82
+
83
+ /** Offers the projects that already exist before a new one is created. null means: create a new project. */
84
+ const pickExisting = async options => {
85
+ if (options.project) {
86
+ const found = await request('GET', `/api/v1/projects/${encodeURIComponent(options.project)}`)
87
+ return found
88
+ }
89
+ if (options.yes) return null
90
+ const projects = await request('GET', '/api/v1/projects')
91
+ if (!projects.length) return null
92
+ const picked = await select({
93
+ message: 'These repos are not in any project yet. Which project are they part of?',
94
+ choices: [
95
+ ...projects.map(project => ({ name: `${project.name} (${project.repos.length} repos)`, value: project })),
96
+ { name: 'None of these: create a new project', value: NEW_PROJECT },
97
+ ],
98
+ })
99
+ return picked === NEW_PROJECT ? null : picked
100
+ }
101
+
102
+ const chooseProject = async (matches, options) => {
103
+ if (matches.length === 1) {
104
+ return matches[0]
105
+ }
106
+ if (options.yes) {
107
+ throw new Error(
108
+ `These repos belong to several projects (${matches.map(p => p.slug).join(', ')}). Run without --yes to choose.`,
109
+ )
110
+ }
111
+ return select({
112
+ message: 'These repos belong to several projects. Which one are you working on?',
113
+ choices: matches.map(project => ({ name: project.name, value: project })),
114
+ })
115
+ }
116
+
117
+ const addMissingRepos = async (project, local, ignored, options) => {
118
+ const missing = local.filter(
119
+ repo =>
120
+ !project.repos.some(r => sameRepo(r, repo)) &&
121
+ !ignored.includes(repo.fingerprint || repo.remoteHash),
122
+ )
123
+ if (!missing.length) {
124
+ return { project, declined: [] }
125
+ }
126
+
127
+ const chosen = await pickRepos(
128
+ `These repos are in this folder but not in "${project.name}". Add which?`,
129
+ missing,
130
+ options,
131
+ )
132
+
133
+ let current = project
134
+ for (const repo of chosen) {
135
+ try {
136
+ current = await request(
137
+ 'POST',
138
+ `/api/v1/projects/${current._id}/repos`,
139
+ toPayload(repo),
140
+ )
141
+ } catch (err) {
142
+ console.log(`Could not add ${repo.name}: ${err.message}`)
143
+ }
144
+ }
145
+
146
+ const declined = missing
147
+ .filter(repo => !chosen.includes(repo))
148
+ .map(repo => repo.fingerprint || repo.remoteHash)
149
+ return { project: current, declined }
150
+ }
151
+
152
+ const setupProject = async (folderArg, options = {}) => {
153
+ const folder = Path.resolve(folderArg || process.cwd())
154
+ const local = scan(folder)
155
+ if (!local.length) {
156
+ throw new Error(
157
+ `No git repos found in ${folder}. Run tspl inside a project folder or one of its repos.`,
158
+ )
159
+ }
160
+
161
+ const matches = await request('POST', '/api/v1/projects/match', {
162
+ fingerprints: local.map(r => r.fingerprint).filter(Boolean),
163
+ remoteHashes: local.map(r => r.remoteHash).filter(Boolean),
164
+ })
165
+
166
+ let project
167
+ let result
168
+ if (matches.length) {
169
+ project = await chooseProject(matches, options)
170
+ } else {
171
+ const existing = await pickExisting(options)
172
+ if (existing) {
173
+ project = existing
174
+ } else {
175
+ const created = await createProject(folder, local, options)
176
+ if (!created.project) {
177
+ return null
178
+ }
179
+ project = created.project
180
+ result = {
181
+ project,
182
+ declined: created.excluded.map(repo => repo.fingerprint || repo.remoteHash),
183
+ }
184
+ }
185
+ }
186
+
187
+ const links = readLinks()
188
+ const key = linkKey(project)
189
+ const previous = result ? {} : links[key] || {}
190
+ if (!result) {
191
+ result = await addMissingRepos(project, local, previous.ignored || [], options)
192
+ }
193
+ project = result.project
194
+
195
+ const repos = {}
196
+ const linked = []
197
+ project.repos.forEach(repo => {
198
+ const found = local.find(l => sameRepo(repo, l))
199
+ if (found) {
200
+ repos[repo._id] = { name: repo.name, path: found.dir }
201
+ linked.push({ ...repo, stacks: found.stacks, path: found.dir })
202
+ }
203
+ })
204
+
205
+ links[key] = {
206
+ projectId: project._id,
207
+ folder,
208
+ repos: { ...(previous.repos || {}), ...repos },
209
+ ignored: [...new Set([...(previous.ignored || []), ...result.declined])],
210
+ }
211
+ writeLinks(links)
212
+
213
+ return { project, folder, linked }
214
+ }
215
+
216
+ const localRepos = project => {
217
+ const known = (readLinks()[linkKey(project)] || {}).repos || {}
218
+ return project.repos
219
+ .filter(repo => known[repo._id] && Fs.existsSync(known[repo._id].path))
220
+ .map(repo => ({ ...repo, path: known[repo._id].path }))
221
+ }
222
+
223
+ const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
224
+
225
+ const findProject = async folder => {
226
+ const dir = Path.resolve(folder)
227
+ const [key, link] =
228
+ Object.entries(readLinks()).find(
229
+ ([, entry]) =>
230
+ inside(dir, entry.folder) || Object.values(entry.repos || {}).some(repo => inside(dir, repo.path)),
231
+ ) || []
232
+ if (!key) {
233
+ throw new Error(`${dir} is not set up for tspl yet. Run tspl in a terminal in this folder first.`)
234
+ }
235
+ try {
236
+ const project = await request('GET', `/api/v1/projects/${encodeURIComponent(key.split('/').pop())}`)
237
+ return { project, repos: localRepos(project), offline: false }
238
+ } catch (err) {
239
+ if (err.statusCode === 0 && link.projectId) {
240
+ return { project: { _id: link.projectId, name: key.split('/').pop() }, repos: [], offline: true }
241
+ }
242
+ throw err
243
+ }
244
+ }
245
+
246
+ module.exports = {
247
+ setupProject,
248
+ localRepos,
249
+ findProject,
250
+ }