@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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 (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,753 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config loader for the per-spec isolation feature (`/spec-env`).
5
+ *
6
+ * Reads `specs/.core/env.config.json` from the project root and normalises it
7
+ * over frozen defaults. The feature is strictly opt-in: when the file is absent
8
+ * the loader never throws — it returns the defaults with `present:false`, which
9
+ * every caller treats as "feature unused".
10
+ *
11
+ * Mirrors the shape/idiom of `assets/scripts/lib/config.js` (frozen defaults,
12
+ * merge known keys only, forward-compatible on unknown keys). Zero-dependency.
13
+ *
14
+ * Shape (see specs/.core/env.config.md for field docs):
15
+ * {
16
+ * mode: "worktree" | "checkout", // where a spec's branch is built —
17
+ * // its own worktree (default), or the primary checkout in place.
18
+ * // NOTE: unrelated to `seedFiles.mode`, which is symlink|copy.
19
+ * worktree: { root, folderPattern },
20
+ * docker: { enabled, composeFile, projectNamePattern, portBase,
21
+ * portsPerSpec, envFile, backupCommand },
22
+ * seedFiles:{ mode, files } | [ ".env", ... ], // gitignored files copied/
23
+ * // symlinked from the main checkout into a fresh worktree before
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
30
+ * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
31
+ * // servers started on the spec's port block (empty = none)
32
+ * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
33
+ * registry: ".spec-env/registry.json",
34
+ * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
35
+ * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
36
+ * // spec alongside its own folder (provider-neutral; {slug} and
37
+ * // {identifier} expand); empty = the spec folder only
38
+ * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
39
+ * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
40
+ * teardown: { deleteRemoteBranch },
41
+ * live: { migrations: [ "glob", ... ] } // migration globs → `live take`
42
+ * // refuses a branch that changes them (code-only v1)
43
+ * hotfix: { bump, cherryPickMain, targets } // `hotfix land`: patch-bump the
44
+ * // deploy tag, also cherry-pick onto the base branch (main), and
45
+ * // onto any extra base tags in `targets` (test/demo lines)
46
+ * }
47
+ */
48
+
49
+ const { createHash } = require('node:crypto')
50
+ const { readFileSync, realpathSync } = require('node:fs')
51
+ const { join, resolve } = require('node:path')
52
+
53
+ const CONFIG_FILE = join('specs', '.core', 'env.config.json')
54
+
55
+ // The window `servePort: "auto"` derives a port from. 7700-7799 keeps the
56
+ // familiar neighbourhood — 7777, the old shared default, is inside it — while
57
+ // giving every repo on a machine its own slot without anyone configuring one.
58
+ //
59
+ // A hundred slots is a SMALL chance of two repos landing together, not no
60
+ // chance. That case is not papered over: the server still refuses the busy
61
+ // port and names `servePort` as the durable fix. Walking up to the next free
62
+ // port would remove the refusal and keep the staleness, because the port would
63
+ // then depend on which repo started first.
64
+ const PORT_BASE = 7700
65
+ const PORT_SPAN = 100
66
+
67
+ /**
68
+ * The repo path the derivation hashes — `realpath`ed ONCE, here.
69
+ *
70
+ * A symlinked spelling of one tree (`/tmp` → `/private/tmp` on macOS, a
71
+ * convenience symlink into a worktree root) is the same repo, and hashing the
72
+ * two spellings separately would hand out two different ports for it — two
73
+ * different URLs, one of them dead. Resolving once is what makes the port a
74
+ * property of the tree rather than of how you typed it.
75
+ *
76
+ * WHAT WOULD FOOL THIS: `realpathSync` throws on a path that does not exist, so
77
+ * it falls back to `resolve`. That is the cannot-tell branch and it is routed
78
+ * to inaction (.claude/rules/negative-checks.md rule 4) — an absolute path is
79
+ * still deterministic, so the worst case is a stable port for a directory that
80
+ * is not there, never a crash on an unrelated command.
81
+ */
82
+ function servePortRoot(dir) {
83
+ try {
84
+ return realpathSync(resolve(dir))
85
+ } catch {
86
+ return resolve(dir)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * `PORT_BASE + hash(path) % PORT_SPAN` — a PURE function of the path.
92
+ *
93
+ * Purity is the whole point, not an implementation detail. A port that is
94
+ * merely *free* is not a port a link handed out yesterday can still resolve, so
95
+ * nothing here may read the registry, `.spec-env/`, or any other state on disk:
96
+ * a port that depends on a file changes when the file is deleted, and this
97
+ * exists precisely so it does not change. The same tree gets the same port
98
+ * across a restart, a reboot, and a `--stop`.
99
+ *
100
+ * sha256 rather than a hand-rolled hash because its distribution is not
101
+ * something this file has to argue for — with a hundred slots, clustering is
102
+ * the only way the derivation could fail at its job.
103
+ */
104
+ function derivedServePort(root) {
105
+ const digest = createHash('sha256').update(root).digest()
106
+ return PORT_BASE + (digest.readUInt32BE(0) % PORT_SPAN)
107
+ }
108
+
109
+ /**
110
+ * The port `spec-env review serve` should use, and HOW it was chosen.
111
+ *
112
+ * Three sources, in precedence order: `--port` on this run, an explicit number
113
+ * in `review.servePort`, else the derivation. The `source` rides along so
114
+ * `--status` can answer "why is this on 7742?" from the tool rather than from
115
+ * reading this file.
116
+ *
117
+ * Returns `{ port, source, root? }`; `root` is the resolved path that was
118
+ * hashed, present only on a derived port.
119
+ */
120
+ function resolveServePort(config, dir, override) {
121
+ const flag = Number(override)
122
+ if (override != null && override !== '' && Number.isFinite(flag)) {
123
+ return { port: flag, source: 'flag' }
124
+ }
125
+ const configured = config && config.review ? config.review.servePort : undefined
126
+ if (typeof configured === 'number' && Number.isFinite(configured)) {
127
+ return { port: configured, source: 'configured' }
128
+ }
129
+ const root = servePortRoot(dir)
130
+ return { port: derivedServePort(root), source: 'derived', root }
131
+ }
132
+
133
+ /** One sentence naming how a resolved port was chosen, for `--status`. */
134
+ function servePortReason(source) {
135
+ if (source === 'flag') return 'this run only, from --port'
136
+ if (source === 'configured') return 'pinned by review.servePort'
137
+ if (source === 'derived') return "derived from this repo's path"
138
+ return ''
139
+ }
140
+
141
+ const DEFAULT_CONFIG = Object.freeze({
142
+ // Where a spec's branch is built. "worktree" gives every spec its own checkout
143
+ // — parallel specs, `main` left free — at the cost of a terminal session per
144
+ // spec. "checkout" builds the branch in the primary checkout instead: one spec
145
+ // at a time, in the terminal you are already sitting in.
146
+ //
147
+ // Defaults to "worktree" so no installed repo changes behaviour on upgrade.
148
+ // Never inferred from whether `dev`/`docker` are configured: a repo with no
149
+ // dev servers may still want parallel specs, and absence of configuration is
150
+ // not evidence of intent (see .claude/rules/negative-checks.md).
151
+ mode: 'worktree',
152
+ worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
153
+ docker: Object.freeze({
154
+ enabled: true,
155
+ composeFile: 'docker-compose.yml',
156
+ projectNamePattern: '{repoSlug}_{slug}',
157
+ portBase: 3000,
158
+ portsPerSpec: 10,
159
+ envFile: '.env',
160
+ backupCommand: '',
161
+ }),
162
+ // Gitignored files seeded from the main checkout into a fresh worktree by
163
+ // `spec-env up`, right after `git worktree add` and before `setup` runs — so a
164
+ // fresh worktree has the .env / local overrides that setup steps depend on.
165
+ // `mode` is "symlink" (default, stays in sync with main) or "copy" (an
166
+ // independent copy). `files` is a list of repo-relative paths. Default: none.
167
+ seedFiles: Object.freeze({ mode: 'symlink', files: Object.freeze([]) }),
168
+ // Bootstrap commands run in the worktree by `spec-env up`, right after
169
+ // `git worktree add` (before Docker/dev), on every provision. Array of shell
170
+ // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
171
+ setup: Object.freeze([]),
172
+ // Commands run in the primary checkout after a land, printed by `spec-env
173
+ // integrate` (including its already-landed no-op) and by `hotfix land` when
174
+ // it cherry-picks onto the base branch. The skill runs them; the engine only
175
+ // plans. Literal strings — no {slug}/{port} tokens, since they run on the base
176
+ // branch where a spec's tokens mean nothing. Default: none.
177
+ afterLand: Object.freeze([]),
178
+ // Host dev servers started on the spec's port block by `spec-env dev up`.
179
+ // Each: { name, command, portVar, health?, frontPort? }. Default: none.
180
+ dev: Object.freeze([]),
181
+ // Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
182
+ // exposes one connected spec's frontPort processes on the canonical ports.
183
+ proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
184
+ registry: '.spec-env/registry.json',
185
+ // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
186
+ // when a tracker provider is linked, {identifier}; `identifierField` names the
187
+ // 00-overview.md frontmatter field a provider writes the ticket id into (empty
188
+ // = no identifier, so patterns referencing {identifier} fall back to type/slug).
189
+ branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
190
+ // Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
191
+ // — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
192
+ // design: the base engine must not know that any particular tracker exists, so
193
+ // the project declares the shape and `{slug}` / `{identifier}` expand exactly as
194
+ // they do in `branch.pattern` ({identifier} via `branch.identifierField`).
195
+ // Default: none, so a spec owns only its own folder.
196
+ spec: Object.freeze({ companionPaths: Object.freeze([]) }),
197
+ // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
198
+ baseBranch: '',
199
+ // `mainIsLandingZone` defaults ON wherever isolation is configured, matching
200
+ // the review gate's precedent: the push toward not working on the base branch
201
+ // is the normal path, and stepping off it is the deliberate act. That does
202
+ // change behaviour for a project on upgrade, which is the honest cost — the
203
+ // refusal names both exits (`/no-spec` and `/allow-main`) and this key.
204
+ guards: Object.freeze({
205
+ refuseTeardownIfDirty: true,
206
+ refuseTeardownIfUnpushed: true,
207
+ mainIsLandingZone: true,
208
+ }),
209
+ // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
210
+ // `spec-env down` does about a branch the USER published by hand — nothing
211
+ // publishes one at provisioning, so there is often no remote ref at all and
212
+ // the plan then says nothing: "prompt" (default)
213
+ // plans the delete in its own confirm-first section for the skill to ask about,
214
+ // "never" omits it, "always" folds it into the run-blind command list. Only ever
215
+ // planned for a LANDED branch — see teardown.js.
216
+ teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
217
+
218
+ // `reader` decides how a diff's location is WORDED, and nothing else. It
219
+ // once also decided whether the engine served — a gate that produced the
220
+ // `file://` link on a local machine, where a `file://` page cannot POST and
221
+ // so the verdict buttons had nowhere to go. `serve` owns that now.
222
+ // It never decides to PUBLISH: publishing
223
+ // leaves a page this tooling cannot remove, so it stays an explicit ask.
224
+ // `detect` sniffs; `local`/`remote` are the operator's own answer and are
225
+ // believed without sniffing, because they know where they are reading and no
226
+ // signal can outrank that.
227
+ // `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
228
+ // ships with skittership, a different package — so it may not be installed,
229
+ // and skitterspec must never vendor a copy of it.
230
+ //
231
+ // There is no off switch, and that is deliberate: `"none"` existed and was
232
+ // removed, because it produced the one thing a review page must not have —
233
+ // a verdict that records itself and does nothing. A review is the guard in
234
+ // front of an action; recording an approval for SOMEONE ELSE to act on is a
235
+ // different mechanism, not a value of this key.
236
+ //
237
+ // `required` decides whether a phase that ended owes a verdict before its
238
+ // work can be committed or the next phase built. It defaults TRUE: the push
239
+ // toward reading the diff is the point, and a project that would rather not
240
+ // be pushed says so once. It is the only key anything reads to decide
241
+ // whether the gate refuses, so turning it off turns off the hook with it.
242
+ // `serve` decides whether a render stands the local server up: `always`
243
+ // (the default) or `never`. It replaced `serveOnRemote`, whose name would now
244
+ // claim to govern remote renders while governing every one of them — and a
245
+ // config key that lies is what made a wrong fix look right. A legacy
246
+ // `serveOnRemote: false` is still read as `serve: "never"`.
247
+ //
248
+ // THE BIND IS NOT THIS KEY'S BUSINESS, and that separation is what keeps
249
+ // serving-everywhere free of new exposure: `reader` still decides it, so a
250
+ // remote reader binds every interface exactly as before and a local or
251
+ // unknown one binds loopback. Serving more never means listening wider.
252
+ // `allowNetwork` and `allowRemote` are the two tiers a project permits. They
253
+ // exist because the engine CANNOT KNOW where the reader is sitting and kept
254
+ // being asked to guess: detection reported `unknown` on a local session and
255
+ // produced a page whose buttons cannot POST, reported `remote` and produced a
256
+ // LAN URL a phone off the network could not reach, and flipped mid-session
257
+ // and changed the address underneath a reader. Each was fixed on its own; the
258
+ // next reader position would have produced a fourth.
259
+ //
260
+ // So the reader picks instead. `allowNetwork` decides THE BIND — on binds
261
+ // every interface, off binds loopback — which is the last decision detection
262
+ // had. `allowRemote` PERMITS publishing; it never publishes, because each
263
+ // publish leaves a page skitterspec cannot delete.
264
+ review: Object.freeze({
265
+ reader: 'detect',
266
+ servePort: 'auto',
267
+ serve: 'always',
268
+ allowNetwork: true,
269
+ allowRemote: false,
270
+ commitWith: '/commit',
271
+ required: true,
272
+ // External code reviewers whose findings render as CHECKS on the page.
273
+ // Empty by default and never written by `init`: configuring one sends the
274
+ // worktree's diff to whatever the command talks to, which is a decision
275
+ // only the project can make. Two entry shapes — `{use}` names a bundled
276
+ // adapter, `{name, command, format}` is bring-your-own.
277
+ reviewers: Object.freeze([]),
278
+ }),
279
+ // Live overlay (`spec-env live`). `migrations` is a list of globs marking
280
+ // migration files; a branch that changes any of them is treated as stateful and
281
+ // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
282
+ live: Object.freeze({ migrations: Object.freeze([]) }),
283
+ // Hotfix landing (`spec-env hotfix land`). `bump` is the version-bump strategy
284
+ // for the new deploy tag: "patch" (X.Y.Z -> X.Y.(Z+1)) or "hotfix-suffix"
285
+ // (X.Y.Z -> X.Y.Z-hotfix.N). The loader keeps whatever string it is given and
286
+ // `nextTag` refuses an unknown one at land time — a loader that threw would
287
+ // break every verb over a key only one of them reads. `cherryPickMain` also cherry-picks
288
+ // the fix onto the base branch for the next release (default true). `targets` is
289
+ // an optional default list of extra base tags to also patch (test/demo lines);
290
+ // `--also <tag>` on the command adds more at run time. Default: patch, main, none.
291
+ // `releaseWith` is a project's own release command, run in the hotfix
292
+ // worktree in place of the built-in tagger — e.g. skittership's
293
+ // `node scripts/release-hotfix.cjs --json`. Empty (the default) = built-in.
294
+ hotfix: Object.freeze({
295
+ bump: 'patch',
296
+ cherryPickMain: true,
297
+ targets: Object.freeze([]),
298
+ releaseWith: '',
299
+ }),
300
+ })
301
+
302
+ function isObject(value) {
303
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
304
+ }
305
+
306
+ /**
307
+ * List the keys in a parsed config that `mergeConfig` will not read, as dotted
308
+ * paths (`open`, `review.readr`). Advisory only — nothing here refuses, and the
309
+ * merge below is unchanged: unknown keys are still dropped, they are just no
310
+ * longer dropped in silence.
311
+ *
312
+ * The known set is DEFAULT_CONFIG itself rather than a list written out beside
313
+ * it. That is not a shortcut: every key `mergeConfig` reads is necessarily a key
314
+ * of the defaults, because a key with no default has nothing to merge onto — so
315
+ * a hand-written list would be a second copy of the same contract, and a second
316
+ * copy is how the two come to disagree. Adding a key to `mergeConfig` without
317
+ * adding its default is already impossible; this check inherits that.
318
+ *
319
+ * It descends only where the DEFAULT is a plain object. An array's elements are
320
+ * data, not keys — `dev` entries, `setup` commands, `spec.companionPaths`,
321
+ * `live.migrations`, `hotfix.targets` — and walking into them would report every
322
+ * path in the list as an unknown key.
323
+ *
324
+ * WHAT IT DELIBERATELY DOES NOT REPORT: a KNOWN key whose value was rejected for
325
+ * its type or for not matching an enum (`mode: "Checkout"`,
326
+ * `teardown.deleteRemoteBranch: "yes"`). Those already fall through to a
327
+ * documented conservative default, each with a comment saying why, and folding
328
+ * them in here would change what this line means from "I ignored a key you wrote"
329
+ * to "I disagreed with a value you wrote".
330
+ */
331
+ function collectUnknownKeys(parsed, known = DEFAULT_CONFIG, prefix = '') {
332
+ if (!isObject(parsed)) return []
333
+ const out = []
334
+ for (const key of Object.keys(parsed)) {
335
+ const dotted = prefix ? `${prefix}.${key}` : key
336
+ if (!Object.prototype.hasOwnProperty.call(known, key)) {
337
+ out.push(dotted)
338
+ continue
339
+ }
340
+ if (isObject(known[key]) && isObject(parsed[key])) {
341
+ out.push(...collectUnknownKeys(parsed[key], known[key], dotted))
342
+ }
343
+ }
344
+ return out
345
+ }
346
+
347
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
348
+ function defaults() {
349
+ return {
350
+ mode: DEFAULT_CONFIG.mode,
351
+ worktree: { ...DEFAULT_CONFIG.worktree },
352
+ docker: { ...DEFAULT_CONFIG.docker },
353
+ seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
354
+ setup: [],
355
+ afterLand: [],
356
+ dev: [],
357
+ proxy: { ...DEFAULT_CONFIG.proxy },
358
+ registry: DEFAULT_CONFIG.registry,
359
+ branch: { ...DEFAULT_CONFIG.branch },
360
+ spec: { companionPaths: [] },
361
+ baseBranch: DEFAULT_CONFIG.baseBranch,
362
+ guards: { ...DEFAULT_CONFIG.guards },
363
+ teardown: { ...DEFAULT_CONFIG.teardown },
364
+ review: { ...DEFAULT_CONFIG.review, reviewers: [] },
365
+ live: { migrations: [] },
366
+ hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
367
+ }
368
+ }
369
+
370
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
371
+ // Strings are trimmed and must be non-empty to override.
372
+ function assign(base, parsed, key, type) {
373
+ const v = parsed[key]
374
+ if (type === 'string') {
375
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
376
+ } else if (type === 'string?') {
377
+ // string that may be intentionally empty (e.g. backupCommand)
378
+ if (typeof v === 'string') base[key] = v
379
+ } else if (type === 'boolean') {
380
+ if (typeof v === 'boolean') base[key] = v
381
+ } else if (type === 'number') {
382
+ if (typeof v === 'number' && Number.isFinite(v)) base[key] = v
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Normalise a parsed `dev` array into well-formed process entries. Each entry
388
+ * needs non-empty string `name`, `command`, and `portVar`; `health` (string) and
389
+ * `frontPort` (finite number) are optional. Malformed entries are dropped
390
+ * (lenient, like the rest of the loader) so a stray entry can't crash provisioning.
391
+ */
392
+ function normalizeDev(parsed) {
393
+ const out = []
394
+ for (const raw of parsed) {
395
+ if (!isObject(raw)) continue
396
+ const name = typeof raw.name === 'string' ? raw.name.trim() : ''
397
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
398
+ const portVar = typeof raw.portVar === 'string' ? raw.portVar.trim() : ''
399
+ if (!name || !command || !portVar) continue
400
+ const entry = { name, command, portVar }
401
+ if (typeof raw.health === 'string' && raw.health.trim()) entry.health = raw.health.trim()
402
+ if (typeof raw.frontPort === 'number' && Number.isFinite(raw.frontPort)) {
403
+ entry.frontPort = raw.frontPort
404
+ }
405
+ out.push(entry)
406
+ }
407
+ return out
408
+ }
409
+
410
+ // Keep only trimmed, non-empty strings from an array of file paths (lenient).
411
+ function normalizeFileList(parsed) {
412
+ const out = []
413
+ for (const raw of parsed) {
414
+ if (typeof raw !== 'string') continue
415
+ const file = raw.trim()
416
+ if (file) out.push(file)
417
+ }
418
+ return out
419
+ }
420
+
421
+ // How long a reviewer gets before it is killed, in seconds. A generous default:
422
+ // these tools take 30s-3min, and the render they precede is about to wait on a
423
+ // human for far longer.
424
+ const DEFAULT_REVIEWER_TIMEOUT = 180
425
+
426
+ // Output formats the runner can parse. `rdjsonl` is the native contract
427
+ // (reviewdog's interchange shape); a bundled adapter brings its own parser and
428
+ // declares no format at all.
429
+ const REVIEWER_FORMATS = ['rdjsonl']
430
+
431
+ /**
432
+ * Read one `review.reviewers` entry. Returns the normalised entry, or `null`
433
+ * with a reason — the caller decides whether to keep it or report it.
434
+ *
435
+ * Two shapes, and they are told apart by which key is present rather than by
436
+ * guessing: `{use}` names a bundled adapter (the engine owns its command line
437
+ * and its parser), `{name, command}` is bring-your-own. An entry carrying both
438
+ * is refused BY NAME rather than resolved in some order — it is two
439
+ * instructions, and picking one would run a reviewer the author did not ask for.
440
+ *
441
+ * WHETHER A NAMED ADAPTER EXISTS IS NOT CHECKED HERE, deliberately: the adapter
442
+ * registry lives in the runner, and this module must not depend on it to answer
443
+ * a question about shape. The runner reports an unknown `use` as its own
444
+ * outcome.
445
+ */
446
+ function readReviewer(raw) {
447
+ if (!isObject(raw)) return { entry: null, reason: 'not an object' }
448
+ const use = typeof raw.use === 'string' ? raw.use.trim() : ''
449
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
450
+ if (use && command) return { entry: null, reason: 'carries both use and command — write one' }
451
+ // A number that is not finite and positive is not a timeout. Falling through
452
+ // to the default is safe in a way `0` would not be: a zero timeout kills every
453
+ // reviewer instantly and would read, on the page, as one that never ran.
454
+ const t = raw.timeout
455
+ const timeout = typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULT_REVIEWER_TIMEOUT
456
+ if (use) {
457
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : use
458
+ return { entry: { name, use, command: null, format: null, timeout }, reason: null }
459
+ }
460
+ if (!command) return { entry: null, reason: 'has neither use nor command' }
461
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : ''
462
+ if (!name) return { entry: null, reason: 'has a command but no name' }
463
+ // An unrecognised format is refused rather than defaulted to `rdjsonl`. The
464
+ // scalars above can fall through because their default is what the author
465
+ // most likely meant; here it is not — parsing one tool's output as another's
466
+ // shape yields zero findings and reports the reviewer as clean, which is the
467
+ // false clean this whole feature is built to avoid.
468
+ const fmt = raw.format === undefined || raw.format === null ? 'rdjsonl' : raw.format
469
+ if (!REVIEWER_FORMATS.includes(fmt)) {
470
+ return { entry: null, reason: `format ${JSON.stringify(raw.format)} is not one of ${REVIEWER_FORMATS.join(', ')}` }
471
+ }
472
+ return { entry: { name, use: null, command, format: fmt, timeout }, reason: null }
473
+ }
474
+
475
+ function normalizeReviewers(parsed) {
476
+ const out = []
477
+ for (const raw of parsed) {
478
+ const { entry } = readReviewer(raw)
479
+ if (entry) out.push(entry)
480
+ }
481
+ return out
482
+ }
483
+
484
+ /**
485
+ * List the `review.reviewers` entries the merge dropped, as
486
+ * `{ index, reason }`. Advisory, like `collectUnknownKeys` — nothing refuses.
487
+ *
488
+ * It exists because `collectUnknownKeys` structurally cannot cover this: it
489
+ * descends only where the DEFAULT is a plain object, and this default is an
490
+ * array. Without this, a reviewer entry with a typo in it is dropped in exactly
491
+ * the silence that check was added to end — and worse than an ignored key,
492
+ * because the page would then show no line for it at all.
493
+ */
494
+ function collectBadReviewers(parsed) {
495
+ if (!isObject(parsed) || !isObject(parsed.review) || !Array.isArray(parsed.review.reviewers)) return []
496
+ const out = []
497
+ parsed.review.reviewers.forEach((raw, index) => {
498
+ const { entry, reason } = readReviewer(raw)
499
+ if (!entry) out.push({ index, reason })
500
+ })
501
+ return out
502
+ }
503
+
504
+ /**
505
+ * Normalise a parsed `seedFiles` value into `{ mode, files }`. Accepts two forms:
506
+ * - the array shorthand `[".env", ...]` → { mode: 'symlink', files: [...] }
507
+ * - the object form `{ mode?, files? }` — `mode` is 'copy' or 'symlink'
508
+ * (anything else falls back to 'symlink'); `files` is a list of paths.
509
+ * Malformed entries are dropped (lenient, like `normalizeSetup`) so a stray
510
+ * value can't crash provisioning.
511
+ */
512
+ function normalizeSeedFiles(parsed) {
513
+ if (Array.isArray(parsed)) {
514
+ return { mode: 'symlink', files: normalizeFileList(parsed) }
515
+ }
516
+ const mode = parsed.mode === 'copy' ? 'copy' : 'symlink'
517
+ const files = Array.isArray(parsed.files) ? normalizeFileList(parsed.files) : []
518
+ return { mode, files }
519
+ }
520
+
521
+ /**
522
+ * Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
523
+ * non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
524
+ * stray entry can't crash provisioning.
525
+ */
526
+ function normalizeSetup(parsed) {
527
+ const out = []
528
+ for (const raw of parsed) {
529
+ if (typeof raw !== 'string') continue
530
+ const cmd = raw.trim()
531
+ if (cmd) out.push(cmd)
532
+ }
533
+ return out
534
+ }
535
+
536
+ /**
537
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
538
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
539
+ */
540
+ function mergeConfig(base, parsed) {
541
+ if (!isObject(parsed)) return base
542
+
543
+ if (isObject(parsed.worktree)) {
544
+ assign(base.worktree, parsed.worktree, 'root', 'string')
545
+ assign(base.worktree, parsed.worktree, 'folderPattern', 'string')
546
+ }
547
+
548
+ if (isObject(parsed.docker)) {
549
+ assign(base.docker, parsed.docker, 'enabled', 'boolean')
550
+ assign(base.docker, parsed.docker, 'composeFile', 'string')
551
+ assign(base.docker, parsed.docker, 'projectNamePattern', 'string')
552
+ assign(base.docker, parsed.docker, 'portBase', 'number')
553
+ assign(base.docker, parsed.docker, 'portsPerSpec', 'number')
554
+ assign(base.docker, parsed.docker, 'envFile', 'string')
555
+ assign(base.docker, parsed.docker, 'backupCommand', 'string?')
556
+ }
557
+
558
+ if (Array.isArray(parsed.seedFiles) || isObject(parsed.seedFiles)) {
559
+ base.seedFiles = normalizeSeedFiles(parsed.seedFiles)
560
+ }
561
+
562
+ if (Array.isArray(parsed.setup)) {
563
+ base.setup = normalizeSetup(parsed.setup)
564
+ }
565
+
566
+ // Same rule as `setup`: a stray entry is dropped, never thrown on.
567
+ if (Array.isArray(parsed.afterLand)) {
568
+ base.afterLand = normalizeSetup(parsed.afterLand)
569
+ }
570
+
571
+ if (Array.isArray(parsed.dev)) {
572
+ base.dev = normalizeDev(parsed.dev)
573
+ }
574
+
575
+ if (isObject(parsed.proxy)) {
576
+ assign(base.proxy, parsed.proxy, 'enabled', 'boolean')
577
+ assign(base.proxy, parsed.proxy, 'host', 'string')
578
+ }
579
+
580
+ if (isObject(parsed.branch)) {
581
+ assign(base.branch, parsed.branch, 'pattern', 'string')
582
+ assign(base.branch, parsed.branch, 'identifierField', 'string')
583
+ }
584
+
585
+ assign(base, parsed, 'registry', 'string')
586
+ assign(base, parsed, 'baseBranch', 'string')
587
+
588
+ // Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
589
+ // an unrecognised value falls through to the default rather than erroring or
590
+ // being taken literally. The fallback direction matters — "worktree" is the
591
+ // conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
592
+ // session, never a spec's work landing somewhere the author did not choose.
593
+ if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
594
+ base.mode = parsed.mode
595
+ }
596
+
597
+ if (isObject(parsed.guards)) {
598
+ assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
599
+ assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
600
+ assign(base.guards, parsed.guards, 'mainIsLandingZone', 'boolean')
601
+ }
602
+
603
+ // An unrecognised policy falls through to the default rather than erroring or
604
+ // being taken literally — a typo ("Always", "yes") must not silently become a
605
+ // stronger setting than the author typed, and "prompt" is the one value that
606
+ // cannot act without a human first.
607
+ if (isObject(parsed.teardown)) {
608
+ const policy = parsed.teardown.deleteRemoteBranch
609
+ if (policy === 'prompt' || policy === 'never' || policy === 'always') {
610
+ base.teardown.deleteRemoteBranch = policy
611
+ }
612
+ }
613
+
614
+ // An unrecognised reader falls through to `detect`, which is the state that
615
+ // claims least: it can answer "unknown", and unknown is wired to today's
616
+ // behaviour. A typo must never become a confident `local`, because a confident
617
+ // `local` is exactly the dead `file://` link this key exists to prevent.
618
+ if (isObject(parsed.review)) {
619
+ const reader = parsed.review.reader
620
+ if (reader === 'local' || reader === 'remote' || reader === 'detect') {
621
+ base.review.reader = reader
622
+ }
623
+ // Two accepted forms: a finite number pins the port, and the string "auto"
624
+ // derives it from the repo's path. Anything else is refused BY NAME — the
625
+ // default stands — exactly as `reader`, `mode` and `deleteRemoteBranch`
626
+ // refuse a value they do not recognise.
627
+ //
628
+ // Falling through costs nothing here, and that is worth stating rather than
629
+ // assuming: the only value a typo can fall through to is "auto", which is
630
+ // also the only string that would have been accepted. So a misspelt "auto"
631
+ // behaves identically to the spelling that was meant, and a misspelt number
632
+ // was never a number. There is no reading of this key where silence hides a
633
+ // port the author pinned.
634
+ if (typeof parsed.review.servePort === 'number' && Number.isFinite(parsed.review.servePort)) {
635
+ base.review.servePort = parsed.review.servePort
636
+ } else if (parsed.review.servePort === 'auto') {
637
+ base.review.servePort = 'auto'
638
+ }
639
+ // `always` | `never`; anything else leaves the default standing, so a typo
640
+ // cannot quietly restore the dead `file://` link this key exists to end.
641
+ if (parsed.review.serve === 'always' || parsed.review.serve === 'never') {
642
+ base.review.serve = parsed.review.serve
643
+ }
644
+ // TOLERANCE, NOT MIGRATION — the same rule `readVerdict` follows for the
645
+ // old `approve` spelling. These configs are committed, so a rename with no
646
+ // tolerance breaks every other checkout on the next pull. Only `false` is
647
+ // read: `serveOnRemote: true` said "serve where it matters", which is what
648
+ // `always` now does anyway, so it needs no translation.
649
+ //
650
+ // An explicit `serve` wins, so a config carrying both is read the way its
651
+ // author most recently meant.
652
+ if (parsed.review.serve === undefined && parsed.review.serveOnRemote === false) {
653
+ base.review.serve = 'never'
654
+ }
655
+ // An empty string leaves `/commit` standing, like every other string key
656
+ // here. There is nothing it could mean instead: the hand-off has no off
657
+ // switch, so a blank value is a typo rather than an instruction.
658
+ assign(base.review, parsed.review, 'commitWith', 'string')
659
+ // Same shape as `serveOnRemote`, and for a sharper reason: a non-boolean
660
+ // leaves the gate ON. Turning off a check that refuses must be something
661
+ // someone WROTE, never something a typo achieved on their behalf.
662
+ assign(base.review, parsed.review, 'required', 'boolean')
663
+ // Same shape as the two above: a non-boolean leaves the default standing, so
664
+ // a typo cannot quietly widen a bind or permit a publish.
665
+ assign(base.review, parsed.review, 'allowNetwork', 'boolean')
666
+ assign(base.review, parsed.review, 'allowRemote', 'boolean')
667
+ // An entry that is neither shape is DROPPED rather than refused, like every
668
+ // other value here — but unlike the scalars above there is no default for it
669
+ // to fall through to, so a dropped entry is a reviewer that silently never
670
+ // runs. `collectUnknownKeys` cannot see it either: the default is an array,
671
+ // and it deliberately does not walk into one. So the dropping is reported
672
+ // separately, by `collectBadReviewers` below.
673
+ if (Array.isArray(parsed.review.reviewers)) {
674
+ base.review.reviewers = normalizeReviewers(parsed.review.reviewers)
675
+ }
676
+ }
677
+
678
+ if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
679
+ base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
680
+ }
681
+
682
+ if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
683
+ base.live.migrations = normalizeFileList(parsed.live.migrations)
684
+ }
685
+
686
+ if (isObject(parsed.hotfix)) {
687
+ assign(base.hotfix, parsed.hotfix, 'bump', 'string')
688
+ assign(base.hotfix, parsed.hotfix, 'cherryPickMain', 'boolean')
689
+ assign(base.hotfix, parsed.hotfix, 'releaseWith', 'string')
690
+ if (Array.isArray(parsed.hotfix.targets)) {
691
+ base.hotfix.targets = normalizeFileList(parsed.hotfix.targets)
692
+ }
693
+ }
694
+
695
+ return base
696
+ }
697
+
698
+ /**
699
+ * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
700
+ * Returns `{ config, present, unknown }`:
701
+ * - missing file → `{ config: defaults, present: false, unknown: [] }`
702
+ * (opt-out; never throws)
703
+ * - present → `{ config: merged, present: true, unknown: [...] }`
704
+ * Malformed JSON → throws a clear Error (callers exit non-zero).
705
+ *
706
+ * `unknown` lists the dotted paths the merge ignored, for a caller to report.
707
+ * It is advisory in the strongest sense: nothing here acts on it, and a config
708
+ * full of strays loads exactly as it always did.
709
+ */
710
+ function loadEnvConfig(dir = process.cwd()) {
711
+ const base = defaults()
712
+ const file = join(dir, CONFIG_FILE)
713
+
714
+ let raw
715
+ try {
716
+ raw = readFileSync(file, 'utf-8')
717
+ } catch (error) {
718
+ if (error.code === 'ENOENT') return { config: base, present: false, unknown: [], badReviewers: [] }
719
+ throw error
720
+ }
721
+
722
+ let parsed
723
+ try {
724
+ parsed = JSON.parse(raw)
725
+ } catch (error) {
726
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
727
+ }
728
+
729
+ return {
730
+ config: mergeConfig(base, parsed),
731
+ present: true,
732
+ unknown: collectUnknownKeys(parsed),
733
+ badReviewers: collectBadReviewers(parsed),
734
+ }
735
+ }
736
+
737
+ module.exports = {
738
+ loadEnvConfig,
739
+ resolveServePort,
740
+ derivedServePort,
741
+ servePortRoot,
742
+ servePortReason,
743
+ PORT_BASE,
744
+ PORT_SPAN,
745
+ collectUnknownKeys,
746
+ collectBadReviewers,
747
+ readReviewer,
748
+ mergeConfig,
749
+ DEFAULT_CONFIG,
750
+ DEFAULT_REVIEWER_TIMEOUT,
751
+ REVIEWER_FORMATS,
752
+ CONFIG_FILE,
753
+ }