@skitterbyte/skitterspec 21.0.0 → 22.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 +218 -0
- package/README.md +113 -6
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +100 -23
- package/assets/review/page.html +1044 -101
- package/assets/rules/spec-planning.md +35 -3
- package/assets/rules/spec-reports.md +131 -20
- package/assets/skills/spec/SKILL.md +129 -1
- package/assets/skills/spec-bug/SKILL.md +29 -24
- package/assets/skills/spec-diff/SKILL.md +131 -36
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-next/SKILL.md +119 -56
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/cli.js +913 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +542 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +60 -9
package/src/env/classify.js
CHANGED
|
@@ -45,7 +45,18 @@ function expandCompanion(pattern, spec, config) {
|
|
|
45
45
|
const tokens = { slug: spec.slug }
|
|
46
46
|
if (/\{identifier\}/.test(pattern)) {
|
|
47
47
|
const field = config && config.branch && config.branch.identifierField
|
|
48
|
-
|
|
48
|
+
// NO PATH IS CANNOT-TELL, not an error. Callers hand in spec objects of two
|
|
49
|
+
// shapes: `resolveSpec` returns a full one, and `allSpecs` returns
|
|
50
|
+
// `{folder, slug, worktreePath}` with no `path` at all. Reading the
|
|
51
|
+
// frontmatter of `undefined` threw, and the throw surfaced as
|
|
52
|
+
// `render failed: The "path" argument must be of type string` on the review
|
|
53
|
+
// server's index — every spec, not just one.
|
|
54
|
+
//
|
|
55
|
+
// It routes to the same answer a missing identifier already routes to:
|
|
56
|
+
// expand nothing, so the pattern matches nothing and the path is not
|
|
57
|
+
// claimed as this spec's. Being wrong here costs a companion file left
|
|
58
|
+
// unstaged; throwing cost the whole page.
|
|
59
|
+
const identifier = spec.path ? readFrontmatterField(spec.path, field) : null
|
|
49
60
|
if (!identifier) return null
|
|
50
61
|
tokens.identifier = identifier
|
|
51
62
|
}
|
|
@@ -88,4 +99,78 @@ function classifyDirtyTree(spec, dirtyPaths, config) {
|
|
|
88
99
|
return { owned, foreign }
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
|
|
102
|
+
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
103
|
+
// can; anything we cannot parse confidently is returned as-is, which makes it
|
|
104
|
+
// fail the spec-folder comparison and land in `foreign` — a refusal, which is the
|
|
105
|
+
// safe direction to be wrong in.
|
|
106
|
+
function unquotePath(p) {
|
|
107
|
+
if (!p.startsWith('"') || !p.endsWith('"')) return p
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(p)
|
|
110
|
+
} catch {
|
|
111
|
+
return p
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Repo-relative paths of everything uncommitted. Returns null when git could not
|
|
117
|
+
* be read at all — the caller must treat that as "nobody looked", never "clean".
|
|
118
|
+
*
|
|
119
|
+
* Two prefix-free listings rather than `git status --porcelain`, deliberately.
|
|
120
|
+
* Porcelain prefixes every path with a two-character status field, and the shared
|
|
121
|
+
* git reader TRIMS its output — which eats the leading space of the first line
|
|
122
|
+
* only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
|
|
123
|
+
* These emit bare paths, so there is no offset to get wrong. `--others` also
|
|
124
|
+
* lists untracked files INDIVIDUALLY, where porcelain collapses them into their
|
|
125
|
+
* topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
|
|
126
|
+
* an ancestor attributable to no single spec, and so refusing the very tree this
|
|
127
|
+
* gate exists to accept. Both were found by running it, not by reading it.
|
|
128
|
+
*/
|
|
129
|
+
function dirtyPaths(git) {
|
|
130
|
+
const lists = [
|
|
131
|
+
git(['diff', '--name-only', 'HEAD']),
|
|
132
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
133
|
+
]
|
|
134
|
+
if (lists.every((l) => l === null)) return null
|
|
135
|
+
const out = []
|
|
136
|
+
for (const list of lists) {
|
|
137
|
+
if (!list) continue
|
|
138
|
+
for (const line of list.split('\n')) {
|
|
139
|
+
const q = line.trim()
|
|
140
|
+
if (q) out.push(unquotePath(q))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A spec's own uncommitted documents in one tree: `{tree, owned}`, or a reason.
|
|
148
|
+
*
|
|
149
|
+
* SHARED ON PURPOSE. Two surfaces render a spec's documents — the CLI's
|
|
150
|
+
* `review --docs` and the review server's route for a spec with no worktree —
|
|
151
|
+
* and if they classified separately they could disagree about what the page
|
|
152
|
+
* shows. A reader's verdict is about the page they read, so the served page and
|
|
153
|
+
* the written page have to be the same page.
|
|
154
|
+
*
|
|
155
|
+
* Three states, never two (`.claude/rules/negative-checks.md` rule 4). An
|
|
156
|
+
* unreadable git is `cannot tell`, and must not collapse into an empty file
|
|
157
|
+
* set: an empty set renders a page saying nothing changed, which is the one
|
|
158
|
+
* reading that is certainly wrong.
|
|
159
|
+
*
|
|
160
|
+
* WHAT WOULD FOOL THIS: a document of this spec's that is already committed.
|
|
161
|
+
* `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
|
|
162
|
+
* are all committed reports `empty` rather than showing its own text. That is
|
|
163
|
+
* the intended reading — there is no change to review — and it is why `empty`
|
|
164
|
+
* is a distinct answer rather than an error.
|
|
165
|
+
*
|
|
166
|
+
* @returns {{tree: string, owned: string[]}|{error: string}|{empty: true}}
|
|
167
|
+
*/
|
|
168
|
+
function specDocsIn(tree, spec, config, git) {
|
|
169
|
+
const paths = dirtyPaths(git)
|
|
170
|
+
if (paths === null) return { error: 'git could not be read' }
|
|
171
|
+
const { owned } = classifyDirtyTree(spec, paths, config)
|
|
172
|
+
if (!owned.length) return { empty: true }
|
|
173
|
+
return { tree, owned }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { classifyDirtyTree, expandCompanion, unquotePath, dirtyPaths, specDocsIn }
|
package/src/env/config.js
CHANGED
|
@@ -43,11 +43,98 @@
|
|
|
43
43
|
* }
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
const {
|
|
47
|
-
const {
|
|
46
|
+
const { createHash } = require('node:crypto')
|
|
47
|
+
const { readFileSync, realpathSync } = require('node:fs')
|
|
48
|
+
const { join, resolve } = require('node:path')
|
|
48
49
|
|
|
49
50
|
const CONFIG_FILE = join('specs', '.core', 'env.config.json')
|
|
50
51
|
|
|
52
|
+
// The window `servePort: "auto"` derives a port from. 7700-7799 keeps the
|
|
53
|
+
// familiar neighbourhood — 7777, the old shared default, is inside it — while
|
|
54
|
+
// giving every repo on a machine its own slot without anyone configuring one.
|
|
55
|
+
//
|
|
56
|
+
// A hundred slots is a SMALL chance of two repos landing together, not no
|
|
57
|
+
// chance. That case is not papered over: the server still refuses the busy
|
|
58
|
+
// port and names `servePort` as the durable fix. Walking up to the next free
|
|
59
|
+
// port would remove the refusal and keep the staleness, because the port would
|
|
60
|
+
// then depend on which repo started first.
|
|
61
|
+
const PORT_BASE = 7700
|
|
62
|
+
const PORT_SPAN = 100
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The repo path the derivation hashes — `realpath`ed ONCE, here.
|
|
66
|
+
*
|
|
67
|
+
* A symlinked spelling of one tree (`/tmp` → `/private/tmp` on macOS, a
|
|
68
|
+
* convenience symlink into a worktree root) is the same repo, and hashing the
|
|
69
|
+
* two spellings separately would hand out two different ports for it — two
|
|
70
|
+
* different URLs, one of them dead. Resolving once is what makes the port a
|
|
71
|
+
* property of the tree rather than of how you typed it.
|
|
72
|
+
*
|
|
73
|
+
* WHAT WOULD FOOL THIS: `realpathSync` throws on a path that does not exist, so
|
|
74
|
+
* it falls back to `resolve`. That is the cannot-tell branch and it is routed
|
|
75
|
+
* to inaction (.claude/rules/negative-checks.md rule 4) — an absolute path is
|
|
76
|
+
* still deterministic, so the worst case is a stable port for a directory that
|
|
77
|
+
* is not there, never a crash on an unrelated command.
|
|
78
|
+
*/
|
|
79
|
+
function servePortRoot(dir) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync(resolve(dir))
|
|
82
|
+
} catch {
|
|
83
|
+
return resolve(dir)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `PORT_BASE + hash(path) % PORT_SPAN` — a PURE function of the path.
|
|
89
|
+
*
|
|
90
|
+
* Purity is the whole point, not an implementation detail. A port that is
|
|
91
|
+
* merely *free* is not a port a link handed out yesterday can still resolve, so
|
|
92
|
+
* nothing here may read the registry, `.spec-env/`, or any other state on disk:
|
|
93
|
+
* a port that depends on a file changes when the file is deleted, and this
|
|
94
|
+
* exists precisely so it does not change. The same tree gets the same port
|
|
95
|
+
* across a restart, a reboot, and a `--stop`.
|
|
96
|
+
*
|
|
97
|
+
* sha256 rather than a hand-rolled hash because its distribution is not
|
|
98
|
+
* something this file has to argue for — with a hundred slots, clustering is
|
|
99
|
+
* the only way the derivation could fail at its job.
|
|
100
|
+
*/
|
|
101
|
+
function derivedServePort(root) {
|
|
102
|
+
const digest = createHash('sha256').update(root).digest()
|
|
103
|
+
return PORT_BASE + (digest.readUInt32BE(0) % PORT_SPAN)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The port `spec-env review serve` should use, and HOW it was chosen.
|
|
108
|
+
*
|
|
109
|
+
* Three sources, in precedence order: `--port` on this run, an explicit number
|
|
110
|
+
* in `review.servePort`, else the derivation. The `source` rides along so
|
|
111
|
+
* `--status` can answer "why is this on 7742?" from the tool rather than from
|
|
112
|
+
* reading this file.
|
|
113
|
+
*
|
|
114
|
+
* Returns `{ port, source, root? }`; `root` is the resolved path that was
|
|
115
|
+
* hashed, present only on a derived port.
|
|
116
|
+
*/
|
|
117
|
+
function resolveServePort(config, dir, override) {
|
|
118
|
+
const flag = Number(override)
|
|
119
|
+
if (override != null && override !== '' && Number.isFinite(flag)) {
|
|
120
|
+
return { port: flag, source: 'flag' }
|
|
121
|
+
}
|
|
122
|
+
const configured = config && config.review ? config.review.servePort : undefined
|
|
123
|
+
if (typeof configured === 'number' && Number.isFinite(configured)) {
|
|
124
|
+
return { port: configured, source: 'configured' }
|
|
125
|
+
}
|
|
126
|
+
const root = servePortRoot(dir)
|
|
127
|
+
return { port: derivedServePort(root), source: 'derived', root }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One sentence naming how a resolved port was chosen, for `--status`. */
|
|
131
|
+
function servePortReason(source) {
|
|
132
|
+
if (source === 'flag') return 'this run only, from --port'
|
|
133
|
+
if (source === 'configured') return 'pinned by review.servePort'
|
|
134
|
+
if (source === 'derived') return "derived from this repo's path"
|
|
135
|
+
return ''
|
|
136
|
+
}
|
|
137
|
+
|
|
51
138
|
const DEFAULT_CONFIG = Object.freeze({
|
|
52
139
|
// Where a spec's branch is built. "worktree" gives every spec its own checkout
|
|
53
140
|
// — parallel specs, `main` left free — at the cost of a terminal session per
|
|
@@ -110,9 +197,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
110
197
|
// planned for a LANDED branch — see teardown.js.
|
|
111
198
|
teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
|
|
112
199
|
|
|
113
|
-
// `reader` decides how a diff's location is
|
|
114
|
-
//
|
|
115
|
-
//
|
|
200
|
+
// `reader` decides how a diff's location is WORDED, and nothing else. It
|
|
201
|
+
// once also decided whether the engine served — a gate that produced the
|
|
202
|
+
// `file://` link on a local machine, where a `file://` page cannot POST and
|
|
203
|
+
// so the verdict buttons had nowhere to go. `serve` owns that now.
|
|
204
|
+
// It never decides to PUBLISH: publishing
|
|
116
205
|
// leaves a page this tooling cannot remove, so it stays an explicit ask.
|
|
117
206
|
// `detect` sniffs; `local`/`remote` are the operator's own answer and are
|
|
118
207
|
// believed without sniffing, because they know where they are reading and no
|
|
@@ -132,10 +221,34 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
132
221
|
// toward reading the diff is the point, and a project that would rather not
|
|
133
222
|
// be pushed says so once. It is the only key anything reads to decide
|
|
134
223
|
// whether the gate refuses, so turning it off turns off the hook with it.
|
|
224
|
+
// `serve` decides whether a render stands the local server up: `always`
|
|
225
|
+
// (the default) or `never`. It replaced `serveOnRemote`, whose name would now
|
|
226
|
+
// claim to govern remote renders while governing every one of them — and a
|
|
227
|
+
// config key that lies is what made a wrong fix look right. A legacy
|
|
228
|
+
// `serveOnRemote: false` is still read as `serve: "never"`.
|
|
229
|
+
//
|
|
230
|
+
// THE BIND IS NOT THIS KEY'S BUSINESS, and that separation is what keeps
|
|
231
|
+
// serving-everywhere free of new exposure: `reader` still decides it, so a
|
|
232
|
+
// remote reader binds every interface exactly as before and a local or
|
|
233
|
+
// unknown one binds loopback. Serving more never means listening wider.
|
|
234
|
+
// `allowNetwork` and `allowRemote` are the two tiers a project permits. They
|
|
235
|
+
// exist because the engine CANNOT KNOW where the reader is sitting and kept
|
|
236
|
+
// being asked to guess: detection reported `unknown` on a local session and
|
|
237
|
+
// produced a page whose buttons cannot POST, reported `remote` and produced a
|
|
238
|
+
// LAN URL a phone off the network could not reach, and flipped mid-session
|
|
239
|
+
// and changed the address underneath a reader. Each was fixed on its own; the
|
|
240
|
+
// next reader position would have produced a fourth.
|
|
241
|
+
//
|
|
242
|
+
// So the reader picks instead. `allowNetwork` decides THE BIND — on binds
|
|
243
|
+
// every interface, off binds loopback — which is the last decision detection
|
|
244
|
+
// had. `allowRemote` PERMITS publishing; it never publishes, because each
|
|
245
|
+
// publish leaves a page skitterspec cannot delete.
|
|
135
246
|
review: Object.freeze({
|
|
136
247
|
reader: 'detect',
|
|
137
|
-
servePort:
|
|
138
|
-
|
|
248
|
+
servePort: 'auto',
|
|
249
|
+
serve: 'always',
|
|
250
|
+
allowNetwork: true,
|
|
251
|
+
allowRemote: false,
|
|
139
252
|
commitWith: '/commit',
|
|
140
253
|
required: true,
|
|
141
254
|
}),
|
|
@@ -155,6 +268,47 @@ function isObject(value) {
|
|
|
155
268
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
156
269
|
}
|
|
157
270
|
|
|
271
|
+
/**
|
|
272
|
+
* List the keys in a parsed config that `mergeConfig` will not read, as dotted
|
|
273
|
+
* paths (`open`, `review.readr`). Advisory only — nothing here refuses, and the
|
|
274
|
+
* merge below is unchanged: unknown keys are still dropped, they are just no
|
|
275
|
+
* longer dropped in silence.
|
|
276
|
+
*
|
|
277
|
+
* The known set is DEFAULT_CONFIG itself rather than a list written out beside
|
|
278
|
+
* it. That is not a shortcut: every key `mergeConfig` reads is necessarily a key
|
|
279
|
+
* of the defaults, because a key with no default has nothing to merge onto — so
|
|
280
|
+
* a hand-written list would be a second copy of the same contract, and a second
|
|
281
|
+
* copy is how the two come to disagree. Adding a key to `mergeConfig` without
|
|
282
|
+
* adding its default is already impossible; this check inherits that.
|
|
283
|
+
*
|
|
284
|
+
* It descends only where the DEFAULT is a plain object. An array's elements are
|
|
285
|
+
* data, not keys — `dev` entries, `setup` commands, `spec.companionPaths`,
|
|
286
|
+
* `live.migrations`, `hotfix.targets` — and walking into them would report every
|
|
287
|
+
* path in the list as an unknown key.
|
|
288
|
+
*
|
|
289
|
+
* WHAT IT DELIBERATELY DOES NOT REPORT: a KNOWN key whose value was rejected for
|
|
290
|
+
* its type or for not matching an enum (`mode: "Checkout"`,
|
|
291
|
+
* `teardown.deleteRemoteBranch: "yes"`). Those already fall through to a
|
|
292
|
+
* documented conservative default, each with a comment saying why, and folding
|
|
293
|
+
* them in here would change what this line means from "I ignored a key you wrote"
|
|
294
|
+
* to "I disagreed with a value you wrote".
|
|
295
|
+
*/
|
|
296
|
+
function collectUnknownKeys(parsed, known = DEFAULT_CONFIG, prefix = '') {
|
|
297
|
+
if (!isObject(parsed)) return []
|
|
298
|
+
const out = []
|
|
299
|
+
for (const key of Object.keys(parsed)) {
|
|
300
|
+
const dotted = prefix ? `${prefix}.${key}` : key
|
|
301
|
+
if (!Object.prototype.hasOwnProperty.call(known, key)) {
|
|
302
|
+
out.push(dotted)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (isObject(known[key]) && isObject(parsed[key])) {
|
|
306
|
+
out.push(...collectUnknownKeys(parsed[key], known[key], dotted))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
158
312
|
// A fresh, deeply-mutable copy of the defaults to merge onto.
|
|
159
313
|
function defaults() {
|
|
160
314
|
return {
|
|
@@ -341,11 +495,38 @@ function mergeConfig(base, parsed) {
|
|
|
341
495
|
if (reader === 'local' || reader === 'remote' || reader === 'detect') {
|
|
342
496
|
base.review.reader = reader
|
|
343
497
|
}
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
// default
|
|
347
|
-
//
|
|
348
|
-
|
|
498
|
+
// Two accepted forms: a finite number pins the port, and the string "auto"
|
|
499
|
+
// derives it from the repo's path. Anything else is refused BY NAME — the
|
|
500
|
+
// default stands — exactly as `reader`, `mode` and `deleteRemoteBranch`
|
|
501
|
+
// refuse a value they do not recognise.
|
|
502
|
+
//
|
|
503
|
+
// Falling through costs nothing here, and that is worth stating rather than
|
|
504
|
+
// assuming: the only value a typo can fall through to is "auto", which is
|
|
505
|
+
// also the only string that would have been accepted. So a misspelt "auto"
|
|
506
|
+
// behaves identically to the spelling that was meant, and a misspelt number
|
|
507
|
+
// was never a number. There is no reading of this key where silence hides a
|
|
508
|
+
// port the author pinned.
|
|
509
|
+
if (typeof parsed.review.servePort === 'number' && Number.isFinite(parsed.review.servePort)) {
|
|
510
|
+
base.review.servePort = parsed.review.servePort
|
|
511
|
+
} else if (parsed.review.servePort === 'auto') {
|
|
512
|
+
base.review.servePort = 'auto'
|
|
513
|
+
}
|
|
514
|
+
// `always` | `never`; anything else leaves the default standing, so a typo
|
|
515
|
+
// cannot quietly restore the dead `file://` link this key exists to end.
|
|
516
|
+
if (parsed.review.serve === 'always' || parsed.review.serve === 'never') {
|
|
517
|
+
base.review.serve = parsed.review.serve
|
|
518
|
+
}
|
|
519
|
+
// TOLERANCE, NOT MIGRATION — the same rule `readVerdict` follows for the
|
|
520
|
+
// old `approve` spelling. These configs are committed, so a rename with no
|
|
521
|
+
// tolerance breaks every other checkout on the next pull. Only `false` is
|
|
522
|
+
// read: `serveOnRemote: true` said "serve where it matters", which is what
|
|
523
|
+
// `always` now does anyway, so it needs no translation.
|
|
524
|
+
//
|
|
525
|
+
// An explicit `serve` wins, so a config carrying both is read the way its
|
|
526
|
+
// author most recently meant.
|
|
527
|
+
if (parsed.review.serve === undefined && parsed.review.serveOnRemote === false) {
|
|
528
|
+
base.review.serve = 'never'
|
|
529
|
+
}
|
|
349
530
|
// An empty string leaves `/commit` standing, like every other string key
|
|
350
531
|
// here. There is nothing it could mean instead: the hand-off has no off
|
|
351
532
|
// switch, so a blank value is a typo rather than an instruction.
|
|
@@ -354,6 +535,10 @@ function mergeConfig(base, parsed) {
|
|
|
354
535
|
// leaves the gate ON. Turning off a check that refuses must be something
|
|
355
536
|
// someone WROTE, never something a typo achieved on their behalf.
|
|
356
537
|
assign(base.review, parsed.review, 'required', 'boolean')
|
|
538
|
+
// Same shape as the two above: a non-boolean leaves the default standing, so
|
|
539
|
+
// a typo cannot quietly widen a bind or permit a publish.
|
|
540
|
+
assign(base.review, parsed.review, 'allowNetwork', 'boolean')
|
|
541
|
+
assign(base.review, parsed.review, 'allowRemote', 'boolean')
|
|
357
542
|
}
|
|
358
543
|
|
|
359
544
|
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
@@ -377,10 +562,15 @@ function mergeConfig(base, parsed) {
|
|
|
377
562
|
|
|
378
563
|
/**
|
|
379
564
|
* Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
|
|
380
|
-
* Returns `{ config, present }`:
|
|
381
|
-
* - missing file → `{ config: defaults, present: false }`
|
|
382
|
-
*
|
|
565
|
+
* Returns `{ config, present, unknown }`:
|
|
566
|
+
* - missing file → `{ config: defaults, present: false, unknown: [] }`
|
|
567
|
+
* (opt-out; never throws)
|
|
568
|
+
* - present → `{ config: merged, present: true, unknown: [...] }`
|
|
383
569
|
* Malformed JSON → throws a clear Error (callers exit non-zero).
|
|
570
|
+
*
|
|
571
|
+
* `unknown` lists the dotted paths the merge ignored, for a caller to report.
|
|
572
|
+
* It is advisory in the strongest sense: nothing here acts on it, and a config
|
|
573
|
+
* full of strays loads exactly as it always did.
|
|
384
574
|
*/
|
|
385
575
|
function loadEnvConfig(dir = process.cwd()) {
|
|
386
576
|
const base = defaults()
|
|
@@ -390,7 +580,7 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
390
580
|
try {
|
|
391
581
|
raw = readFileSync(file, 'utf-8')
|
|
392
582
|
} catch (error) {
|
|
393
|
-
if (error.code === 'ENOENT') return { config: base, present: false }
|
|
583
|
+
if (error.code === 'ENOENT') return { config: base, present: false, unknown: [] }
|
|
394
584
|
throw error
|
|
395
585
|
}
|
|
396
586
|
|
|
@@ -401,11 +591,18 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
401
591
|
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
402
592
|
}
|
|
403
593
|
|
|
404
|
-
return { config: mergeConfig(base, parsed), present: true }
|
|
594
|
+
return { config: mergeConfig(base, parsed), present: true, unknown: collectUnknownKeys(parsed) }
|
|
405
595
|
}
|
|
406
596
|
|
|
407
597
|
module.exports = {
|
|
408
598
|
loadEnvConfig,
|
|
599
|
+
resolveServePort,
|
|
600
|
+
derivedServePort,
|
|
601
|
+
servePortRoot,
|
|
602
|
+
servePortReason,
|
|
603
|
+
PORT_BASE,
|
|
604
|
+
PORT_SPAN,
|
|
605
|
+
collectUnknownKeys,
|
|
409
606
|
mergeConfig,
|
|
410
607
|
DEFAULT_CONFIG,
|
|
411
608
|
CONFIG_FILE,
|
package/src/env/live.js
CHANGED
|
@@ -85,6 +85,98 @@ function summarizeReceipt(receipt) {
|
|
|
85
85
|
)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// --- is this spec live? ---------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a spec is currently live, in a form a page can carry. Pure.
|
|
92
|
+
*
|
|
93
|
+
* FOUR STATES, NOT TWO, and the fourth is what keeps this quiet in a healthy
|
|
94
|
+
* repo. `on` and `off` are the two a reader acts on; `held` is another spec
|
|
95
|
+
* holding the workbench, which is a refusal with a named way out; and
|
|
96
|
+
* `unavailable` is **cannot tell** — routed to silence rather than to a warning
|
|
97
|
+
* (`.claude/rules/negative-checks.md` rule 4). A project with no isolation and a
|
|
98
|
+
* spec with no worktree both land there, and both are perfectly healthy.
|
|
99
|
+
*
|
|
100
|
+
* THE BRANCH IS THE AUTHORITY, NOT THE RECEIPT, exactly as this file's header
|
|
101
|
+
* says: the receipt is metadata for `live status` and crash recovery. So an
|
|
102
|
+
* unreadable or absent receipt costs the *holder's name* and nothing else — the
|
|
103
|
+
* state still comes from which branch the primary checkout is on. Deriving the
|
|
104
|
+
* state from the receipt instead would report `off` for a checkout plainly
|
|
105
|
+
* sitting on a feature branch, which is the one answer that would let something
|
|
106
|
+
* act.
|
|
107
|
+
*
|
|
108
|
+
* WHAT WOULD FOOL THIS: `onBase` and `primaryBranch` are read from the primary
|
|
109
|
+
* checkout by the caller. A caller that passes a *worktree's* git reader would
|
|
110
|
+
* describe the worktree's HEAD and call it the workbench — so `ctx` is built by
|
|
111
|
+
* `assertPrimaryOnMain` against the repo root and nowhere else.
|
|
112
|
+
*
|
|
113
|
+
* ctx: { isolated, onBase, primaryBranch, receipt, worktreeExists, url }
|
|
114
|
+
* @returns {{state:'on'|'off'|'held'|'unavailable', holder:string|null, url:string|null, reason:string|null}}
|
|
115
|
+
*/
|
|
116
|
+
function liveStateFor(spec, ctx) {
|
|
117
|
+
const c = ctx || {}
|
|
118
|
+
const unavailable = (reason) => ({ state: 'unavailable', holder: null, url: null, reason })
|
|
119
|
+
|
|
120
|
+
if (!spec || !spec.branch) return unavailable('no spec to ask about')
|
|
121
|
+
if (c.isolated === false) return unavailable('isolation is not configured')
|
|
122
|
+
|
|
123
|
+
// Not on base — something holds the workbench. Which something is the only
|
|
124
|
+
// question left, and the branch answers it without the receipt.
|
|
125
|
+
if (c.onBase === false) {
|
|
126
|
+
const branch = c.primaryBranch || null
|
|
127
|
+
if (branch && branch === spec.branch) {
|
|
128
|
+
return { state: 'on', holder: null, url: c.url || null, reason: null }
|
|
129
|
+
}
|
|
130
|
+
const holder = c.receipt && c.receipt.spec ? String(c.receipt.spec) : null
|
|
131
|
+
const on = branch || '(detached)'
|
|
132
|
+
return {
|
|
133
|
+
state: 'held',
|
|
134
|
+
holder,
|
|
135
|
+
url: null,
|
|
136
|
+
// Every way out, because this is the only explanation the reader gets —
|
|
137
|
+
// the same reasoning `planTake`'s guard 1 records for its own refusal.
|
|
138
|
+
reason: holder
|
|
139
|
+
? `${holder} holds the workbench (branch ${on})`
|
|
140
|
+
: `the workbench is on ${on} — no receipt; switched by hand?`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `onBase` is deliberately three-valued: anything but a definite `true` here
|
|
145
|
+
// is a checkout we could not read, and that is not evidence the spec is off.
|
|
146
|
+
if (c.onBase !== true) return unavailable('could not read the primary checkout')
|
|
147
|
+
if (!c.worktreeExists) return unavailable('no worktree to put live')
|
|
148
|
+
|
|
149
|
+
return { state: 'off', holder: null, url: null, reason: null }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one line the render prints for a live state, or null for `unavailable`.
|
|
154
|
+
* Pure.
|
|
155
|
+
*
|
|
156
|
+
* NULL IS THE POINT. `unavailable` prints nothing at all — not a dash, not an
|
|
157
|
+
* explanation — because every input that produces it is a healthy repo that
|
|
158
|
+
* simply has no live surface, and a line about one is an accusation.
|
|
159
|
+
*/
|
|
160
|
+
function liveStateLine(live) {
|
|
161
|
+
if (!live || live.state === 'unavailable') return null
|
|
162
|
+
const label = ' live:'.padEnd(11)
|
|
163
|
+
// IT NAMES THE COMMAND, not the capability. This line read
|
|
164
|
+
// `off — the page can put it live`, which told the reader a page somewhere
|
|
165
|
+
// could do it and left them to find the verb — and the verb is the thing they
|
|
166
|
+
// actually need when they are in a terminal rather than on the page. It is
|
|
167
|
+
// `/spec-live` rather than `spec-env live take` because that command is what a
|
|
168
|
+
// person types: it is user-only by its own marking, and the model cannot run
|
|
169
|
+
// it either way.
|
|
170
|
+
if (live.state === 'on') {
|
|
171
|
+
return (
|
|
172
|
+
`${label}on${live.url ? ` — running at ${live.url}` : ''}` +
|
|
173
|
+
'; /spec-live main to restore main'
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
if (live.state === 'held') return `${label}held — ${live.reason}`
|
|
177
|
+
return `${label}off — /spec-live to put it live`
|
|
178
|
+
}
|
|
179
|
+
|
|
88
180
|
// --- stateful detection (glob matching) -----------------------------------
|
|
89
181
|
|
|
90
182
|
// Translate a restricted glob into an anchored RegExp: `**` matches across path
|
|
@@ -378,4 +470,6 @@ module.exports = {
|
|
|
378
470
|
planTake,
|
|
379
471
|
planRelease,
|
|
380
472
|
planAbort,
|
|
473
|
+
liveStateFor,
|
|
474
|
+
liveStateLine,
|
|
381
475
|
}
|
package/src/env/resolve.js
CHANGED
|
@@ -167,7 +167,37 @@ function parsePhase(text, name) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* The spec
|
|
170
|
+
* The tracker ticket a spec is linked to, as `{ id, url }`, or null.
|
|
171
|
+
*
|
|
172
|
+
* Read straight out of the overview's frontmatter, where every provider stamps
|
|
173
|
+
* it. The base is tracker-free, so this knows nothing about Linear beyond the
|
|
174
|
+
* two key names a provider writes — an unlinked spec, or a project with no
|
|
175
|
+
* provider installed, simply has neither and gets `null`.
|
|
176
|
+
*
|
|
177
|
+
* ONLY http(s) URLS SURVIVE. The value comes out of a file someone edits, and
|
|
178
|
+
* the page turns it into an `href`; `javascript:` in that position is script
|
|
179
|
+
* execution on a page served over the network. An id with an unusable url is
|
|
180
|
+
* still worth having, so the id is kept and the link dropped rather than the
|
|
181
|
+
* whole ticket.
|
|
182
|
+
*/
|
|
183
|
+
function readTicket(text) {
|
|
184
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text)
|
|
185
|
+
if (!fm) return null
|
|
186
|
+
const field = (name) => {
|
|
187
|
+
const m = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(fm[1])
|
|
188
|
+
if (!m) return null
|
|
189
|
+
const v = m[1].trim().replace(/^["']|["']$/g, '').trim()
|
|
190
|
+
return v || null
|
|
191
|
+
}
|
|
192
|
+
const id = field('linear_identifier')
|
|
193
|
+
if (!id) return null
|
|
194
|
+
const url = field('linear_url')
|
|
195
|
+
return { id, url: url && /^https?:\/\//i.test(url) ? url : null }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The spec's own `## Problem` and `## Impact`, plus its tracker ticket, for the
|
|
200
|
+
* page's header.
|
|
171
201
|
*
|
|
172
202
|
* WHAT WOULD FOOL THIS: a spec that renames those headings, or a legacy bare
|
|
173
203
|
* `<name>.md` with no overview at all. Both yield `null`, which the caller
|
|
@@ -183,10 +213,14 @@ function readOverview(specDir, { overviewFile = '00-overview.md' } = {}) {
|
|
|
183
213
|
}
|
|
184
214
|
const problem = sectionOf(text, 'Problem') || sectionOf(text, 'Symptom')
|
|
185
215
|
const impact = impactRows(sectionOf(text, 'Impact'))
|
|
186
|
-
|
|
216
|
+
const ticket = readTicket(text)
|
|
217
|
+
// The ticket counts towards "is there anything to say": a spec linked to a
|
|
218
|
+
// tracker but carrying neither section still has a header worth drawing.
|
|
219
|
+
if (!problem && !impact && !ticket) return null
|
|
187
220
|
return {
|
|
188
221
|
...(problem ? { problem } : {}),
|
|
189
222
|
...(impact ? { impact } : {}),
|
|
223
|
+
...(ticket ? { ticket } : {}),
|
|
190
224
|
}
|
|
191
225
|
}
|
|
192
226
|
|