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,107 @@
1
+ 'use strict'
2
+
3
+ const { setupProject, localRepos } = require('../workspace')
4
+ const { refreshIndex } = require('../analysis/analyzer')
5
+ const { createGraph } = require('../find/graph')
6
+ const { createSearch } = require('../find/search')
7
+ const { fetchDue, syncInBackground } = require('./sync.command')
8
+
9
+ const CONFIDENCE = { high: 'HIGH', medium: 'MEDIUM', low: 'LOW' }
10
+
11
+ const printResult = (result, context) => {
12
+ console.log(`Searched ${context.files.toLocaleString()} files in ${context.repos} repos of ${context.project} in ${context.ms}ms. AI tokens used: 0.\n`)
13
+ if (!result.files.length) {
14
+ console.log('Nothing matched. Try other words, e.g. a screen name, field name or API path.')
15
+ return
16
+ }
17
+
18
+ const { level, reasons } = result.confidence
19
+ console.log(`Confidence: ${CONFIDENCE[level]} (${reasons.join('; ')})\n`)
20
+
21
+ const width = Math.max(...result.files.map(file => file.repo.length))
22
+ result.files.forEach((file, i) => {
23
+ console.log(`${String(i + 1).padStart(3)}. ${file.repo.padEnd(width)} ${file.path}`)
24
+ if (file.symbols.length) {
25
+ console.log(`${' '.repeat(width + 7)}${file.symbols.map(symbol => (symbol.line ? `${symbol.name} (line ${symbol.line})` : symbol.name)).join(', ')}`)
26
+ }
27
+ if (file.why.length) {
28
+ console.log(`${' '.repeat(width + 7)}why: ${file.why.join(' · ')}`)
29
+ }
30
+ if (file.similar.length) {
31
+ console.log(`${' '.repeat(width + 7)}similar: ${file.similar.join(', ')}`)
32
+ }
33
+ })
34
+
35
+ if (result.searchSteps.length) {
36
+ console.log('\nAlso search for:')
37
+ result.searchSteps.forEach(step => console.log(` ${step.pattern} (${step.why})`))
38
+ }
39
+
40
+ if (result.chains.length) {
41
+ console.log('\nHow they connect:')
42
+ result.chains.forEach(chain => {
43
+ chain.forEach((step, i) => {
44
+ const arrow = i ? ` → ${step.detail ? `${step.detail} → ` : ''}` : ' '
45
+ console.log(` ${arrow}${step.repo} ${step.path}`)
46
+ })
47
+ console.log('')
48
+ })
49
+ }
50
+
51
+ if (result.placement.length) {
52
+ console.log(`${result.chains.length ? '' : '\n'}New files, if needed, usually go:`)
53
+ result.placement.forEach(place => console.log(` ${place.repo} ${place.pattern} (${place.count} like this)`))
54
+ }
55
+
56
+ console.log(`\nWords used: ${result.terms.join(', ')}${result.unknown.length ? ` · not found in code: ${result.unknown.join(', ')}` : ''}`)
57
+ }
58
+
59
+ const find = async (words, options) => {
60
+ const task = words.join(' ').trim()
61
+ if (!task) {
62
+ throw new Error('Describe the task, e.g. tspl find "add notes when closing work orders"')
63
+ }
64
+
65
+ const setup = await setupProject(process.cwd(), {})
66
+ if (!setup) {
67
+ return
68
+ }
69
+ const repos = localRepos(setup.project)
70
+ if (!repos.length) {
71
+ throw new Error('No repos of this project are linked on this computer. Run tspl in the project folder first.')
72
+ }
73
+
74
+ const started = Date.now()
75
+ const failed = []
76
+ let changed = false
77
+ const index = await refreshIndex(setup.project, repos, (repo, repoIndex, err, isNew) => {
78
+ if (err) failed.push(`${repo.name}: ${err.message}`)
79
+ changed = changed || Boolean(isNew)
80
+ })
81
+ if (changed || fetchDue(setup.project._id)) syncInBackground(process.cwd())
82
+ const graph = createGraph(index)
83
+ const result = createSearch(graph).find(task, { limit: Number(options.limit) || 15 })
84
+
85
+ if (options.json) {
86
+ console.log(JSON.stringify({ ...result, files: result.files.map(({ id, ...file }) => file), skippedRepos: failed }, null, 2))
87
+ return
88
+ }
89
+ failed.forEach(message => console.log(`Skipped ${message}`))
90
+ printResult(result, {
91
+ project: setup.project.name,
92
+ repos: graph.repos.length,
93
+ files: graph.files.length,
94
+ ms: Date.now() - started,
95
+ })
96
+ }
97
+
98
+ module.exports = {
99
+ register(program) {
100
+ program
101
+ .command('find <task...>')
102
+ .description('Find the code a task is about, across all repos of the project (no AI, no tokens)')
103
+ .option('--limit <n>', 'how many files to show', '15')
104
+ .option('--json', 'print the result as JSON')
105
+ .action(find)
106
+ },
107
+ }
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ const Prompts = require('@inquirer/prompts')
4
+
5
+ const { request } = require('../api-client')
6
+ const config = require('../config-store')
7
+ const { setupProject } = require('../workspace')
8
+ const { initializeProject } = require('../project-init')
9
+ const { organizationOfFolder } = require('../active-org')
10
+
11
+ const defaultDeps = () => ({
12
+ prompts: Prompts,
13
+ request,
14
+ credentials: { read: config.readCredentials },
15
+ links: { read: config.readLinks, write: config.writeLinks },
16
+ setupProject,
17
+ })
18
+
19
+ /** The organization this project belongs to: the one already saved for the folder, else the one you pick. */
20
+ const chooseOrganization = async (folder, options) => {
21
+ const known = organizationOfFolder(folder || process.cwd())
22
+ const accounts = [...new Map(config.listAccounts().map(account => [account.organization, config.accountFor(account.organization)])).values()]
23
+ const saved = options.org || known
24
+ const slugs = accounts.map(account => account.organization)
25
+ if (saved) {
26
+ if (!slugs.includes(saved)) throw new Error(`You are not logged in to "${saved}" on this computer. Run: tspl login --org ${saved}`)
27
+ return saved
28
+ }
29
+ if (slugs.length <= 1) return slugs[0] || null
30
+ const current = config.readCredentials().organization
31
+ return Prompts.select({
32
+ message: 'Which organization does this project belong to?',
33
+ choices: accounts.map(account => ({ name: `${account.organization} (${account.email})`, value: account.organization })),
34
+ default: slugs.includes(current) ? current : undefined,
35
+ })
36
+ }
37
+
38
+ const init = async (folder, options) => {
39
+ const organization = await chooseOrganization(folder, options)
40
+ if (organization) {
41
+ config.setActiveOrganization(organization)
42
+ console.log(`Organization: ${organization}`)
43
+ }
44
+ const result = await initializeProject(folder, options, defaultDeps())
45
+ if (!result) {
46
+ console.log('Nothing initialized.')
47
+ return
48
+ }
49
+ const { root, config: project, created, readiness } = result
50
+ if (project) {
51
+ console.log(`${created ? 'Initialized' : 'Adopted'} ${project.project.slug} in ${root}`)
52
+ console.log(` ${project.repos.length} repo${project.repos.length === 1 ? '' : 's'}, ${project.checks.length} check${project.checks.length === 1 ? '' : 's'}`)
53
+ if (created) console.log(' Commit .tspl/ so the whole team shares it. Personal paths stay in ~/.tspl.')
54
+ }
55
+ readiness.warnings.forEach(line => console.log(` ! ${line}`))
56
+ if (readiness.ready) {
57
+ console.log('\nReady. Next: tspl task new "<what should change>"')
58
+ return
59
+ }
60
+ console.log('\nNot ready for a tested-PR workflow yet:')
61
+ readiness.problems.forEach(line => console.log(` ✗ ${line}`))
62
+ process.exitCode = 1
63
+ }
64
+
65
+ module.exports = {
66
+ init,
67
+ register(program) {
68
+ program
69
+ .command('init [folder]')
70
+ .description('Set up this project folder: link it, find its checks and save shared project rules in .tspl/')
71
+ .option('-y, --yes', 'accept supported suggestions without asking')
72
+ .option('--org <slug>', 'the organization this project belongs to, if you are logged in to several')
73
+ .option('--project <slug>', 'add this folder to an existing project instead of choosing or creating one')
74
+ .option('--name <name>', 'project name, if a new project is created')
75
+ .option('--manifest-home <repo>', 'for a folder of several repos: the repo that holds the shared .tspl files')
76
+ .option('--no-e2e', 'do not set up browser tests (Playwright) for web apps that have none')
77
+ .action(init)
78
+ },
79
+ }
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path')
4
+
5
+ const { setupProject } = require('../workspace')
6
+ const { readCredentials, readLinks, writeLinks } = require('../config-store')
7
+
8
+ const printSetup = setup => {
9
+ if (!setup) {
10
+ console.log('Nothing linked.')
11
+ return
12
+ }
13
+ const { project, folder, linked } = setup
14
+ console.log(`${project.name} (${project.slug}): ${linked.length} of ${project.repos.length} repos linked from ${folder}`)
15
+ linked.forEach(repo => {
16
+ console.log(` ${repo.name.padEnd(24)} [${repo.defaultBaseBranch}] ${repo.stacks.join(', ') || 'unknown stack'}`)
17
+ })
18
+ }
19
+
20
+ const inside = (dir, folder) =>
21
+ Boolean(folder) && (dir === folder || dir.startsWith(folder + Path.sep))
22
+
23
+ const unlink = async projectRef => {
24
+ const links = readLinks()
25
+ const cwd = process.cwd()
26
+ const keys = projectRef
27
+ ? [`${readCredentials().organization}/${projectRef}`].filter(key => links[key])
28
+ : Object.keys(links).filter(
29
+ key =>
30
+ inside(cwd, links[key].folder) ||
31
+ Object.values(links[key].repos || {}).some(repo => inside(cwd, repo.path)),
32
+ )
33
+
34
+ if (!keys.length) {
35
+ console.log(
36
+ projectRef
37
+ ? `"${projectRef}" is not linked on this machine.`
38
+ : 'This folder is not linked to any project on this machine.',
39
+ )
40
+ return
41
+ }
42
+
43
+ keys.forEach(key => delete links[key])
44
+ writeLinks(links)
45
+ console.log(
46
+ `Forgot ${keys.map(key => key.split('/')[1]).join(', ')} on this machine. Run tspl in the project folder to link again.`,
47
+ )
48
+ }
49
+
50
+ module.exports = {
51
+ printSetup,
52
+ register(program) {
53
+ program
54
+ .command('link [folder]', { hidden: true })
55
+ .description('Connect the repos in a folder to their project')
56
+ .option('-y, --yes', 'accept every suggestion without asking')
57
+ .option('--name <name>', 'project name, if a new project is created')
58
+ .action(async (folder, options) => printSetup(await setupProject(folder, options)))
59
+
60
+ program
61
+ .command('unlink [project]')
62
+ .description('Forget where a project\'s repos are on this machine (default: the project in this folder)')
63
+ .action(unlink)
64
+ },
65
+ }
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ const { startServer } = require('../mcp/server')
4
+
5
+ module.exports = {
6
+ register(program) {
7
+ program
8
+ .command('mcp', { hidden: true })
9
+ .description('Run the tspl plug-in for AI tools (started by the AI tool, not by hand)')
10
+ .action(() => startServer(process.cwd()))
11
+ },
12
+ }
@@ -0,0 +1,82 @@
1
+ 'use strict'
2
+
3
+ const { confirm } = require('@inquirer/prompts')
4
+ const { ROLES } = require('../shared')
5
+
6
+ const { request } = require('../api-client')
7
+
8
+ const ROLE_NAMES = Object.values(ROLES)
9
+
10
+ const checkRole = role => {
11
+ if (!ROLE_NAMES.includes(role)) {
12
+ throw new Error(`Role must be one of: ${ROLE_NAMES.join(', ')}`)
13
+ }
14
+ return role
15
+ }
16
+
17
+ const find = async email => {
18
+ const members = await request('GET', '/api/v1/members')
19
+ const member = members.find(item => item.email === email.toLowerCase())
20
+ if (!member) {
21
+ throw new Error(`${email} is not in your organization`)
22
+ }
23
+ return member
24
+ }
25
+
26
+ const add = async (email, options) => {
27
+ const member = await request('POST', '/api/v1/members', {
28
+ email,
29
+ name: options.name,
30
+ role: checkRole(options.role),
31
+ })
32
+ if (member.existingAccount) {
33
+ console.log(`${member.email} already had a TSPL account; they are now ${member.role} in your organization and log in with their own password.`)
34
+ return
35
+ }
36
+ console.log(`Added ${member.name} <${member.email}> as ${member.role}.`)
37
+ console.log(`Temporary password: ${member.temporaryPassword}`)
38
+ console.log('Send it to them privately (not by email with the address). They log in with "tspl login" and must then set their own with "tspl password".')
39
+ }
40
+
41
+ const list = async () => {
42
+ const members = await request('GET', '/api/v1/members')
43
+ const width = Math.max(...members.map(member => member.email.length))
44
+ members.forEach(member => {
45
+ const seen = member.lastLoginAt ? `last login ${new Date(member.lastLoginAt).toLocaleDateString()}` : 'never logged in'
46
+ console.log(`${member.email.padEnd(width)} ${member.role.padEnd(9)} ${member.name} · ${seen}${member.passwordChangeRequired ? ' · temporary password' : ''}`)
47
+ })
48
+ }
49
+
50
+ const role = async (email, newRole) => {
51
+ const member = await find(email)
52
+ await request('PUT', `/api/v1/members/${member.userId}`, { role: checkRole(newRole) })
53
+ console.log(`${email} is now ${newRole}.`)
54
+ }
55
+
56
+ const remove = async (email, options) => {
57
+ const member = await find(email)
58
+ if (!options.yes && !(await confirm({ message: `Remove ${email} from the organization? They lose access at once.`, default: false }))) {
59
+ return
60
+ }
61
+ await request('DELETE', `/api/v1/members/${member.userId}`)
62
+ console.log(`${email} was removed and logged out.`)
63
+ }
64
+
65
+ module.exports = {
66
+ register(program) {
67
+ const member = program.command('member').description('People in your organization and their roles')
68
+ member
69
+ .command('add <email>')
70
+ .description('Add a person (owner or admin); new accounts get a temporary password')
71
+ .requiredOption('--name <name>', 'their name')
72
+ .requiredOption('--role <role>', `one of: ${ROLE_NAMES.join(', ')}`)
73
+ .action(add)
74
+ member.command('list').description('Everyone in the organization with their role (owner, admin or PM)').action(list)
75
+ member.command('role <email> <role>').description('Change a person\'s role (owner or admin)').action(role)
76
+ member
77
+ .command('remove <email>')
78
+ .description('Remove a person from the organization (owner or admin)')
79
+ .option('-y, --yes', 'do not ask to confirm')
80
+ .action(remove)
81
+ },
82
+ }
@@ -0,0 +1,161 @@
1
+ 'use strict'
2
+
3
+ const { spawn } = require('child_process')
4
+ const { confirm } = require('@inquirer/prompts')
5
+
6
+ const { request } = require('../api-client')
7
+ const { findProject } = require('../workspace')
8
+ const { writePlanPage } = require('../config-store')
9
+ const { renderChecklist } = require('../task/plan')
10
+ const { renderPlanPage } = require('../task/plan-page')
11
+
12
+ const STATUS = {
13
+ draft: 'no plan yet',
14
+ 'plan-review': 'waiting for the developer',
15
+ 'pm-review': 'waiting for the PM',
16
+ approved: 'approved, ready to build',
17
+ 'changes-requested': 'changes requested',
18
+ building: 'building',
19
+ done: 'done',
20
+ }
21
+
22
+ const projectOf = async options => {
23
+ if (options.project) {
24
+ return request('GET', `/api/v1/projects/${encodeURIComponent(options.project)}`)
25
+ }
26
+ try {
27
+ return (await findProject(process.cwd())).project
28
+ } catch (err) {
29
+ throw new Error('Run this inside a project folder, or add --project <name>')
30
+ }
31
+ }
32
+
33
+ const tasksUrl = (project, key = '') => `/api/v1/projects/${project._id}/tasks${key ? `/${encodeURIComponent(key)}` : ''}`
34
+
35
+ const openFile = file => {
36
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'
37
+ const child = spawn(command, [file], { detached: true, stdio: 'ignore' })
38
+ child.on('error', () => {})
39
+ child.unref()
40
+ }
41
+
42
+ const decisionLines = task =>
43
+ (task.decisions || []).map(decision =>
44
+ ` ${decision.approved ? '✓' : '✗'} ${decision.stage} ${decision.approved ? 'approved' : 'asked for changes'} v${decision.planVersion || '-'}` +
45
+ `${decision.userName ? ` by ${decision.userName}` : ''}${decision.at ? ` on ${new Date(decision.at).toLocaleString()}` : ''}${decision.note ? ` — "${decision.note}"` : ''}`)
46
+
47
+ const list = async options => {
48
+ const project = await projectOf(options)
49
+ const query = options.all ? '' : '?status=plan-review,pm-review,changes-requested,approved'
50
+ const tasks = await request('GET', `${tasksUrl(project)}${query}`)
51
+ if (!tasks.length) {
52
+ console.log(options.all ? 'No tasks yet.' : 'No plans waiting. Use --all to see every task.')
53
+ return
54
+ }
55
+ tasks.forEach(task => {
56
+ const version = task.plan && task.plan.version ? ` v${task.plan.version}` : ''
57
+ console.log(`${task.key.padEnd(18)} ${(STATUS[task.status] || task.status).padEnd(26)} ${task.ticket ? `${task.ticket} · ` : ''}${task.title || ''}${version}`)
58
+ })
59
+ }
60
+
61
+ const show = async (key, options) => {
62
+ const project = await projectOf(options)
63
+ const task = await request('GET', tasksUrl(project, key))
64
+ if (options.page) {
65
+ const file = writePlanPage(task.key, renderPlanPage(task, project.name))
66
+ openFile(file)
67
+ console.log(`Opened the plan page: ${file}`)
68
+ return
69
+ }
70
+ console.log(`${task.key} · ${STATUS[task.status] || task.status}${task.requirePmApproval === false ? ' · PM approval not required' : ''}`)
71
+ decisionLines(task).forEach(line => console.log(line))
72
+ console.log('')
73
+ if (!task.plan) {
74
+ console.log('No plan yet.')
75
+ return
76
+ }
77
+ const card = { apps: [], doneWhen: [], ...task.card }
78
+ const review = { edit: [], create: [], remove: [], screens: [], tests: [], warnings: [], companions: [], notes: '', ...task.plan }
79
+ console.log(renderChecklist(review, card, `${task.key} v${task.plan.version}`))
80
+ console.log(`\nSee it as a page: tspl plan show ${task.key} --page`)
81
+ }
82
+
83
+ const AI_SHELL = ['CLAUDECODE', 'CODEX_SANDBOX', 'CODEX_CI', 'GEMINI_CLI', 'CURSOR_AGENT']
84
+
85
+ const atKeyboard = () => Boolean(process.stdin.isTTY && process.stdout.isTTY) && !AI_SHELL.some(name => process.env[name])
86
+
87
+ const decide = approved => async (key, options) => {
88
+ if (!approved && !options.note) {
89
+ throw new Error('Say what should change: --note "…"')
90
+ }
91
+ if (!atKeyboard()) {
92
+ throw new Error('Plans are approved by a person: run this in your own terminal, or use the tspl console. It does not work from an AI tool.')
93
+ }
94
+ const project = await projectOf(options)
95
+ const task = await request('GET', tasksUrl(project, key))
96
+ const stage = task.status === 'pm-review' ? 'pm' : 'developer'
97
+ if (!['plan-review', 'changes-requested', 'pm-review'].includes(task.status)) {
98
+ throw new Error(`Nothing to decide: the plan is ${STATUS[task.status] || task.status}`)
99
+ }
100
+ const card = { apps: [], doneWhen: [], ...task.card }
101
+ const review = { edit: [], create: [], remove: [], screens: [], tests: [], warnings: [], companions: [], notes: '', ...task.plan }
102
+ console.log(`${renderChecklist(review, card, `${task.key} v${task.plan.version}`)}\n`)
103
+ const sure = await confirm({
104
+ message: approved ? `Approve plan v${task.plan.version} as ${stage === 'pm' ? 'PM' : 'developer'}?` : `Send plan v${task.plan.version} back with your note?`,
105
+ default: approved,
106
+ })
107
+ if (!sure) {
108
+ console.log('Nothing recorded.')
109
+ return
110
+ }
111
+ const updated = await request('POST', `${tasksUrl(project, key)}/decisions`, {
112
+ stage,
113
+ approved,
114
+ note: options.note || '',
115
+ planVersion: task.plan.version,
116
+ })
117
+ console.log(`${stage === 'pm' ? 'PM' : 'Developer'} ${approved ? 'approved' : 'asked for changes to'} plan v${updated.plan.version}. Now: ${STATUS[updated.status] || updated.status}.`)
118
+ }
119
+
120
+ const amend = async (key, options) => {
121
+ const project = await projectOf(options)
122
+ const result = await request('POST', `${tasksUrl(project, key)}/workflow/amend`, { note: options.note || '' })
123
+ console.log(`${key}: the run was cancelled and the plan can be changed. Status: ${STATUS[result.task.status] || result.task.status}. Ask your AI tool for a new plan version (/tspl ${key}).`)
124
+ }
125
+
126
+ module.exports = {
127
+ atKeyboard,
128
+ register(program) {
129
+ const plan = program.command('plan').description('See and approve task plans (developer, then PM)')
130
+ plan
131
+ .command('list')
132
+ .description('Plans waiting for a decision (--all for every task)')
133
+ .option('--all', 'every task, not only plans waiting for a decision')
134
+ .option('--project <name>', 'the project, if you are not in its folder')
135
+ .action(list)
136
+ plan
137
+ .command('show <task>')
138
+ .description('Show a plan: files to edit, create and remove per repo, tests, approvals')
139
+ .option('--page', 'open it as a page in the browser')
140
+ .option('--project <name>', 'the project, if you are not in its folder')
141
+ .action(show)
142
+ plan
143
+ .command('approve <task>')
144
+ .description('Approve a plan (as developer, or as PM when it waits for the PM)')
145
+ .option('--note <text>', 'a note to keep with the approval')
146
+ .option('--project <name>', 'the project, if you are not in its folder')
147
+ .action(decide(true))
148
+ plan
149
+ .command('amend <task>')
150
+ .description('Cancel a stopped build or run so a new plan version can be submitted (the old approvals and results no longer count)')
151
+ .option('--note <text>', 'why the plan changes')
152
+ .option('--project <name>', 'the project, if you are not in its folder')
153
+ .action(amend)
154
+ plan
155
+ .command('reject <task>')
156
+ .description('Send a plan back with what should change')
157
+ .requiredOption('--note <text>', 'what should change')
158
+ .option('--project <name>', 'the project, if you are not in its folder')
159
+ .action(decide(false))
160
+ },
161
+ }