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