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,165 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Os = require('os')
5
+ const Path = require('path')
6
+ const { spawnSync } = require('child_process')
7
+ const { hashValue, approachBasis, TEST_FILE } = require('../shared')
8
+ const { buildBrief } = require('./brief')
9
+
10
+ const MAX_CONTEXT_CHARS = 6000
11
+ const MAX_EXCERPT_CHARS = 3500
12
+ const MAX_CHARS = MAX_CONTEXT_CHARS + MAX_EXCERPT_CHARS + 600
13
+ const clip = (value, max = 160) => {
14
+ const text = String(value || '').replace(/\s+/g, ' ')
15
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
16
+ }
17
+ const compactBrief = brief => {
18
+ const reasons = (brief.confidence.reasons || []).join('; ')
19
+ const lines = [`CODE MAP: confidence ${brief.confidence.level}${reasons ? ` (${clip(reasons, 240)})` : ''}. Ranked by local search of indexed base commits; a starting point, not proof.`]
20
+ let omitted = false
21
+ const add = line => {
22
+ if (lines.join('\n').length + line.length + 1 > MAX_CONTEXT_CHARS - 100) { omitted = true; return }
23
+ lines.push(line)
24
+ }
25
+ for (const [repo, basis] of Object.entries(brief.basis || {})) add(`Commit: ${repo}@${basis.commit} (${basis.branch})`)
26
+ add('Relevant files (ranked):')
27
+ for (const file of brief.files.slice(0, 8)) {
28
+ const symbols = file.symbols.slice(0, 3).map(symbol => `${clip(symbol.name, 64)}${symbol.line ? `@${symbol.line}` : ''}`).join(', ')
29
+ add(`${file.repo}/${file.path}${symbols ? ` — ${symbols}` : ''}${file.why.length ? `; ${clip(file.why[0])}` : ''}`)
30
+ }
31
+ if (!brief.files.length) add('No matching files; use the targeted search hints below.')
32
+ for (const chain of brief.chains.slice(0, 4)) add(`Chain: ${clip(chain.map(step => `${step.repo}/${step.path}${step.detail ? ` (${step.detail})` : ''}`).join(' → '), 300)}`)
33
+ for (const step of (brief.searchSteps || []).slice(0, 4)) add(`Search hint: ${clip(step.pattern, 200)}`)
34
+ for (const place of brief.placement.slice(0, 3)) add(`New files: ${place.repo}/${place.pattern}`)
35
+ for (const rule of (brief.conventions || []).slice(0, 3)) add(`Convention: ${clip(rule, 200)}`)
36
+ if (brief.files.length > 8 || omitted) lines.push('More context omitted to keep the brief bounded; ask for targeted details when needed.')
37
+ return lines.join('\n')
38
+ }
39
+
40
+ const showFile = (dir, commit, file) => {
41
+ const result = spawnSync('git', ['-C', dir, 'show', `${commit}:${file}`], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
42
+ return result.status === 0 ? result.stdout : null
43
+ }
44
+ const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45
+ const MAX_EXCERPT_LINES = 60
46
+ /** Names worth showing: the search's own hits ("method: getBrowserInfo") first, then the file's listed symbols (definitions only). */
47
+ const namesOf = file => {
48
+ const hits = file.why.flatMap(why => [...String(why).matchAll(/\b(?:method|function|class|mentions|calls|uses):\s*([A-Za-z_$][\w$]*)/g)].map(match => match[1]))
49
+ const seen = new Set()
50
+ return [...hits.map(name => ({ name, mention: true })), ...file.symbols.map(symbol => ({ name: symbol.name, mention: false }))]
51
+ .filter(item => /^[A-Za-z_$][\w$]{2,}$/.test(item.name) && !seen.has(item.name) && seen.add(item.name))
52
+ }
53
+ /** One definition (to its closing brace), else, when allowed, a few lines around the first mention. */
54
+ const excerptOf = (lines, name, mention) => {
55
+ const definition = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:(?:public|private|protected|static|async|readonly|override|function|const|let|var|def|class)\\s+)*${escape(name)}\\s*(?:[(<=:]|\\bextends\\b|\\{|$)`)
56
+ let start = lines.findIndex(line => definition.test(line))
57
+ let end
58
+ if (start !== -1) {
59
+ let depth = 0, opened = false
60
+ for (end = start; end < lines.length && end <= start + MAX_EXCERPT_LINES; end++) {
61
+ for (const char of lines[end]) {
62
+ if (char === '{') { depth++; opened = true } else if (char === '}') depth--
63
+ }
64
+ if (opened && depth <= 0) break
65
+ // No brace in the first lines (Python, one-line arrows): show a fixed window instead.
66
+ if (!opened && end >= start + 2) { end = Math.min(lines.length - 1, start + 11); break }
67
+ }
68
+ } else {
69
+ if (!mention) return null
70
+ const pattern = new RegExp(`\\b${escape(name)}\\b`)
71
+ const at = lines.findIndex(line => pattern.test(line))
72
+ if (at === -1) return null
73
+ start = Math.max(0, at - 2); end = Math.min(lines.length - 1, at + 3)
74
+ }
75
+ if (end - start >= MAX_EXCERPT_LINES) return null // a whole class is a file dump, not an excerpt
76
+ return { start, end: Math.min(end, lines.length - 1) }
77
+ }
78
+ /** Source of the matched symbols at the indexed commit, so the planner does not have to search for them. */
79
+ const codeExcerpts = (loaded, brief, paths) => {
80
+ const repos = Object.values(loaded.index.repos)
81
+ const parts = []
82
+ const notes = []
83
+ let used = 0
84
+ for (const file of brief.files.slice(0, 3)) {
85
+ const repo = repos.find(item => item.name === file.repo)
86
+ const dir = paths.get(file.repo)
87
+ if (!repo || !dir || !repo.commitSha) continue
88
+ const indexed = showFile(dir, repo.commitSha, file.path)
89
+ let text = null
90
+ try { text = Fs.readFileSync(Path.join(dir, file.path), 'utf8') } catch (err) { text = null }
91
+ if (text === null) {
92
+ if (indexed !== null) notes.push(`${file.repo}/${file.path} is deleted in the working tree.`)
93
+ continue
94
+ }
95
+ if (indexed !== null && indexed !== text) notes.push(`${file.repo}/${file.path} has changed since the indexed commit; the code below is the current working tree.`)
96
+ const lines = text.split('\n')
97
+ const ranges = []
98
+ for (const { name, mention } of namesOf(file)) {
99
+ const range = excerptOf(lines, name, mention)
100
+ if (range && !ranges.some(other => range.start <= other.end && other.start <= range.end)) ranges.push(range)
101
+ if (ranges.length >= 2) break
102
+ }
103
+ for (const range of ranges.sort((a, b) => a.start - b.start)) {
104
+ const body = lines.slice(range.start, range.end + 1).map((line, i) => `${String(range.start + i + 1).padStart(4)} ${line.replace(/\s+$/, '')}`).join('\n')
105
+ const part = `--- ${file.repo}/${file.path} lines ${range.start + 1}-${range.end + 1}\n${body}`
106
+ if (used + part.length > MAX_EXCERPT_CHARS) continue
107
+ parts.push(part)
108
+ used += part.length
109
+ }
110
+ }
111
+ return [...notes, ...(parts.length ? [`CODE (current working tree):\n${parts.join('\n')}`] : [])].join('\n')
112
+ }
113
+ /** Whether tests exist and how they run; without this the planner globs the repo for spec files. */
114
+ const testSetup = (loaded, brief, paths) =>
115
+ Object.values(loaded.index.repos).filter(repo => brief.basis && brief.basis[repo.name]).map(repo => {
116
+ const tests = (repo.files || []).map(file => file.path).filter(path => TEST_FILE.test(path) && !/\.json$/.test(path))
117
+ let script = null
118
+ const dir = paths.get(repo.name)
119
+ if (dir && repo.commitSha) {
120
+ try { script = (JSON.parse(showFile(dir, repo.commitSha, 'package.json') || '{}').scripts || {}).test || null } catch (err) { script = null }
121
+ }
122
+ return `Tests in ${repo.name}: ${tests.length ? `${tests.length} test files, e.g. ${tests.slice(0, 3).join(', ')}` : 'none found (no spec/test files)'}${script ? `; npm test runs: ${clip(script, 120)}` : ''}`
123
+ }).join('\n')
124
+
125
+ /** Uncommitted work changes the excerpts, so it is part of the cache key. */
126
+ const worktreeState = paths =>
127
+ [...paths].sort(([a], [b]) => a.localeCompare(b)).map(([name, dir]) => {
128
+ const git = args => {
129
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
130
+ return result.status === 0 ? result.stdout : ''
131
+ }
132
+ return [name, hashValue([git(['rev-parse', 'HEAD']), git(['status', '--porcelain']), git(['diff', 'HEAD'])])]
133
+ })
134
+
135
+ /**
136
+ * Local derived metadata only, keyed by requirements, keywords, feedback, indexed commits and the working tree.
137
+ * Returns the prompt text and the ranked files ("repo/path"), so a plan can be checked against what was found.
138
+ */
139
+ const planningBrief = (loaded, task, { paths = null } = {}) => {
140
+ const identity = hashValue({ project: String(loaded.project._id), task: task.key || task.id })
141
+ const basis = hashValue({
142
+ format: 3, requirements: approachBasis(task.card), keywords: task.keywords || task.brief?.keywords || [],
143
+ commits: Object.entries(loaded.index.repos).map(([id, repo]) => [id, repo.name, repo.branch, repo.commitSha]).sort(),
144
+ feedback: (loaded.graph.files || []).filter(file => file.history?.length).map(file => [file.repo, file.path, file.history]),
145
+ code: paths ? worktreeState(paths) : null,
146
+ })
147
+ const dir = Path.join(process.env.TSPL_HOME || Path.join(Os.homedir(), '.tspl'), 'planning-context')
148
+ const file = Path.join(dir, `${identity}.json`)
149
+ try {
150
+ const cached = JSON.parse(Fs.readFileSync(file, 'utf8'))
151
+ if (cached.basis === basis && typeof cached.text === 'string' && cached.text.length <= MAX_CHARS && Array.isArray(cached.files)) return { text: cached.text, files: cached.files }
152
+ } catch (err) {
153
+ if (err.code !== 'ENOENT' && !(err instanceof SyntaxError)) throw err
154
+ }
155
+ const { brief } = buildBrief(loaded, { ...task, id: task.id || task.key }, { keywords: task.keywords || task.brief?.keywords || [] })
156
+ const files = brief.files.map(item => `${item.repo}/${item.path}`)
157
+ const text = [compactBrief(brief), ...(paths ? [testSetup(loaded, brief, paths), codeExcerpts(loaded, brief, paths)] : [])].filter(Boolean).join('\n')
158
+ Fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
159
+ // A partial/interleaved cache write is disposable and rebuilt on the next read.
160
+ Fs.writeFileSync(file, JSON.stringify({ basis, text, files }), { mode: 0o600 })
161
+ Fs.chmodSync(file, 0o600)
162
+ return { text, files }
163
+ }
164
+ const planningContext = (loaded, task, options) => planningBrief(loaded, task, options).text
165
+ module.exports = { planningContext, planningBrief, compactBrief, MAX_CONTEXT_CHARS }
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ const { readEngines } = require('../config-store')
4
+ const { ENGINES, detectAll, defaultAssignments, run } = require('../engines')
5
+ const { renderFiles } = require('./brief')
6
+
7
+ const list = value => (Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : [])
8
+
9
+ const engineFor = step => {
10
+ const saved = readEngines()
11
+ const assignments = saved.assignments || defaultAssignments(detectAll())
12
+ const name = assignments[step]
13
+ if (!name) throw new Error('No AI engine is set up on this computer. Run: tspl engines')
14
+ return { name, model: (saved.models || {})[step] || null }
15
+ }
16
+
17
+ const refinePrompt = (task, brief) =>
18
+ [
19
+ 'You help prepare a software task before any code is planned. Answer from the text below only; do not open files or run tools.',
20
+ '',
21
+ 'TASK',
22
+ task.summary,
23
+ '',
24
+ `FILES tspl FOUND without AI (confidence ${brief.confidence.level.toUpperCase()}: ${brief.confidence.reasons.join('; ')})`,
25
+ ...renderFiles(brief.files.slice(0, 8)),
26
+ brief.unknown && brief.unknown.length ? `Words of the task that never appear in the code: ${brief.unknown.join(', ')}` : null,
27
+ '',
28
+ 'Reply with JSON only, no other text:',
29
+ '{"keywords": ["5-15 names or words the code probably uses for this task: functions, fields, labels, API paths, other words for the user\'s words"],',
30
+ ' "doneWhen": ["2-4 short testable checks, only if the task lists none"],',
31
+ ' "questions": ["up to 3 questions for the user, only if something important is unclear"],',
32
+ ' "note": "one sentence on where the change most likely lives and why"}',
33
+ ]
34
+ .filter(line => line !== null)
35
+ .join('\n')
36
+
37
+ const parseRefinement = text => {
38
+ const match = String(text || '').match(/\{[\s\S]*\}/)
39
+ if (!match) return null
40
+ try {
41
+ const data = JSON.parse(match[0])
42
+ return {
43
+ keywords: list(data.keywords).slice(0, 20),
44
+ doneWhen: list(data.doneWhen).slice(0, 6),
45
+ questions: list(data.questions).slice(0, 3),
46
+ note: typeof data.note === 'string' ? data.note.trim() : '',
47
+ }
48
+ } catch (err) {
49
+ return null
50
+ }
51
+ }
52
+
53
+ const refineTask = async (task, brief, { cwd } = {}) => {
54
+ const { name, model } = engineFor('plan')
55
+ const result = await run(name, refinePrompt(task, brief), { model, cwd, noTools: true, timeoutMs: 180000 })
56
+ if (!result.ok) {
57
+ throw new Error(
58
+ result.limitHit
59
+ ? `${ENGINES[name].label} has reached its usage limit. Try later, or another engine: tspl engines set plan <engine>`
60
+ : `${ENGINES[name].label} failed: ${result.error}`,
61
+ )
62
+ }
63
+ const parsed = parseRefinement(result.text)
64
+ if (!parsed) throw new Error(`${ENGINES[name].label} did not answer with the expected JSON`)
65
+ return { ...parsed, engine: name, model, usage: result.usage || null, ms: result.ms, at: new Date().toISOString() }
66
+ }
67
+
68
+ module.exports = {
69
+ engineFor,
70
+ refinePrompt,
71
+ parseRefinement,
72
+ refineTask,
73
+ }
@@ -0,0 +1,336 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+ const { spawnSync } = require('child_process')
5
+ const { TEST_FILE, validateApproachProposal, bindApproach } = require('../shared')
6
+
7
+ const { agentCapabilities, agentCommand, parseOutput, firstError, AUTH_PATTERN } = require('../engines')
8
+ const { runProcess } = require('./process')
9
+
10
+ const LIMIT_PATTERN = /usage limit|rate limit|limit reached|limit exceeded|quota|too many requests|\b429\b/i
11
+
12
+ class AgentError extends Error {
13
+ /** code: unsupported, auth, limit, timeout, cancelled, invalid-output, stale, modified-worktree, failed */
14
+ constructor(code, message) {
15
+ super(message)
16
+ this.code = code
17
+ }
18
+ }
19
+
20
+ const newSessionId = () => Crypto.randomUUID()
21
+
22
+ /** Runs one provider invocation with the shared process lifecycle (timeout, cancellation, bounded output). */
23
+ const runAgent = async (engine, role, { prompt, model, dirs, sessionId, schema, timeoutMs, signal }) => {
24
+ const [command, args] = agentCommand(engine, role, { prompt, model, dirs, sessionId, schema })
25
+ const result = await runProcess(command, args, { cwd: dirs[0], timeoutMs, signal, maxOutputBytes: 1024 * 1024 })
26
+ const parsed = parseOutput(engine, result.stdout)
27
+ const message = `${parsed.text}\n${result.stderr}`
28
+ const problem = result.exitCode !== 0 || parsed.failed || result.error
29
+ return {
30
+ ok: !problem && !result.timedOut && !result.cancelled,
31
+ text: parsed.text,
32
+ usage: parsed.usage,
33
+ sessionId: parsed.sessionId || sessionId,
34
+ timedOut: result.timedOut,
35
+ cancelled: result.cancelled,
36
+ limitHit: Boolean(problem) && LIMIT_PATTERN.test(message),
37
+ authFailed: Boolean(problem) && AUTH_PATTERN.test(message),
38
+ error: problem ? result.error || firstError(result.stderr) || firstError(parsed.text) || `exited with code ${result.exitCode}` : null,
39
+ }
40
+ }
41
+
42
+ const checkCapability = (engine, role) => {
43
+ let capability
44
+ try {
45
+ capability = agentCapabilities(engine)
46
+ } catch (err) {
47
+ throw new AgentError('unsupported', err.message)
48
+ }
49
+ if (!capability[role]) throw new AgentError('unsupported', `${engine} cannot do the "${role}" step on its own; choose another engine with: tspl engines set`)
50
+ return capability
51
+ }
52
+
53
+ /** Calls the agent and turns provider failures into blockers; success is only returned for a clean, finished run. */
54
+ const invoke = async (engine, role, options, deps) => {
55
+ const agent = (deps && deps.agent) || runAgent
56
+ const result = await agent(engine, role, options)
57
+ if (result.cancelled) throw new AgentError('cancelled', `The ${role} run was cancelled`)
58
+ if (result.timedOut) throw new AgentError('timeout', `The ${role} run ran out of time`)
59
+ if (!result.ok) {
60
+ if (result.limitHit) throw new AgentError('limit', `${engine}'s usage limit is reached; resume later`)
61
+ if (result.authFailed) throw new AgentError('auth', `${engine} is not logged in or has no access: ${result.error}`)
62
+ throw new AgentError('failed', `${engine} failed: ${result.error}`)
63
+ }
64
+ return result
65
+ }
66
+
67
+ /** Pulls the first balanced JSON object out of a reply, tolerating prose and code fences around it. */
68
+ const extractJson = text => {
69
+ const source = String(text || '')
70
+ for (let start = source.indexOf('{'); start !== -1; start = source.indexOf('{', start + 1)) {
71
+ let depth = 0
72
+ let inString = false
73
+ for (let i = start; i < source.length; i++) {
74
+ const char = source[i]
75
+ if (inString) {
76
+ if (char === '\\') i++
77
+ else if (char === '"') inString = false
78
+ } else if (char === '"') inString = true
79
+ else if (char === '{') depth++
80
+ else if (char === '}' && --depth === 0) {
81
+ try {
82
+ return JSON.parse(source.slice(start, i + 1))
83
+ } catch (err) {
84
+ break
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ const FILE_ITEM = { type: 'object', properties: { file: { type: 'string' }, why: { type: 'string' } }, required: ['file', 'why'] }
93
+
94
+ const PLAN_SCHEMA = {
95
+ type: 'object',
96
+ properties: {
97
+ edit: { type: 'array', items: FILE_ITEM },
98
+ create: { type: 'array', items: FILE_ITEM },
99
+ remove: { type: 'array', items: FILE_ITEM },
100
+ screens: { type: 'array', items: { type: 'string' } },
101
+ tests: {
102
+ type: 'array',
103
+ items: {
104
+ type: 'object',
105
+ properties: { name: { type: 'string' }, file: { type: 'string' }, checks: { type: 'array', items: { type: 'number' } }, command: { type: 'string' } },
106
+ required: ['name'],
107
+ },
108
+ },
109
+ notes: { type: 'string' },
110
+ testCases: {
111
+ type: 'array',
112
+ items: {
113
+ type: 'object',
114
+ properties: { title: { type: 'string' }, steps: { type: 'array', items: { type: 'string' } }, expected: { type: 'string' } },
115
+ required: ['title'],
116
+ },
117
+ },
118
+ },
119
+ required: ['edit', 'create', 'remove', 'tests'],
120
+ }
121
+
122
+ const IMPLEMENT_SCHEMA = {
123
+ type: 'object',
124
+ properties: { summary: { type: 'string' }, notes: { type: 'string' } },
125
+ required: ['summary'],
126
+ }
127
+
128
+ const isList = value => Array.isArray(value)
129
+
130
+ const validatePlan = value => {
131
+ if (!value || typeof value !== 'object') throw new AgentError('invalid-output', 'The plan was not a JSON object')
132
+ ;['edit', 'create', 'remove', 'tests'].forEach(field => {
133
+ if (!isList(value[field])) throw new AgentError('invalid-output', `The plan has no "${field}" list`)
134
+ })
135
+ ;['edit', 'create', 'remove'].forEach(field => {
136
+ value[field].forEach(item => {
137
+ if (!item || typeof item.file !== 'string' || !item.file.trim()) throw new AgentError('invalid-output', `A "${field}" entry has no file`)
138
+ })
139
+ })
140
+ value.tests.forEach(test => {
141
+ if (!test || typeof test.name !== 'string' || !test.name.trim()) throw new AgentError('invalid-output', 'A test entry has no name')
142
+ })
143
+ return value
144
+ }
145
+
146
+ const list = items => items.map(item => ` ${item}`).join('\n')
147
+
148
+ /** The task's test cases as the AI should see them: TC<n> is done-when item n, with its steps and expected result. */
149
+ const testCaseLines = card =>
150
+ ((card && card.doneWhen) || []).flatMap((text, i) => {
151
+ const testCase = ((card && card.testCases) || [])[i] || {}
152
+ return [
153
+ ` TC${i + 1}: ${text}`,
154
+ ...(testCase.steps || []).map((step, n) => ` step ${n + 1}: ${step}`),
155
+ ...(testCase.expected && testCase.expected !== text ? [` expected: ${testCase.expected}`] : []),
156
+ ]
157
+ })
158
+
159
+ const TEST_NAMING = [
160
+ 'Name every test after the test case it proves, starting with its id, for example test(\'TC2: a blank email shows an error\'). tspl reports each test case from these names.',
161
+ 'For a screen, write a browser test (Playwright) when the project has one, and save a screenshot after each step:',
162
+ ' await page.screenshot({ path: test.info().outputPath(\'step-1.png\') })',
163
+ ].join('\n')
164
+
165
+ const CONTEXT_READING_RULES = 'The code map comes from a local search and can be wrong or incomplete. Start from it: first check that the listed code (the excerpts usually show it) really is where the task\'s behaviour lives. If it is, read only what you still need around it, without searching the whole repository. If it is not, if confidence is LOW, or if task words were not found, search for the task\'s key names and terms before planning, then read at most eight relevant files. Never plan an edit to code you have not seen. Avoid whole-file dumps where a symbol or line range answers the question.'
166
+ /** Brief paths are "<repo>/<path>"; without this mapping the model guesses the folder and its first reads fail. */
167
+ const repoFolders = paths =>
168
+ paths && paths.size ? `REPO FOLDERS: a file reference "<repo>/<path>" is <path> inside that repo's folder:\n${[...paths].map(([name, dir]) => ` ${name} = ${dir}`).join('\n')}` : ''
169
+ const TOP_LEVEL_JSON = 'Put these fields at the top level of the reply object; do not wrap them in another key or a string.'
170
+ const codeNoteLines = notes =>
171
+ notes && notes.length
172
+ ? ['CODE ALREADY READ IN THE APPROACH STEP (trust these; read code only for facts missing here)', ...notes.map(item => ` ${item.file}${item.lines ? `:${item.lines}` : ''} — ${item.note}`)].join('\n')
173
+ : ''
174
+ const selectedApproachContext = task => {
175
+ const selected = bindApproach(task.approaches, task.card, { version: task.approaches.version, optionId: task.approaches.selection && task.approaches.selection.optionId })
176
+ return { version: selected.version, optionId: selected.optionId, title: selected.option.title, summary: selected.option.summary, benefits: selected.option.benefits, drawbacks: selected.option.drawbacks, files: selected.option.files }
177
+ }
178
+
179
+ const testCaseSection = card =>
180
+ (card && card.doneWhen && card.doneWhen.length)
181
+ ? ['TEST CASES (each must be proven by a test, or by a person when it cannot be automated)', ...testCaseLines(card)]
182
+ : ['TEST CASES: none yet. Propose 2-6 in "testCases" (title, steps, expected); they become TC1..TCn and each test\'s "checks" lists the numbers it proves.']
183
+ const PLAN_REPLY = '{ "edit": [{"file": "repo/path", "why": "..."}], "create": [...], "remove": [...], "screens": [], "tests": [{"name": "TC1: ...", "file": "repo/path", "checks": [test case numbers], "command": "command that runs it from the repo folder"}], "testCases": [{"title": "...", "steps": ["..."], "expected": "..."}], "notes": "" }'
184
+ const PLAN_FILES_RULE = 'List every file the change touches, including new test files under "create".'
185
+
186
+ const planPrompt = ({ task, instructions, brief, paths }) =>
187
+ [
188
+ 'You are planning a software change. Read the code with your read-only tools; do not change anything.',
189
+ repoFolders(paths),
190
+ '',
191
+ `TASK ${task.id || task.key}: ${JSON.stringify(task.card || {})}`,
192
+ ...testCaseSection(task.card),
193
+ brief ? `\nFILES FOUND BY tspl\n${brief}\n${CONTEXT_READING_RULES}` : '',
194
+ instructions ? `\nPROJECT RULES\n${instructions}` : '',
195
+ task.approaches ? `SELECTED APPROACH\n${JSON.stringify(selectedApproachContext(task))}\nCreate the plan using this selected approach; explain its application in notes.` : '',
196
+ task.approaches ? codeNoteLines(task.approaches.codeNotes) : '',
197
+ '',
198
+ `Reply with JSON only: ${PLAN_REPLY}. ${PLAN_FILES_RULE}`,
199
+ TOP_LEVEL_JSON,
200
+ ].join('\n')
201
+
202
+ const planFiles = plan =>
203
+ new Set(['edit', 'create', 'remove'].flatMap(kind => ((plan && plan[kind]) || []).map(item => (typeof item === 'string' ? item : item.file))))
204
+
205
+ const gitOut = (dir, args) => {
206
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
207
+ return result.status === 0 ? result.stdout : ''
208
+ }
209
+
210
+ /** Everything changed since `before` in the repo: new commits and uncommitted files. Independent of what the AI says. */
211
+ const changedSince = (dir, before) => {
212
+ const files = new Set()
213
+ if (before) gitOut(dir, ['diff', '--name-only', `${before}..HEAD`]).split('\n').filter(Boolean).forEach(file => files.add(file))
214
+ gitOut(dir, ['status', '--porcelain', '--untracked-files=all'])
215
+ .split('\n')
216
+ .filter(Boolean)
217
+ .forEach(line => files.add(line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()))
218
+ return [...files].sort()
219
+ }
220
+
221
+ const implementPrompt = ({ task, instructions, findings, branch }) =>
222
+ [
223
+ `You are implementing task ${task.id || task.key} on branch ${branch || 'the current branch'}. Change only the files in the approved plan (and tests); anything else is refused.`,
224
+ `Stay on ${branch ? `branch ${branch}` : 'the current branch'} in every repo: do not create, switch, rename, delete or reset branches, and do not run git checkout, switch, reset, rebase, merge, stash, commit or push. Work that ends on another branch is refused.`,
225
+ '',
226
+ 'APPROVED PLAN',
227
+ list(['edit', 'create', 'remove'].flatMap(kind => ((task.plan && task.plan[kind]) || []).map(item => `${kind}: ${item.file}${item.why ? ` - ${item.why}` : ''}`))),
228
+ '',
229
+ 'TEST CASES',
230
+ ...testCaseLines(task.card),
231
+ TEST_NAMING,
232
+ instructions ? `\nPROJECT RULES\n${instructions}` : '',
233
+ findings && findings.length
234
+ ? `\nFIX THESE FINDINGS FIRST (from the last test run or review):\n${list(findings.map(finding => (typeof finding === 'string' ? finding : `${finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''} ` : ''}${finding.message}`)))}`
235
+ : '',
236
+ '',
237
+ 'Run the relevant tests yourself while you work. Do not commit; tspl commits and checks your work. When finished reply with JSON only: {"summary": "what you changed"}.',
238
+ ].join('\n')
239
+
240
+ const APPROACH_SCHEMA = {
241
+ type: 'object', properties: {
242
+ options: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'object', properties: {
243
+ id: { type: 'string' }, title: { type: 'string' }, summary: { type: 'string' },
244
+ benefits: { type: 'array', items: { type: 'string' } }, drawbacks: { type: 'array', items: { type: 'string' } }, files: { type: 'array', items: { type: 'string' } },
245
+ // files is optional (the validator defaults it): a schema miss makes the provider regenerate the whole reply.
246
+ }, required: ['id', 'title', 'summary', 'benefits', 'drawbacks'] } },
247
+ recommendedId: { type: 'string' }, reason: { type: 'string' },
248
+ codeNotes: { type: 'array', maxItems: 12, items: { type: 'object', properties: { file: { type: 'string' }, lines: { type: 'string' }, note: { type: 'string' } }, required: ['file', 'note'] } },
249
+ }, required: ['options', 'recommendedId', 'reason'],
250
+ }
251
+ const APPROACH_WITH_PLAN_SCHEMA = { ...APPROACH_SCHEMA, properties: { ...APPROACH_SCHEMA.properties, plan: PLAN_SCHEMA }, required: [...APPROACH_SCHEMA.required, 'plan'] }
252
+ /**
253
+ * Offers approaches for the user to choose from. With withPlan, the same run also drafts the plan for its recommended
254
+ * option (returned as draftPlan, never shown before the choice), so choosing the recommendation needs no second run.
255
+ */
256
+ const proposeTaskApproaches = async (context, deps = {}) => {
257
+ checkCapability(context.engine, 'plan')
258
+ const withPlan = Boolean(context.withPlan)
259
+ const prompt = [
260
+ 'Read relevant code with read-only tools; do not edit. Compare two or three meaningful implementation approaches before planning.',
261
+ repoFolders(context.paths),
262
+ `TASK ${JSON.stringify(context.task.card)}`,
263
+ ...(withPlan ? testCaseSection(context.task.card) : []),
264
+ context.brief ? `FILES FOUND\n${context.brief}\n${CONTEXT_READING_RULES}` : '',
265
+ context.instructions ? `PROJECT RULES\n${context.instructions}` : '',
266
+ 'Reply with JSON only: {"options":[{"id":"stable-id","title":"Name","summary":"What changes","benefits":["Benefit"],"drawbacks":["Trade-off"],"files":["repo/path"]}],"recommendedId":"one offered ID","reason":"Why it fits this task"}. Offer a third option only when it is genuinely distinct. Keep each summary at most 60 words, with at most two benefits and two drawbacks, each a short phrase. The user chooses the option; do not select one.',
267
+ 'Also return "codeNotes": [{"file":"repo/path","lines":"120-140","note":"what is there"}]: at most 6, one short sentence each, recording only the facts a later planning step would need (functions to change, their current behaviour, test setup and test command or that none exists) so it does not search again.',
268
+ withPlan ? `Also return "plan": the implementation plan for your recommended option only, shaped ${PLAN_REPLY}. ${PLAN_FILES_RULE} Keep "notes" under 150 words. It stays hidden until the user chooses and is used only if they choose that option.` : '',
269
+ TOP_LEVEL_JSON,
270
+ ].join('\n')
271
+ const result = await invoke(context.engine, 'plan', { prompt, model: context.model, dirs: [...context.paths.values()], sessionId: newSessionId(), schema: withPlan ? APPROACH_WITH_PLAN_SCHEMA : APPROACH_SCHEMA, timeoutMs: context.timeoutMs, signal: context.signal }, deps)
272
+ const reply = extractJson(result.text)
273
+ let proposal
274
+ try { proposal = validateApproachProposal(reply) } catch (err) { throw new AgentError('invalid-output', err.message) }
275
+ if (!withPlan) return proposal
276
+ // A bad draft only costs the separate planning run it would have saved.
277
+ let draftPlan = null
278
+ try { draftPlan = validatePlan(reply.plan) } catch (err) { draftPlan = null }
279
+ return { ...proposal, draftPlan }
280
+ }
281
+
282
+ /** Plans a task headlessly with a read-only engine. Returns a schema-valid plan. */
283
+ const planTask = async (context, deps = {}) => {
284
+ checkCapability(context.engine, 'plan')
285
+ let approach
286
+ try { approach = bindApproach(context.task.approaches, context.task.card, { version: context.task.approaches && context.task.approaches.version, optionId: context.task.approaches && context.task.approaches.selection && context.task.approaches.selection.optionId }) } catch (err) { throw new AgentError('stale', err.message) }
287
+ const binding = { version: approach.version, optionId: approach.optionId }
288
+ if (context.draft && context.draft.optionId === approach.optionId) {
289
+ try { return { ...validatePlan(structuredClone(context.draft.plan)), approach: binding } } catch (err) { /* plan it afresh below */ }
290
+ }
291
+ const dirs = [...context.paths.values()]
292
+ const result = await invoke(context.engine, 'plan', { prompt: planPrompt(context), model: context.model, dirs, sessionId: newSessionId(), schema: PLAN_SCHEMA, timeoutMs: context.timeoutMs, signal: context.signal }, deps)
293
+ const plan = extractJson(result.text)
294
+ if (!plan) throw new AgentError('invalid-output', 'The plan reply had no JSON')
295
+ return { ...validatePlan(plan), approach: binding }
296
+ }
297
+
298
+ /**
299
+ * Lets the coding engine edit the repos. What changed is read from Git afterwards, never from the engine's own report.
300
+ * Returns { summary, sessionId, usage, changes: [{ repo, files }], outOfPlan: [files] }.
301
+ */
302
+ const implementTask = async (context, deps = {}) => {
303
+ checkCapability(context.engine, 'edit')
304
+ const before = new Map(context.heads.map(head => [head.name, head.headSha]))
305
+ const dirs = [...context.paths.values()]
306
+ const sessionId = newSessionId()
307
+ const result = await invoke(context.engine, 'edit', {
308
+ prompt: implementPrompt({ task: context.task, instructions: context.instructions, findings: context.findings, branch: context.branch }),
309
+ model: context.model, dirs, sessionId, schema: IMPLEMENT_SCHEMA, timeoutMs: context.timeoutMs, signal: context.signal,
310
+ }, deps)
311
+ const reply = extractJson(result.text) || {}
312
+ const planned = planFiles(context.task.plan)
313
+ const changes = [...context.paths.entries()].map(([repo, dir]) => ({ repo, files: changedSince(dir, before.get(repo)) })).filter(entry => entry.files.length)
314
+ const outOfPlan = changes.flatMap(entry =>
315
+ entry.files.filter(file => !planned.has(`${entry.repo}/${file}`) && !TEST_FILE.test(file)).map(file => `${entry.repo}/${file}`),
316
+ )
317
+ return { summary: String(reply.summary || '').slice(0, 2000), sessionId: result.sessionId || sessionId, usage: result.usage || null, changes, outOfPlan }
318
+ }
319
+
320
+ module.exports = {
321
+ testCaseLines,
322
+ TEST_NAMING,
323
+ AgentError,
324
+ PLAN_SCHEMA,
325
+ IMPLEMENT_SCHEMA,
326
+ newSessionId,
327
+ runAgent,
328
+ checkCapability,
329
+ invoke,
330
+ extractJson,
331
+ validatePlan,
332
+ changedSince,
333
+ planTask,
334
+ proposeTaskApproaches,
335
+ implementTask,
336
+ }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ const Crypto = require('crypto')
4
+ const Fs = require('fs')
5
+ const Os = require('os')
6
+ const Path = require('path')
7
+
8
+ const TTL_MS = 60 * 1000
9
+
10
+ const homeDir = () => process.env.TSPL_HOME || Path.join(Os.homedir(), '.tspl')
11
+
12
+ const lockFile = dir => Path.join(homeDir(), 'locks', `${Crypto.createHash('sha1').update(Fs.realpathSync(dir)).digest('hex')}.json`)
13
+
14
+ const alive = pid => {
15
+ try {
16
+ process.kill(pid, 0)
17
+ return true
18
+ } catch (err) {
19
+ return err.code === 'EPERM'
20
+ }
21
+ }
22
+
23
+ const readLock = file => {
24
+ try {
25
+ return JSON.parse(Fs.readFileSync(file, 'utf8'))
26
+ } catch (err) {
27
+ return null
28
+ }
29
+ }
30
+
31
+ const live = (lock, now) => Boolean(lock) && lock.expiresAt > now && alive(lock.pid)
32
+
33
+ /**
34
+ * One task at a time per checkout on this machine, whatever the server lease says. A lock expires with its process or
35
+ * after a minute without renewal. Returns { renew(), release() } or throws naming the task that holds the checkout.
36
+ */
37
+ const claimCheckouts = (paths, { runId, taskId }, deps = {}) => {
38
+ const now = deps.now || Date.now
39
+ const pid = deps.pid || process.pid
40
+ const dirs = [...new Set([...paths.values()])]
41
+ const held = []
42
+ const release = () => {
43
+ held.forEach(file => {
44
+ const lock = readLock(file)
45
+ if (lock && lock.runId === runId) Fs.rmSync(file, { force: true })
46
+ })
47
+ }
48
+ try {
49
+ dirs.forEach(dir => {
50
+ const file = lockFile(dir)
51
+ Fs.mkdirSync(Path.dirname(file), { recursive: true, mode: 0o700 })
52
+ const body = () => JSON.stringify({ runId, taskId, pid, dir, expiresAt: now() + TTL_MS })
53
+ for (let attempt = 0; attempt < 3; attempt++) {
54
+ try {
55
+ Fs.writeFileSync(file, body(), { flag: 'wx', mode: 0o600 })
56
+ held.push(file)
57
+ return
58
+ } catch (err) {
59
+ if (err.code !== 'EEXIST') throw err
60
+ }
61
+ const other = readLock(file)
62
+ if (other && other.runId === runId) {
63
+ Fs.writeFileSync(file, body(), { mode: 0o600 })
64
+ held.push(file)
65
+ return
66
+ }
67
+ if (live(other, now())) {
68
+ throw new Error(`${Path.basename(dir)} is in use by task ${other.taskId} on this computer (process ${other.pid}); wait for it or stop it first: tspl build stop ${other.taskId}`)
69
+ }
70
+ Fs.rmSync(file, { force: true })
71
+ }
72
+ throw new Error(`Could not lock ${dir}; try again`)
73
+ })
74
+ } catch (err) {
75
+ release()
76
+ throw err
77
+ }
78
+ return {
79
+ renew() {
80
+ held.forEach(file => {
81
+ const lock = readLock(file)
82
+ if (lock && lock.runId === runId) Fs.writeFileSync(file, JSON.stringify({ ...lock, expiresAt: now() + TTL_MS }), { mode: 0o600 })
83
+ })
84
+ },
85
+ release,
86
+ }
87
+ }
88
+
89
+ module.exports = {
90
+ TTL_MS,
91
+ claimCheckouts,
92
+ }