@skitterbyte/skitterspec-jira 1.0.0 → 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 (42) hide show
  1. package/MIGRATION.md +178 -1
  2. package/README.md +3 -1
  3. package/assets/core/SETUP.md +1 -1
  4. package/assets/core/env.config.md +54 -2
  5. package/assets/core/jira.config.md +33 -6
  6. package/assets/hooks/main-guard.cjs +1 -1
  7. package/assets/hooks/review-gate.cjs +1 -1
  8. package/assets/rules/spec-planning.md +13 -3
  9. package/assets/rules/spec-reports.md +19 -4
  10. package/assets/skills/no-spec/SKILL.md +6 -3
  11. package/assets/skills/spec-bug/SKILL.md +3 -1
  12. package/assets/skills/spec-complete/SKILL.md +6 -3
  13. package/assets/skills/spec-hotfix/SKILL.md +3 -1
  14. package/assets/skills/spec-jira-setup/SKILL.md +2 -1
  15. package/assets/skills/spec-next/SKILL.md +3 -1
  16. package/assets/skills/spec-status/SKILL.md +27 -25
  17. package/assets/skills/spec-sync/SKILL.md +6 -4
  18. package/assets/skills/spec-to-main/SKILL.md +5 -3
  19. package/bin/skitterspec-jira.js +7 -0
  20. package/package.json +1 -1
  21. package/src/cli.js +82 -16
  22. package/src/env/config.js +111 -14
  23. package/src/env/glob.js +34 -0
  24. package/src/env/hotfix.js +33 -5
  25. package/src/env/integrate.js +52 -11
  26. package/src/env/live.js +1 -26
  27. package/src/env/proxy.js +33 -0
  28. package/src/env/review.js +47 -2
  29. package/src/init.js +3 -3
  30. package/src/vendor/jira/cli-sync.js +5 -9
  31. package/src/vendor/jira/config.js +5 -15
  32. package/src/vendor/provider-kit/src/apply.js +10 -0
  33. package/src/vendor/provider-kit/src/assignment.js +277 -0
  34. package/src/vendor/provider-kit/src/config.js +101 -48
  35. package/src/vendor/provider-kit/src/http.js +30 -11
  36. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  37. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  38. package/src/vendor/provider-kit/src/released.js +80 -87
  39. package/src/vendor/provider-kit/src/specs.js +89 -7
  40. package/src/vendor/sync-core/index.js +5 -5
  41. package/src/vendor/sync-core/src/compare.js +38 -6
  42. package/src/vendor/sync-core/src/normalize.js +40 -58
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: spec-status
3
- description: Show a spec's one-way sync status against Jira — a read-only drift report naming what would push, and whether the tracker's status has drifted from the spec's. Changes nothing. Opt-in — needs specs/.core/jira.config.json. Use when the user says "/spec-status", "is this spec in sync with Jira", "what would push", or "show spec sync status".
3
+ description: Show a spec's one-way sync status against Jira — a read-only report naming what would push, and whether someone edited the issue's description since the last push. Changes nothing. Opt-in — needs specs/.core/jira.config.json. Use when the user says "/spec-status", "is this spec in sync with Jira", "what would push", or "show spec sync status".
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
- # /spec-status — one-way sync drift report
7
+ # /spec-status — one-way sync report
8
8
 
9
9
  > Stay silent while this runs — speak only to ask something you cannot answer
10
10
  > yourself, or to report a failure at the moment it happens. Read
@@ -15,13 +15,17 @@ Read-only. Reports two things and writes nothing:
15
15
 
16
16
  1. **Pending push** — has the spec changed since the last push (is there an issue
17
17
  description/state or any phase subtask to create or update)?
18
- 2. **State drift** — does Jira's issue status differ from the spec's status?
19
- (The repo wins on the next push; this is just a heads-up, e.g. a card moved
20
- on the board.)
18
+ 2. **Description edited** — did someone edit the issue's description on Jira
19
+ since the last push? The next push replaces it, so this is worth reading
20
+ first.
21
+
22
+ **Status is not compared, deliberately.** Skitterspec tells Jira about its
23
+ own transitions and never reads a status back: a card someone moved by hand is
24
+ theirs to have moved, and push resends State only when the spec itself moves.
21
25
 
22
26
  The repo is the source of truth; Jira is a generated mirror, so there is no
23
- per-field "conflict" — only "what would the next push send" and "did the mirror
24
- drift".
27
+ per-field "conflict" — only "what would the next push send" and "did someone
28
+ write over the mirror's text".
25
29
 
26
30
  **Opt-in**: only runs when `specs/.core/jira.config.json` exists. If absent,
27
31
  tell the user how to enable Jira sync and stop.
@@ -30,18 +34,16 @@ tell the user how to enable Jira sync and stop.
30
34
 
31
35
  Use the argument, else the spec in context; ask if unclear.
32
36
 
33
- ## 2. Fetch the Jira issue (optional, for drift)
37
+ ## 2. Fetch the Jira issue (optional, for the description check)
34
38
 
35
39
  If the spec has a `jira_identifier`, discover the Jira MCP read tool
36
40
  (`getJiraIssue`) and write the issue JSON to a temp file. If Jira isn't
37
- connected, skip the drift lines (still report pending-push).
41
+ connected, skip the description check (still report pending-push).
38
42
 
39
- **Write the whole issue, including its `description`.** The engine reports two
40
- independent kinds of drift off that one file: the status someone moved, and the
41
- **description someone edited**. Write only the status fields and the second
42
- check has nothing to look at — and it says nothing rather than guessing, so the
43
- loss is silent. Keep `url` too: it is what the description line points the
44
- reader at.
43
+ **Write the whole issue, including its `description`.** That is the field the
44
+ check reads; without it the check has nothing to look at — and it says nothing
45
+ rather than guessing, so the loss is silent. Keep `url` too: it is what the
46
+ description line points the reader at.
45
47
 
46
48
  **The description itself never enters the conversation.** The engine hashes it
47
49
  and compares hashes; what it prints is *that* the text changed, plus where to
@@ -58,8 +60,9 @@ skitterspec spec-sync status <spec> [--remote <issuefile>] [--workspace-states <
58
60
  ```
59
61
 
60
62
  - Reports `push: pending — N to create, M to update` or `up to date`.
61
- - With `--remote`, adds a `drift:` line comparing Jira's issue status to the
62
- spec's status.
63
+ - With `--remote`, adds a `drift:` line when the issue's description was
64
+ edited on Jira since the last push, and nothing otherwise. It never
65
+ reports the issue's state.
63
66
  - With `--workspace-states`, fails loudly if a configured status name isn't in
64
67
  the project (no workflow transition could ever reach it, so the push would
65
68
  refuse at apply time).
@@ -88,18 +91,17 @@ the shape; this section carries only what is specific here.
88
91
  **Verdicts**
89
92
 
90
93
  - `✅` — in sync; nothing would push.
91
- - `⚠️` — drift: N objects would push, Jira's status was moved by hand, or the
92
- issue's **description** was edited on Jira since the last push. None is an
93
- error — the repo wins on the next push — but all are the reason someone ran
94
- this. **Say which**, since the three call for different things: a pending push
95
- is routine, a moved status is usually someone else's automation, and an edited
96
- description means a person wrote something that the next push replaces.
94
+ - `⚠️` — N objects would push, or the issue's **description** was edited on
95
+ Jira since the last push. Neither is an error, but both are the reason
96
+ someone ran this. **Say which**, since they call for different things: a
97
+ pending push is routine, and an edited description means a person wrote
98
+ something that the next push replaces.
97
99
  - `⏸` — no config, or the spec is not linked. Nothing to compare.
98
100
 
99
101
  **Fields:** `Tracker` · `Follow-ups` · `Next`
100
102
 
101
- `Tracker` is the drift in one line — what would push, and whether the tracker's
102
- status diverged. `Next` is `/spec-push` when a push is pending, and nothing to
103
+ `Tracker` is the answer in one line — what would push, and whether the
104
+ description was edited. `Next` is `/spec-push` when a push is pending, and nothing to
103
105
  do when it is not.
104
106
 
105
107
  A **`phases: <mode>`** line names the phase mode that resolved for this spec's
@@ -44,7 +44,7 @@ In a project that installs the Jira superset the binary is
44
44
  | "link this spec to PROJ-1 by hand" | `stamp <spec> --issue PROJ-1` |
45
45
  | "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
46
46
  | "what ticket am I on?", writing a commit | `ref` |
47
- | "what shipped in this release?" | `released` |
47
+ | "what shipped in this release?", "which tickets does CI mark deployed?" | `released` — `--json` for a script; recording deploys is the project's own |
48
48
  | "is any of this wired up?", "why isn't sync working?" | `doctor` — scaffold, isolation, tracker and credentials in one |
49
49
  | "set my Jira credentials", "am I authenticated?" | `credentials <status\|set\|unset>` |
50
50
  | push one spec, or "what would push?" | **defer** — see below |
@@ -66,7 +66,7 @@ Two front doors to one write path is worse than none:
66
66
 
67
67
  - **Push one spec** → `/spec-push`. It owns the transport choice, the
68
68
  status-name gate, the plan, the apply, the stamping and the snapshot.
69
- - **Per-spec drift** ("is this spec in sync?", "what would push?") →
69
+ - **Per-spec status** ("is this spec in sync?", "what would push?") →
70
70
  `/spec-status`.
71
71
 
72
72
  Say which skill you're handing to and stop; don't reimplement either.
@@ -170,8 +170,10 @@ and never write `Refs: none`.
170
170
 
171
171
  `released` reports the tickets in a commit range — by default since the most
172
172
  recent tag, and it always prints the range it chose so a wrong default is
173
- visible. It is **read-only**: it does not move anything in Jira. A release can
174
- be cut and never deployed, so transitioning tickets stays a deliberate act.
173
+ visible. It is **read-only**: it does not move anything in Jira. Each ticket
174
+ row names the `spec` and `url` behind it (`null` when no spec linked the ref),
175
+ so `--json` is what a project's CI pipes into its own tooling to record a
176
+ release or a deploy. Recording those is the project's job, not skitterspec's.
175
177
 
176
178
  Relay the **unreferenced count** even when it is zero. A chore commit
177
179
  legitimately carries no ticket and a *missed* trailer looks identical, so
@@ -91,7 +91,9 @@ and continue — the branch has no commits base doesn't already have.
91
91
  `then, in the primary checkout (<path>), run:` section — the project's
92
92
  `afterLand`, such as a data migration — run those commands in that directory,
93
93
  in order, after the fast-forward and **before** the re-test below, which may
94
- need them. Run them on the no-op too: the engine prints them there because a
94
+ need them. Run only the lines under that heading; a
95
+ `skipped (nothing in the land matched):` block lists conditional entries this
96
+ land did not need, and is information, never commands. Run them on the no-op too: the engine prints them there because a
95
97
  land made elsewhere leaves them pending here.
96
98
  **Stop at the first non-zero exit**, and carry on with §5: a failure makes the run `⚠️`, naming the command
97
99
  that failed and the ones that did not run, and it **never undoes the land**.
@@ -129,8 +131,8 @@ the shape; this section carries only what is specific here.
129
131
 
130
132
  **Fields:** `Branch` · `Tests` · `Notes` · `Landed` · `Follow-ups` · `Next`
131
133
 
132
- `Notes` carries the after-land commands §4 ran, and where — or the one that
133
- failed and the ones that did not run. Nothing about it when none are
134
+ `Notes` carries the after-land commands §4 ran, and where, and any it
135
+ skipped because the land matched no `when` — or the one that failed and the ones that did not run. Nothing about it when none are
134
136
  configured.
135
137
 
136
138
  **No `Spec` field, deliberately.** This skill changes no status and moves no
@@ -20,6 +20,13 @@ if (!existsSync(join(__dirname, '..', 'src'))) {
20
20
  }
21
21
 
22
22
  const { run, HELP } = require('../src/cli.js')
23
+
24
+ // The provider's isolation defaults, registered beneath the project's own
25
+ // env.config.json so `spec-env stage` claims this provider's snapshot sidecar
26
+ // without a hand edit (see common src/env/config.js, setProviderEnvDefaults).
27
+ require('../src/env/config.js').setProviderEnvDefaults(
28
+ require('../src/vendor/jira/config.js').ENV_CONFIG_DEFAULTS,
29
+ )
23
30
  const {
24
31
  PROVIDER_COMMANDS,
25
32
  providerHelpSection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-jira",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Jira Cloud sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Jira is a generated mirror. Install this OR the base OR the Linear superset — exactly one.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -88,6 +88,8 @@ const {
88
88
  collectReview,
89
89
  reviewTierStack,
90
90
  reviewTierLine,
91
+ isTailnetAddress,
92
+ tailnetLabel,
91
93
  renderReviewPage,
92
94
  renderReviewBlock,
93
95
  reviewOutPath,
@@ -165,7 +167,13 @@ const { planHotfixLand, planHotfixFinish } = require('./env/hotfix.js')
165
167
  const { runHotfixLand, describeStep: describeHotfixStep } = require('./env/hotfixland.js')
166
168
  const { planDev } = require('./env/dev.js')
167
169
  const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
168
- const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
170
+ const {
171
+ renderRoutes,
172
+ portsHeld,
173
+ portsAnswering,
174
+ portsInUseOn,
175
+ waitListening,
176
+ } = require('./env/proxy.js')
169
177
  const {
170
178
  mintToken,
171
179
  servableSpecs,
@@ -185,7 +193,7 @@ const pkg = require('../package.json')
185
193
  // supersets the hand-off names both where both ship the command, and the one
186
194
  // that does where only one does.
187
195
  const PROVIDER_COMMANDS = {
188
- 'spec-sync': '@skitterbyte/skitterspec-linear or @skitterbyte/skitterspec-jira',
196
+ 'spec-sync': '@skitterbyte/skitterspec-linear, @skitterbyte/skitterspec-jira, @skitterbyte/skitterspec-github or @skitterbyte/skitterspec-azure-boards',
189
197
  'spec-sanitise': '@skitterbyte/skitterspec-linear',
190
198
  }
191
199
 
@@ -1914,6 +1922,7 @@ function specEnvIntegrate(dir, config, specArg) {
1914
1922
  aheadOfBase: cahead !== null && Number(cahead) > 0,
1915
1923
  checkoutPath: dir,
1916
1924
  onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
1925
+ changedFiles: landRange(cgit, base, spec.branch),
1917
1926
  })
1918
1927
  if (cplan.blocked) {
1919
1928
  process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
@@ -2011,6 +2020,7 @@ function specEnvIntegrate(dir, config, specArg) {
2011
2020
  base,
2012
2021
  aheadOfBase,
2013
2022
  mainRepoPath: dir,
2023
+ changedFiles: landRange(gitReader(dir), base, spec.branch),
2014
2024
  })
2015
2025
 
2016
2026
  if (plan.blocked) {
@@ -2037,13 +2047,47 @@ function specEnvIntegrate(dir, config, specArg) {
2037
2047
  process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
2038
2048
  }
2039
2049
 
2050
+ // The files a land brings into the base branch: `base...branch`, merge-base to
2051
+ // the branch tip. Read before the rebase runs, so it is the branch's own
2052
+ // changes — `base..branch` would also list what the base gained meanwhile and
2053
+ // over-match. A diff git would not answer is null: unknown, never empty.
2054
+ function landRange(git, base, branch) {
2055
+ const out = git(['diff', '--name-only', `${base}...${branch}`])
2056
+ if (out === null) return null
2057
+ return out.split('\n').map((l) => l.trim()).filter(Boolean)
2058
+ }
2059
+
2040
2060
  // The `afterLand` commands, as a section printed after a land plan — or after
2041
2061
  // integrate's already-landed line. Empty string when none apply, so a project
2042
- // without the key sees exactly the output it always has.
2062
+ // without the key sees exactly the output it always has, and one of plain
2063
+ // strings sees exactly what it saw before entries could be conditional.
2043
2064
  function afterLandSection(afterLand) {
2044
- if (!afterLand || !afterLand.commands || afterLand.commands.length === 0) return ''
2045
- const out = ['', ` then, in the primary checkout (${afterLand.cwd}), run (stop at the first failure):`]
2046
- for (const cmd of afterLand.commands) out.push(` ${cmd}`)
2065
+ if (!afterLand || !afterLand.entries || afterLand.entries.length === 0) return ''
2066
+ const entries = afterLand.entries
2067
+ const ran = entries.filter((e) => e.decision !== 'skipped')
2068
+ const skipped = entries.filter((e) => e.decision === 'skipped')
2069
+ const width = Math.max(...entries.map((e) => e.run.length))
2070
+ const out = ['']
2071
+ if (ran.length) {
2072
+ out.push(` then, in the primary checkout (${afterLand.cwd}), run (stop at the first failure):`)
2073
+ for (const e of ran) {
2074
+ // The reason is a shell COMMENT, not prose after a dash: these lines are
2075
+ // run as printed, and `cmd — path changed` would hand the command two
2076
+ // stray arguments.
2077
+ if (e.decision === 'matched') out.push(` ${e.run.padEnd(width)} # ${e.matched} changed`)
2078
+ else if (e.decision === 'unknown') out.push(` ${e.run.padEnd(width)} # range unknown`)
2079
+ else out.push(` ${e.run}`)
2080
+ }
2081
+ } else {
2082
+ out.push(` after-land, in the primary checkout (${afterLand.cwd}): nothing to run.`)
2083
+ }
2084
+ if (ran.some((e) => e.decision === 'unknown')) {
2085
+ out.push(" the land's changed files are unknown here, so the conditional entries run.")
2086
+ }
2087
+ if (skipped.length) {
2088
+ out.push(' skipped (nothing in the land matched):')
2089
+ for (const e of skipped) out.push(` ${e.run.padEnd(width)} # when ${e.when.join(', ')}`)
2090
+ }
2047
2091
  return out.join('\n') + '\n'
2048
2092
  }
2049
2093
 
@@ -2105,6 +2149,12 @@ function hotfixCarry(dir, spec, base, wtGit, released) {
2105
2149
  .map((l) => l.slice(2).trim()),
2106
2150
  )
2107
2151
  const commits = fixCommits.filter((sha) => !already.has(sha))
2152
+ // What main gains from the whole fix, for afterLand's `when` — taken from
2153
+ // every fix commit rather than the remaining ones, so a carry resumed after a
2154
+ // partial run still sees what the earlier picks changed. A commit git would
2155
+ // not describe makes the range unknown (null), which runs the conditional
2156
+ // entries rather than skipping them.
2157
+ const files = carryFiles(wtGit, fixCommits)
2108
2158
 
2109
2159
  // Which checkout, if any, has the base branch checked out.
2110
2160
  const list = git(['worktree', 'list', '--porcelain']) || ''
@@ -2117,14 +2167,24 @@ function hotfixCarry(dir, spec, base, wtGit, released) {
2117
2167
  let carry
2118
2168
  if (where) {
2119
2169
  const st = gitReader(where)(['status', '--porcelain'])
2120
- carry = { strategy: st ? 'dirty' : 'inplace', path: where, commits }
2170
+ carry = { strategy: st ? 'dirty' : 'inplace', path: where, commits, files }
2121
2171
  } else {
2122
2172
  const tmpPath = path.join(path.dirname(spec.worktreePath), `${spec.slug}-carry-${base}`)
2123
- carry = { strategy: 'worktree', path: dir, tmpPath, commits }
2173
+ carry = { strategy: 'worktree', path: dir, tmpPath, commits, files }
2124
2174
  }
2125
2175
  return { fixCommits, carry }
2126
2176
  }
2127
2177
 
2178
+ function carryFiles(git, commits) {
2179
+ const files = new Set()
2180
+ for (const sha of commits) {
2181
+ const out = git(['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
2182
+ if (out === null) return null
2183
+ for (const line of out.split('\n')) if (line.trim()) files.add(line.trim())
2184
+ }
2185
+ return [...files]
2186
+ }
2187
+
2128
2188
  // `hotfix finish`: tear a landed hotfix down once its deploy is out. The guards
2129
2189
  // live in `planHotfixFinish`; this gathers the facts, refuses from inside the
2130
2190
  // worktree it is about to remove, and runs the two git commands itself.
@@ -4200,6 +4260,10 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
4200
4260
  // use, and the engine cannot know — that guess failed three separate ways
4201
4261
  // in one day. Now every tier is listed, labelled, and either a URL or the
4202
4262
  // one command that turns it on.
4263
+ // The runners-up print as `also:` lines inside the network tier's own
4264
+ // rendering — `reviewTierLine` carries them, which is what puts them
4265
+ // under `network:` rather than dangling after the stack (they used to
4266
+ // print below `remote:`, against the comment that placed them).
4203
4267
  reviewTierStack({
4204
4268
  served,
4205
4269
  fileUrl: reviewFileUrl(out),
@@ -4208,11 +4272,6 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
4208
4272
  })
4209
4273
  .map((t) => reviewTierLine(t) + '\n')
4210
4274
  .join('') +
4211
- // The runners-up sit UNDER the network tier, because that is what they are
4212
- // alternatives to — and virtual adapters are gone from them entirely.
4213
- (served && !served.loopback && (served.alternates || []).length
4214
- ? served.alternates.map((u) => ` also: ${u}\n`).join('')
4215
- : '') +
4216
4275
  // WHICH TIERS THE WAIT COVERS, once, next to the stack. `local` and
4217
4276
  // `network` are two doors into one room — the page POSTs to
4218
4277
  // `location.pathname`, so both reach this server and this pending store.
@@ -4514,8 +4573,14 @@ function lanAddresses(nets = require('node:os').networkInterfaces()) {
4514
4573
  * where the opposite hides the only address that works.
4515
4574
  */
4516
4575
  function offerableLanAddresses(nets = require('node:os').networkInterfaces()) {
4517
- return rankLanAddresses(nets)
4576
+ const ranked = rankLanAddresses(nets)
4577
+ // A tailnet address IS reachable from a phone off the LAN, but macOS puts it
4578
+ // on `utunN`, which the virtual filter drops. It is appended after every
4579
+ // other offerable address so the wifi one stays the best guess at home.
4580
+ const tailnet = ranked.filter((e) => VIRTUAL_IFACE.test(e.iface) && isTailnetAddress(e.address))
4581
+ return ranked
4518
4582
  .filter((e) => !VIRTUAL_IFACE.test(e.iface))
4583
+ .concat(tailnet)
4519
4584
  .map((e) => e.address)
4520
4585
  }
4521
4586
 
@@ -4988,7 +5053,7 @@ async function specEnvConnect(dir, config, specArg) {
4988
5053
  // Stop any proxy we already run (a previous connect), freeing the canonical
4989
5054
  // ports, then refuse if the primary checkout still holds one of them.
4990
5055
  await stopProcess(proxyProc, { rootDir: dir })
4991
- const busy = await portsInUse(routes.map((r) => r.frontPort), config.proxy.host)
5056
+ const busy = await portsHeld(routes.map((r) => r.frontPort), config.proxy.host)
4992
5057
  if (busy.length) {
4993
5058
  process.stdout.write(
4994
5059
  `spec-connect: canonical port(s) ${busy.join(', ')} are in use (your main dev server?).\n` +
@@ -5199,7 +5264,7 @@ async function specEnvLiveTake(dir, config, specArg, flags = {}) {
5199
5264
  const canonicalPorts = config.dev.map((d) => d.frontPort).filter((p) => typeof p === 'number')
5200
5265
  let serverUp = null
5201
5266
  if (canonicalPorts.length) {
5202
- const up = await portsInUse(canonicalPorts, config.proxy.host)
5267
+ const up = await portsAnswering(canonicalPorts, config.proxy.host)
5203
5268
  serverUp = up.length === canonicalPorts.length
5204
5269
  }
5205
5270
 
@@ -5892,6 +5957,7 @@ module.exports = {
5892
5957
  unknownCommand,
5893
5958
  rankLanAddresses,
5894
5959
  offerableLanAddresses,
5960
+ tailnetLabel,
5895
5961
  reviewTierStack,
5896
5962
  serveProcFor,
5897
5963
  serverScriptOk,
package/src/env/config.js CHANGED
@@ -22,11 +22,16 @@
22
22
  * seedFiles:{ mode, files } | [ ".env", ... ], // gitignored files copied/
23
23
  * // symlinked from the main checkout into a fresh worktree before
24
24
  * // setup runs (mode: "symlink" default | "copy"); empty = none
25
- * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
26
- * // after `git worktree add` (e.g. install deps); empty = none
27
- * afterLand:[ "cmd", ... ], // commands run in the PRIMARY checkout after a
28
- * // land (integrate's ff, hotfix land's main cherry-pick), e.g. a
29
- * // data migration; literal, no token expansion; empty = none
25
+ * setup: [ "cmd" | { run, when }, ... ], // bootstrap commands run in
26
+ * // the worktree right after `git worktree add` (e.g. install
27
+ * // deps); `when` is ignored there — normalised to strings, with
28
+ * // the triggered entries also kept as `setupTriggers`
29
+ * afterLand:[ "cmd" | { run, when } | { setup: true }, ... ], // commands
30
+ * // run in the PRIMARY checkout after a land (integrate's ff,
31
+ * // hotfix land's main cherry-pick), e.g. a data migration;
32
+ * // `when` globs gate an entry on the land's changed files, and
33
+ * // `{ setup: true }` expands to `setupTriggers`; literal, no
34
+ * // token expansion; normalised to `{ run, when }`; empty = none
30
35
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
31
36
  * // servers started on the spec's port block (empty = none)
32
37
  * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
@@ -169,11 +174,16 @@ const DEFAULT_CONFIG = Object.freeze({
169
174
  // `git worktree add` (before Docker/dev), on every provision. Array of shell
170
175
  // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
171
176
  setup: Object.freeze([]),
177
+ // The `setup` entries that declared `when` globs, as `{ run, when }`. Derived
178
+ // at load, never configured directly: `afterLand`'s `{ setup: true }` expands
179
+ // to these, so a setup step can re-run after a land that changes its inputs.
180
+ setupTriggers: Object.freeze([]),
172
181
  // Commands run in the primary checkout after a land, printed by `spec-env
173
182
  // integrate` (including its already-landed no-op) and by `hotfix land` when
174
183
  // it cherry-picks onto the base branch. The skill runs them; the engine only
175
184
  // plans. Literal strings — no {slug}/{port} tokens, since they run on the base
176
- // branch where a spec's tokens mean nothing. Default: none.
185
+ // branch where a spec's tokens mean nothing. Normalised to `{ run, when }`,
186
+ // `when` null for an always-run entry. Default: none.
177
187
  afterLand: Object.freeze([]),
178
188
  // Host dev servers started on the spec's port block by `spec-env dev up`.
179
189
  // Each: { name, command, portVar, health?, frontPort? }. Default: none.
@@ -352,6 +362,7 @@ function defaults() {
352
362
  docker: { ...DEFAULT_CONFIG.docker },
353
363
  seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
354
364
  setup: [],
365
+ setupTriggers: [],
355
366
  afterLand: [],
356
367
  dev: [],
357
368
  proxy: { ...DEFAULT_CONFIG.proxy },
@@ -524,11 +535,49 @@ function normalizeSeedFiles(parsed) {
524
535
  * stray entry can't crash provisioning.
525
536
  */
526
537
  function normalizeSetup(parsed) {
538
+ return normalizeCommands(parsed).map((e) => e.run)
539
+ }
540
+
541
+ /**
542
+ * Normalise a list of command entries into `{ run, when }`. A string is an
543
+ * always-run entry (`when: null`); `{ run, when }` keeps its trigger globs, with
544
+ * a string `when` coerced to a one-item list. An object whose `run` is blank, or
545
+ * whose `when` holds no usable glob, is dropped rather than demoted to
546
+ * always-run — a malformed trigger must not quietly widen into "every time".
547
+ */
548
+ function normalizeCommands(parsed) {
527
549
  const out = []
528
550
  for (const raw of parsed) {
529
- if (typeof raw !== 'string') continue
530
- const cmd = raw.trim()
531
- if (cmd) out.push(cmd)
551
+ if (typeof raw === 'string') {
552
+ const run = raw.trim()
553
+ if (run) out.push({ run, when: null })
554
+ continue
555
+ }
556
+ if (!isObject(raw) || typeof raw.run !== 'string') continue
557
+ const run = raw.run.trim()
558
+ if (!run) continue
559
+ if (raw.when === undefined) {
560
+ out.push({ run, when: null })
561
+ continue
562
+ }
563
+ const globs = typeof raw.when === 'string' ? [raw.when] : Array.isArray(raw.when) ? raw.when : []
564
+ const when = normalizeFileList(globs)
565
+ if (when.length) out.push({ run, when })
566
+ }
567
+ return out
568
+ }
569
+
570
+ // `afterLand` entries: `normalizeCommands`, plus `{ setup: true }` expanded in
571
+ // place to the triggered setup entries. With none declared it expands to
572
+ // nothing — the shorthand names a set, and an empty set is a valid one.
573
+ function normalizeAfterLand(parsed, setupTriggers) {
574
+ const out = []
575
+ for (const raw of parsed) {
576
+ if (isObject(raw) && raw.setup === true) {
577
+ for (const t of setupTriggers) out.push({ run: t.run, when: [...t.when] })
578
+ continue
579
+ }
580
+ out.push(...normalizeCommands([raw]))
532
581
  }
533
582
  return out
534
583
  }
@@ -560,12 +609,15 @@ function mergeConfig(base, parsed) {
560
609
  }
561
610
 
562
611
  if (Array.isArray(parsed.setup)) {
563
- base.setup = normalizeSetup(parsed.setup)
612
+ const entries = normalizeCommands(parsed.setup)
613
+ base.setup = entries.map((e) => e.run)
614
+ base.setupTriggers = entries.filter((e) => e.when)
564
615
  }
565
616
 
566
- // Same rule as `setup`: a stray entry is dropped, never thrown on.
617
+ // Same rule as `setup`: a stray entry is dropped, never thrown on. Read after
618
+ // `setup`, which `{ setup: true }` expands from.
567
619
  if (Array.isArray(parsed.afterLand)) {
568
- base.afterLand = normalizeSetup(parsed.afterLand)
620
+ base.afterLand = normalizeAfterLand(parsed.afterLand, base.setupTriggers)
569
621
  }
570
622
 
571
623
  if (Array.isArray(parsed.dev)) {
@@ -707,6 +759,48 @@ function mergeConfig(base, parsed) {
707
759
  * It is advisory in the strongest sense: nothing here acts on it, and a config
708
760
  * full of strays loads exactly as it always did.
709
761
  */
762
+ // The isolation keys a ticketing provider needs a project to declare — set
763
+ // once per process by a provider superset's bin, beneath whatever the
764
+ // project's own env.config.json says. They used to reach only the
765
+ // `env.config.json.example` composed at build time, which left every project
766
+ // that adopted a provider over an EXISTING isolation config (or skipped the
767
+ // example) with a `spec.companionPaths` that never claims the provider's
768
+ // snapshot sidecar — `spec-env stage` then classifies the spec's own
769
+ // `<baseDir>/<identifier>.base.json` as foreign and `/commit` leaves it
770
+ // behind. The base distribution registers nothing and behaves as before.
771
+ let providerEnvDefaults = null
772
+ function setProviderEnvDefaults(overrides) {
773
+ providerEnvDefaults = overrides || null
774
+ }
775
+
776
+ // Fill ONLY the keys the project's file does not mention: a key the file sets —
777
+ // including explicitly to '' or [] — is the project's decision and stands
778
+ // ("the two keys are one fix" in scripts/build-dist.test.js is the contract:
779
+ // blanking identifierField must keep disabling the companion expansion).
780
+ function applyProviderEnvDefaults(config, parsed) {
781
+ if (!providerEnvDefaults) return config
782
+ const d = providerEnvDefaults
783
+ const pBranch = parsed && typeof parsed.branch === 'object' && parsed.branch !== null ? parsed.branch : null
784
+ if (
785
+ d.branch &&
786
+ typeof d.branch.identifierField === 'string' &&
787
+ !(pBranch && 'identifierField' in pBranch) &&
788
+ !config.branch.identifierField
789
+ ) {
790
+ config.branch.identifierField = d.branch.identifierField
791
+ }
792
+ const pSpec = parsed && typeof parsed.spec === 'object' && parsed.spec !== null ? parsed.spec : null
793
+ if (
794
+ d.spec &&
795
+ Array.isArray(d.spec.companionPaths) &&
796
+ !(pSpec && 'companionPaths' in pSpec) &&
797
+ !config.spec.companionPaths.length
798
+ ) {
799
+ config.spec.companionPaths = [...d.spec.companionPaths]
800
+ }
801
+ return config
802
+ }
803
+
710
804
  function loadEnvConfig(dir = process.cwd()) {
711
805
  const base = defaults()
712
806
  const file = join(dir, CONFIG_FILE)
@@ -715,7 +809,9 @@ function loadEnvConfig(dir = process.cwd()) {
715
809
  try {
716
810
  raw = readFileSync(file, 'utf-8')
717
811
  } catch (error) {
718
- if (error.code === 'ENOENT') return { config: base, present: false, unknown: [], badReviewers: [] }
812
+ if (error.code === 'ENOENT') {
813
+ return { config: applyProviderEnvDefaults(base, null), present: false, unknown: [], badReviewers: [] }
814
+ }
719
815
  throw error
720
816
  }
721
817
 
@@ -727,7 +823,7 @@ function loadEnvConfig(dir = process.cwd()) {
727
823
  }
728
824
 
729
825
  return {
730
- config: mergeConfig(base, parsed),
826
+ config: applyProviderEnvDefaults(mergeConfig(base, parsed), parsed),
731
827
  present: true,
732
828
  unknown: collectUnknownKeys(parsed),
733
829
  badReviewers: collectBadReviewers(parsed),
@@ -736,6 +832,7 @@ function loadEnvConfig(dir = process.cwd()) {
736
832
 
737
833
  module.exports = {
738
834
  loadEnvConfig,
835
+ setProviderEnvDefaults,
739
836
  resolveServePort,
740
837
  derivedServePort,
741
838
  servePortRoot,
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Restricted glob matching for repo-relative paths, shared by the live-overlay
5
+ * migration check and the conditional `afterLand` entries. Zero-dependency.
6
+ */
7
+
8
+ // Translate a restricted glob into an anchored RegExp: `**` matches across path
9
+ // separators (with an optional trailing `/`), `*` matches within a segment, `?`
10
+ // a single non-separator char. Enough for repo-path globs, no dependency.
11
+ function globToRegExp(glob) {
12
+ let re = ''
13
+ for (let i = 0; i < glob.length; i++) {
14
+ const ch = glob[i]
15
+ if (ch === '*') {
16
+ if (glob[i + 1] === '*') {
17
+ re += '.*'
18
+ i++
19
+ if (glob[i + 1] === '/') i++ // consume the `/` in `**/`
20
+ } else {
21
+ re += '[^/]*'
22
+ }
23
+ } else if (ch === '?') {
24
+ re += '[^/]'
25
+ } else if ('.+^${}()|[]\\/'.includes(ch)) {
26
+ re += '\\' + ch
27
+ } else {
28
+ re += ch
29
+ }
30
+ }
31
+ return new RegExp('^' + re + '$')
32
+ }
33
+
34
+ module.exports = { globToRegExp }