@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.0

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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,138 @@
1
+ 'use strict'
2
+
3
+ // Cleanup path for projects that installed release tooling from an older
4
+ // skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
5
+ // detects the leftover files and — only on an explicit interactive "yes" or the
6
+ // --remove-release-tooling flag — removes exactly what skitterspec used to
7
+ // install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
8
+ // any script it didn't add.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ const SKITTERSHIP = '@skitterbyte/skittership'
14
+
15
+ // Files/dirs skitterspec used to install for release tooling (repo-relative).
16
+ const RELEASE_PATHS = [
17
+ 'skitterspec.config.json',
18
+ path.join('scripts', 'generate-changelog.js'),
19
+ path.join('scripts', 'generate-releases.js'),
20
+ path.join('scripts', 'lib', 'git-commits.js'),
21
+ path.join('scripts', 'lib', 'config.js'),
22
+ path.join('.claude', 'skills', 'commit'),
23
+ path.join('.claude', 'rules', 'commit-messages.md'),
24
+ ]
25
+
26
+ // npm scripts skitterspec used to wire; only removed when their value still
27
+ // matches the generator command (so a user's custom override is preserved).
28
+ const HELPER_SCRIPTS = {
29
+ changelog: 'node scripts/generate-changelog.js',
30
+ 'changelog:retro': 'node scripts/generate-changelog.js --retro',
31
+ releases: 'node scripts/generate-releases.js',
32
+ 'releases:retro': 'node scripts/generate-releases.js --retro',
33
+ }
34
+
35
+ function readPkg(dir) {
36
+ const pkgPath = path.join(dir, 'package.json')
37
+ if (!fs.existsSync(pkgPath)) return null
38
+ try {
39
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ // Does package.json have a `version` script that runs the release generators?
46
+ function versionHookReferencesGenerators(pkg) {
47
+ const v = pkg && pkg.scripts && pkg.scripts.version
48
+ return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
49
+ }
50
+
51
+ // Is skittership the source of the release tooling here (rather than a leftover
52
+ // legacy skitterspec install)? True when the project has adopted skittership —
53
+ // its config file is present, or it's a declared dependency. In that case the
54
+ // release files are skittership's current install and must NOT be offered for
55
+ // removal (they'd just come back on the next `skittership init`).
56
+ function skittershipAdopted(dir) {
57
+ if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
58
+ const pkg = readPkg(dir)
59
+ const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
60
+ return Boolean(deps['@skitterbyte/skittership'])
61
+ }
62
+
63
+ // Report which release-tooling artifacts are present. `present` is true only when
64
+ // there are legacy artifacts to clean up (a file/dir or a generator-driven version
65
+ // hook) AND skittership hasn't been adopted — otherwise the files belong to a
66
+ // live skittership install, not an old bundled-skitterspec one.
67
+ function detectReleaseTooling(dir) {
68
+ const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
69
+ const pkg = readPkg(dir)
70
+ const versionHook = versionHookReferencesGenerators(pkg)
71
+ const adopted = skittershipAdopted(dir)
72
+ return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
73
+ }
74
+
75
+ // Remove an emptied directory, walking up while parents are left empty. Never
76
+ // climbs out of `dir`.
77
+ function pruneEmptyDirs(dir, startAbs) {
78
+ let cur = startAbs
79
+ while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
80
+ if (fs.readdirSync(cur).length > 0) break
81
+ fs.rmdirSync(cur)
82
+ cur = path.dirname(cur)
83
+ }
84
+ }
85
+
86
+ // Remove exactly the detected artifacts + unwire the version hook. Returns a
87
+ // report of what was removed. Scoped and non-destructive: only skitterspec's own
88
+ // files, and only npm scripts whose value still matches the generator command.
89
+ function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
90
+ const removed = []
91
+
92
+ for (const rel of detection.files) {
93
+ const abs = path.join(dir, rel)
94
+ if (!fs.existsSync(abs)) continue
95
+ fs.rmSync(abs, { recursive: true, force: true })
96
+ removed.push(rel)
97
+ pruneEmptyDirs(dir, path.dirname(abs))
98
+ }
99
+
100
+ const pkg = readPkg(dir)
101
+ if (pkg && pkg.scripts) {
102
+ let changed = false
103
+ if (versionHookReferencesGenerators(pkg)) {
104
+ delete pkg.scripts.version
105
+ removed.push('package.json (version hook)')
106
+ changed = true
107
+ }
108
+ for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
109
+ if (pkg.scripts[name] === cmd) {
110
+ delete pkg.scripts[name]
111
+ changed = true
112
+ }
113
+ }
114
+ if (changed) {
115
+ if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
116
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
117
+ }
118
+ }
119
+
120
+ return { removed }
121
+ }
122
+
123
+ // One-line pointer shown when we detect release tooling but don't remove it
124
+ // (declined, or a non-interactive run).
125
+ function releaseToolingNotice() {
126
+ return (
127
+ `Release tooling has moved to ${SKITTERSHIP} — run ` +
128
+ `npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
129
+ )
130
+ }
131
+
132
+ module.exports = {
133
+ detectReleaseTooling,
134
+ removeReleaseTooling,
135
+ releaseToolingNotice,
136
+ RELEASE_PATHS,
137
+ SKITTERSHIP,
138
+ }
@@ -0,0 +1,188 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Run a worktree's bootstrap steps — the engine half of `spec-env bootstrap`.
5
+ *
6
+ * `planUp` decides WHAT a fresh worktree needs (`worktreeSteps`); this module
7
+ * does it. Seeds and the env file are plain filesystem work, done here with
8
+ * `fs` and no shell at all. `setup` commands and the stack are spawned with the
9
+ * worktree as their cwd.
10
+ *
11
+ * WHY THE ENGINE RUNS THEM. These steps used to be printed as shell for the
12
+ * agent to run, and an agent does not copy shell faithfully. A consumer's
13
+ * secrets hook refuses any Bash call naming a `.env` file, with one exception
14
+ * for the exact printed shape — so every rewrite was refused, and the retry was
15
+ * refused again by auto mode as a workaround. The call that replaces them,
16
+ * `skitterspec spec-env bootstrap <name>`, names no file at all.
17
+ *
18
+ * Behaviour is the printed commands' behaviour, kept exactly: idempotent, one
19
+ * `seeded …` / `… — skipped` line per seed, a seed missing from the primary
20
+ * checkout is skipped rather than failed, a symlinked env file is refused before
21
+ * anything is written through it, and the run stops at the first failing step.
22
+ *
23
+ * Every side effect goes through `io`, so the tests drive it with a real temp
24
+ * directory and a fake spawn — no docker, no package manager.
25
+ */
26
+
27
+ const realFs = require('node:fs')
28
+ const path = require('node:path')
29
+ const { spawnSync } = require('node:child_process')
30
+
31
+ /** One line describing a step, for `--plan` and for the failure report. */
32
+ function describeStep(step) {
33
+ switch (step.kind) {
34
+ case 'seed':
35
+ return `seed ${step.file} (${step.mode} from the primary checkout)`
36
+ case 'envFile':
37
+ return `env file ${step.file} (COMPOSE_PROJECT_NAME, PORT_OFFSET)`
38
+ case 'setup':
39
+ return `setup: ${step.command}`
40
+ case 'docker':
41
+ return `docker: ${step.argv.join(' ')}`
42
+ default:
43
+ return `unknown step ${step.kind}`
44
+ }
45
+ }
46
+
47
+ // `lstat` that answers null for a missing path rather than throwing.
48
+ function lstatOrNull(fs, p) {
49
+ try {
50
+ return fs.lstatSync(p)
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Seed one gitignored file from the primary checkout.
58
+ *
59
+ * The source is tested with `existsSync`, which follows links — a dangling
60
+ * link in the primary checkout is "not in main", exactly as `[ -e ]` read it.
61
+ * The target is tested with `lstat`, which does not: an existing link, even a
62
+ * dangling one, is somebody's deliberate state and is left alone.
63
+ */
64
+ function runSeed(step, { cwd, mainRoot, fs }) {
65
+ const src = path.join(mainRoot, step.file)
66
+ const dst = path.join(cwd, step.file)
67
+ if (!fs.existsSync(src)) {
68
+ return { outcome: 'skipped', line: `seed ${step.file}: not in main — skipped` }
69
+ }
70
+ if (lstatOrNull(fs, dst)) {
71
+ return { outcome: 'skipped', line: `seed ${step.file}: exists — skipped` }
72
+ }
73
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
74
+ if (step.mode === 'copy') fs.copyFileSync(src, dst)
75
+ else fs.symlinkSync(src, dst)
76
+ return { outcome: 'seeded', line: `seeded ${step.file} → ${src}` }
77
+ }
78
+
79
+ /**
80
+ * Make sure the spec's env file sets its project name and port block.
81
+ *
82
+ * Additive: a key is appended only when no line already sets it, so a re-run
83
+ * writes nothing and a file `setup` already wrote to keeps every value it holds
84
+ * — a deliberate `PORT_OFFSET` of its own included.
85
+ *
86
+ * A SYMLINK IS REFUSED, before anything is written. Seeding links gitignored
87
+ * files from the primary checkout, so a seeded env file IS the primary
88
+ * checkout's own, and appending through it would write this spec's port block
89
+ * into the main stack's configuration. The fix is a `docker.envFile` naming a
90
+ * file of its own.
91
+ */
92
+ function runEnvFile(step, { cwd, fs }) {
93
+ const p = path.join(cwd, step.file)
94
+ const st = lstatOrNull(fs, p)
95
+ if (st && st.isSymbolicLink()) {
96
+ return {
97
+ outcome: 'refused',
98
+ line:
99
+ `env file ${step.file} is a symlink — docker.envFile must name a file of its own, ` +
100
+ "not a seeded one; refusing to write this spec's port block through it",
101
+ }
102
+ }
103
+ const before = st ? fs.readFileSync(p, 'utf8') : ''
104
+ let add = ''
105
+ // A file without a trailing newline would glue the first appended key onto
106
+ // its last line.
107
+ const sep = before.length && !before.endsWith('\n') ? '\n' : ''
108
+ for (const [key, value] of [
109
+ ['COMPOSE_PROJECT_NAME', step.projectName],
110
+ ['PORT_OFFSET', step.offset],
111
+ ]) {
112
+ if (!new RegExp(`^${key}=`, 'm').test(before)) add += `${key}=${value}\n`
113
+ }
114
+ if (!st || add) {
115
+ fs.mkdirSync(path.dirname(p), { recursive: true })
116
+ fs.writeFileSync(p, before + (add ? sep + add : ''))
117
+ }
118
+ return { outcome: add ? 'written' : 'skipped', line: `env file ${step.file}: ready` }
119
+ }
120
+
121
+ /**
122
+ * Spawn a setup command (through the shell — it is the project's own command
123
+ * line) or the stack (an argv, no shell).
124
+ *
125
+ * `stdout` is where the child's own output goes: inherited normally, so an
126
+ * install streams as it runs, and moved to stderr under `--json` so the payload
127
+ * on stdout stays parseable.
128
+ */
129
+ function runSpawned(step, { cwd, spawn, childStdout }) {
130
+ const stdio = ['ignore', childStdout, 'inherit']
131
+ const res =
132
+ step.kind === 'setup'
133
+ ? spawn(step.command, { cwd, shell: true, stdio })
134
+ : spawn(step.argv[0], step.argv.slice(1), { cwd, stdio })
135
+ if (res.error) {
136
+ return { outcome: 'failed', line: `${describeStep(step)} — ${res.error.message}` }
137
+ }
138
+ if (res.status !== 0) {
139
+ const why = res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
140
+ return { outcome: 'failed', line: `${describeStep(step)} — ${why}` }
141
+ }
142
+ return { outcome: 'ran', line: null }
143
+ }
144
+
145
+ /**
146
+ * Run `steps` in order in `cwd`, stopping at the first that fails.
147
+ *
148
+ * io: { cwd, mainRoot, fs?, spawn?, write?, childStdout? }
149
+ * - `write(line)` receives every report line as it happens
150
+ * - `childStdout` is the stdio slot for spawned output ('inherit' default)
151
+ *
152
+ * Returns { ok, results: [{ step, outcome, line }], failedAt } — `failedAt` is
153
+ * the 1-based index of the step that stopped the run, or null.
154
+ */
155
+ function runBootstrap(steps, io) {
156
+ const ctx = {
157
+ cwd: io.cwd,
158
+ mainRoot: io.mainRoot,
159
+ fs: io.fs || realFs,
160
+ spawn: io.spawn || spawnSync,
161
+ childStdout: io.childStdout || 'inherit',
162
+ }
163
+ const write = io.write || (() => {})
164
+ const results = []
165
+ for (let i = 0; i < steps.length; i++) {
166
+ const step = steps[i]
167
+ // A spawned step announces itself BEFORE it runs, so the streamed output
168
+ // that follows has a heading.
169
+ if (step.kind === 'setup' || step.kind === 'docker') write(describeStep(step))
170
+ let r
171
+ try {
172
+ if (step.kind === 'seed') r = runSeed(step, ctx)
173
+ else if (step.kind === 'envFile') r = runEnvFile(step, ctx)
174
+ else if (step.kind === 'setup' || step.kind === 'docker') r = runSpawned(step, ctx)
175
+ else r = { outcome: 'failed', line: describeStep(step) }
176
+ } catch (err) {
177
+ r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
178
+ }
179
+ results.push({ step, ...r })
180
+ if (r.line && r.outcome !== 'failed' && r.outcome !== 'refused') write(r.line)
181
+ if (r.outcome === 'failed' || r.outcome === 'refused') {
182
+ return { ok: false, results, failedAt: i + 1 }
183
+ }
184
+ }
185
+ return { ok: true, results, failedAt: null }
186
+ }
187
+
188
+ module.exports = { runBootstrap, describeStep }
@@ -0,0 +1,143 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The primary-checkout leak guard.
5
+ *
6
+ * A phase can be built in a worktree from a session standing somewhere else —
7
+ * every write an absolute path, every command `cd`-prefixed. That works;
8
+ * `spec-env review` already reads a worktree exactly that way. What it cannot do
9
+ * is *prove* it worked: one relative path and the edit lands in the primary
10
+ * checkout, on the base branch, silently.
11
+ *
12
+ * So record what the primary checkout looked like before the build
13
+ * (`--record-primary`) and compare after it (`--assert-primary-clean`).
14
+ *
15
+ * The baseline is the whole point. `negative-checks.md` rule 1 asks for a
16
+ * positive signal rather than an absence, and "these were the dirty paths at a
17
+ * known moment" is one; a bare "the primary must be clean" check would accuse
18
+ * anyone who left an unrelated edit open in another window. Rule 4 supplies the
19
+ * third state: when the baseline is missing, or is for another spec, the check
20
+ * cannot tell — and cannot-tell reports and exits 0 rather than accusing.
21
+ */
22
+
23
+ const path = require('node:path')
24
+
25
+ // The state dir sits beside the registry file, e.g. `.spec-env` — the same
26
+ // convention `dev.js` uses for logs and pids.
27
+ function stateDir(config) {
28
+ return path.posix.dirname(config.registry) || '.spec-env'
29
+ }
30
+
31
+ /** Where the baseline lives. Gitignored with the rest of `.spec-env/`. */
32
+ function baselinePath(dir, config) {
33
+ return path.join(dir, stateDir(config), 'building.json')
34
+ }
35
+
36
+ /**
37
+ * Merge one or more newline-separated git path listings into a sorted, deduped
38
+ * array. Blank lines are dropped; nothing is parsed out of the line.
39
+ *
40
+ * DELIBERATELY NOT `git status --porcelain`, for two reasons, both found by
41
+ * running this against a real worktree:
42
+ *
43
+ * 1. Porcelain puts the path behind two fixed status columns and a space, and
44
+ * the repo's `gitReader` TRIMS its stdout — so the first line loses its
45
+ * leading space and a fixed `slice(3)` eats the first character of the
46
+ * first path. A guard that renames the file it is accusing you of is worse
47
+ * than no guard.
48
+ * 2. Porcelain reports stat-dirty entries: a file whose mtime moved but whose
49
+ * content is identical to HEAD. One was observed here, reported as ` M`
50
+ * with an empty `git diff`. Accusing someone of leaking a file they never
51
+ * changed is exactly the false accusation `negative-checks.md` exists to
52
+ * prevent.
53
+ *
54
+ * `git diff --name-only HEAD` compares CONTENT, and
55
+ * `git ls-files --others --exclude-standard` lists genuinely new files. Between
56
+ * them they answer "what did this tree gain?" without a column to miscount.
57
+ */
58
+ function mergePaths(...listings) {
59
+ const out = new Set()
60
+ for (const listing of listings) {
61
+ for (const line of String(listing || '').split('\n')) {
62
+ const p = line.replace(/\r$/, '').trim()
63
+ if (p !== '') out.add(p)
64
+ }
65
+ }
66
+ return [...out].sort()
67
+ }
68
+
69
+ /** Are these two paths the same tree? */
70
+ function sameTree(a, b) {
71
+ return path.resolve(a) === path.resolve(b)
72
+ }
73
+
74
+ /** The record written by `--record-primary`. Pure. */
75
+ function buildBaseline({ spec, worktreePath, primary, paths }) {
76
+ return { spec, worktreePath, primary, paths: [...paths].sort() }
77
+ }
78
+
79
+ /**
80
+ * Compare a recorded baseline against the primary checkout's current state.
81
+ *
82
+ * Three verdicts, never two (`negative-checks.md` rule 4):
83
+ * - `clean` — nothing new appeared; say nothing and exit 0.
84
+ * - `leaked` — these paths appeared since; name them and exit non-zero.
85
+ * - `unknown` — the check cannot tell; say why and exit 0.
86
+ *
87
+ * Pure: every input is supplied, nothing is read from disk or git.
88
+ *
89
+ * WHAT WOULD FOOL THIS CHECK, deliberately unhandled:
90
+ * - Work the build COMMITTED in the primary checkout. A commit empties the
91
+ * porcelain, so a leak that was tidied away looks identical to no leak. The
92
+ * guard is aimed at the actual failure mode — writes going astray mid-build,
93
+ * while nothing has been committed yet — and runs before the phase commit
94
+ * for that reason.
95
+ * - A write to a GITIGNORED path in the primary (`.spec-env/` itself, build
96
+ * output, `node_modules`). `git status` cannot see it, by design, and the
97
+ * baseline file lives in exactly such a path so that recording it is not
98
+ * itself a change the next comparison trips over.
99
+ * - A path dirty at record time and then edited FURTHER by the build. It is in
100
+ * the baseline, so it stays silent. That is the deliberate trade: silence
101
+ * there costs a missed edit to a file someone was already working on, and
102
+ * the opposite default accuses every healthy concurrent edit.
103
+ */
104
+ function compare(baseline, currentPaths, { spec, worktreePath, primary }) {
105
+ if (sameTree(primary, worktreePath)) {
106
+ return {
107
+ verdict: 'unknown',
108
+ paths: [],
109
+ reason: 'the worktree IS the primary checkout — there is no second tree to leak into',
110
+ }
111
+ }
112
+ if (!baseline) {
113
+ return { verdict: 'unknown', paths: [], reason: 'no baseline recorded' }
114
+ }
115
+ if (baseline.spec !== spec) {
116
+ return {
117
+ verdict: 'unknown',
118
+ paths: [],
119
+ reason: `the baseline was recorded for ${baseline.spec}, not ${spec}`,
120
+ }
121
+ }
122
+ if (!sameTree(baseline.worktreePath, worktreePath)) {
123
+ return {
124
+ verdict: 'unknown',
125
+ paths: [],
126
+ reason: 'the baseline records a different worktree for this spec',
127
+ }
128
+ }
129
+ const before = new Set(baseline.paths || [])
130
+ const appeared = [...currentPaths].filter((p) => !before.has(p)).sort()
131
+ return appeared.length === 0
132
+ ? { verdict: 'clean', paths: [], reason: null }
133
+ : { verdict: 'leaked', paths: appeared, reason: null }
134
+ }
135
+
136
+ module.exports = {
137
+ stateDir,
138
+ baselinePath,
139
+ mergePaths,
140
+ sameTree,
141
+ buildBaseline,
142
+ compare,
143
+ }
@@ -0,0 +1,176 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which uncommitted paths belong to ONE spec?
5
+ *
6
+ * `/spec-start` refuses a dirty tree because moving another spec's unfinished
7
+ * work is not ours to do. But the commonest dirty tree there is — the spec you
8
+ * just authored and are now starting — is not another spec's work at all, and
9
+ * refusing it costs a round trip through `/commit` on every single start.
10
+ *
11
+ * The distinction this module draws is deliberately NOT "does the dirt look
12
+ * important?" (a judgement, and the gate rightly refuses to make one) but
13
+ * membership in an exactly-known set: the spec's own folder, plus whatever the
14
+ * project declares in `spec.companionPaths`. Everything else is foreign, and one
15
+ * foreign path disqualifies the whole tree.
16
+ */
17
+
18
+ const path = require('node:path')
19
+ const { BUCKETS, expandTokens, readFrontmatterField } = require('./resolve.js')
20
+
21
+ // git reports repo-relative, forward-slashed paths; an untracked directory comes
22
+ // back with a trailing slash. Normalise both away so comparisons are exact.
23
+ function normalize(p) {
24
+ return String(p || '')
25
+ .trim()
26
+ .replace(/\\/g, '/')
27
+ .replace(/\/+$/, '')
28
+ }
29
+
30
+ /**
31
+ * Expand a `companionPaths` pattern for this spec, or return null when it cannot
32
+ * be expanded.
33
+ *
34
+ * A PATTERN THAT REFERENCES `{identifier}` WHEN NO IDENTIFIER RESOLVES MATCHES
35
+ * NOTHING, deliberately. Two ordinary situations produce a spec with no id — a
36
+ * project that never set `branch.identifierField`, and a spec deliberately kept
37
+ * local, never pushed to a tracker — and in both the file the pattern describes
38
+ * either does not exist or belongs to some OTHER spec. Expanding `{identifier}`
39
+ * to a placeholder, or dropping the token, would widen the owned set to a path
40
+ * this spec has no claim on and sweep another spec's snapshot into its commit.
41
+ * Being wrong this way costs a refusal the operator can fix with `/commit`; the
42
+ * other way costs them a file they never staged.
43
+ */
44
+ function expandCompanion(pattern, spec, config) {
45
+ const tokens = { slug: spec.slug }
46
+ if (/\{identifier\}/.test(pattern)) {
47
+ const field = config && config.branch && config.branch.identifierField
48
+ // NO PATH IS CANNOT-TELL, not an error. Callers hand in spec objects of two
49
+ // shapes: `resolveSpec` returns a full one, and `allSpecs` returns
50
+ // `{folder, slug, worktreePath}` with no `path` at all. Reading the
51
+ // frontmatter of `undefined` threw, and the throw surfaced as
52
+ // `render failed: The "path" argument must be of type string` on the review
53
+ // server's index — every spec, not just one.
54
+ //
55
+ // It routes to the same answer a missing identifier already routes to:
56
+ // expand nothing, so the pattern matches nothing and the path is not
57
+ // claimed as this spec's. Being wrong here costs a companion file left
58
+ // unstaged; throwing cost the whole page.
59
+ const identifier = spec.path ? readFrontmatterField(spec.path, field) : null
60
+ if (!identifier) return null
61
+ tokens.identifier = identifier
62
+ }
63
+ return normalize(expandTokens(pattern, tokens))
64
+ }
65
+
66
+ /**
67
+ * Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
68
+ * not. Repo-relative paths in, repo-relative paths out; nothing is read from git
69
+ * and nothing is written.
70
+ *
71
+ * Owned:
72
+ * - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
73
+ * checked rather than the spec's current one because starting a spec MOVES it
74
+ * (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
75
+ * and both halves are the same spec's.
76
+ * - each expandable `spec.companionPaths` entry.
77
+ *
78
+ * @returns {{owned: string[], foreign: string[]}}
79
+ */
80
+ function classifyDirtyTree(spec, dirtyPaths, config) {
81
+ const owned = []
82
+ const foreign = []
83
+ if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
84
+
85
+ const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
86
+ const companions = new Set()
87
+ for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
88
+ const expanded = expandCompanion(pattern, spec, config)
89
+ if (expanded) companions.add(expanded)
90
+ }
91
+
92
+ for (const raw of dirtyPaths || []) {
93
+ const p = normalize(raw)
94
+ if (!p) continue
95
+ const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
96
+ if (inFolder || companions.has(p)) owned.push(p)
97
+ else foreign.push(p)
98
+ }
99
+ return { owned, foreign }
100
+ }
101
+
102
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
103
+ // can; anything we cannot parse confidently is returned as-is, which makes it
104
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
105
+ // safe direction to be wrong in.
106
+ function unquotePath(p) {
107
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
108
+ try {
109
+ return JSON.parse(p)
110
+ } catch {
111
+ return p
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
117
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
118
+ *
119
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
120
+ * Porcelain prefixes every path with a two-character status field, and the shared
121
+ * git reader TRIMS its output — which eats the leading space of the first line
122
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
123
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
124
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
125
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
126
+ * an ancestor attributable to no single spec, and so refusing the very tree this
127
+ * gate exists to accept. Both were found by running it, not by reading it.
128
+ */
129
+ function dirtyPaths(git) {
130
+ const lists = [
131
+ git(['diff', '--name-only', 'HEAD']),
132
+ git(['ls-files', '--others', '--exclude-standard']),
133
+ ]
134
+ if (lists.every((l) => l === null)) return null
135
+ const out = []
136
+ for (const list of lists) {
137
+ if (!list) continue
138
+ for (const line of list.split('\n')) {
139
+ const q = line.trim()
140
+ if (q) out.push(unquotePath(q))
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ /**
147
+ * A spec's own uncommitted documents in one tree: `{tree, owned}`, or a reason.
148
+ *
149
+ * SHARED ON PURPOSE. Two surfaces render a spec's documents — the CLI's
150
+ * `review --docs` and the review server's route for a spec with no worktree —
151
+ * and if they classified separately they could disagree about what the page
152
+ * shows. A reader's verdict is about the page they read, so the served page and
153
+ * the written page have to be the same page.
154
+ *
155
+ * Three states, never two (`.claude/rules/negative-checks.md` rule 4). An
156
+ * unreadable git is `cannot tell`, and must not collapse into an empty file
157
+ * set: an empty set renders a page saying nothing changed, which is the one
158
+ * reading that is certainly wrong.
159
+ *
160
+ * WHAT WOULD FOOL THIS: a document of this spec's that is already committed.
161
+ * `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
162
+ * are all committed reports `empty` rather than showing its own text. That is
163
+ * the intended reading — there is no change to review — and it is why `empty`
164
+ * is a distinct answer rather than an error.
165
+ *
166
+ * @returns {{tree: string, owned: string[]}|{error: string}|{empty: true}}
167
+ */
168
+ function specDocsIn(tree, spec, config, git) {
169
+ const paths = dirtyPaths(git)
170
+ if (paths === null) return { error: 'git could not be read' }
171
+ const { owned } = classifyDirtyTree(spec, paths, config)
172
+ if (!owned.length) return { empty: true }
173
+ return { tree, owned }
174
+ }
175
+
176
+ module.exports = { classifyDirtyTree, expandCompanion, unquotePath, dirtyPaths, specDocsIn }