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,276 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+ const { TEST_CATEGORIES, checkHash } = require('../shared')
6
+
7
+ const { runProcess } = require('./process')
8
+
9
+ const OUTPUT_TAIL = 4000
10
+ const SECRET = /(bearer\s+)[\w.~+/=-]+|\b(gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[abp]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b|((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)\S+/gi
11
+ // eslint-disable-next-line no-control-regex
12
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
13
+
14
+ const sanitize = (text, replacements = []) => {
15
+ let out = String(text || '').replace(ANSI, '').replace(/\r/g, '')
16
+ replacements.filter(([from]) => from).forEach(([from, to]) => { out = out.split(from).join(to) })
17
+ out = out.replace(SECRET, (match, bearer, token, assignment) => (bearer ? `${bearer}[redacted]` : assignment ? `${assignment}[redacted]` : '[redacted]'))
18
+ return out.length > OUTPUT_TAIL ? `…${out.slice(out.length - OUTPUT_TAIL)}` : out
19
+ }
20
+
21
+ const git = (dir, args) => {
22
+ const result = require('child_process').spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
23
+ return result.status === 0 ? result.stdout.trim() : null
24
+ }
25
+
26
+ /** The current commit, branch and cleanliness of each repo of a build. build = { branch, repos: [{ name, baseSha|base }] } */
27
+ const captureHeads = (paths, build) =>
28
+ (build.repos || []).map(entry => {
29
+ const dir = paths.get(entry.name)
30
+ const headSha = dir ? git(dir, ['rev-parse', 'HEAD']) : null
31
+ const status = dir ? git(dir, ['status', '--porcelain', '--untracked-files=all']) : null
32
+ return {
33
+ name: entry.name,
34
+ baseSha: entry.baseSha || entry.base || null,
35
+ headSha,
36
+ branch: dir ? git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) : null,
37
+ clean: Boolean(dir) && status === '',
38
+ }
39
+ })
40
+
41
+ const number = (text, pattern) => {
42
+ const all = [...String(text).matchAll(pattern)]
43
+ return all.length ? Number(all[all.length - 1][1]) : null
44
+ }
45
+
46
+ const CASE_TAG = /\bTC\s*-?\s*(\d+)\b/i
47
+ const MEDIA = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4' }
48
+ const MAX_FILES = 30
49
+ const MAX_FILE_BYTES = 10 * 1024 * 1024
50
+
51
+ /** "TC2: blank email shows an error" belongs to done-when item 2. */
52
+ const criterionOf = title => {
53
+ const match = CASE_TAG.exec(String(title || ''))
54
+ return match ? Number(match[1]) : null
55
+ }
56
+
57
+ /** Individual test results from node's TAP report, so each done-when item can show its own pass or fail. */
58
+ const nodeCases = text =>
59
+ [...String(text).matchAll(/^\s*(not ok|ok) \d+ - (.+?)(?:\s+#\s*(SKIP|TODO)\b.*)?$/gm)]
60
+ .filter(match => !/\.[cm]?[jt]s$/.test(match[2].trim()))
61
+ .map(match => ({ title: match[2].trim(), status: match[3] ? 'skipped' : match[1] === 'ok' ? 'passed' : 'failed', criterion: criterionOf(match[2]) }))
62
+ .slice(0, 300)
63
+
64
+ const readPlaywright = text => {
65
+ const source = String(text || '')
66
+ const start = source.indexOf('{')
67
+ const end = source.lastIndexOf('}')
68
+ if (start === -1 || end <= start) return null
69
+ try {
70
+ const data = JSON.parse(source.slice(start, end + 1))
71
+ return data && data.stats ? data : null
72
+ } catch (err) {
73
+ return null
74
+ }
75
+ }
76
+
77
+ /** Specs of a Playwright JSON report with their final status and attached screenshots or videos. */
78
+ const playwrightCases = data => {
79
+ const cases = []
80
+ const attachments = []
81
+ const walk = (suite, parents) => {
82
+ const titles = suite.title ? [...parents, suite.title] : parents
83
+ ;(suite.specs || []).forEach(spec => {
84
+ const title = [...titles, spec.title].filter(Boolean).join(' › ')
85
+ const results = (spec.tests || []).flatMap(item => item.results || [])
86
+ const last = results[results.length - 1] || {}
87
+ const status = last.status === 'passed' ? 'passed' : last.status === 'skipped' || !results.length ? 'skipped' : 'failed'
88
+ const criterion = criterionOf(title)
89
+ cases.push({ title, status, criterion })
90
+ results.forEach(result => (result.attachments || []).filter(item => item.path).forEach(item => attachments.push({ path: item.path, name: item.name, contentType: item.contentType, criterion, step: title })))
91
+ })
92
+ ;(suite.suites || []).forEach(child => walk(child, titles))
93
+ }
94
+ ;(data.suites || []).forEach(suite => walk(suite, []))
95
+ return { cases: cases.slice(0, 300), attachments }
96
+ }
97
+
98
+ /** Images and videos a check left in its artifacts folder during this run. */
99
+ const collectArtifacts = (dir, sinceMs) => {
100
+ const found = []
101
+ const visit = folder => {
102
+ let entries = []
103
+ try {
104
+ entries = Fs.readdirSync(folder, { withFileTypes: true })
105
+ } catch (err) {
106
+ return
107
+ }
108
+ entries.forEach(entry => {
109
+ const file = Path.join(folder, entry.name)
110
+ if (entry.isDirectory()) return visit(file)
111
+ const type = MEDIA[Path.extname(entry.name).toLowerCase()]
112
+ if (!type) return
113
+ const stat = Fs.statSync(file)
114
+ if (stat.mtimeMs + 1000 < sinceMs) return
115
+ const relative = Path.relative(dir, file)
116
+ found.push({ path: file, name: entry.name, contentType: type, criterion: criterionOf(relative), step: Path.dirname(relative) === '.' ? null : Path.dirname(relative) })
117
+ })
118
+ }
119
+ visit(dir)
120
+ return found
121
+ }
122
+
123
+ /** Keeps the uploadable files: known media types, not too big, a bounded number. */
124
+ const usableAttachments = list =>
125
+ list
126
+ .filter(item => MEDIA[Path.extname(item.path).toLowerCase()] || /^(image|video)\//.test(item.contentType || ''))
127
+ .filter(item => {
128
+ try {
129
+ const size = Fs.statSync(item.path).size
130
+ return size > 0 && size <= MAX_FILE_BYTES
131
+ } catch (err) {
132
+ return false
133
+ }
134
+ })
135
+ .map(item => ({ ...item, contentType: MEDIA[Path.extname(item.path).toLowerCase()] || item.contentType }))
136
+ .slice(0, MAX_FILES)
137
+
138
+ /** Counts executed tests from a runner's own report. Returns null when the report cannot be read. */
139
+ const parseResults = (parser, stdout, stderr) => {
140
+ const text = `${stdout}\n${stderr}`
141
+ if (parser === 'playwright-json') {
142
+ const data = readPlaywright(stdout)
143
+ if (!data) return null
144
+ const stats = data.stats
145
+ const failed = (stats.unexpected || 0)
146
+ const { cases, attachments } = playwrightCases(data)
147
+ return { testCount: (stats.expected || 0) + failed + (stats.flaky || 0), failed, skipped: stats.skipped || 0, cases, attachments }
148
+ }
149
+ if (parser === 'node-tap') {
150
+ const total = number(text, /^# tests (\d+)/gm) ?? number(text, /^ℹ tests (\d+)/gm)
151
+ if (total === null) return null
152
+ const skipped = number(text, /^# skipped (\d+)/gm) ?? number(text, /^ℹ skipped (\d+)/gm) ?? 0
153
+ const todo = number(text, /^# todo (\d+)/gm) ?? number(text, /^ℹ todo (\d+)/gm) ?? 0
154
+ const cancelled = number(text, /^# cancelled (\d+)/gm) ?? number(text, /^ℹ cancelled (\d+)/gm) ?? 0
155
+ const failed = number(text, /^# fail (\d+)/gm) ?? number(text, /^ℹ fail (\d+)/gm) ?? 0
156
+ // Node reports a test file that registered no tests as one passing "test" named after the file.
157
+ const emptyFiles = [...text.matchAll(/^# Subtest: (\S+\.[cm]?[jt]s)\nok \d+ - \1$/gm)].length
158
+ return { testCount: Math.max(0, total - skipped - todo - cancelled - emptyFiles), failed: failed + cancelled, skipped, cases: nodeCases(text) }
159
+ }
160
+ if (parser === 'jest-json') {
161
+ const json = text.match(/\{[^{}]*"numTotalTests"\s*:\s*\d+[\s\S]*?\}(?=\s*$)/m) || text.match(/\{[\s\S]*"numTotalTests"[\s\S]*\}/)
162
+ if (json) {
163
+ try {
164
+ const data = JSON.parse(json[0])
165
+ return { testCount: (data.numPassedTests || 0) + (data.numFailedTests || 0), failed: data.numFailedTests || 0, skipped: (data.numPendingTests || 0) + (data.numTodoTests || 0) }
166
+ } catch (err) {
167
+ // fall through to the summary line
168
+ }
169
+ }
170
+ const line = text.match(/^Tests:\s+(.*)$/m)
171
+ if (!line) return null
172
+ const count = name => Number((line[1].match(new RegExp(`(\\d+) ${name}`)) || [0, 0])[1])
173
+ return { testCount: count('passed') + count('failed'), failed: count('failed'), skipped: count('skipped') + count('todo') }
174
+ }
175
+ return null
176
+ }
177
+
178
+ const insideRepo = (repoDir, cwd) => {
179
+ try {
180
+ const real = Fs.realpathSync(Path.resolve(repoDir, cwd))
181
+ const repoReal = Fs.realpathSync(repoDir)
182
+ return real === repoReal || real.startsWith(`${repoReal}${Path.sep}`) ? real : null
183
+ } catch (err) {
184
+ return null
185
+ }
186
+ }
187
+
188
+ const headChanged = (before, after) =>
189
+ !after || after.headSha !== before.headSha || after.branch !== before.branch || after.clean !== true
190
+
191
+ /**
192
+ * Runs the approved checks against the captured heads and returns head-bound evidence.
193
+ * Nothing here trusts an AI report: a check passes only when it ran, exited 0 and (for tests) the runner counted tests.
194
+ * deps: { run, now, capture } for tests; defaults run real processes.
195
+ */
196
+ const runChecks = async ({ snapshot, heads, paths, signal, build }, deps = {}) => {
197
+ const run = deps.run || runProcess
198
+ const now = deps.now || (() => new Date().toISOString())
199
+ const capture = deps.capture || captureHeads
200
+ const evidence = []
201
+ const redactions = [...paths.entries()].map(([name, dir]) => [dir, `<${name}>`])
202
+
203
+ for (const check of snapshot.checks.filter(item => item.required)) {
204
+ const head = heads.find(item => item.name === check.repo)
205
+ const dir = paths.get(check.repo)
206
+ const base = {
207
+ checkId: check.id,
208
+ repo: check.repo,
209
+ headSha: head ? head.headSha : null,
210
+ configHash: snapshot.configHash,
211
+ commandHash: checkHash(check),
212
+ status: 'missing',
213
+ exitCode: null,
214
+ testCount: null,
215
+ executed: false,
216
+ source: 'cli',
217
+ startedAt: now(),
218
+ finishedAt: null,
219
+ output: '',
220
+ }
221
+ const done = (patch, output) => evidence.push({ ...base, ...patch, finishedAt: now(), output: sanitize(output, redactions) })
222
+
223
+ if (signal && signal.aborted) { done({ status: 'cancelled' }, 'Cancelled before it started.'); continue }
224
+ if (!head || !dir) { done({ status: 'missing' }, `Repo ${check.repo} is not available on this machine.`); continue }
225
+ const current = capture(paths, { branch: build && build.branch, repos: [head] })[0]
226
+ if (headChanged(head, current)) {
227
+ done({ status: 'stale' }, current && current.clean === false ? 'The repo has uncommitted changes, so this commit is not what would be tested.' : 'The branch or commit moved since the heads were captured.')
228
+ continue
229
+ }
230
+ if (build && build.branch && current.branch !== build.branch) { done({ status: 'stale' }, `Expected branch ${build.branch} but the repo is on ${current.branch}.`); continue }
231
+ const cwd = insideRepo(dir, check.cwd)
232
+ if (!cwd) { done({ status: 'missing' }, `Working directory ${check.cwd} does not exist inside ${check.repo}.`); continue }
233
+ const isTest = TEST_CATEGORIES.includes(check.category)
234
+ if (isTest && check.parser === 'custom' && check.attested !== true) { done({ status: 'missing' }, 'A custom test command must be attested in the project file before it counts.'); continue }
235
+
236
+ base.startedAt = now()
237
+ const result = await run('sh', ['-c', check.command], { cwd, timeoutMs: check.timeoutMs, signal, env: { CI: '1', NODE_TEST_CONTEXT: undefined } })
238
+ const after = capture(paths, { branch: build && build.branch, repos: [head] })[0]
239
+ const output = `${result.stdout}\n${result.stderr}${result.error ? `\n${result.error}` : ''}`.trim()
240
+ const patch = { executed: !result.error, exitCode: result.exitCode }
241
+ const startedMs = Date.parse(base.startedAt) || Date.now()
242
+ const files = check.artifacts ? collectArtifacts(Path.join(cwd, check.artifacts), startedMs) : []
243
+ if (files.length) patch.attachments = usableAttachments(files)
244
+ const counted = isTest && check.parser !== 'custom' ? parseResults(check.parser, result.stdout, result.stderr) : null
245
+ if (counted) {
246
+ patch.cases = counted.cases || []
247
+ patch.attachments = usableAttachments([...(counted.attachments || []), ...files.filter(file => !(counted.attachments || []).some(item => item.path === file.path))])
248
+ }
249
+
250
+ if (headChanged(head, after)) { done({ ...patch, status: 'stale' }, `The commit or worktree changed while the check ran.\n${output}`); continue }
251
+ if (result.cancelled) { done({ ...patch, status: 'cancelled' }, output); continue }
252
+ if (result.timedOut) { done({ ...patch, status: 'timed-out' }, output); continue }
253
+ if (result.error || result.exitCode !== 0) { done({ ...patch, status: 'failed' }, output); continue }
254
+
255
+ if (isTest && check.parser !== 'custom') {
256
+ if (!counted) { done({ ...patch, status: 'failed' }, `tspl could not read the ${check.parser} test results, so the check cannot count as passed.\n${output}`); continue }
257
+ if (counted.failed > 0) { done({ ...patch, status: 'failed', testCount: counted.testCount }, output); continue }
258
+ if (counted.testCount === 0) { done({ ...patch, status: 'failed', testCount: 0 }, `The check ran zero tests${counted.skipped ? ` (${counted.skipped} skipped)` : ''}; that is not evidence.\n${output}`); continue }
259
+ done({ ...patch, status: 'passed', testCount: counted.testCount }, output)
260
+ continue
261
+ }
262
+ done({ ...patch, status: 'passed' }, output)
263
+ }
264
+ return evidence
265
+ }
266
+
267
+ module.exports = {
268
+ criterionOf,
269
+ collectArtifacts,
270
+ usableAttachments,
271
+ OUTPUT_TAIL,
272
+ sanitize,
273
+ captureHeads,
274
+ parseResults,
275
+ runChecks,
276
+ }
@@ -0,0 +1,52 @@
1
+ 'use strict'
2
+
3
+ const START = '<!-- tspl:start -->'
4
+ const END = '<!-- tspl:end -->'
5
+
6
+ const row = (...cells) => `| ${cells.join(' | ')} |`
7
+
8
+ /** The tspl-managed section of a pull request description: what was asked, what ran on which commit, what review said. */
9
+ const renderPrBody = ({ task, summary, repo, head, evidence = [], criteria = [], review = null, limitations = [], ci = null }) => {
10
+ const lines = [
11
+ START,
12
+ `## ${task.card && task.card.ticket ? `${task.card.ticket} · ` : ''}${(task.card && task.card.what) || task.id}`,
13
+ '',
14
+ `tspl task \`${task.id || task.key}\` · repo \`${repo}\` · tested commit \`${head.headSha}\` (base \`${head.baseSha}\`)`,
15
+ '',
16
+ summary ? `${summary}\n` : '',
17
+ '### Acceptance',
18
+ ...(criteria.length ? criteria.map(item => `- ${item.text}: ${item.how}`) : ['- (no done-when items)']),
19
+ '',
20
+ '### Checks run on this commit (by the tspl CLI on the developer machine)',
21
+ row('Check', 'Result', 'Tests'),
22
+ row('---', '---', '---'),
23
+ ...evidence.map(item => row(item.checkId, item.status, item.testCount === null || item.testCount === undefined ? '-' : item.testCount)),
24
+ '',
25
+ '### Separate review',
26
+ review
27
+ ? `${review.verdict === 'passed' ? 'Passed' : 'Changes requested'} by ${review.engine || 'an AI reviewer'} in a fresh session on \`${review.heads.map(head => head.headSha).join(', ')}\`.${review.findings.length ? `\n${review.findings.map(item => `- [${item.severity}] ${item.file ? `${item.file}${item.line ? `:${item.line}` : ''}: ` : ''}${item.message}`).join('\n')}` : ' No findings.'}`
28
+ : 'No review recorded.',
29
+ '',
30
+ '### Required CI',
31
+ ci || 'Reported by the provider after the PR is opened; this section does not claim it.',
32
+ ...(limitations.length ? ['', '### Limitations', ...limitations.map(item => `- ${item}`)] : []),
33
+ END,
34
+ ]
35
+ return lines.join('\n')
36
+ }
37
+
38
+ /** Replaces only the tspl section; text a person wrote above or below it stays. */
39
+ const mergeBody = (existing, managed) => {
40
+ const body = String(existing || '')
41
+ const start = body.indexOf(START)
42
+ const end = body.indexOf(END)
43
+ if (start !== -1 && end > start) return `${body.slice(0, start)}${managed}${body.slice(end + END.length)}`
44
+ return `${body.trimEnd()}${body.trim() ? '\n\n' : ''}${managed}\n`
45
+ }
46
+
47
+ module.exports = {
48
+ START,
49
+ END,
50
+ renderPrBody,
51
+ mergeBody,
52
+ }
@@ -0,0 +1,82 @@
1
+ 'use strict'
2
+
3
+ const { spawn } = require('child_process')
4
+
5
+ const DEFAULT_MAX_OUTPUT = 64 * 1024
6
+ const KILL_GRACE_MS = 2000
7
+
8
+ const keepTail = (current, chunk, max) => {
9
+ const next = current + chunk
10
+ return next.length > max ? next.slice(next.length - max) : next
11
+ }
12
+
13
+ const killGroup = (child, signal) => {
14
+ try {
15
+ process.kill(-child.pid, signal)
16
+ } catch (err) {
17
+ try {
18
+ child.kill(signal)
19
+ } catch (ignored) {
20
+ // already gone
21
+ }
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Runs a command with a timeout and cancellation. The child gets its own process group so a timeout or an abort
27
+ * ends everything it started. Only the last maxOutputBytes of each stream are kept.
28
+ * Resolves (never rejects) with { exitCode, signal, stdout, stderr, timedOut, cancelled, error }.
29
+ */
30
+ const runProcess = (command, args = [], { cwd, timeoutMs, signal, input, maxOutputBytes = DEFAULT_MAX_OUTPUT, env } = {}) =>
31
+ new Promise(resolve => {
32
+ if (signal && signal.aborted) {
33
+ resolve({ exitCode: null, signal: null, stdout: '', stderr: '', timedOut: false, cancelled: true, error: null })
34
+ return
35
+ }
36
+ let stdout = ''
37
+ let stderr = ''
38
+ let timedOut = false
39
+ let cancelled = false
40
+ let settled = false
41
+ let forceTimer = null
42
+ const childEnv = { ...process.env, ...(env || {}) }
43
+ Object.keys(childEnv).forEach(key => childEnv[key] === undefined && delete childEnv[key])
44
+ const child = spawn(command, args, {
45
+ cwd,
46
+ env: childEnv,
47
+ detached: true,
48
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
49
+ })
50
+
51
+ const stop = () => {
52
+ killGroup(child, 'SIGTERM')
53
+ forceTimer = setTimeout(() => killGroup(child, 'SIGKILL'), KILL_GRACE_MS)
54
+ }
55
+ const timer = timeoutMs ? setTimeout(() => { timedOut = true; stop() }, timeoutMs) : null
56
+ const onAbort = () => { cancelled = true; stop() }
57
+ if (signal) signal.addEventListener('abort', onAbort, { once: true })
58
+
59
+ const finish = result => {
60
+ if (settled) return
61
+ settled = true
62
+ clearTimeout(timer)
63
+ clearTimeout(forceTimer)
64
+ if (signal) signal.removeEventListener('abort', onAbort)
65
+ killGroup(child, 'SIGKILL')
66
+ resolve({ stdout, stderr, timedOut, cancelled, error: null, ...result })
67
+ }
68
+
69
+ child.stdout.on('data', chunk => { stdout = keepTail(stdout, chunk.toString('utf8'), maxOutputBytes) })
70
+ child.stderr.on('data', chunk => { stderr = keepTail(stderr, chunk.toString('utf8'), maxOutputBytes) })
71
+ child.on('error', err => finish({ exitCode: null, signal: null, error: err.message }))
72
+ child.on('close', (code, sig) => finish({ exitCode: code, signal: sig }))
73
+ if (input !== undefined) {
74
+ child.stdin.on('error', () => {})
75
+ child.stdin.end(input)
76
+ }
77
+ })
78
+
79
+ module.exports = {
80
+ DEFAULT_MAX_OUTPUT,
81
+ runProcess,
82
+ }
@@ -0,0 +1,128 @@
1
+ 'use strict'
2
+
3
+ const { spawnSync } = require('child_process')
4
+
5
+ const { AgentError, checkCapability, invoke, extractJson, newSessionId, testCaseLines } = require('./agents')
6
+ const { captureHeads } = require('./evidence')
7
+
8
+ const MAX_DIFF = 60 * 1024
9
+ const SEVERITIES = ['low', 'medium', 'high', 'critical']
10
+
11
+ const REVIEW_SCHEMA = {
12
+ type: 'object',
13
+ properties: {
14
+ verdict: { type: 'string', enum: ['passed', 'changes-requested'] },
15
+ heads: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, headSha: { type: 'string' } }, required: ['name', 'headSha'] } },
16
+ findings: {
17
+ type: 'array',
18
+ items: {
19
+ type: 'object',
20
+ properties: { severity: { type: 'string', enum: SEVERITIES }, repo: { type: 'string' }, file: { type: 'string' }, line: { type: 'number' }, message: { type: 'string' } },
21
+ required: ['severity', 'message'],
22
+ },
23
+ },
24
+ },
25
+ required: ['verdict', 'heads', 'findings'],
26
+ }
27
+
28
+ const diffFor = (dir, head) => {
29
+ const result = spawnSync('git', ['-C', dir, 'diff', `${head.baseSha}...${head.headSha}`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
30
+ const diff = result.status === 0 ? result.stdout : '(the diff could not be read)'
31
+ return diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}\n… (diff cut at ${MAX_DIFF} bytes; read the files for the rest)` : diff
32
+ }
33
+
34
+ const reviewPrompt = ({ task, instructions, heads, paths, evidence }) =>
35
+ [
36
+ 'You are an independent reviewer. You did not write this change. Judge it only from the task, the approved scope, the diff, the project rules and the test results below. Do NOT edit any file.',
37
+ '',
38
+ `TASK ${task.id || task.key}: ${(task.card && task.card.what) || task.summary || ''}`,
39
+ 'TEST CASES (check that each one is really proven by a test named TC<n>, or left for a person)',
40
+ ...testCaseLines(task.card),
41
+ '',
42
+ 'APPROVED SCOPE',
43
+ ...['edit', 'create', 'remove'].flatMap(kind => ((task.plan && task.plan[kind]) || []).map(item => ` ${kind}: ${item.file}`)),
44
+ instructions ? `\nPROJECT RULES\n${instructions}` : '',
45
+ '',
46
+ 'TEST EVIDENCE',
47
+ ...(evidence || []).flatMap(item => [
48
+ ` ${item.checkId}: ${item.status}${item.testCount ? `, ${item.testCount} tests` : ''}`,
49
+ ...(item.cases || []).filter(entry => entry.criterion).map(entry => ` ${entry.title}: ${entry.status}`),
50
+ ]),
51
+ '',
52
+ ...heads.flatMap(head => [`COMMITS ${head.name}: ${head.baseSha}..${head.headSha}`, '```diff', diffFor(paths.get(head.name), head), '```', '']),
53
+ 'Reply with JSON only: {"verdict": "passed" | "changes-requested", "heads": [{"name": "<repo>", "headSha": "<the exact head sha above>"}], "findings": [{"severity": "low|medium|high|critical", "repo": "", "file": "", "line": 0, "message": ""}]}.',
54
+ 'Use "passed" only when nothing blocks merging. Bugs, missing tests, scope creep and rule violations are findings.',
55
+ ].join('\n')
56
+
57
+ const validateReview = (value, heads) => {
58
+ if (!value || typeof value !== 'object') throw new AgentError('invalid-output', 'The review was not a JSON object')
59
+ if (!['passed', 'changes-requested'].includes(value.verdict)) throw new AgentError('invalid-output', 'The review has no valid verdict')
60
+ if (!Array.isArray(value.heads) || !Array.isArray(value.findings)) throw new AgentError('invalid-output', 'The review needs "heads" and "findings" lists')
61
+ value.findings.forEach(finding => {
62
+ if (!finding || typeof finding.message !== 'string' || !finding.message.trim()) throw new AgentError('invalid-output', 'A finding has no message')
63
+ if (finding.severity && !SEVERITIES.includes(finding.severity)) throw new AgentError('invalid-output', `Unknown finding severity ${finding.severity}`)
64
+ })
65
+ heads.forEach(head => {
66
+ const seen = value.heads.find(item => item && item.name === head.name)
67
+ if (!seen || seen.headSha !== head.headSha) {
68
+ throw new AgentError('stale', `The review is for ${seen ? seen.headSha : 'no commit'} of ${head.name}, not the tested commit ${head.headSha}`)
69
+ }
70
+ })
71
+ if (value.verdict === 'passed' && value.findings.some(finding => ['high', 'critical'].includes(finding.severity))) {
72
+ throw new AgentError('invalid-output', 'The review passed the change but lists a high-severity finding')
73
+ }
74
+ return value
75
+ }
76
+
77
+ const sameState = (before, after) =>
78
+ before.length === after.length &&
79
+ before.every((head, i) => head.headSha === after[i].headSha && head.branch === after[i].branch && head.clean === after[i].clean)
80
+
81
+ /**
82
+ * Reviews the tested commits in a brand-new session. Anything but a schema-valid verdict on exactly these commits is a
83
+ * blocker (thrown as AgentError), never an approval. Edits made during the review invalidate it; they are reported, not undone.
84
+ */
85
+ const reviewTask = async (context, deps = {}) => {
86
+ checkCapability(context.engine, 'review')
87
+ const build = { repos: context.heads }
88
+ const current = (deps.capture || captureHeads)(context.paths, build)
89
+ context.heads.forEach(head => {
90
+ const now = current.find(item => item.name === head.name)
91
+ if (!now || now.headSha !== head.headSha) throw new AgentError('stale', `${head.name} is no longer at the tested commit ${head.headSha}; test again before reviewing`)
92
+ if (!now.clean) throw new AgentError('stale', `${head.name} has uncommitted changes; the review needs a clean, committed branch`)
93
+ })
94
+
95
+ const sessionId = newSessionId()
96
+ if (context.buildSessionId && context.buildSessionId === sessionId) throw new AgentError('invalid-output', 'The review must not reuse the coding session')
97
+ const dirs = [...context.paths.values()]
98
+ const result = await invoke(context.engine, 'review', {
99
+ prompt: reviewPrompt(context), model: context.model, dirs, sessionId, schema: REVIEW_SCHEMA, timeoutMs: context.timeoutMs, signal: context.signal,
100
+ }, deps)
101
+
102
+ if (context.buildSessionId && result.sessionId === context.buildSessionId) {
103
+ throw new AgentError('invalid-output', 'The review ran in the coding session; it must be a separate session')
104
+ }
105
+ const after = (deps.capture || captureHeads)(context.paths, build)
106
+ if (!sameState(current, after)) {
107
+ throw new AgentError('modified-worktree', 'The reviewer changed the repository. The review does not count; nothing was discarded, so look at git status.')
108
+ }
109
+ const reply = extractJson(result.text)
110
+ if (!reply) throw new AgentError('invalid-output', 'The review reply had no JSON')
111
+ validateReview(reply, context.heads)
112
+
113
+ return {
114
+ heads: context.heads.map(head => ({ name: head.name, headSha: head.headSha })),
115
+ verdict: reply.verdict,
116
+ findings: reply.findings.map(finding => ({ severity: finding.severity || 'medium', repo: finding.repo || null, file: finding.file || null, line: Number.isInteger(finding.line) ? finding.line : null, message: finding.message })),
117
+ engine: context.engine,
118
+ model: context.model || null,
119
+ sessionId: result.sessionId || sessionId,
120
+ reviewedAt: new Date().toISOString(),
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ REVIEW_SCHEMA,
126
+ validateReview,
127
+ reviewTask,
128
+ }