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,82 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+ const { execFileSync } = require('child_process')
6
+
7
+ const BIN = Path.join(__dirname, '..', '..', 'bin', 'tspl.js')
8
+ const HOOKS = ['pre-commit', 'pre-push']
9
+ const MARK = '# tspl edit lock'
10
+
11
+ const hooksDir = repoPath => {
12
+ const dir = execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
13
+ return Path.isAbsolute(dir) ? dir : Path.join(repoPath, dir)
14
+ }
15
+
16
+ const script = hook =>
17
+ [
18
+ '#!/bin/sh',
19
+ MARK,
20
+ `export TSPL_HOME="${process.env.TSPL_HOME || Path.join(require('os').homedir(), '.tspl')}"`,
21
+ 'prev="$0.tspl-prev"',
22
+ 'if [ -x "$prev" ]; then "$prev" "$@" || exit $?; fi',
23
+ `NODE="${process.execPath}"`,
24
+ '[ -x "$NODE" ] || NODE=node',
25
+ `exec "$NODE" "${BIN}" guard ${hook} "$@"`,
26
+ '',
27
+ ].join('\n')
28
+
29
+ const ours = file => {
30
+ try {
31
+ return Fs.readFileSync(file, 'utf8').includes(MARK)
32
+ } catch (err) {
33
+ return false
34
+ }
35
+ }
36
+
37
+ const installHooks = repoPath => {
38
+ const dir = hooksDir(repoPath)
39
+ Fs.mkdirSync(dir, { recursive: true })
40
+ HOOKS.forEach(hook => {
41
+ const file = Path.join(dir, hook)
42
+ if (Fs.existsSync(file) && !ours(file)) Fs.renameSync(file, `${file}.tspl-prev`)
43
+ Fs.writeFileSync(file, script(hook), { mode: 0o755 })
44
+ Fs.chmodSync(file, 0o755)
45
+ })
46
+ return dir
47
+ }
48
+
49
+ const removeHooks = repoPath => {
50
+ let dir
51
+ try {
52
+ dir = hooksDir(repoPath)
53
+ } catch (err) {
54
+ return false
55
+ }
56
+ let removed = false
57
+ HOOKS.forEach(hook => {
58
+ const file = Path.join(dir, hook)
59
+ if (ours(file)) {
60
+ Fs.rmSync(file, { force: true })
61
+ removed = true
62
+ }
63
+ if (Fs.existsSync(`${file}.tspl-prev`) && !Fs.existsSync(file)) Fs.renameSync(`${file}.tspl-prev`, file)
64
+ })
65
+ return removed
66
+ }
67
+
68
+ const hooksInstalled = repoPath => {
69
+ try {
70
+ const dir = hooksDir(repoPath)
71
+ return HOOKS.every(hook => ours(Path.join(dir, hook)))
72
+ } catch (err) {
73
+ return false
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ HOOKS,
79
+ installHooks,
80
+ removeHooks,
81
+ hooksInstalled,
82
+ }
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Os = require('os')
5
+ const Path = require('path')
6
+ const { hashValue, approachBasis } = require('../shared')
7
+
8
+ /**
9
+ * The plan drafted alongside the approaches, kept on this computer until the user chooses. It is only valid for the
10
+ * same requirements, the same offered options and the same code context; anything else plans afresh.
11
+ */
12
+ const dirOf = () => Path.join(process.env.TSPL_HOME || Path.join(Os.homedir(), '.tspl'), 'plan-drafts')
13
+ const fileOf = (projectId, task) => Path.join(dirOf(), `${hashValue({ project: String(projectId), task: task.key || task.id })}.json`)
14
+ const fingerprint = (card, approaches, brief) => hashValue({
15
+ requirements: approachBasis(card),
16
+ options: approaches.options.map(option => [option.id, option.title, option.summary]),
17
+ recommendedId: approaches.recommendedId,
18
+ brief: brief || '',
19
+ })
20
+
21
+ const saveDraft = (projectId, task, proposal, plan, brief) => {
22
+ Fs.mkdirSync(dirOf(), { recursive: true, mode: 0o700 })
23
+ const file = fileOf(projectId, task)
24
+ Fs.writeFileSync(file, JSON.stringify({ fingerprint: fingerprint(task.card, proposal, brief), optionId: proposal.recommendedId, plan }), { mode: 0o600 })
25
+ Fs.chmodSync(file, 0o600)
26
+ }
27
+
28
+ /** Returns { optionId, plan } once, or null; a used or stale draft is removed so a re-plan never repeats it. */
29
+ const takeDraft = (projectId, task, brief) => {
30
+ const file = fileOf(projectId, task)
31
+ let draft
32
+ try {
33
+ draft = JSON.parse(Fs.readFileSync(file, 'utf8'))
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT' || err instanceof SyntaxError) return null
36
+ throw err
37
+ }
38
+ Fs.rmSync(file, { force: true })
39
+ if (!task.approaches || draft.fingerprint !== fingerprint(task.card, task.approaches, brief)) return null
40
+ return { optionId: draft.optionId, plan: draft.plan }
41
+ }
42
+
43
+ module.exports = { saveDraft, takeDraft }
@@ -0,0 +1,123 @@
1
+ 'use strict'
2
+
3
+ const { byRepo, countLine, changesSince } = require('./plan')
4
+
5
+ const escape = value =>
6
+ String(value === undefined || value === null ? '' : value)
7
+ .replace(/&/g, '&')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+
12
+ const STATUS_LABELS = {
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 KIND_LABELS = { edit: 'edit', create: 'new', remove: 'remove' }
23
+
24
+ const approvalsHtml = task => {
25
+ const decision = stage => [...(task.decisions || [])].reverse().find(item => item.stage === stage && item.planVersion === (task.plan && task.plan.version))
26
+ const row = (stage, label) => {
27
+ const found = decision(stage)
28
+ if (!found) return `<li class="pending">${label}: waiting</li>`
29
+ const when = found.at ? new Date(found.at).toLocaleString() : ''
30
+ return `<li class="${found.approved ? 'yes' : 'no'}">${label}: ${found.approved ? 'approved' : 'asked for changes'} by ${escape(found.userName || 'someone')} ${escape(when)}${found.note ? ` — “${escape(found.note)}”` : ''}</li>`
31
+ }
32
+ return `<ul class="approvals">${row('developer', 'Developer')}${task.requirePmApproval === false ? '<li class="skip">PM: not required for this project</li>' : row('pm', 'PM')}</ul>`
33
+ }
34
+
35
+ const renderPlanPage = (task, projectName) => {
36
+ const plan = task.plan
37
+ const card = task.card || {}
38
+ const review = {
39
+ edit: (plan && plan.edit) || [],
40
+ create: (plan && plan.create) || [],
41
+ remove: (plan && plan.remove) || [],
42
+ tests: (plan && plan.tests) || [],
43
+ }
44
+ const testsFor = n => review.tests.filter(test => (test.checks || []).includes(n)).map(test => test.name)
45
+ const repos = []
46
+ byRepo(review).forEach((items, repo) => {
47
+ repos.push(`<section class="repo"><h3>${escape(repo)}</h3><ul class="files">${items
48
+ .map(item => `<li class="${item.kind}"><span class="badge ${item.kind}">${KIND_LABELS[item.kind]}</span><code>${escape(item.path)}</code>${item.why ? `<span class="why">${escape(item.why)}</span>` : ''}</li>`)
49
+ .join('')}</ul></section>`)
50
+ })
51
+ const versions = (task.plans || [])
52
+ .map((previous, i) => {
53
+ const next = i + 1 < (task.plans || []).length ? task.plans[i + 1] : plan
54
+ const changes = changesSince(previous, { edit: next.edit || [], create: next.create || [], remove: next.remove || [], tests: next.tests || [] })
55
+ return `<li>v${escape(previous.version)} → v${escape(next.version)}: ${changes.length ? changes.map(escape).join(', ') : 'no file changes'}</li>`
56
+ })
57
+ .join('')
58
+ const fields = [
59
+ ['Who', card.who],
60
+ ['Where', (card.apps || []).join(', ')],
61
+ ['How it works', card.how],
62
+ ['Steps', card.steps],
63
+ ['Now', card.now],
64
+ ['Expected', card.expected],
65
+ ].filter(([, value]) => value)
66
+
67
+ return `<!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1">
72
+ <title>Plan ${escape(task.key)}</title>
73
+ <style>
74
+ :root { --bg: #f7f7f5; --card: #ffffff; --text: #1d1d1b; --muted: #6b6b66; --line: #e3e3de;
75
+ --edit: #b26b00; --edit-bg: #fff4e0; --new: #1f7a3a; --new-bg: #e6f5ea; --remove: #b3261e; --remove-bg: #fde8e6; --accent: #2f5bd3; }
76
+ @media (prefers-color-scheme: dark) { :root { --bg: #151514; --card: #1f1f1d; --text: #ecece8; --muted: #a2a29b; --line: #34342f;
77
+ --edit: #f0b35a; --edit-bg: #3a2a10; --new: #6fd08c; --new-bg: #13301c; --remove: #ff8a80; --remove-bg: #3b1714; --accent: #8fb0ff; } }
78
+ * { box-sizing: border-box; }
79
+ body { margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
80
+ main { max-width: 960px; margin: 0 auto; }
81
+ h1 { font-size: 22px; margin: 4px 0 8px; } h2 { font-size: 16px; margin: 28px 0 10px; } h3 { font-size: 14px; margin: 0 0 8px; color: var(--muted); }
82
+ .meta { color: var(--muted); font-size: 13px; } .status { display: inline-block; padding: 2px 10px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); font-weight: 600; }
83
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin: 12px 0; }
84
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; } dt { color: var(--muted); } dd { margin: 0; }
85
+ .count { font-weight: 600; margin-bottom: 8px; } .repo { margin-top: 14px; }
86
+ .files { list-style: none; padding: 0; margin: 0; } .files li { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; padding: 6px 0; border-top: 1px solid var(--line); }
87
+ code { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; } .why { color: var(--muted); flex-basis: 100%; padding-left: 64px; font-size: 13px; }
88
+ .badge { font-size: 11px; font-weight: 700; text-transform: uppercase; padding: 1px 8px; border-radius: 6px; min-width: 56px; text-align: center; }
89
+ .badge.edit { color: var(--edit); background: var(--edit-bg); } .badge.create { color: var(--new); background: var(--new-bg); } .badge.remove { color: var(--remove); background: var(--remove-bg); }
90
+ table { width: 100%; border-collapse: collapse; } td, th { text-align: left; padding: 6px 8px; border-top: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; }
91
+ .missing { color: var(--remove); } ul.approvals { list-style: none; padding: 0; margin: 0; } .approvals li { padding: 4px 0; }
92
+ .approvals .yes::before { content: "✓ "; color: var(--new); } .approvals .no::before { content: "✗ "; color: var(--remove); } .approvals .pending::before { content: "○ "; color: var(--muted); } .approvals .skip { color: var(--muted); }
93
+ .warn li { color: var(--edit); } pre { white-space: pre-wrap; background: var(--bg); padding: 10px; border-radius: 8px; }
94
+ @media (max-width: 600px) { .why { padding-left: 0; } dl { grid-template-columns: 1fr; } }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <main>
99
+ <div class="meta">${escape(projectName)} · ${escape(task.key)}${task.ticket ? ` · ${escape(task.ticket)}` : ''} · ${escape(task.type || '')}${plan ? ` · plan v${escape(plan.version)}` : ''}</div>
100
+ <h1>${escape(task.title || card.what || 'Task')}</h1>
101
+ <span class="status">${escape(STATUS_LABELS[task.status] || task.status)}</span>
102
+ <div class="card"><h2 style="margin-top:0">Approvals</h2>${approvalsHtml(task)}</div>
103
+ ${fields.length ? `<div class="card"><dl>${fields.map(([label, value]) => `<dt>${label}</dt><dd>${escape(value)}</dd>`).join('')}</dl></div>` : ''}
104
+ ${plan ? `<h2>Files</h2><div class="card"><div class="count">${escape(countLine({ ...review, edit: review.edit, create: review.create, remove: review.remove }))}</div>${repos.join('') || '<p>No files.</p>'}</div>` : '<p>No plan submitted yet.</p>'}
105
+ ${(card.doneWhen || []).length ? `<h2>Done when</h2><div class="card"><table><tr><th>#</th><th>Check</th><th>Tested by</th></tr>${card.doneWhen
106
+ .map((check, i) => `<tr><td>${i + 1}</td><td>${escape(check)}</td><td>${testsFor(i + 1).length ? testsFor(i + 1).map(escape).join('<br>') : '<span class="missing">no test yet</span>'}</td></tr>`)
107
+ .join('')}</table></div>` : ''}
108
+ ${plan && (plan.screens || []).length ? `<h2>Screens</h2><div class="card"><ul>${plan.screens.map(screen => `<li>${escape(screen)}</li>`).join('')}</ul></div>` : ''}
109
+ ${plan && plan.notes ? `<h2>Notes</h2><div class="card">${escape(plan.notes)}</div>` : ''}
110
+ ${plan && (plan.warnings || []).length ? `<h2>tspl checks</h2><div class="card"><ul class="warn">${plan.warnings.map(warning => `<li>${escape(warning)}</li>`).join('')}</ul></div>` : ''}
111
+ ${versions ? `<h2>Earlier versions</h2><div class="card"><ul>${versions}</ul></div>` : ''}
112
+ <h2>Decide</h2>
113
+ <div class="card"><pre>tspl plan approve ${escape(task.key)}
114
+ tspl plan reject ${escape(task.key)} --note "what should change"</pre></div>
115
+ </main>
116
+ </body>
117
+ </html>
118
+ `
119
+ }
120
+
121
+ module.exports = {
122
+ renderPlanPage,
123
+ }
@@ -0,0 +1,293 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path').posix
4
+ const { editDistance } = require('../find/text')
5
+ const { TEST_FILE } = require('../shared')
6
+ const { styleOf, majority } = require('./conventions')
7
+ const { findProjectConfig, snapshotProject } = require('../project-config')
8
+
9
+ const KIND_FILE = /^(.+?)\.([a-z][a-z0-9-]*)\.([a-z]+)$/
10
+ const USED_BY = new Set(['imports', 'uses', 'renders', 'calls-api', 'template'])
11
+
12
+ const keyOf = file => String(file || '').trim().replace(/^([^:/\s]+):\s*/, '$1/')
13
+
14
+ const items = (plan, kind) =>
15
+ (Array.isArray(plan[kind]) ? plan[kind] : [])
16
+ .map(item => (typeof item === 'string' ? { file: item } : item || {}))
17
+ .map(item => ({ file: keyOf(item.file), why: String(item.why || '').trim() }))
18
+ .filter(item => item.file)
19
+
20
+ const closestPath = (graph, key) => {
21
+ const repo = key.split('/')[0]
22
+ const base = Path.basename(key)
23
+ const candidates = graph.files
24
+ .map(file => ({ file, distance: editDistance(Path.basename(file.path), base, 2) + (file.repo === repo ? 0 : 1) }))
25
+ .filter(item => item.distance <= 2)
26
+ .sort((a, b) => a.distance - b.distance)
27
+ return candidates.length ? ` Did you mean ${candidates[0].file.repo}/${candidates[0].file.path}?` : ''
28
+ }
29
+
30
+ const placementNote = (graph, key) => {
31
+ const repo = key.split('/')[0]
32
+ const path = key.slice(repo.length + 1)
33
+ const match = Path.basename(path).match(KIND_FILE)
34
+ if (!match) return null
35
+ const suffix = `.${match[2]}.${match[3]}`
36
+ const sameKind = graph.files.filter(file => file.repo === repo && file.path.endsWith(suffix))
37
+ if (!sameKind.length) return null
38
+ const parts = Path.dirname(path).split('/')
39
+ const area = parts.slice(0, Math.max(1, parts.length - 2)).join('/')
40
+ const nearby = sameKind.some(file => file.path.startsWith(`${area}/`))
41
+ if (nearby) return null
42
+ const folders = new Map()
43
+ sameKind.forEach(file => {
44
+ const folder = Path.basename(Path.dirname(file.path)) === file.path.split('/').pop().split('.')[0]
45
+ ? Path.dirname(Path.dirname(file.path))
46
+ : Path.dirname(file.path)
47
+ folders.set(folder, (folders.get(folder) || 0) + 1)
48
+ })
49
+ const [folder, count] = [...folders.entries()].sort((a, b) => b[1] - a[1])[0]
50
+ return `Create ${key}: no *${suffix} files live near there; most (${count}) are in ${repo}/${folder}.`
51
+ }
52
+
53
+ const namingNote = (graph, key) => {
54
+ const repo = key.split('/')[0]
55
+ const match = Path.basename(key).match(KIND_FILE)
56
+ if (!match) return null
57
+ const suffix = `.${match[2]}.${match[3]}`
58
+ const sameKind = graph.files.filter(file => file.repo === repo && file.path.endsWith(suffix))
59
+ if (sameKind.length < 3) return null
60
+ const styles = sameKind.map(file => styleOf(Path.basename(file.path).match(KIND_FILE)[1])).filter(value => value && value !== 'lowercase')
61
+ const [style, count] = majority(styles)
62
+ const mine = styleOf(match[1])
63
+ if (!style || !mine || mine === 'lowercase' || mine === style || count < styles.length * 0.7) return null
64
+ const example = sameKind.map(file => file.path).sort((a, b) => a.length - b.length)[0]
65
+ return `Create ${key}: files like it are named in ${style} (for example ${example}); "${match[1]}" is ${mine}.`
66
+ }
67
+
68
+ /** briefFiles: the files tspl's search found for the task; an edit outside them may mean the search, or the plan, is off. */
69
+ const reviewPlan = (plan, card, graph, { briefFiles = null } = {}) => {
70
+ const byKey = new Map(graph.files.map(file => [`${file.repo}/${file.path}`, file]))
71
+ const edit = items(plan, 'edit')
72
+ const create = items(plan, 'create')
73
+ const remove = items(plan, 'remove')
74
+ const planned = new Set([...edit, ...create, ...remove].map(item => item.file))
75
+ const removed = new Set(remove.map(item => item.file))
76
+ const warnings = []
77
+ const companions = []
78
+
79
+ const found = briefFiles && briefFiles.length ? new Set(briefFiles) : null
80
+ edit.forEach(item => {
81
+ if (!byKey.has(item.file)) warnings.push(`Edit ${item.file}: not in tspl's copy of the code (new, uncommitted, or a typo?).${closestPath(graph, item.file)}`)
82
+ else if (found && !found.has(item.file) && !TEST_FILE.test(item.file)) warnings.push(`Edit ${item.file}: tspl's search did not find this file for the task; check that the change belongs here, or reword the task so the search finds it.`)
83
+ if (!item.why) warnings.push(`Edit ${item.file}: say what changes in it.`)
84
+ })
85
+ create.forEach(item => {
86
+ if (byKey.has(item.file)) warnings.push(`Create ${item.file}: this file already exists; edit it instead?`)
87
+ else {
88
+ const note = placementNote(graph, item.file)
89
+ if (note) warnings.push(note)
90
+ const naming = namingNote(graph, item.file)
91
+ if (naming) warnings.push(naming)
92
+ }
93
+ })
94
+ remove.forEach(item => {
95
+ const file = byKey.get(item.file)
96
+ if (!file) {
97
+ warnings.push(`Remove ${item.file}: not in tspl's copy of the code.${closestPath(graph, item.file)}`)
98
+ return
99
+ }
100
+ const users = graph.into[file.id]
101
+ .filter(edge => USED_BY.has(edge.type))
102
+ .map(edge => graph.files[edge.from])
103
+ .filter(user => !removed.has(`${user.repo}/${user.path}`))
104
+ if (users.length) {
105
+ warnings.push(`Remove ${item.file}: still used by ${users.slice(0, 3).map(user => `${user.repo}/${user.path}`).join(', ')}${users.length > 3 ? ` and ${users.length - 3} more` : ''}.`)
106
+ }
107
+ })
108
+
109
+ edit.forEach(item => {
110
+ const file = byKey.get(item.file)
111
+ if (!file) return
112
+ const stem = Path.basename(file.path).split('.')[0]
113
+ const linked = [
114
+ ...graph.out[file.id].filter(edge => edge.type === 'template' || edge.type === 'imports').map(edge => graph.files[edge.to]),
115
+ ...graph.into[file.id].filter(edge => edge.type === 'template').map(edge => graph.files[edge.from]),
116
+ ]
117
+ linked
118
+ .filter(other => other.repo === file.repo && !planned.has(`${other.repo}/${other.path}`))
119
+ .filter(other => Path.basename(other.path).split('.')[0] === stem)
120
+ .forEach(other => {
121
+ const key = `${other.repo}/${other.path}`
122
+ if (!companions.some(entry => entry.file === key)) companions.push({ file: key, with: item.file })
123
+ })
124
+ })
125
+
126
+ const tests = (Array.isArray(plan.tests) ? plan.tests : [])
127
+ .map(test => (typeof test === 'string' ? { name: test } : test || {}))
128
+ .map(test => ({
129
+ name: String(test.name || '').trim(),
130
+ file: test.file ? keyOf(test.file) : '',
131
+ checks: (test.checks || []).map(Number).filter(Boolean),
132
+ ...(test.command ? { command: String(test.command).trim() } : {}),
133
+ }))
134
+ .filter(test => test.name)
135
+ const covered = new Set(tests.flatMap(test => test.checks))
136
+ const testCases = (Array.isArray(plan.testCases) ? plan.testCases : [])
137
+ .map(item => (typeof item === 'string' ? { title: item } : item || {}))
138
+ .map(item => ({
139
+ title: String(item.title || '').trim(),
140
+ steps: (Array.isArray(item.steps) ? item.steps : []).map(step => String(step).trim()).filter(Boolean),
141
+ expected: String(item.expected || '').trim(),
142
+ }))
143
+ .filter(item => item.title)
144
+ .slice(0, 30)
145
+ const doneWhen = (card.doneWhen || []).length ? card.doneWhen : testCases.map(item => item.title)
146
+ if (!(card.doneWhen || []).length && !testCases.length) warnings.push('The task has no test cases: propose them (testCases) so each can be proven.')
147
+ const uncovered = doneWhen.map((check, i) => ({ check, n: i + 1 })).filter(item => !covered.has(item.n))
148
+ uncovered.forEach(item => warnings.push(`Done-when ${item.n} "${item.check}" has no test.`))
149
+ if (!edit.length && !create.length && !remove.length) warnings.push('The plan changes no files.')
150
+
151
+ return {
152
+ edit,
153
+ create,
154
+ remove,
155
+ screens: (Array.isArray(plan.screens) ? plan.screens : []).map(String).filter(Boolean),
156
+ tests,
157
+ notes: String(plan.notes || '').trim(),
158
+ ...(testCases.length && !(card.doneWhen || []).length ? { testCases } : {}),
159
+ warnings,
160
+ companions,
161
+ }
162
+ }
163
+
164
+ const MARKS = { edit: '✎', create: '➕', remove: '➖' }
165
+
166
+ const byRepo = review => {
167
+ const repos = new Map()
168
+ ;['edit', 'create', 'remove'].forEach(kind => {
169
+ review[kind].forEach(item => {
170
+ const repo = item.file.split('/')[0]
171
+ if (!repos.has(repo)) repos.set(repo, [])
172
+ repos.get(repo).push({ ...item, kind, path: item.file.slice(repo.length + 1) })
173
+ })
174
+ })
175
+ return repos
176
+ }
177
+
178
+ const countLine = review => {
179
+ const total = review.edit.length + review.create.length + review.remove.length
180
+ return `${total} file${total === 1 ? '' : 's'} in ${byRepo(review).size} repo${byRepo(review).size === 1 ? '' : 's'}: ${MARKS.edit} ${review.edit.length} edit ${MARKS.create} ${review.create.length} new ${MARKS.remove} ${review.remove.length} remove`
181
+ }
182
+
183
+ const renderChecklist = (review, card, taskId) => {
184
+ const testsFor = n => review.tests.filter(test => test.checks.includes(n)).map(test => test.name)
185
+ const files = []
186
+ byRepo(review).forEach((items, repo) => {
187
+ files.push(repo)
188
+ items.forEach(item => files.push(` ${MARKS[item.kind]} ${item.path}${item.why ? ` — ${item.why}` : ''}`))
189
+ })
190
+ return [
191
+ `PLAN ${taskId}${card.ticket ? ` · ${card.ticket}` : ''} · ${card.type} · ${card.what}`,
192
+ review.approach ? `Selected approach: ${review.approach.option ? review.approach.option.title : review.approach.optionId} [${review.approach.optionId}] (v${review.approach.version})` : null,
193
+ card.apps.length ? `Where: ${card.apps.join(', ')}${card.screen ? ` · screen: ${card.screen}` : ''}` : null,
194
+ '',
195
+ countLine(review),
196
+ ...(files.length ? files : [' (no files)']),
197
+ ...(review.screens.length ? ['', 'SCREENS', ...review.screens.map(screen => ` • ${screen}`)] : []),
198
+ '',
199
+ `TESTS (${review.tests.length})`,
200
+ ...(review.tests.length
201
+ ? review.tests.map(test => ` ☐ ${test.name}${test.file ? ` (${test.file})` : ''}${test.command ? ` $ ${test.command}` : ''}${test.checks.length ? ` → done-when ${test.checks.join(', ')}` : ''}`)
202
+ : [' —']),
203
+ ...(card.doneWhen.length
204
+ ? ['', 'DONE WHEN', ...card.doneWhen.map((check, i) => ` ${i + 1}. ${check}${testsFor(i + 1).length ? '' : ' ⚠ no test yet'}`)]
205
+ : []),
206
+ ...(review.notes ? ['', 'NOTES', ` ${review.notes}`] : []),
207
+ ...(review.companions.length
208
+ ? ['', 'OFTEN CHANGED TOGETHER (check if needed)', ...review.companions.map(entry => ` ? ${entry.file} (with ${entry.with.split('/').pop()})`)]
209
+ : []),
210
+ '',
211
+ ...(review.warnings.length
212
+ ? ['TSPL CHECKS', ...review.warnings.map(warning => ` ⚠ ${warning}`)]
213
+ : ['TSPL CHECKS', ' ✓ files exist, new files sit with similar files, nothing removed is still used, every done-when has a test']),
214
+ ]
215
+ .filter(line => line !== null)
216
+ .join('\n')
217
+ }
218
+
219
+ const changesSince = (previous, review) => {
220
+ if (!previous) return []
221
+ const lines = []
222
+ ;['edit', 'create', 'remove'].forEach(kind => {
223
+ const before = new Set((previous[kind] || []).map(item => item.file))
224
+ const after = new Set(review[kind].map(item => item.file))
225
+ after.forEach(file => !before.has(file) && lines.push(`+ ${kind} ${file}`))
226
+ before.forEach(file => !after.has(file) && lines.push(`- ${kind} ${file}`))
227
+ })
228
+ const testsBefore = new Set((previous.tests || []).map(test => test.name))
229
+ review.tests.filter(test => !testsBefore.has(test.name)).forEach(test => lines.push(`+ test ${test.name}`))
230
+ ;(previous.tests || []).filter(test => !review.tests.some(item => item.name === test.name)).forEach(test => lines.push(`- test ${test.name}`))
231
+ return lines
232
+ }
233
+
234
+ const testParser = command => {
235
+ if (/\bplaywright\s+test\b/.test(command)) return 'playwright-json'
236
+ if (/node\s+(--[\w-]+\s+)*--test\b/.test(command)) return 'node-tap'
237
+ if (/\bjest\b/.test(command)) return 'jest-json'
238
+ // Vitest's JSON reporter writes the same summary as Jest's
239
+ if (/\bvitest\b/.test(command)) return 'jest-json'
240
+ return null
241
+ }
242
+
243
+ const withReporter = (command, parser) => {
244
+ if (parser === 'playwright-json' && !/--reporter[ =]json/.test(command)) return `${command} --reporter=json`
245
+ if (parser === 'jest-json' && /\bvitest\b/.test(command) && !/--reporter[ =]json/.test(command)) return `${command} --reporter=json`
246
+ return command
247
+ }
248
+
249
+ /**
250
+ * Freezes the project's checks, the plan's own runnable tests and the rules into the plan. A test that tspl cannot
251
+ * parse (or one with no command) is not executable evidence, so the done-when items it covers need a human check.
252
+ * Folders without a project file keep the legacy plan unchanged.
253
+ */
254
+ const withSnapshot = (review, card, cwd) => {
255
+ const found = findProjectConfig(cwd)
256
+ if (!found) return review
257
+ const { root, config } = found
258
+ const names = config.repos.map(repo => repo.name)
259
+ const checks = []
260
+ review.tests.forEach((test, i) => {
261
+ const repo = test.file ? test.file.split('/')[0] : null
262
+ const parser = test.command ? testParser(test.command) : null
263
+ if (!parser || !names.includes(repo)) return
264
+ const browser = parser === 'playwright-json'
265
+ checks.push({
266
+ id: `task-test-${i + 1}`,
267
+ repo,
268
+ cwd: '.',
269
+ category: browser ? 'e2e' : 'unit',
270
+ command: withReporter(test.command, parser),
271
+ ...(browser ? { artifacts: 'test-results' } : {}),
272
+ timeoutMs: config.workflow.checkTimeoutMs,
273
+ required: true,
274
+ parser,
275
+ attested: false,
276
+ criteria: test.checks,
277
+ })
278
+ })
279
+ const acceptance = (card.doneWhen || []).length || (review.testCases || []).length
280
+ const covered = new Set([...config.checks, ...checks].filter(check => check.required).flatMap(check => check.criteria || []))
281
+ const manualCriteria = Array.from({ length: acceptance }, (_, i) => i + 1).filter(n => !covered.has(n))
282
+ return { ...review, workflow: snapshotProject(root, config, { checks, manualCriteria, acceptance }) }
283
+ }
284
+
285
+ module.exports = {
286
+ withSnapshot,
287
+ reviewPlan,
288
+ renderChecklist,
289
+ changesSince,
290
+ byRepo,
291
+ countLine,
292
+ MARKS,
293
+ }