@skitterbyte/skitterspec-linear 11.0.0 → 13.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 +260 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +48 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +9 -3
- package/assets/core/env.config.md +102 -30
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +67 -8
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +224 -15
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +64 -13
- package/assets/skills/spec-bug/SKILL.md +193 -27
- package/assets/skills/spec-cancel/SKILL.md +99 -8
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +123 -22
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +202 -22
- package/assets/skills/spec-init/SKILL.md +49 -9
- package/assets/skills/spec-linear-setup/SKILL.md +86 -7
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +300 -7
- package/assets/skills/spec-push/SKILL.md +45 -22
- package/assets/skills/spec-review/SKILL.md +59 -11
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +426 -66
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +47 -11
- package/assets/skills/spec-to-main/SKILL.md +42 -20
- package/package.json +11 -7
- package/src/cli.js +1710 -80
- package/src/env/building.js +143 -0
- package/src/env/classify.js +91 -0
- package/src/env/config.js +57 -9
- package/src/env/provision.js +192 -19
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +296 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/gating.js +155 -0
- package/src/init.js +124 -2
- package/src/prompts.js +10 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +874 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +74 -5
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- package/LICENSE +0 -21
|
@@ -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,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
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
28
28
|
* // servers started on the spec's port block (empty = none)
|
|
29
29
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
30
|
-
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
31
30
|
* registry: ".spec-env/registry.json",
|
|
32
31
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
32
|
+
* spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
|
|
33
|
+
* // spec alongside its own folder (provider-neutral; {slug} and
|
|
34
|
+
* // {identifier} expand); empty = the spec folder only
|
|
33
35
|
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
34
36
|
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
|
|
35
37
|
* teardown: { deleteRemoteBranch },
|
|
@@ -83,22 +85,48 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
83
85
|
// Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
|
|
84
86
|
// exposes one connected spec's frontPort processes on the canonical ports.
|
|
85
87
|
proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
|
|
86
|
-
open: Object.freeze({ command: '' }),
|
|
87
88
|
registry: '.spec-env/registry.json',
|
|
88
89
|
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
89
90
|
// when a tracker provider is linked, {identifier}; `identifierField` names the
|
|
90
91
|
// 00-overview.md frontmatter field a provider writes the ticket id into (empty
|
|
91
92
|
// = no identifier, so patterns referencing {identifier} fall back to type/slug).
|
|
92
93
|
branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
|
|
94
|
+
// Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
|
|
95
|
+
// — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
|
|
96
|
+
// design: the base engine must not know that any particular tracker exists, so
|
|
97
|
+
// the project declares the shape and `{slug}` / `{identifier}` expand exactly as
|
|
98
|
+
// they do in `branch.pattern` ({identifier} via `branch.identifierField`).
|
|
99
|
+
// Default: none, so a spec owns only its own folder.
|
|
100
|
+
spec: Object.freeze({ companionPaths: Object.freeze([]) }),
|
|
93
101
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
94
102
|
baseBranch: '',
|
|
95
103
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
96
104
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
97
|
-
// `spec-env down` does about
|
|
105
|
+
// `spec-env down` does about a branch the USER published by hand — nothing
|
|
106
|
+
// publishes one at provisioning, so there is often no remote ref at all and
|
|
107
|
+
// the plan then says nothing: "prompt" (default)
|
|
98
108
|
// plans the delete in its own confirm-first section for the skill to ask about,
|
|
99
109
|
// "never" omits it, "always" folds it into the run-blind command list. Only ever
|
|
100
110
|
// planned for a LANDED branch — see teardown.js.
|
|
101
111
|
teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
|
|
112
|
+
|
|
113
|
+
// `reader` decides how a diff's location is worded, and — via
|
|
114
|
+
// `serveOnRemote` — whether the engine stands the local server up so a remote
|
|
115
|
+
// reader gets a link that opens. It never decides to PUBLISH: publishing
|
|
116
|
+
// leaves a page this tooling cannot remove, so it stays an explicit ask.
|
|
117
|
+
// `detect` sniffs; `local`/`remote` are the operator's own answer and are
|
|
118
|
+
// believed without sniffing, because they know where they are reading and no
|
|
119
|
+
// signal can outrank that.
|
|
120
|
+
// `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
|
|
121
|
+
// ships with skittership, a different package — so it may not be installed,
|
|
122
|
+
// and skitterspec must never vendor a copy of it.
|
|
123
|
+
//
|
|
124
|
+
// There is no off switch, and that is deliberate: `"none"` existed and was
|
|
125
|
+
// removed, because it produced the one thing a review page must not have —
|
|
126
|
+
// a verdict that records itself and does nothing. A review is the guard in
|
|
127
|
+
// front of an action; recording an approval for SOMEONE ELSE to act on is a
|
|
128
|
+
// different mechanism, not a value of this key.
|
|
129
|
+
review: Object.freeze({ reader: 'detect', servePort: 7777, serveOnRemote: true, commitWith: '/commit' }),
|
|
102
130
|
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
103
131
|
// migration files; a branch that changes any of them is treated as stateful and
|
|
104
132
|
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
@@ -125,12 +153,13 @@ function defaults() {
|
|
|
125
153
|
setup: [],
|
|
126
154
|
dev: [],
|
|
127
155
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
128
|
-
open: { ...DEFAULT_CONFIG.open },
|
|
129
156
|
registry: DEFAULT_CONFIG.registry,
|
|
130
157
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
158
|
+
spec: { companionPaths: [] },
|
|
131
159
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
132
160
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
133
161
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
162
|
+
review: { ...DEFAULT_CONFIG.review },
|
|
134
163
|
live: { migrations: [] },
|
|
135
164
|
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
136
165
|
}
|
|
@@ -258,11 +287,6 @@ function mergeConfig(base, parsed) {
|
|
|
258
287
|
assign(base.proxy, parsed.proxy, 'host', 'string')
|
|
259
288
|
}
|
|
260
289
|
|
|
261
|
-
if (isObject(parsed.open)) {
|
|
262
|
-
// command may be intentionally empty (no auto-open)
|
|
263
|
-
assign(base.open, parsed.open, 'command', 'string?')
|
|
264
|
-
}
|
|
265
|
-
|
|
266
290
|
if (isObject(parsed.branch)) {
|
|
267
291
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
268
292
|
assign(base.branch, parsed.branch, 'identifierField', 'string')
|
|
@@ -296,6 +320,30 @@ function mergeConfig(base, parsed) {
|
|
|
296
320
|
}
|
|
297
321
|
}
|
|
298
322
|
|
|
323
|
+
// An unrecognised reader falls through to `detect`, which is the state that
|
|
324
|
+
// claims least: it can answer "unknown", and unknown is wired to today's
|
|
325
|
+
// behaviour. A typo must never become a confident `local`, because a confident
|
|
326
|
+
// `local` is exactly the dead `file://` link this key exists to prevent.
|
|
327
|
+
if (isObject(parsed.review)) {
|
|
328
|
+
const reader = parsed.review.reader
|
|
329
|
+
if (reader === 'local' || reader === 'remote' || reader === 'detect') {
|
|
330
|
+
base.review.reader = reader
|
|
331
|
+
}
|
|
332
|
+
assign(base.review, parsed.review, 'servePort', 'number')
|
|
333
|
+
// Opting OUT is the only thing this key can do — a non-boolean leaves the
|
|
334
|
+
// default in place rather than being read as a refusal, so a typo cannot
|
|
335
|
+
// quietly restore the dead `file://` link on a remote reader.
|
|
336
|
+
assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
|
|
337
|
+
// An empty string leaves `/commit` standing, like every other string key
|
|
338
|
+
// here. There is nothing it could mean instead: the hand-off has no off
|
|
339
|
+
// switch, so a blank value is a typo rather than an instruction.
|
|
340
|
+
assign(base.review, parsed.review, 'commitWith', 'string')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
344
|
+
base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
|
|
345
|
+
}
|
|
346
|
+
|
|
299
347
|
if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
|
|
300
348
|
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
301
349
|
}
|
package/src/env/provision.js
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Given a resolved spec and its allocated slot, `planUp` returns the exact
|
|
7
7
|
* side-effecting commands the `/spec-env` skill runs (`git worktree add`,
|
|
8
|
-
* `docker compose up`)
|
|
9
|
-
*
|
|
8
|
+
* `docker compose up`) and the rendered `.env` contents — but performs no side
|
|
9
|
+
* effects itself. The caller (the CLI) reads/allocates the
|
|
10
10
|
* registry and passes the slot; this stays deterministic and unit-testable with
|
|
11
11
|
* no live git/docker.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { classifyDirtyTree } = require('./classify.js')
|
|
14
15
|
const { portOffset } = require('./registry.js')
|
|
15
|
-
const { renderEnvFile
|
|
16
|
+
const { renderEnvFile } = require('./render.js')
|
|
16
17
|
const { expandTokens } = require('./resolve.js')
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -71,6 +72,164 @@ 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
|
+
* FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
|
|
118
|
+
* uncommitted work refuses in checkout mode, where `git switch -c` would carry
|
|
119
|
+
* it onto the new branch, and does not in worktree mode, where nothing carries
|
|
120
|
+
* anywhere. Refusing there fired on the commonest tree this workflow produces —
|
|
121
|
+
* author spec B while spec A is still uncommitted, then start B — and prevented
|
|
122
|
+
* nothing, since the only write to the primary checkout is a pathspec-limited
|
|
123
|
+
* commit of this spec's own paths. It is reported instead, on `foreign`.
|
|
124
|
+
*
|
|
125
|
+
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
126
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[],
|
|
127
|
+
* owned: string[], foreign: string[]}}
|
|
128
|
+
*/
|
|
129
|
+
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
130
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
|
|
131
|
+
const c = ctx || {}
|
|
132
|
+
|
|
133
|
+
if (!Array.isArray(c.dirtyPaths)) {
|
|
134
|
+
// Nobody looked: never commit, and refuse only where switching could carry
|
|
135
|
+
// work we cannot see (see the asymmetry above).
|
|
136
|
+
if (carriesChanges && c.clean === false) {
|
|
137
|
+
return {
|
|
138
|
+
blocked: true,
|
|
139
|
+
reason:
|
|
140
|
+
'the primary checkout has uncommitted changes — commit or stash them first' +
|
|
141
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
|
|
142
|
+
commands: [],
|
|
143
|
+
owned: [],
|
|
144
|
+
verb: null,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return ok
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
151
|
+
|
|
152
|
+
// FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
|
|
153
|
+
// mode and nowhere else. `git switch -c` carries the working tree onto the new
|
|
154
|
+
// branch, silently, so there it is someone else's work being moved without
|
|
155
|
+
// them asking. `git worktree add` carries nothing and forks from a commit, so
|
|
156
|
+
// in worktree mode the same tree is simply none of this run's business.
|
|
157
|
+
//
|
|
158
|
+
// WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
|
|
159
|
+
// run does to the primary checkout is the spec commit below, and it is safe to
|
|
160
|
+
// leave a colleague's files sitting beside it only because that commit is
|
|
161
|
+
// pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
|
|
162
|
+
// cannot reach a path it does not own — not even one another session has
|
|
163
|
+
// already staged into the shared index. Drop the `--` and this stops being a
|
|
164
|
+
// false guard and starts being a missing one.
|
|
165
|
+
if (foreign.length && carriesChanges) {
|
|
166
|
+
return {
|
|
167
|
+
blocked: true,
|
|
168
|
+
reason:
|
|
169
|
+
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
170
|
+
'commit or stash them first (switching would carry them onto the new branch)' +
|
|
171
|
+
`: ${listPaths(foreign)}`,
|
|
172
|
+
commands: [],
|
|
173
|
+
owned: [],
|
|
174
|
+
verb: null,
|
|
175
|
+
foreign,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (owned.length) {
|
|
180
|
+
// `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
|
|
181
|
+
// rather than inferred from the shape of the status output. The inference —
|
|
182
|
+
// "a bare directory entry means the folder is wholly untracked" — was wrong in
|
|
183
|
+
// the ordinary case of the FIRST spec in a bucket, where git reports the
|
|
184
|
+
// bucket as the untracked directory instead. The old inference stays as a
|
|
185
|
+
// fallback for callers that pass no fact; unknown yields `update`, which is
|
|
186
|
+
// never a false claim.
|
|
187
|
+
const isNew =
|
|
188
|
+
typeof c.specUntracked === 'boolean'
|
|
189
|
+
? c.specUntracked
|
|
190
|
+
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
191
|
+
const verb = isNew ? 'add' : 'update'
|
|
192
|
+
// BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
|
|
193
|
+
// `add` is what makes a path git has never seen committable at all — every
|
|
194
|
+
// brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
|
|
195
|
+
// what lands: a checkout has one `.git/index` and every session standing in
|
|
196
|
+
// it shares that index, so a bare `git commit` takes whatever another
|
|
197
|
+
// session has already staged, however exactly this one named its own paths.
|
|
198
|
+
// Dropping the `--` would leave the naming above as decoration.
|
|
199
|
+
const paths = owned.map((p) => `"${p}"`).join(' ')
|
|
200
|
+
return {
|
|
201
|
+
blocked: false,
|
|
202
|
+
reason: null,
|
|
203
|
+
owned,
|
|
204
|
+
verb,
|
|
205
|
+
foreign,
|
|
206
|
+
commands: [
|
|
207
|
+
`git add -- ${paths}`,
|
|
208
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
214
|
+
// worktree forks without it.
|
|
215
|
+
// Nothing of this spec's is uncommitted, but somebody else's may be — say so
|
|
216
|
+
// rather than returning the bare `ok`, or the caller has no way to report what
|
|
217
|
+
// it left alone.
|
|
218
|
+
if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
|
|
219
|
+
|
|
220
|
+
if (c.specOnFork === false) {
|
|
221
|
+
return {
|
|
222
|
+
blocked: true,
|
|
223
|
+
reason:
|
|
224
|
+
`${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
|
|
225
|
+
(c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
|
|
226
|
+
' — the worktree would fork without the spec it is for',
|
|
227
|
+
commands: [],
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return ok
|
|
231
|
+
}
|
|
232
|
+
|
|
74
233
|
/**
|
|
75
234
|
* Plan a provisioning run.
|
|
76
235
|
*
|
|
@@ -80,10 +239,10 @@ function seedCommandFor(file, mode) {
|
|
|
80
239
|
* existed in the registry (re-run → attach, don't clobber).
|
|
81
240
|
* @param {object} config normalised env config.
|
|
82
241
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
83
|
-
* envContents,
|
|
242
|
+
* envContents, commands, seedCommands,
|
|
84
243
|
* setupCommands, attached }
|
|
85
244
|
*/
|
|
86
|
-
function planUp(spec, alloc, config) {
|
|
245
|
+
function planUp(spec, alloc, config, ctx) {
|
|
87
246
|
const { slot, attached } = alloc
|
|
88
247
|
|
|
89
248
|
// Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
|
|
@@ -107,7 +266,6 @@ function planUp(spec, alloc, config) {
|
|
|
107
266
|
portOffset: offset === null ? '' : String(offset),
|
|
108
267
|
}
|
|
109
268
|
|
|
110
|
-
const openCommand = expandOpenCommand(config.open.command, tokens)
|
|
111
269
|
|
|
112
270
|
// File seeding runs *in the worktree* after `git worktree add`, before the
|
|
113
271
|
// setup commands (which may depend on the seeded .env). Each entry becomes an
|
|
@@ -143,17 +301,31 @@ function planUp(spec, alloc, config) {
|
|
|
143
301
|
commands.push(`docker compose --project-name ${spec.projectName} up -d`)
|
|
144
302
|
}
|
|
145
303
|
|
|
304
|
+
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
305
|
+
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
306
|
+
// where that gets committed, or refused. Worktree mode had NO clean gate before
|
|
307
|
+
// this: `git worktree add` does not carry uncommitted changes anywhere, so the
|
|
308
|
+
// failure surfaced three steps later as a live-overlay refusal naming the wrong
|
|
309
|
+
// stage. An absent `ctx` means no caller looked, and changes nothing.
|
|
310
|
+
const gate = planSpecCommit(spec, ctx, config)
|
|
311
|
+
|
|
146
312
|
return {
|
|
313
|
+
blocked: gate.blocked,
|
|
314
|
+
reason: gate.reason,
|
|
315
|
+
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
316
|
+
// What this run deliberately did not touch. Carried out rather than dropped
|
|
317
|
+
// because "provisioned, and left four of your files alone" is a different
|
|
318
|
+
// report from "provisioned", and the caller cannot reconstruct it.
|
|
319
|
+
untouched: gate.foreign || [],
|
|
147
320
|
worktreePath: spec.worktreePath,
|
|
148
321
|
branch: spec.branch,
|
|
149
322
|
projectName: spec.projectName,
|
|
150
323
|
slot: wantsDocker ? slot : null,
|
|
151
324
|
portOffset: offset,
|
|
152
325
|
envContents,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
setupCommands,
|
|
326
|
+
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
327
|
+
seedCommands: gate.blocked ? [] : seedCommands,
|
|
328
|
+
setupCommands: gate.blocked ? [] : setupCommands,
|
|
157
329
|
attached,
|
|
158
330
|
}
|
|
159
331
|
}
|
|
@@ -174,8 +346,7 @@ function planUp(spec, alloc, config) {
|
|
|
174
346
|
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
175
347
|
* refusal — it is the re-run, and the answer is "already attached".
|
|
176
348
|
*
|
|
177
|
-
* There is no bootstrap
|
|
178
|
-
* dependencies, and no new session is being opened.
|
|
349
|
+
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
179
350
|
*/
|
|
180
351
|
function planCheckoutUp(spec, ctx, config) {
|
|
181
352
|
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
@@ -196,12 +367,10 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
196
367
|
if (ctx.current && ctx.current === spec.branch) {
|
|
197
368
|
return { ...result, attached: true }
|
|
198
369
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
}
|
|
370
|
+
// Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
|
|
371
|
+
// work onto the new branch, so the refusal keeps saying so.
|
|
372
|
+
const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
|
|
373
|
+
if (gate.blocked) return block(gate.reason)
|
|
205
374
|
if (!ctx.onBase) {
|
|
206
375
|
return block(
|
|
207
376
|
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
@@ -209,10 +378,14 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
209
378
|
)
|
|
210
379
|
}
|
|
211
380
|
|
|
381
|
+
if (gate.owned && gate.owned.length) {
|
|
382
|
+
result.specCommit = { paths: gate.owned, verb: gate.verb }
|
|
383
|
+
}
|
|
384
|
+
result.commands.push(...gate.commands)
|
|
212
385
|
result.commands.push(
|
|
213
386
|
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
214
387
|
)
|
|
215
388
|
return result
|
|
216
389
|
}
|
|
217
390
|
|
|
218
|
-
module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
|
|
391
|
+
module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
|
package/src/env/proxy.js
CHANGED
|
@@ -141,7 +141,40 @@ async function waitListening(
|
|
|
141
141
|
return false
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Ask whether `port` is free on EVERY host it will need, one host at a time.
|
|
147
|
+
*
|
|
148
|
+
* SEQUENTIAL IS THE WHOLE POINT, and it is not a style choice. The probe works
|
|
149
|
+
* by binding, so two probes of the same port running concurrently contend with
|
|
150
|
+
* each other — and on Linux a wildcard bind and a loopback bind of one port are
|
|
151
|
+
* mutually exclusive, so the pair reports a completely free port as busy. The
|
|
152
|
+
* review server then refused to start on every Linux machine, which is to say
|
|
153
|
+
* on CI, while every macOS run stayed green because BSD lets the two coexist.
|
|
154
|
+
*
|
|
155
|
+
* Probing one host at a time asks the question that was always intended — is
|
|
156
|
+
* anything ELSE holding this port — instead of racing the caller against
|
|
157
|
+
* itself. Verified on node:24-alpine: parallel reports busy on a free port,
|
|
158
|
+
* sequential does not; both still refuse a real squatter on either address.
|
|
159
|
+
*
|
|
160
|
+
* `probe` is injectable so the concurrency can be asserted without sockets.
|
|
161
|
+
*/
|
|
162
|
+
async function portsInUseOn(port, hosts, probe = portsInUse) {
|
|
163
|
+
const busy = []
|
|
164
|
+
for (const host of [...new Set(hosts)]) {
|
|
165
|
+
busy.push(...(await probe([port], host)))
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(busy)]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
renderRoutes,
|
|
172
|
+
createRouteServer,
|
|
173
|
+
startProxy,
|
|
174
|
+
portsInUse,
|
|
175
|
+
portsInUseOn,
|
|
176
|
+
waitListening,
|
|
177
|
+
}
|
|
145
178
|
|
|
146
179
|
// Entry point: run as a detached process by the CLI. Reads its routes from a
|
|
147
180
|
// file so a re-`connect` just rewrites the file and restarts this (tiny) process.
|