@skitterbyte/skitterspec-linear 11.0.0 → 12.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.
- package/MIGRATION.md +52 -0
- package/assets/claude-md-section.md +10 -0
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +36 -11
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +9 -8
- package/assets/rules/spec-planning.md +31 -10
- package/assets/skills/spec/SKILL.md +31 -8
- package/assets/skills/spec-bug/SKILL.md +21 -18
- package/assets/skills/spec-cancel/SKILL.md +16 -2
- package/assets/skills/spec-complete/SKILL.md +47 -15
- package/assets/skills/spec-hotfix/SKILL.md +30 -11
- package/assets/skills/spec-init/SKILL.md +15 -2
- package/assets/skills/spec-linear-setup/SKILL.md +31 -6
- package/assets/skills/spec-next/SKILL.md +2 -2
- package/assets/skills/spec-push/SKILL.md +13 -14
- package/assets/skills/spec-review/SKILL.md +19 -6
- package/assets/skills/spec-start/SKILL.md +133 -53
- package/assets/skills/spec-sync/SKILL.md +7 -7
- package/assets/skills/spec-to-main/SKILL.md +14 -14
- package/package.json +1 -1
- package/src/cli.js +209 -3
- package/src/env/classify.js +91 -0
- package/src/env/config.js +15 -0
- package/src/env/provision.js +145 -11
- package/src/env/resolve.js +1 -0
- package/src/gating.js +155 -0
- package/src/init.js +28 -1
- package/src/prompts.js +10 -1
- package/src/vendor/linear/cli-sync.js +20 -0
- package/src/vendor/sync-core/src/compare.js +25 -2
package/src/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
resolveBaseBranch,
|
|
23
23
|
resolvePrimaryCheckout,
|
|
24
24
|
assertPrimaryOnMain,
|
|
25
|
+
currentBranch,
|
|
25
26
|
repoInfo,
|
|
26
27
|
expandTokens,
|
|
27
28
|
splitPrefix,
|
|
@@ -94,6 +95,11 @@ Usage:
|
|
|
94
95
|
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
95
96
|
status list provisioned specs + port blocks
|
|
96
97
|
resolve <spec> print resolved slug/type/branch/paths
|
|
98
|
+
skitterspec gating <cmd> Release-gating check (opt-in; needs
|
|
99
|
+
specs/.core/gating.config.json). Subcommands:
|
|
100
|
+
check [spec] report specs with no recorded gating
|
|
101
|
+
decision (--all, --json). Advisory:
|
|
102
|
+
always exits 0, never blocks.
|
|
97
103
|
skitterspec --help Show this help
|
|
98
104
|
skitterspec --version Print version
|
|
99
105
|
|
|
@@ -105,6 +111,8 @@ Options (init / update):
|
|
|
105
111
|
--diff (update) Show the upstream changes each customized
|
|
106
112
|
file declined, as a unified diff
|
|
107
113
|
--dir <path> Target project dir (default: positional arg or cwd)
|
|
114
|
+
--gating (init) Adopt release gating: each spec records whether
|
|
115
|
+
it ships behind a feature flag
|
|
108
116
|
--no-claude-md Skip creating/patching CLAUDE.md
|
|
109
117
|
--yes, -y Accept defaults; skip the interactive setup prompts
|
|
110
118
|
--isolation / --no-isolation Enable/skip per-spec isolation (a git
|
|
@@ -139,6 +147,10 @@ function parse(argv) {
|
|
|
139
147
|
else if (a === '--yes' || a === '-y') opts.yes = true
|
|
140
148
|
else if (a === '--isolation') opts.isolation = true
|
|
141
149
|
else if (a === '--no-isolation') opts.isolation = false
|
|
150
|
+
else if (a === '--gating') opts.gating = true
|
|
151
|
+
else if (a === '--no-gating') opts.gating = false
|
|
152
|
+
else if (a === '--all') opts.all = true
|
|
153
|
+
else if (a === '--json') opts.json = true
|
|
142
154
|
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
143
155
|
else if (a === '--resync') opts.resync = true
|
|
144
156
|
else if (a === '--reset') opts.reset = true
|
|
@@ -233,13 +245,172 @@ function specEnvStatus(dir, config) {
|
|
|
233
245
|
// the /spec-env skill executes (git worktree add, docker compose up, .env,
|
|
234
246
|
// opener). This creates no worktree and starts no stack — the caller runs the
|
|
235
247
|
// printed commands. Keep the output's verb honest about that.
|
|
248
|
+
|
|
249
|
+
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
250
|
+
// can; anything we cannot parse confidently is returned as-is, which makes it
|
|
251
|
+
// fail the spec-folder comparison and land in `foreign` — a refusal, which is the
|
|
252
|
+
// safe direction to be wrong in.
|
|
253
|
+
function unquotePath(p) {
|
|
254
|
+
if (!p.startsWith('"') || !p.endsWith('"')) return p
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(p)
|
|
257
|
+
} catch {
|
|
258
|
+
return p
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Repo-relative paths of everything uncommitted. Returns null when git could not
|
|
264
|
+
* be read at all — the caller must treat that as "nobody looked", never "clean".
|
|
265
|
+
*
|
|
266
|
+
* Two prefix-free listings rather than `git status --porcelain`, deliberately.
|
|
267
|
+
* Porcelain prefixes every path with a two-character status field, and the shared
|
|
268
|
+
* git reader TRIMS its output — which eats the leading space of the first line
|
|
269
|
+
* only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
|
|
270
|
+
* These emit bare paths, so there is no offset to get wrong. `--others` also
|
|
271
|
+
* lists untracked files INDIVIDUALLY, where porcelain collapses them into their
|
|
272
|
+
* topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
|
|
273
|
+
* an ancestor attributable to no single spec, and so refusing the very tree this
|
|
274
|
+
* gate exists to accept. Both were found by running it, not by reading it.
|
|
275
|
+
*/
|
|
276
|
+
function dirtyPaths(git) {
|
|
277
|
+
const lists = [
|
|
278
|
+
git(['diff', '--name-only', 'HEAD']),
|
|
279
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
280
|
+
]
|
|
281
|
+
if (lists.every((l) => l === null)) return null
|
|
282
|
+
const out = []
|
|
283
|
+
for (const list of lists) {
|
|
284
|
+
if (!list) continue
|
|
285
|
+
for (const line of list.split('\n')) {
|
|
286
|
+
const q = line.trim()
|
|
287
|
+
if (q) out.push(unquotePath(q))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Is this spec new to git? Asked directly, so the commit subject does not depend
|
|
294
|
+
// on the shape of `git status` output.
|
|
295
|
+
function specIsUntracked(dir, git, spec) {
|
|
296
|
+
const rel = path.relative(dir, spec.path).split(path.sep).join('/')
|
|
297
|
+
const tracked = git(['ls-files', '--', rel])
|
|
298
|
+
return tracked === null ? null : tracked.length === 0
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Is this spec present in the commit the worktree will fork from?
|
|
303
|
+
*
|
|
304
|
+
* A worktree forks from HEAD (`git worktree add -b`), so a spec absent there
|
|
305
|
+
* yields a branch missing the very spec it is for — and a clean working tree
|
|
306
|
+
* cannot detect that, because the spec may be perfectly well committed elsewhere.
|
|
307
|
+
* This asks the positive question rather than inferring it from cleanliness.
|
|
308
|
+
*
|
|
309
|
+
* A HOTFIX IS EXEMPT, and not as an edge case: it forks from a released tag
|
|
310
|
+
* (`spec.baseRef`) that by definition predates the spec describing the fix, so
|
|
311
|
+
* the spec is *supposed* to be absent there. Checking it would refuse every
|
|
312
|
+
* hotfix — an accusation aimed squarely at correct behaviour.
|
|
313
|
+
*
|
|
314
|
+
* Returns `{ onFork, foundOn }`; `onFork: null` means "cannot tell" (a hotfix, or
|
|
315
|
+
* a git that would not answer), which the planner routes to carrying on.
|
|
316
|
+
*/
|
|
317
|
+
function specOnForkPoint(dir, git, spec) {
|
|
318
|
+
if (spec.baseRef) return { onFork: null, foundOn: null }
|
|
319
|
+
const rel = path.relative(dir, spec.path).split(path.sep).join('/')
|
|
320
|
+
if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
|
|
321
|
+
return { onFork: true, foundOn: null }
|
|
322
|
+
}
|
|
323
|
+
// Best-effort: name the branch that does have it, so the refusal is actionable.
|
|
324
|
+
let foundOn = null
|
|
325
|
+
const sha = git(['log', '--all', '--format=%H', '-1', '--', rel])
|
|
326
|
+
if (sha) {
|
|
327
|
+
const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
|
|
328
|
+
if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
|
|
329
|
+
}
|
|
330
|
+
return { onFork: false, foundOn }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
// Say what is about to be committed, and why it qualified. The commit is planned
|
|
335
|
+
// on the operator's behalf, so it is never allowed to be a surprise: the paths are
|
|
336
|
+
// listed before the commands that stage them.
|
|
337
|
+
function specCommitLines(plan, folder) {
|
|
338
|
+
if (!plan.specCommit) return []
|
|
339
|
+
const out = ['', ` uncommitted, and all of it is ${folder}'s — it will be committed first:`]
|
|
340
|
+
for (const p of plan.specCommit.paths) out.push(` ${p}`)
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
|
|
236
344
|
// `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
|
|
237
345
|
// planner, and prints the plan or the refusal.
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `skitterspec gating check [spec] [--all] [--json]`
|
|
349
|
+
*
|
|
350
|
+
* ADVISORY BY CONSTRUCTION. It reports and exits 0 — always, including when it
|
|
351
|
+
* finds something. The point of release gating is that the decision is recorded
|
|
352
|
+
* and reviewable, not that a machine enforces it: a project that has not decided
|
|
353
|
+
* yet is not broken, and a check that stopped someone's work over a missing
|
|
354
|
+
* header would be a worse failure than the omission it names.
|
|
355
|
+
*/
|
|
356
|
+
function gatingCheck(dir, argv) {
|
|
357
|
+
const { opts, positional } = parse(argv)
|
|
358
|
+
const { checkGating, activeSpecs } = require('./gating.js')
|
|
359
|
+
|
|
360
|
+
let specs = null
|
|
361
|
+
if (positional.length && !opts.all) {
|
|
362
|
+
const name = path.basename(positional[0])
|
|
363
|
+
const found = activeSpecs(dir).filter((s) => s.folder === name)
|
|
364
|
+
if (!found.length) {
|
|
365
|
+
// Not an accusation: a name that matches no ACTIVE spec is usually a
|
|
366
|
+
// finished one, which gating never covers anyway.
|
|
367
|
+
process.stdout.write(`gating: no active spec named ${name} — nothing to check.\n`)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
specs = found
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = checkGating(dir, specs)
|
|
374
|
+
|
|
375
|
+
if (opts.json) {
|
|
376
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (!result.configured) {
|
|
380
|
+
process.stdout.write('gating: not configured — nothing to check.\n')
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (!result.findings.length) {
|
|
384
|
+
process.stdout.write(
|
|
385
|
+
`gating: ${result.checked} spec(s) checked — every one records a decision.\n`,
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
const lines = []
|
|
390
|
+
for (const f of result.findings) {
|
|
391
|
+
lines.push(
|
|
392
|
+
f.kind === 'missing'
|
|
393
|
+
? ` ${f.folder} (${f.bucket}): no Gating: header`
|
|
394
|
+
: ` ${f.folder} (${f.bucket}): Gating: "${f.raw}" says nothing — a bare "none" is not a reason`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
lines.push('')
|
|
398
|
+
lines.push(' decide, then record it on 00-overview.md beside Stack:')
|
|
399
|
+
lines.push(' > **Gating:** <flag name — or "none: <one-line reason>">')
|
|
400
|
+
if (result.guidance) lines.push(` see ${result.guidance}`)
|
|
401
|
+
process.stdout.write(
|
|
402
|
+
`gating: ${result.findings.length} of ${result.checked} spec(s) record no decision\n` +
|
|
403
|
+
lines.join('\n') +
|
|
404
|
+
'\n',
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
238
408
|
function specEnvUpCheckout(dir, config, spec) {
|
|
239
409
|
const git = gitReader(dir)
|
|
240
410
|
const primary = assertPrimaryOnMain(config, git)
|
|
241
411
|
const base = resolveBaseBranch(config, git)
|
|
242
|
-
const status = git(['status', '--porcelain'])
|
|
412
|
+
const status = git(['status', '--porcelain', '-uall'])
|
|
413
|
+
const onFork = specOnForkPoint(dir, git, spec)
|
|
243
414
|
|
|
244
415
|
const plan = planCheckoutUp(
|
|
245
416
|
spec,
|
|
@@ -251,6 +422,11 @@ function specEnvUpCheckout(dir, config, spec) {
|
|
|
251
422
|
// the harmless outcome of being wrong is a refusal the operator can act
|
|
252
423
|
// on, and the harmful one is carrying their work onto a new branch.
|
|
253
424
|
clean: status !== null && status.length === 0,
|
|
425
|
+
dirtyPaths: dirtyPaths(git),
|
|
426
|
+
specOnFork: onFork.onFork,
|
|
427
|
+
specFoundOn: onFork.foundOn,
|
|
428
|
+
forkRef: primary.branch || 'HEAD',
|
|
429
|
+
specUntracked: specIsUntracked(dir, git, spec),
|
|
254
430
|
branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
|
|
255
431
|
checkoutPath: dir,
|
|
256
432
|
},
|
|
@@ -271,6 +447,7 @@ function specEnvUpCheckout(dir, config, spec) {
|
|
|
271
447
|
` branch: ${plan.branch}`,
|
|
272
448
|
' stack: checkout-only (no worktree, no docker, no port block)',
|
|
273
449
|
]
|
|
450
|
+
out.push(...specCommitLines(plan, spec.folder))
|
|
274
451
|
if (plan.commands.length) {
|
|
275
452
|
out.push('')
|
|
276
453
|
out.push(' to provision, run:')
|
|
@@ -326,7 +503,24 @@ function specEnvUp(dir, config, specArg) {
|
|
|
326
503
|
attached = fs.existsSync(spec.worktreePath)
|
|
327
504
|
}
|
|
328
505
|
|
|
329
|
-
|
|
506
|
+
// The tree gate: the same facts the checkout planner gets. A worktree forks
|
|
507
|
+
// from base, so an uncommitted spec would produce a branch without it.
|
|
508
|
+
const upGit = gitReader(dir)
|
|
509
|
+
const upStatus = upGit(['status', '--porcelain'])
|
|
510
|
+
const upOnFork = specOnForkPoint(dir, upGit, spec)
|
|
511
|
+
const plan = planUp(spec, { slot, attached }, config, {
|
|
512
|
+
clean: upStatus !== null && upStatus.length === 0,
|
|
513
|
+
dirtyPaths: dirtyPaths(upGit),
|
|
514
|
+
specOnFork: upOnFork.onFork,
|
|
515
|
+
specFoundOn: upOnFork.foundOn,
|
|
516
|
+
forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
|
|
517
|
+
specUntracked: specIsUntracked(dir, upGit, spec),
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
if (plan.blocked) {
|
|
521
|
+
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
330
524
|
|
|
331
525
|
const out = []
|
|
332
526
|
// `up` is a planner: it prints commands for the caller to run and creates no
|
|
@@ -364,6 +558,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
364
558
|
`(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
|
|
365
559
|
)
|
|
366
560
|
}
|
|
561
|
+
out.push(...specCommitLines(plan, spec.folder))
|
|
367
562
|
out.push('')
|
|
368
563
|
out.push(' to provision, run:')
|
|
369
564
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
@@ -1687,6 +1882,14 @@ async function run(argv) {
|
|
|
1687
1882
|
|
|
1688
1883
|
const [cmd, ...rest] = argv
|
|
1689
1884
|
|
|
1885
|
+
if (cmd === 'gating') {
|
|
1886
|
+
const [sub, ...gArgs] = rest
|
|
1887
|
+
const gDir = process.cwd()
|
|
1888
|
+
if (sub === 'check') gatingCheck(gDir, gArgs)
|
|
1889
|
+
else process.stdout.write('Usage: skitterspec gating check [spec] [--all] [--json]\n')
|
|
1890
|
+
return
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1690
1893
|
if (cmd === 'spec-env') {
|
|
1691
1894
|
await specEnv(rest)
|
|
1692
1895
|
return
|
|
@@ -1735,11 +1938,13 @@ async function run(argv) {
|
|
|
1735
1938
|
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1736
1939
|
let isolation = opts.isolation === true
|
|
1737
1940
|
let workspaceMode = 'worktree'
|
|
1941
|
+
let gating = opts.gating === true
|
|
1738
1942
|
if (interactive && !isExistingSetup(dir)) {
|
|
1739
1943
|
const { promptSetup } = require('./prompts.js')
|
|
1740
|
-
const answers = await promptSetup({ isolationSeed: isolation })
|
|
1944
|
+
const answers = await promptSetup({ isolationSeed: isolation, gatingSeed: gating })
|
|
1741
1945
|
isolation = answers.isolation
|
|
1742
1946
|
workspaceMode = answers.mode
|
|
1947
|
+
gating = answers.gating
|
|
1743
1948
|
}
|
|
1744
1949
|
await init({
|
|
1745
1950
|
dir,
|
|
@@ -1748,6 +1953,7 @@ async function run(argv) {
|
|
|
1748
1953
|
mode: 'init',
|
|
1749
1954
|
isolation,
|
|
1750
1955
|
workspaceMode,
|
|
1956
|
+
gating,
|
|
1751
1957
|
})
|
|
1752
1958
|
break
|
|
1753
1959
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
const identifier = readFrontmatterField(spec.path, field)
|
|
49
|
+
if (!identifier) return null
|
|
50
|
+
tokens.identifier = identifier
|
|
51
|
+
}
|
|
52
|
+
return normalize(expandTokens(pattern, tokens))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
|
|
57
|
+
* not. Repo-relative paths in, repo-relative paths out; nothing is read from git
|
|
58
|
+
* and nothing is written.
|
|
59
|
+
*
|
|
60
|
+
* Owned:
|
|
61
|
+
* - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
|
|
62
|
+
* checked rather than the spec's current one because starting a spec MOVES it
|
|
63
|
+
* (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
|
|
64
|
+
* and both halves are the same spec's.
|
|
65
|
+
* - each expandable `spec.companionPaths` entry.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{owned: string[], foreign: string[]}}
|
|
68
|
+
*/
|
|
69
|
+
function classifyDirtyTree(spec, dirtyPaths, config) {
|
|
70
|
+
const owned = []
|
|
71
|
+
const foreign = []
|
|
72
|
+
if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
|
|
73
|
+
|
|
74
|
+
const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
|
|
75
|
+
const companions = new Set()
|
|
76
|
+
for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
|
|
77
|
+
const expanded = expandCompanion(pattern, spec, config)
|
|
78
|
+
if (expanded) companions.add(expanded)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const raw of dirtyPaths || []) {
|
|
82
|
+
const p = normalize(raw)
|
|
83
|
+
if (!p) continue
|
|
84
|
+
const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
|
|
85
|
+
if (inFolder || companions.has(p)) owned.push(p)
|
|
86
|
+
else foreign.push(p)
|
|
87
|
+
}
|
|
88
|
+
return { owned, foreign }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { classifyDirtyTree, expandCompanion }
|
package/src/env/config.js
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
31
31
|
* registry: ".spec-env/registry.json",
|
|
32
32
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
33
|
+
* spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
|
|
34
|
+
* // spec alongside its own folder (provider-neutral; {slug} and
|
|
35
|
+
* // {identifier} expand); empty = the spec folder only
|
|
33
36
|
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
34
37
|
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
|
|
35
38
|
* teardown: { deleteRemoteBranch },
|
|
@@ -90,6 +93,13 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
90
93
|
// 00-overview.md frontmatter field a provider writes the ticket id into (empty
|
|
91
94
|
// = no identifier, so patterns referencing {identifier} fall back to type/slug).
|
|
92
95
|
branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
|
|
96
|
+
// Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
|
|
97
|
+
// — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
|
|
98
|
+
// design: the base engine must not know that any particular tracker exists, so
|
|
99
|
+
// the project declares the shape and `{slug}` / `{identifier}` expand exactly as
|
|
100
|
+
// they do in `branch.pattern` ({identifier} via `branch.identifierField`).
|
|
101
|
+
// Default: none, so a spec owns only its own folder.
|
|
102
|
+
spec: Object.freeze({ companionPaths: Object.freeze([]) }),
|
|
93
103
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
94
104
|
baseBranch: '',
|
|
95
105
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
@@ -128,6 +138,7 @@ function defaults() {
|
|
|
128
138
|
open: { ...DEFAULT_CONFIG.open },
|
|
129
139
|
registry: DEFAULT_CONFIG.registry,
|
|
130
140
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
141
|
+
spec: { companionPaths: [] },
|
|
131
142
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
132
143
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
133
144
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
@@ -296,6 +307,10 @@ function mergeConfig(base, parsed) {
|
|
|
296
307
|
}
|
|
297
308
|
}
|
|
298
309
|
|
|
310
|
+
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
311
|
+
base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
|
|
312
|
+
}
|
|
313
|
+
|
|
299
314
|
if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
|
|
300
315
|
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
301
316
|
}
|
package/src/env/provision.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* no live git/docker.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { classifyDirtyTree } = require('./classify.js')
|
|
14
15
|
const { portOffset } = require('./registry.js')
|
|
15
16
|
const { renderEnvFile, expandOpenCommand } = require('./render.js')
|
|
16
17
|
const { expandTokens } = require('./resolve.js')
|
|
@@ -71,6 +72,126 @@ function seedCommandFor(file, mode) {
|
|
|
71
72
|
)
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
|
|
76
|
+
// Cap a path list in a message: enough to recognise, not a wall of text.
|
|
77
|
+
function listPaths(paths) {
|
|
78
|
+
const shown = paths.slice(0, 5).join(', ')
|
|
79
|
+
return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide what an uncommitted tree means for a provisioning run: commit it, refuse
|
|
84
|
+
* it, or ignore it.
|
|
85
|
+
*
|
|
86
|
+
* `/spec-start` refuses a dirty tree so another spec's unfinished work is never
|
|
87
|
+
* moved without its author asking. But the commonest dirty tree of all is the
|
|
88
|
+
* spec you just wrote and are now starting, and refusing THAT costs a round trip
|
|
89
|
+
* through `/commit` on every start. So the question asked here is not "does this
|
|
90
|
+
* dirt look important?" — a judgement the gate rightly declines to make — but
|
|
91
|
+
* membership in an exactly-known set (see `classify.js`). One foreign path
|
|
92
|
+
* disqualifies the whole tree.
|
|
93
|
+
*
|
|
94
|
+
* WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
|
|
95
|
+
* clean" — it is "nobody looked", which happens on every legacy caller and
|
|
96
|
+
* whenever git itself could not be read. Either way it is never permission to
|
|
97
|
+
* commit: with no classification there is no owned set, so no commit is planned.
|
|
98
|
+
*
|
|
99
|
+
* Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
|
|
100
|
+
* two modes' actual risk, not an inconsistency:
|
|
101
|
+
*
|
|
102
|
+
* - checkout mode (`carriesChanges`) — `git switch -c` silently carries
|
|
103
|
+
* uncommitted work onto the new branch. Unable to see the tree means unable
|
|
104
|
+
* to rule that out, so it refuses, exactly as it did before this gate existed.
|
|
105
|
+
* - worktree mode — `git worktree add` carries nothing, and forks from a commit
|
|
106
|
+
* regardless. Refusing here would fire on a healthy repo whose git we merely
|
|
107
|
+
* could not read, which is a cost paid by someone who did nothing wrong. So
|
|
108
|
+
* it plans no commit and provisions as before.
|
|
109
|
+
*
|
|
110
|
+
* `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
|
|
111
|
+
* the worktree forks from a specific commit, so a spec absent from it produces a
|
|
112
|
+
* branch missing the very spec it is for. A clean tree cannot detect that — the
|
|
113
|
+
* spec may be committed, just somewhere else. `null` means the caller could not
|
|
114
|
+
* tell (an unreadable git, or a hotfix, which forks from a tag predating its own
|
|
115
|
+
* spec), and routes to carrying on, never to refusing.
|
|
116
|
+
*
|
|
117
|
+
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
118
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[]}}
|
|
119
|
+
*/
|
|
120
|
+
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
121
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
|
|
122
|
+
const c = ctx || {}
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(c.dirtyPaths)) {
|
|
125
|
+
// Nobody looked: never commit, and refuse only where switching could carry
|
|
126
|
+
// work we cannot see (see the asymmetry above).
|
|
127
|
+
if (carriesChanges && c.clean === false) {
|
|
128
|
+
return {
|
|
129
|
+
blocked: true,
|
|
130
|
+
reason:
|
|
131
|
+
'the primary checkout has uncommitted changes — commit or stash them first' +
|
|
132
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
|
|
133
|
+
commands: [],
|
|
134
|
+
owned: [],
|
|
135
|
+
verb: null,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return ok
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
142
|
+
|
|
143
|
+
if (foreign.length) {
|
|
144
|
+
return {
|
|
145
|
+
blocked: true,
|
|
146
|
+
reason:
|
|
147
|
+
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
148
|
+
'commit or stash them first' +
|
|
149
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
|
|
150
|
+
`: ${listPaths(foreign)}`,
|
|
151
|
+
commands: [],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (owned.length) {
|
|
156
|
+
// `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
|
|
157
|
+
// rather than inferred from the shape of the status output. The inference —
|
|
158
|
+
// "a bare directory entry means the folder is wholly untracked" — was wrong in
|
|
159
|
+
// the ordinary case of the FIRST spec in a bucket, where git reports the
|
|
160
|
+
// bucket as the untracked directory instead. The old inference stays as a
|
|
161
|
+
// fallback for callers that pass no fact; unknown yields `update`, which is
|
|
162
|
+
// never a false claim.
|
|
163
|
+
const isNew =
|
|
164
|
+
typeof c.specUntracked === 'boolean'
|
|
165
|
+
? c.specUntracked
|
|
166
|
+
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
167
|
+
const verb = isNew ? 'add' : 'update'
|
|
168
|
+
return {
|
|
169
|
+
blocked: false,
|
|
170
|
+
reason: null,
|
|
171
|
+
owned,
|
|
172
|
+
verb,
|
|
173
|
+
commands: [
|
|
174
|
+
`git add ${owned.map((p) => `"${p}"`).join(' ')}`,
|
|
175
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}"`,
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
181
|
+
// worktree forks without it.
|
|
182
|
+
if (c.specOnFork === false) {
|
|
183
|
+
return {
|
|
184
|
+
blocked: true,
|
|
185
|
+
reason:
|
|
186
|
+
`${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
|
|
187
|
+
(c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
|
|
188
|
+
' — the worktree would fork without the spec it is for',
|
|
189
|
+
commands: [],
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return ok
|
|
193
|
+
}
|
|
194
|
+
|
|
74
195
|
/**
|
|
75
196
|
* Plan a provisioning run.
|
|
76
197
|
*
|
|
@@ -83,7 +204,7 @@ function seedCommandFor(file, mode) {
|
|
|
83
204
|
* envContents, openCommand, commands, seedCommands,
|
|
84
205
|
* setupCommands, attached }
|
|
85
206
|
*/
|
|
86
|
-
function planUp(spec, alloc, config) {
|
|
207
|
+
function planUp(spec, alloc, config, ctx) {
|
|
87
208
|
const { slot, attached } = alloc
|
|
88
209
|
|
|
89
210
|
// Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
|
|
@@ -143,7 +264,18 @@ function planUp(spec, alloc, config) {
|
|
|
143
264
|
commands.push(`docker compose --project-name ${spec.projectName} up -d`)
|
|
144
265
|
}
|
|
145
266
|
|
|
267
|
+
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
268
|
+
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
269
|
+
// where that gets committed, or refused. Worktree mode had NO clean gate before
|
|
270
|
+
// this: `git worktree add` does not carry uncommitted changes anywhere, so the
|
|
271
|
+
// failure surfaced three steps later as a live-overlay refusal naming the wrong
|
|
272
|
+
// stage. An absent `ctx` means no caller looked, and changes nothing.
|
|
273
|
+
const gate = planSpecCommit(spec, ctx, config)
|
|
274
|
+
|
|
146
275
|
return {
|
|
276
|
+
blocked: gate.blocked,
|
|
277
|
+
reason: gate.reason,
|
|
278
|
+
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
147
279
|
worktreePath: spec.worktreePath,
|
|
148
280
|
branch: spec.branch,
|
|
149
281
|
projectName: spec.projectName,
|
|
@@ -151,9 +283,9 @@ function planUp(spec, alloc, config) {
|
|
|
151
283
|
portOffset: offset,
|
|
152
284
|
envContents,
|
|
153
285
|
openCommand,
|
|
154
|
-
commands,
|
|
155
|
-
seedCommands,
|
|
156
|
-
setupCommands,
|
|
286
|
+
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
287
|
+
seedCommands: gate.blocked ? [] : seedCommands,
|
|
288
|
+
setupCommands: gate.blocked ? [] : setupCommands,
|
|
157
289
|
attached,
|
|
158
290
|
}
|
|
159
291
|
}
|
|
@@ -196,12 +328,10 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
196
328
|
if (ctx.current && ctx.current === spec.branch) {
|
|
197
329
|
return { ...result, attached: true }
|
|
198
330
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
}
|
|
331
|
+
// Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
|
|
332
|
+
// work onto the new branch, so the refusal keeps saying so.
|
|
333
|
+
const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
|
|
334
|
+
if (gate.blocked) return block(gate.reason)
|
|
205
335
|
if (!ctx.onBase) {
|
|
206
336
|
return block(
|
|
207
337
|
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
@@ -209,10 +339,14 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
209
339
|
)
|
|
210
340
|
}
|
|
211
341
|
|
|
342
|
+
if (gate.owned && gate.owned.length) {
|
|
343
|
+
result.specCommit = { paths: gate.owned, verb: gate.verb }
|
|
344
|
+
}
|
|
345
|
+
result.commands.push(...gate.commands)
|
|
212
346
|
result.commands.push(
|
|
213
347
|
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
214
348
|
)
|
|
215
349
|
return result
|
|
216
350
|
}
|
|
217
351
|
|
|
218
|
-
module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
|
|
352
|
+
module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
|