@skitterbyte/skitterspec-linear 14.0.0 → 17.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 +304 -4
- package/README.md +34 -1
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/SETUP.md +10 -6
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +103 -25
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +49 -22
- package/assets/review/page.html +1101 -108
- package/assets/rules/spec-planning.md +39 -7
- package/assets/rules/spec-reports.md +210 -31
- package/assets/skills/spec/SKILL.md +161 -4
- package/assets/skills/spec-bug/SKILL.md +102 -49
- package/assets/skills/spec-cancel/SKILL.md +2 -2
- package/assets/skills/spec-claim/SKILL.md +12 -4
- package/assets/skills/spec-complete/SKILL.md +2 -2
- package/assets/skills/spec-diff/SKILL.md +183 -39
- package/assets/skills/spec-hotfix/SKILL.md +96 -49
- package/assets/skills/spec-init/SKILL.md +18 -6
- package/assets/skills/spec-linear-setup/SKILL.md +19 -11
- package/assets/skills/spec-next/SKILL.md +147 -62
- package/assets/skills/spec-push/SKILL.md +45 -0
- package/assets/skills/spec-review/SKILL.md +89 -2
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +26 -3
- package/assets/skills/spec-status/SKILL.md +20 -6
- package/assets/skills/spec-sync/SKILL.md +1 -0
- package/package.json +2 -2
- package/src/cli.js +940 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/hooks.js +49 -9
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +581 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +88 -13
- package/src/vendor/linear/api.js +111 -2
- package/src/vendor/linear/cli-sync.js +661 -11
- package/src/vendor/linear/config.js +41 -13
- package/src/vendor/linear/doctor.js +6 -5
- package/src/vendor/sync-core/index.js +11 -3
- package/src/vendor/sync-core/src/compare.js +65 -0
- package/src/vendor/sync-core/src/normalize.js +26 -0
- package/src/vendor/sync-core/src/retarget.js +1 -1
- package/src/vendor/sync-core/src/task-block.js +2 -2
- /package/assets/hooks/{review-gate.js → review-gate.cjs} +0 -0
package/src/cli.js
CHANGED
|
@@ -9,7 +9,38 @@ const {
|
|
|
9
9
|
removeReleaseTooling,
|
|
10
10
|
releaseToolingNotice,
|
|
11
11
|
} = require('./deprecate.js')
|
|
12
|
-
const { loadEnvConfig } = require('./env/config.js')
|
|
12
|
+
const { loadEnvConfig, resolveServePort, servePortReason } = require('./env/config.js')
|
|
13
|
+
|
|
14
|
+
// Named once so the advisory below and any future caller agree on the wording.
|
|
15
|
+
const ENV_CONFIG_LABEL = 'env.config.json'
|
|
16
|
+
|
|
17
|
+
// The `spec-env` verbs, in the order the usage line prints them. ONE list,
|
|
18
|
+
// because two drifted: `live` and `stage` were each added to the dispatcher and
|
|
19
|
+
// to this usage line while `--help` was left listing ten of twelve — separately,
|
|
20
|
+
// months apart, which is what makes it a missing constraint rather than two
|
|
21
|
+
// slips. An undocumented verb reads as a removed one.
|
|
22
|
+
//
|
|
23
|
+
// `cli-help-verbs.test.js` holds the three ends together: every verb here
|
|
24
|
+
// appears in HELP, HELP names no verb that is not here, and this list matches
|
|
25
|
+
// the `case` labels the dispatcher actually handles.
|
|
26
|
+
//
|
|
27
|
+
// The `review` SUB-ACTIONS (serve/arm/gate/skip) are deliberately absent — they
|
|
28
|
+
// are arguments to `review`, not verbs, and the usage block spells them out on
|
|
29
|
+
// their own lines below.
|
|
30
|
+
const SPEC_ENV_VERBS = Object.freeze([
|
|
31
|
+
'up',
|
|
32
|
+
'down',
|
|
33
|
+
'prune',
|
|
34
|
+
'dev',
|
|
35
|
+
'connect',
|
|
36
|
+
'integrate',
|
|
37
|
+
'hotfix',
|
|
38
|
+
'live',
|
|
39
|
+
'review',
|
|
40
|
+
'stage',
|
|
41
|
+
'status',
|
|
42
|
+
'resolve',
|
|
43
|
+
])
|
|
13
44
|
const {
|
|
14
45
|
readRegistry,
|
|
15
46
|
writeRegistry,
|
|
@@ -39,6 +70,8 @@ const {
|
|
|
39
70
|
summarizeReceipt,
|
|
40
71
|
migrationsHit,
|
|
41
72
|
planTake,
|
|
73
|
+
liveStateFor,
|
|
74
|
+
liveStateLine,
|
|
42
75
|
planRelease,
|
|
43
76
|
planAbort,
|
|
44
77
|
} = require('./env/live.js')
|
|
@@ -46,6 +79,8 @@ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
|
46
79
|
const {
|
|
47
80
|
rawGitReader,
|
|
48
81
|
collectReview,
|
|
82
|
+
reviewTierStack,
|
|
83
|
+
reviewTierLine,
|
|
49
84
|
renderReviewPage,
|
|
50
85
|
renderReviewBlock,
|
|
51
86
|
reviewOutPath,
|
|
@@ -61,6 +96,7 @@ const {
|
|
|
61
96
|
reviewNotesPath,
|
|
62
97
|
readNotes,
|
|
63
98
|
writeNotes,
|
|
99
|
+
VERDICTS,
|
|
64
100
|
validateNotesBlob,
|
|
65
101
|
judgeVerdict,
|
|
66
102
|
appendDecision,
|
|
@@ -69,6 +105,8 @@ const {
|
|
|
69
105
|
writePending,
|
|
70
106
|
claimPending,
|
|
71
107
|
passesSince,
|
|
108
|
+
waitForPass,
|
|
109
|
+
waitingPasses,
|
|
72
110
|
describePending,
|
|
73
111
|
pendingAge,
|
|
74
112
|
reviewPendingPath,
|
|
@@ -76,6 +114,8 @@ const {
|
|
|
76
114
|
mergeNotes,
|
|
77
115
|
applyResolutions,
|
|
78
116
|
COMMITTING,
|
|
117
|
+
BUTTON_SETS,
|
|
118
|
+
DEFAULT_BUTTON_SET,
|
|
79
119
|
reviewGatePath,
|
|
80
120
|
readGate,
|
|
81
121
|
writeGate,
|
|
@@ -84,7 +124,7 @@ const {
|
|
|
84
124
|
gateState,
|
|
85
125
|
} = require('./env/review.js')
|
|
86
126
|
const { planUp, planCheckoutUp } = require('./env/provision.js')
|
|
87
|
-
const { classifyDirtyTree } = require('./env/classify.js')
|
|
127
|
+
const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
|
|
88
128
|
const { isGitCommit } = require('./env/commitcmd.js')
|
|
89
129
|
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
90
130
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
@@ -93,7 +133,13 @@ const { planHotfixLand } = require('./env/hotfix.js')
|
|
|
93
133
|
const { planDev } = require('./env/dev.js')
|
|
94
134
|
const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
|
|
95
135
|
const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
|
|
96
|
-
const {
|
|
136
|
+
const {
|
|
137
|
+
mintToken,
|
|
138
|
+
servableSpecs,
|
|
139
|
+
engineVersionFor,
|
|
140
|
+
staleServer,
|
|
141
|
+
pageTiers,
|
|
142
|
+
} = require('./env/serve.js')
|
|
97
143
|
|
|
98
144
|
const pkg = require('../package.json')
|
|
99
145
|
|
|
@@ -142,11 +188,16 @@ Usage:
|
|
|
142
188
|
connect <spec> expose a spec on the canonical ports (main = off)
|
|
143
189
|
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
144
190
|
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
145
|
-
|
|
191
|
+
live <spec> check a spec out in the primary checkout so
|
|
192
|
+
the running dev server serves it (take |
|
|
193
|
+
release | abort | status; main hands it back)
|
|
146
194
|
review <spec> write an HTML page of the spec's diff
|
|
147
195
|
(--branch for the whole spec; --out, --json)
|
|
148
196
|
(--notes <json> merges a review pass back;
|
|
149
197
|
--resolve <json> records what was done)
|
|
198
|
+
stage [spec] split the uncommitted tree into this spec's
|
|
199
|
+
documents and everything else
|
|
200
|
+
status list provisioned specs + port blocks
|
|
150
201
|
resolve <spec> print resolved slug/type/branch/paths
|
|
151
202
|
skitterspec gating <cmd> Release-gating check (opt-in; needs
|
|
152
203
|
specs/.core/gating.config.json). Subcommands:
|
|
@@ -269,6 +320,61 @@ async function cleanupReleaseTooling(dir, opts) {
|
|
|
269
320
|
* `git worktree prune`) stays listed until pruned. That over-reports, which is
|
|
270
321
|
* the harmless direction for a read-only report.
|
|
271
322
|
*/
|
|
323
|
+
/**
|
|
324
|
+
* The reviews sidecar directory for this repo. Named once, because the scan and
|
|
325
|
+
* everything that renders it must agree on where to look.
|
|
326
|
+
*/
|
|
327
|
+
function reviewsDirFor(dir, config) {
|
|
328
|
+
return path.join(dir, stateDirLabel(config), 'reviews')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Render "what is waiting" for a human. Returns '' when nothing is — a repo
|
|
333
|
+
* with no waiting pass must read exactly as it did before this existed, so the
|
|
334
|
+
* heading is absent rather than printed over an empty list.
|
|
335
|
+
*/
|
|
336
|
+
function waitingSection(dir, config, now = new Date().toISOString()) {
|
|
337
|
+
const found = waitingPasses(reviewsDirFor(dir, config))
|
|
338
|
+
if (!found.passes.length && !found.unreadable.length) return ''
|
|
339
|
+
const width = Math.max(0, ...found.passes.map((p) => p.spec.length))
|
|
340
|
+
const rows = found.passes
|
|
341
|
+
.map(
|
|
342
|
+
(p) =>
|
|
343
|
+
` ${p.spec.padEnd(width)} ${p.code} · ${p.action || p.verdict || 'no verdict'} · ` +
|
|
344
|
+
`${pendingAge(p.at, now)}\n`,
|
|
345
|
+
)
|
|
346
|
+
.join('')
|
|
347
|
+
// Named, never counted as zero: an unreadable store holds someone's pass, and
|
|
348
|
+
// reporting "nothing waiting" over it is the one answer certainly wrong.
|
|
349
|
+
const broken = found.unreadable
|
|
350
|
+
.map((folder) => ` ${folder}: its pending store is not readable JSON — move it aside\n`)
|
|
351
|
+
.join('')
|
|
352
|
+
// The disown line only where there is something to disown. It is information
|
|
353
|
+
// beside a list, not an instruction to act on every pass in it.
|
|
354
|
+
const how = found.passes.length
|
|
355
|
+
? ' disown one with: skitterspec spec-env review <spec> --drop <code>\n'
|
|
356
|
+
: ''
|
|
357
|
+
return `\nReviews waiting:\n${rows}${broken}${how}`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// `review waiting` — the same answer on its own, for a caller who wants only
|
|
361
|
+
// this. It claims nothing and exits 0 whatever it finds: a waiting pass is
|
|
362
|
+
// information, never an accusation.
|
|
363
|
+
function specEnvReviewWaiting(dir, config, flags) {
|
|
364
|
+
const found = waitingPasses(reviewsDirFor(dir, config))
|
|
365
|
+
if (flags.json) {
|
|
366
|
+
// ABSENT, NOT EMPTY. A consumer that predates this must see a
|
|
367
|
+
// byte-identical object when there is nothing waiting.
|
|
368
|
+
const out = {}
|
|
369
|
+
if (found.passes.length) out.waiting = found.passes
|
|
370
|
+
if (found.unreadable.length) out.unreadable = found.unreadable
|
|
371
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
const section = waitingSection(dir, config)
|
|
375
|
+
process.stdout.write(section ? `${section.replace(/^\n/, '')}` : 'spec-env review: nothing waiting.\n')
|
|
376
|
+
}
|
|
377
|
+
|
|
272
378
|
function specEnvStatus(dir, config) {
|
|
273
379
|
const worktreePaths = liveWorktreePaths(gitReader(dir))
|
|
274
380
|
const provisioned = allSpecs(dir, config, worktreePaths)
|
|
@@ -278,8 +384,11 @@ function specEnvStatus(dir, config) {
|
|
|
278
384
|
.filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
|
|
279
385
|
.sort((a, b) => a.folder.localeCompare(b.folder))
|
|
280
386
|
|
|
387
|
+
// REACHED EITHER WAY. This used to return here, so a repo with nothing in
|
|
388
|
+
// flight could say nothing about a waiting pass — and a repo with nothing in
|
|
389
|
+
// flight is exactly where one hides longest.
|
|
281
390
|
if (!provisioned.length) {
|
|
282
|
-
process.stdout.write(
|
|
391
|
+
process.stdout.write(`spec-env: no provisioned specs.\n${waitingSection(dir, config)}`)
|
|
283
392
|
return
|
|
284
393
|
}
|
|
285
394
|
|
|
@@ -294,6 +403,7 @@ function specEnvStatus(dir, config) {
|
|
|
294
403
|
}
|
|
295
404
|
process.stdout.write(` ${folder}${ports}\n ${path.relative(dir, wt) || wt}\n`)
|
|
296
405
|
}
|
|
406
|
+
process.stdout.write(waitingSection(dir, config))
|
|
297
407
|
}
|
|
298
408
|
|
|
299
409
|
// Plan a provision: allocate the slot, persist the registry, and print the plan
|
|
@@ -301,49 +411,6 @@ function specEnvStatus(dir, config) {
|
|
|
301
411
|
// This creates no worktree and starts no stack — the caller runs the
|
|
302
412
|
// printed commands. Keep the output's verb honest about that.
|
|
303
413
|
|
|
304
|
-
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
305
|
-
// can; anything we cannot parse confidently is returned as-is, which makes it
|
|
306
|
-
// fail the spec-folder comparison and land in `foreign` — a refusal, which is the
|
|
307
|
-
// safe direction to be wrong in.
|
|
308
|
-
function unquotePath(p) {
|
|
309
|
-
if (!p.startsWith('"') || !p.endsWith('"')) return p
|
|
310
|
-
try {
|
|
311
|
-
return JSON.parse(p)
|
|
312
|
-
} catch {
|
|
313
|
-
return p
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Repo-relative paths of everything uncommitted. Returns null when git could not
|
|
319
|
-
* be read at all — the caller must treat that as "nobody looked", never "clean".
|
|
320
|
-
*
|
|
321
|
-
* Two prefix-free listings rather than `git status --porcelain`, deliberately.
|
|
322
|
-
* Porcelain prefixes every path with a two-character status field, and the shared
|
|
323
|
-
* git reader TRIMS its output — which eats the leading space of the first line
|
|
324
|
-
* only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
|
|
325
|
-
* These emit bare paths, so there is no offset to get wrong. `--others` also
|
|
326
|
-
* lists untracked files INDIVIDUALLY, where porcelain collapses them into their
|
|
327
|
-
* topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
|
|
328
|
-
* an ancestor attributable to no single spec, and so refusing the very tree this
|
|
329
|
-
* gate exists to accept. Both were found by running it, not by reading it.
|
|
330
|
-
*/
|
|
331
|
-
function dirtyPaths(git) {
|
|
332
|
-
const lists = [
|
|
333
|
-
git(['diff', '--name-only', 'HEAD']),
|
|
334
|
-
git(['ls-files', '--others', '--exclude-standard']),
|
|
335
|
-
]
|
|
336
|
-
if (lists.every((l) => l === null)) return null
|
|
337
|
-
const out = []
|
|
338
|
-
for (const list of lists) {
|
|
339
|
-
if (!list) continue
|
|
340
|
-
for (const line of list.split('\n')) {
|
|
341
|
-
const q = line.trim()
|
|
342
|
-
if (q) out.push(unquotePath(q))
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return out
|
|
346
|
-
}
|
|
347
414
|
|
|
348
415
|
// Is this spec new to git? Asked directly, so the commit subject does not depend
|
|
349
416
|
// on the shape of `git status` output.
|
|
@@ -1615,6 +1682,54 @@ function specEnvStage(dir, config, specArg, flags = {}, invokedFrom = dir) {
|
|
|
1615
1682
|
process.stdout.write(out.join('\n') + '\n')
|
|
1616
1683
|
}
|
|
1617
1684
|
|
|
1685
|
+
/**
|
|
1686
|
+
* The tree and the file set behind `review --docs`: this spec's own uncommitted
|
|
1687
|
+
* documents, in the checkout the caller is standing in.
|
|
1688
|
+
*
|
|
1689
|
+
* It is `stage`'s split, reused rather than re-derived — same tree resolution,
|
|
1690
|
+
* same `classifyDirtyTree`. THE `owned` HALF IS WHAT MAKES THIS SAFE TO RENDER:
|
|
1691
|
+
* a checkout is shared by every session standing in it, so the uncommitted tree
|
|
1692
|
+
* routinely holds spec documents this spec has no claim on. Rendering the tree
|
|
1693
|
+
* would put them on this page, and a committing verdict here would then commit
|
|
1694
|
+
* them under this spec's ticket.
|
|
1695
|
+
*
|
|
1696
|
+
* WHAT WOULD FOOL THIS: nothing, for a path outside the spec's folder that the
|
|
1697
|
+
* project declared a companion — those are owned by construction. What it
|
|
1698
|
+
* cannot see is a document belonging to this spec that is already committed:
|
|
1699
|
+
* `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
|
|
1700
|
+
* are all committed renders as nothing to review rather than as its own text.
|
|
1701
|
+
* That is the intended reading — there is no change to review — and it is why
|
|
1702
|
+
* the empty case says so instead of drawing an empty page.
|
|
1703
|
+
*
|
|
1704
|
+
* Three states, never two (`.claude/rules/negative-checks.md` rule 4). A git
|
|
1705
|
+
* that could not be read is `cannot tell`, and must not become an empty file
|
|
1706
|
+
* set: an empty set renders a page saying nothing changed, which is the one
|
|
1707
|
+
* reading that is certainly wrong.
|
|
1708
|
+
*
|
|
1709
|
+
* @returns {{tree: string, owned: string[]}|{error: string}}
|
|
1710
|
+
*/
|
|
1711
|
+
function resolveSpecDocs(config, spec, invokedFrom) {
|
|
1712
|
+
const git = gitReader(invokedFrom)
|
|
1713
|
+
const tree = git(['rev-parse', '--show-toplevel']) || invokedFrom
|
|
1714
|
+
// THE CLASSIFICATION IS SHARED with the review server's route for the same
|
|
1715
|
+
// spec (`specDocsIn`), so the served page and the written page cannot
|
|
1716
|
+
// disagree about which files they show. This function adds only the wording.
|
|
1717
|
+
const found = specDocsIn(tree, spec, config, gitReader(tree))
|
|
1718
|
+
if (found.error) {
|
|
1719
|
+
return {
|
|
1720
|
+
error:
|
|
1721
|
+
`git could not be read at ${tree}, so ${spec.folder}'s documents were not classified — ` +
|
|
1722
|
+
'nothing rendered. This is not "nothing to review".',
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (found.empty) {
|
|
1726
|
+
return {
|
|
1727
|
+
error: `${spec.folder} has no uncommitted documents in ${tree} — nothing to review.`,
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return found
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1618
1733
|
/**
|
|
1619
1734
|
* Write a self-contained HTML review of a spec's diff.
|
|
1620
1735
|
*
|
|
@@ -1633,6 +1748,14 @@ function verdictSaid(v) {
|
|
|
1633
1748
|
// thing that will happen to the repo and the reader should see it coming.
|
|
1634
1749
|
if (v.effective === 'commit') return `committing with ${v.commitWith}`
|
|
1635
1750
|
if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
|
|
1751
|
+
// EVERY COMMITTING VERDICT NEEDS A LINE HERE, and the fall-through below is
|
|
1752
|
+
// why: a verdict this function does not know reads as `discuss first`, so a
|
|
1753
|
+
// reader who pressed a green button would be told their review ended in a
|
|
1754
|
+
// conversation. Adding one to `COMMITTING` and not to this list is silent.
|
|
1755
|
+
if (v.effective === 'commit-start') return `committing with ${v.commitWith}, then putting it in flight`
|
|
1756
|
+
// Names what it does NOT do, because the reader of a mid-run page has just
|
|
1757
|
+
// pressed a green button and must not read it as a commit.
|
|
1758
|
+
if (v.effective === 'continue') return 'read — carrying on, nothing committed'
|
|
1636
1759
|
if (v.effective === 'changes') return 'changes requested'
|
|
1637
1760
|
return 'discuss first'
|
|
1638
1761
|
}
|
|
@@ -1657,6 +1780,97 @@ function gateTarget(dir, config, specArg) {
|
|
|
1657
1780
|
}
|
|
1658
1781
|
}
|
|
1659
1782
|
|
|
1783
|
+
/**
|
|
1784
|
+
* `review wait` — block until a verdict arrives, so nobody has to improvise it.
|
|
1785
|
+
*
|
|
1786
|
+
* THE COMMAND EXISTS BECAUSE THE WAIT DID NOT. The skills said "watch the
|
|
1787
|
+
* pending store and end your turn", so every run wrote its own watcher in
|
|
1788
|
+
* shell — and one of them wrote `[ "$x" \> "$y" ]`, which is valid bash and a
|
|
1789
|
+
* syntax error in zsh. It could never be true, spun for five minutes, and
|
|
1790
|
+
* looked exactly like patience the whole time.
|
|
1791
|
+
*
|
|
1792
|
+
* So this says it started. A caller can then tell a live wait from a dead one,
|
|
1793
|
+
* which is the one thing none of the improvised watchers could offer.
|
|
1794
|
+
*
|
|
1795
|
+
* It is a WAIT and not a claim: the pass is left in the holding area for
|
|
1796
|
+
* `--claim-since` to take, so the rule that a pass is never claimed without a
|
|
1797
|
+
* person asking (`/spec-diff` §0) is untouched by anything here.
|
|
1798
|
+
*/
|
|
1799
|
+
async function specEnvReviewWait(dir, config, specArg, flags) {
|
|
1800
|
+
let spec
|
|
1801
|
+
try {
|
|
1802
|
+
spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
process.stdout.write(`spec-env review wait: ${err.message}\n`)
|
|
1805
|
+
process.exitCode = 1
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// A WINDOW IS REQUIRED, and an absent one is not an open one. Without it
|
|
1810
|
+
// there is no way to tell this sitting's pass from a stranger's, and waiting
|
|
1811
|
+
// for "any pass at all" is how one gets swept up.
|
|
1812
|
+
if (!flags.since) {
|
|
1813
|
+
process.stdout.write(
|
|
1814
|
+
'spec-env review wait: --since <iso> is required — it is the window that decides which pass is yours\n',
|
|
1815
|
+
)
|
|
1816
|
+
process.exitCode = 1
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const out = reviewOutPath(dir, spec.folder, flags.out)
|
|
1821
|
+
const timeoutMs =
|
|
1822
|
+
flags.timeout === undefined || flags.timeout === null ? null : Number(flags.timeout) * 1000
|
|
1823
|
+
if (timeoutMs !== null && !Number.isFinite(timeoutMs)) {
|
|
1824
|
+
process.stdout.write(`spec-env review wait: --timeout ${flags.timeout} is not a number of seconds\n`)
|
|
1825
|
+
process.exitCode = 1
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// SAID BEFORE THE FIRST POLL, not after it. The whole point is that a caller
|
|
1830
|
+
// knows the wait is running; a line printed on the way out would arrive only
|
|
1831
|
+
// for the waits that already worked.
|
|
1832
|
+
if (!flags.json) {
|
|
1833
|
+
process.stdout.write(
|
|
1834
|
+
`spec-env review wait: ${spec.folder} — waiting for a verdict sent since ${flags.since}` +
|
|
1835
|
+
`${timeoutMs === null ? '' : ` (up to ${flags.timeout}s)`}\n`,
|
|
1836
|
+
)
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const result = await waitForPass(() => readPending(out, spec.folder), flags.since, { timeoutMs })
|
|
1840
|
+
|
|
1841
|
+
if (flags.json) {
|
|
1842
|
+
process.stdout.write(`${JSON.stringify({ spec: spec.folder, since: flags.since, ...result }, null, 2)}\n`)
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (result.state === 'arrived') {
|
|
1846
|
+
if (!flags.json) {
|
|
1847
|
+
process.stdout.write(
|
|
1848
|
+
` arrived: ${result.code}\n` +
|
|
1849
|
+
` claim it: skitterspec spec-env review ${spec.folder} --claim-since ${flags.since}\n`,
|
|
1850
|
+
)
|
|
1851
|
+
}
|
|
1852
|
+
return
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
process.exitCode = 1
|
|
1856
|
+
if (flags.json) return
|
|
1857
|
+
|
|
1858
|
+
if (result.state === 'unusable') {
|
|
1859
|
+
process.stdout.write(` --since ${flags.since} is not a timestamp — nothing to wait inside\n`)
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
if (result.state === 'ambiguous') {
|
|
1863
|
+
// NAMES THE COUNT, NEVER THE CODES — the same silence `--claim-since` keeps.
|
|
1864
|
+
// Two in one window is two sittings or two people, and the operator has the
|
|
1865
|
+
// codes; printing them here would hand a guesser the answer.
|
|
1866
|
+
process.stdout.write(
|
|
1867
|
+
` ${result.count} passes arrived in that window — claim one by its code rather than guessing\n`,
|
|
1868
|
+
)
|
|
1869
|
+
return
|
|
1870
|
+
}
|
|
1871
|
+
process.stdout.write(' timed out — no verdict arrived in the window\n')
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1660
1874
|
// `review arm` — a phase ended, and its diff is now owed a verdict.
|
|
1661
1875
|
function specEnvReviewArm(dir, config, specArg, flags) {
|
|
1662
1876
|
const target = gateTarget(dir, config, specArg)
|
|
@@ -1782,6 +1996,134 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
1782
1996
|
if (flags.check && judged.state === 'armed') process.exitCode = 1
|
|
1783
1997
|
}
|
|
1784
1998
|
|
|
1999
|
+
/**
|
|
2000
|
+
* `review allow <network|remote> [--off]` — permit or withdraw a review tier.
|
|
2001
|
+
*
|
|
2002
|
+
* THE FIRST THING IN THIS ENGINE TO WRITE `env.config.json`, and that is why it
|
|
2003
|
+
* is narrow: it reads, sets one key under `review`, and writes back with the
|
|
2004
|
+
* indent the file already uses. It never reorders, never adds a key nobody
|
|
2005
|
+
* asked for, and never touches another section.
|
|
2006
|
+
*
|
|
2007
|
+
* WHAT WOULD FOOL THIS: a config written with comments. `loadEnvConfig` parses
|
|
2008
|
+
* it with `JSON.parse`, so such a file already fails to load and there is no
|
|
2009
|
+
* comment-preserving case to protect — but a file indented with anything other
|
|
2010
|
+
* than two spaces IS reformatted, which is cosmetic and worth knowing before it
|
|
2011
|
+
* shows up in someone's diff.
|
|
2012
|
+
*
|
|
2013
|
+
* IT EDITS A COMMITTED FILE. Turning network reviews on for yourself turns them
|
|
2014
|
+
* on for everyone who pulls, so the output says so rather than leaving it to be
|
|
2015
|
+
* discovered by a colleague's render.
|
|
2016
|
+
*/
|
|
2017
|
+
function specEnvReviewAllow(dir, config, tier, flags) {
|
|
2018
|
+
const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
|
|
2019
|
+
const key = TIERS[String(tier || '').trim()]
|
|
2020
|
+
if (!key) {
|
|
2021
|
+
// Refused by name, never coerced to a default: silently permitting the
|
|
2022
|
+
// wrong tier is the one outcome worth more than a round trip.
|
|
2023
|
+
process.stdout.write(
|
|
2024
|
+
`spec-env review allow: ${JSON.stringify(tier || '')} is not a tier — ` +
|
|
2025
|
+
`one of ${Object.keys(TIERS).join(', ')}. Nothing changed.\n`,
|
|
2026
|
+
)
|
|
2027
|
+
process.exitCode = 1
|
|
2028
|
+
return
|
|
2029
|
+
}
|
|
2030
|
+
// THREE WAYS TO SAY WHAT THE TIER SHOULD BE, and only the third is new.
|
|
2031
|
+
// Bare `allow <tier>` turns it ON and `--off` turns it off, exactly as they
|
|
2032
|
+
// always have — every existing caller and test reads those.
|
|
2033
|
+
//
|
|
2034
|
+
// `--set` exists because a SLASH COMMAND can only make one static
|
|
2035
|
+
// substitution: `/spec-remote-review on` has to reach the engine as the word
|
|
2036
|
+
// the person typed, not as a flag the command file worked out. An EMPTY value
|
|
2037
|
+
// is the bare form of that command, and it toggles — which is the common case,
|
|
2038
|
+
// because a reader flipping a tier is looking at the line that says which way
|
|
2039
|
+
// it currently is.
|
|
2040
|
+
let on = !flags.off
|
|
2041
|
+
if (flags.set !== undefined) {
|
|
2042
|
+
const said = String(flags.set).trim().toLowerCase()
|
|
2043
|
+
if (said === '') {
|
|
2044
|
+
// Toggle. Read through the same precedence the report below uses, so
|
|
2045
|
+
// "turn it to the other thing" means the other thing the render showed.
|
|
2046
|
+
const current = Boolean(
|
|
2047
|
+
(() => {
|
|
2048
|
+
try {
|
|
2049
|
+
const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
|
|
2050
|
+
return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
|
|
2051
|
+
} catch {
|
|
2052
|
+
return config.review[key]
|
|
2053
|
+
}
|
|
2054
|
+
})(),
|
|
2055
|
+
)
|
|
2056
|
+
on = !current
|
|
2057
|
+
} else if (said === 'on' || said === 'off') {
|
|
2058
|
+
on = said === 'on'
|
|
2059
|
+
} else {
|
|
2060
|
+
// Refused by name, like an unknown tier. A misspelt state coerced to a
|
|
2061
|
+
// default is the one outcome worth more than a round trip — and the
|
|
2062
|
+
// wrong default here opens a port or permits a publish.
|
|
2063
|
+
process.stdout.write(
|
|
2064
|
+
`spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
|
|
2065
|
+
'one of on, off, or nothing at all to toggle. Nothing changed.\n',
|
|
2066
|
+
)
|
|
2067
|
+
process.exitCode = 1
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
const file = path.resolve(dir, 'specs/.core/env.config.json')
|
|
2072
|
+
let parsed
|
|
2073
|
+
try {
|
|
2074
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
// Cannot tell what is in there, so write nothing. Overwriting a config we
|
|
2077
|
+
// could not read is unrecoverable, and the alternative costs one message.
|
|
2078
|
+
//
|
|
2079
|
+
// NARROWER THAN IT LOOKS: `loadEnvConfig` already refuses the whole
|
|
2080
|
+
// `spec-env` command on an unparseable config, naming the file and the
|
|
2081
|
+
// parse position — so this branch is reachable only if the file changes
|
|
2082
|
+
// between that load and this write. It is kept for that race rather than
|
|
2083
|
+
// deleted as dead, and the upstream refusal is what the test asserts.
|
|
2084
|
+
process.stdout.write(
|
|
2085
|
+
`spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
|
|
2086
|
+
'nothing changed.\n',
|
|
2087
|
+
)
|
|
2088
|
+
process.exitCode = 1
|
|
2089
|
+
return
|
|
2090
|
+
}
|
|
2091
|
+
const before = Boolean(
|
|
2092
|
+
parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
|
|
2093
|
+
)
|
|
2094
|
+
parsed.review = { ...(parsed.review || {}), [key]: on }
|
|
2095
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
|
|
2096
|
+
|
|
2097
|
+
const lines = [
|
|
2098
|
+
`spec-env review allow: ${tier} reviews are now ${on ? 'ON' : 'OFF'}` +
|
|
2099
|
+
(before === on ? ' (unchanged)' : ''),
|
|
2100
|
+
// THE ABSOLUTE PATH, not one relative to `dir`. `dir` is re-anchored to the
|
|
2101
|
+
// primary checkout, so run from inside a worktree this writes a file in a
|
|
2102
|
+
// DIFFERENT TREE — and a relative path reads as the tree you are standing
|
|
2103
|
+
// in. Found by running it from a worktree and reverting the surprise.
|
|
2104
|
+
` wrote: ${file}`,
|
|
2105
|
+
' that is the primary checkout, whichever tree you ran this from, and the file is',
|
|
2106
|
+
' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
|
|
2107
|
+
]
|
|
2108
|
+
if (key === 'allowNetwork') {
|
|
2109
|
+
lines.push(
|
|
2110
|
+
on
|
|
2111
|
+
? ' the next render binds every interface, so the page opens on your phone.'
|
|
2112
|
+
: ' the next render binds 127.0.0.1 only, reachable from this machine.',
|
|
2113
|
+
)
|
|
2114
|
+
} else {
|
|
2115
|
+
lines.push(
|
|
2116
|
+
on
|
|
2117
|
+
? ' this PERMITS publishing; it publishes nothing. A published page cannot be\n' +
|
|
2118
|
+
' deleted by skitterspec, and a verdict there needs /spec-reviewed.\n' +
|
|
2119
|
+
' turn it back off with: /spec-remote-review'
|
|
2120
|
+
: ' publishing is no longer permitted; any page already published stays up.\n' +
|
|
2121
|
+
' turn it back on with: /spec-remote-review',
|
|
2122
|
+
)
|
|
2123
|
+
}
|
|
2124
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1785
2127
|
// `review skip` — move on without a verdict, on the record.
|
|
1786
2128
|
function specEnvReviewSkip(dir, config, reason, flags) {
|
|
1787
2129
|
const said = String(reason || '').trim()
|
|
@@ -1826,26 +2168,89 @@ function specEnvReviewSkip(dir, config, reason, flags) {
|
|
|
1826
2168
|
)
|
|
1827
2169
|
}
|
|
1828
2170
|
|
|
1829
|
-
async function specEnvReview(dir, config, specArg, flags) {
|
|
2171
|
+
async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
2172
|
+
// REFUSED BY NAME, never coerced to the default. A typo'd button set silently
|
|
2173
|
+
// rendering the committing page is the same failure the verdict validator
|
|
2174
|
+
// refuses for the same reason: a caller asking for the mid-run page and
|
|
2175
|
+
// getting the committing one would offer a reader a commit on unfinished
|
|
2176
|
+
// work, and nothing would have said so.
|
|
2177
|
+
if (flags.buttons !== null && !BUTTON_SETS.includes(flags.buttons)) {
|
|
2178
|
+
process.stdout.write(
|
|
2179
|
+
`spec-env review: --buttons ${JSON.stringify(flags.buttons)} is not one of ` +
|
|
2180
|
+
`${BUTTON_SETS.join(', ')} — nothing rendered.\n`,
|
|
2181
|
+
)
|
|
2182
|
+
return
|
|
2183
|
+
}
|
|
2184
|
+
const buttons = flags.buttons || DEFAULT_BUTTON_SET
|
|
2185
|
+
|
|
1830
2186
|
// An unknown name throws here rather than falling back to the branch: a review
|
|
1831
2187
|
// of the wrong spec looks exactly like a review of the right one.
|
|
1832
2188
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1833
2189
|
|
|
2190
|
+
// `--docs` reviews the spec's OWN DOCUMENTS from the tree in hand, and so
|
|
2191
|
+
// never consults a worktree — a spec in `backlog/` has none, which is exactly
|
|
2192
|
+
// the case with no page today.
|
|
2193
|
+
let docs = null
|
|
2194
|
+
if (flags.docs) {
|
|
2195
|
+
docs = resolveSpecDocs(config, spec, invokedFrom)
|
|
2196
|
+
if (docs.error) {
|
|
2197
|
+
process.stdout.write(`spec-env review: ${docs.error}\n`)
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1834
2202
|
// The worktree is what we read; without it there is nothing to say. This is an
|
|
1835
2203
|
// absence that means something — `git worktree list` is the same source that
|
|
1836
2204
|
// resolved the path — so it is safe to act on.
|
|
1837
|
-
|
|
2205
|
+
//
|
|
2206
|
+
// It names `--docs` because for a spec that has landed or has not yet started,
|
|
2207
|
+
// provisioning a worktree is not what the reader wanted: they wanted to read
|
|
2208
|
+
// the spec.
|
|
2209
|
+
// A LIVE SPEC IS THE EXCEPTION, and it is not a loosening of this absence.
|
|
2210
|
+
// `live take` moves the branch into the primary checkout, so the work is
|
|
2211
|
+
// somewhere readable even when the worktree has gone — refusing here would
|
|
2212
|
+
// refuse a spec whose diff is right there. Every other missing worktree still
|
|
2213
|
+
// refuses exactly as before.
|
|
2214
|
+
const liveHere = (() => {
|
|
2215
|
+
try {
|
|
2216
|
+
const st = assertPrimaryOnMain(config, gitReader(dir))
|
|
2217
|
+
return st.onBase === false && st.branch === spec.branch
|
|
2218
|
+
} catch {
|
|
2219
|
+
return false
|
|
2220
|
+
}
|
|
2221
|
+
})()
|
|
2222
|
+
if (!docs && !liveHere && !fs.existsSync(spec.worktreePath)) {
|
|
1838
2223
|
process.stdout.write(
|
|
1839
2224
|
`spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
|
|
1840
|
-
'run /spec-start to provision it.\n',
|
|
2225
|
+
'run /spec-start to provision it, or --docs to review the spec itself.\n',
|
|
1841
2226
|
)
|
|
1842
2227
|
return
|
|
1843
2228
|
}
|
|
1844
2229
|
|
|
1845
|
-
|
|
1846
|
-
|
|
2230
|
+
// WHERE THE BRANCH ACTUALLY IS. `live take` detaches the worktree and checks
|
|
2231
|
+
// the branch out in the primary checkout, so while a spec is live the tree
|
|
2232
|
+
// holding its work — including anything uncommitted, since a fix made while
|
|
2233
|
+
// live is made there — is the primary checkout. Reading the worktree then
|
|
2234
|
+
// reports a clean tree and shows the reader nothing.
|
|
2235
|
+
//
|
|
2236
|
+
// WHAT WOULD FOOL THIS: it trusts the branch the primary checkout is on, so a
|
|
2237
|
+
// branch checked out there by hand with no receipt still reads as live. That
|
|
2238
|
+
// is the right answer — the work IS there — and it is the same authority
|
|
2239
|
+
// `assertPrimaryOnMain` and `liveStateFor` use.
|
|
2240
|
+
const primaryState = assertPrimaryOnMain(config, gitReader(dir))
|
|
2241
|
+
const live = liveStateFor(
|
|
2242
|
+
spec,
|
|
2243
|
+
liveContext(dir, config, spec, {
|
|
2244
|
+
onBase: primaryState.onBase,
|
|
2245
|
+
primaryBranch: primaryState.branch,
|
|
2246
|
+
}),
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2249
|
+
const readFrom = docs ? docs.tree : live.state === 'on' ? dir : spec.worktreePath
|
|
2250
|
+
const git = rawGitReader(readFrom)
|
|
2251
|
+
const trimmed = gitReader(readFrom)
|
|
1847
2252
|
|
|
1848
|
-
let mode = 'working'
|
|
2253
|
+
let mode = docs ? 'docs' : 'working'
|
|
1849
2254
|
let ref = 'HEAD'
|
|
1850
2255
|
let base = null
|
|
1851
2256
|
|
|
@@ -1928,6 +2333,39 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
1928
2333
|
claimCode = window.codes[0]
|
|
1929
2334
|
}
|
|
1930
2335
|
|
|
2336
|
+
// ONE VERDICT PER INVOCATION, and this refuses rather than reconciles. The
|
|
2337
|
+
// three ways a verdict arrives can carry three different words, and there is
|
|
2338
|
+
// no honest rule for picking between them: acting on either would commit
|
|
2339
|
+
// somebody's work on the strength of a word they did not mean as the answer.
|
|
2340
|
+
// Refused BEFORE anything is looked up, so a rejected combination cannot also
|
|
2341
|
+
// spend a pending code or read a blob off disk.
|
|
2342
|
+
if (flags.verdict && (flags.claim || flags.claimSince || flags.notes)) {
|
|
2343
|
+
process.stdout.write(
|
|
2344
|
+
'spec-env review: --verdict carries one verdict and so does ' +
|
|
2345
|
+
`${flags.notes ? '--notes' : '--claim'} — send one, not both\n`,
|
|
2346
|
+
)
|
|
2347
|
+
return
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// A VERDICT WITH NOTHING ATTACHED — the word a `file://` page hands over when
|
|
2351
|
+
// the pass it would otherwise build carries nothing but the conclusion. It
|
|
2352
|
+
// joins the same merge below rather than forking: `judgeVerdict` still
|
|
2353
|
+
// refuses a commit over open comments, the log still records it, and the gate
|
|
2354
|
+
// still clears. What it cannot carry is marks, and it does not pretend to —
|
|
2355
|
+
// `merged` stays null, because nothing was.
|
|
2356
|
+
if (flags.verdict) {
|
|
2357
|
+
const word = String(flags.verdict).trim()
|
|
2358
|
+
if (!VERDICTS.includes(word)) {
|
|
2359
|
+
// The engine's own vocabulary, named in full. A word refused without the
|
|
2360
|
+
// list is a typo the reader has to go looking for.
|
|
2361
|
+
process.stdout.write(
|
|
2362
|
+
`spec-env review: verdict "${word}" is not one of ${VERDICTS.join(', ')}\n`,
|
|
2363
|
+
)
|
|
2364
|
+
return
|
|
2365
|
+
}
|
|
2366
|
+
sentVerdict = word
|
|
2367
|
+
}
|
|
2368
|
+
|
|
1931
2369
|
// A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
|
|
1932
2370
|
// pass out of the holding area and hands it to exactly the same merge a
|
|
1933
2371
|
// pasted blob goes through, so nothing downstream can tell — or behave
|
|
@@ -2090,7 +2528,16 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2090
2528
|
// The log is appended only for a verdict that was ACTED ON. A refused
|
|
2091
2529
|
// approval did not happen, and recording it as history would leave a
|
|
2092
2530
|
// trail of decisions the repo never took.
|
|
2093
|
-
|
|
2531
|
+
// THE CODE GOES IN THE LOG, and it is the only present thing that lets
|
|
2532
|
+
// the page conclude a pass was picked up. Without it `claimed` could only
|
|
2533
|
+
// be read off the code being gone from the holding area — which `--drop`,
|
|
2534
|
+
// a moved store and a mistyped folder all achieve without anyone claiming
|
|
2535
|
+
// anything. Null for a pasted blob, which carried no code to record.
|
|
2536
|
+
notes = appendDecision(notes, {
|
|
2537
|
+
verdict: judged.effective,
|
|
2538
|
+
at: new Date().toISOString(),
|
|
2539
|
+
code: claimed ? claimed.code : null,
|
|
2540
|
+
})
|
|
2094
2541
|
writeNotes(out, notes)
|
|
2095
2542
|
}
|
|
2096
2543
|
verdictReport = {
|
|
@@ -2166,7 +2613,23 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2166
2613
|
const gate = gateNow.corrupt ? null : gateNow.gate
|
|
2167
2614
|
|
|
2168
2615
|
const now = new Date().toISOString()
|
|
2169
|
-
|
|
2616
|
+
// The surfaces block, on a worktree view only: a `--docs` page belongs to a
|
|
2617
|
+
// spec with no branch to put live (decision 6), so it carries neither key and
|
|
2618
|
+
// renders exactly as it did before this existed.
|
|
2619
|
+
const surfaceArgs = docs ? {} : { live, tiers: pageTiers(config) }
|
|
2620
|
+
let data = collectReview({
|
|
2621
|
+
spec,
|
|
2622
|
+
git,
|
|
2623
|
+
mode,
|
|
2624
|
+
ref,
|
|
2625
|
+
base,
|
|
2626
|
+
now,
|
|
2627
|
+
notes,
|
|
2628
|
+
gate,
|
|
2629
|
+
buttons,
|
|
2630
|
+
...surfaceArgs,
|
|
2631
|
+
...(docs ? { only: docs.owned, treePath: docs.tree } : {}),
|
|
2632
|
+
})
|
|
2170
2633
|
|
|
2171
2634
|
// A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
|
|
2172
2635
|
// ends in: the page is rendered before the commit, the commit happens
|
|
@@ -2182,8 +2645,14 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2182
2645
|
// An explicit `--branch` is never re-interpreted, and a non-empty working tree
|
|
2183
2646
|
// is never swapped out from under the reader. The swap only ever replaces an
|
|
2184
2647
|
// empty view, so no information is lost by it.
|
|
2648
|
+
//
|
|
2649
|
+
// `--docs` NEVER FALLS BACK. Its file set is this spec's documents and the
|
|
2650
|
+
// branch range is every file on the branch, so the swap would replace "the
|
|
2651
|
+
// spec you asked for" with "everything this branch changed" — and on the base
|
|
2652
|
+
// branch, where a backlog spec is read, that range is the whole of main. The
|
|
2653
|
+
// empty case is already refused above, so there is nothing here to rescue.
|
|
2185
2654
|
let fellBack = false
|
|
2186
|
-
if (!flags.branch && data.totals.files === 0) {
|
|
2655
|
+
if (!docs && !flags.branch && data.totals.files === 0) {
|
|
2187
2656
|
const fallbackBase = reviewBase()
|
|
2188
2657
|
const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
|
|
2189
2658
|
// Cannot tell -> do nothing, exactly as the `--branch` path refuses. No
|
|
@@ -2199,6 +2668,8 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2199
2668
|
now,
|
|
2200
2669
|
notes,
|
|
2201
2670
|
gate,
|
|
2671
|
+
buttons,
|
|
2672
|
+
...surfaceArgs,
|
|
2202
2673
|
fellBack: true,
|
|
2203
2674
|
})
|
|
2204
2675
|
if (wider.totals.files > 0) {
|
|
@@ -2249,8 +2720,41 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2249
2720
|
// nothing at all: the ordinary render must read exactly as it did before any
|
|
2250
2721
|
// of this existed.
|
|
2251
2722
|
let serverSaid = null
|
|
2252
|
-
|
|
2253
|
-
|
|
2723
|
+
// EVERY RENDER SERVES, because a `file://` page has no server to POST to and
|
|
2724
|
+
// so its verdict buttons have nowhere to go. The reader is not consulted here
|
|
2725
|
+
// any more — that gate is what handed a local machine a page it could read
|
|
2726
|
+
// and not answer.
|
|
2727
|
+
//
|
|
2728
|
+
// THE BIND STILL COMES FROM THE READER, and that is what makes this free of
|
|
2729
|
+
// new exposure: a remote reader binds every interface exactly as before, and
|
|
2730
|
+
// a local or unknown one binds loopback — `http://127.0.0.1` instead of
|
|
2731
|
+
// `file://`, which opens on the machine holding the page and, unlike
|
|
2732
|
+
// `file://`, can POST. Cannot-tell binds loopback, the harmless direction
|
|
2733
|
+
// (`.claude/rules/negative-checks.md` rule 4).
|
|
2734
|
+
// WHY THERE IS NO SERVED URL, when there is none. The `file://` line used to
|
|
2735
|
+
// mean "nobody asked for serving"; now that every render asks, it can only
|
|
2736
|
+
// mean the ask did not land — so the render says which: the operator turned
|
|
2737
|
+
// it off, or it failed and here is the reason.
|
|
2738
|
+
let noServeBecause = config.review.serve === 'never' ? 'review.serve is "never"' : null
|
|
2739
|
+
if (config.review.serve === 'always') {
|
|
2740
|
+
// THE BIND COMES FROM THE SETTING, not from a guess about the reader. That
|
|
2741
|
+
// guess was the last decision detection had, and it was wrong every time the
|
|
2742
|
+
// reader moved — a phone off the LAN, a local session reading a page whose
|
|
2743
|
+
// buttons cannot POST. `allowNetwork` is the project saying which surfaces
|
|
2744
|
+
// it permits, and the reader picks from what is listed.
|
|
2745
|
+
const host = config.review.allowNetwork ? '0.0.0.0' : '127.0.0.1'
|
|
2746
|
+
const up = await ensureReviewServer(dir, config, { host })
|
|
2747
|
+
// TRANSLATED, because `error` is a code for a caller and this line is read
|
|
2748
|
+
// by a person: `busy` alone does not say which port, and the port is the
|
|
2749
|
+
// whole of what they can act on. An unmapped code is passed through rather
|
|
2750
|
+
// than swallowed — a reason nobody anticipated still beats silence.
|
|
2751
|
+
if (up.error === 'busy') {
|
|
2752
|
+
noServeBecause = `port ${up.port} is already in use`
|
|
2753
|
+
} else if (up.error === 'unreadable') {
|
|
2754
|
+
noServeBecause = `a server is running (pid ${up.pid}) whose settings could not be read`
|
|
2755
|
+
} else if (up.error) {
|
|
2756
|
+
noServeBecause = String(up.error)
|
|
2757
|
+
}
|
|
2254
2758
|
if (up.replaced === 'engine') {
|
|
2255
2759
|
serverSaid = up.error
|
|
2256
2760
|
? // BOTH facts. A reader told only "could not start" cannot see why it
|
|
@@ -2264,7 +2768,7 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2264
2768
|
// The URLs come from the bind the server HAS, not the one asked for just
|
|
2265
2769
|
// above — adoption can hand back a loopback server whatever was
|
|
2266
2770
|
// requested. See `reviewServedUrls`.
|
|
2267
|
-
const urls = reviewServedUrls(up,
|
|
2771
|
+
const urls = reviewServedUrls(up, offerableLanAddresses(), spec.folder)
|
|
2268
2772
|
if (urls) {
|
|
2269
2773
|
served = { ...urls, port: up.port, token: up.token, started: up.started }
|
|
2270
2774
|
}
|
|
@@ -2277,20 +2781,47 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2277
2781
|
{
|
|
2278
2782
|
spec: spec.folder,
|
|
2279
2783
|
branch: spec.branch,
|
|
2280
|
-
|
|
2784
|
+
// The tree the diff was read from — `spec.worktreePath` for every
|
|
2785
|
+
// mode but `docs`, where it would name a path that does not exist.
|
|
2786
|
+
worktree: readFrom,
|
|
2281
2787
|
mode,
|
|
2282
2788
|
base,
|
|
2283
2789
|
fellBack,
|
|
2790
|
+
// The paths a committing verdict on this page must commit, so the
|
|
2791
|
+
// skill that routes on it never recomputes the set the reader saw.
|
|
2792
|
+
// Absent for every other mode, which keeps their output identical.
|
|
2793
|
+
...(docs ? { docs: { paths: docs.owned } } : {}),
|
|
2284
2794
|
out,
|
|
2285
2795
|
publishCopy,
|
|
2286
2796
|
reader: reader.reader,
|
|
2287
2797
|
readerWhy: reader.why,
|
|
2288
2798
|
served,
|
|
2799
|
+
// The same stack the text prints, so a skill reads the tiers rather
|
|
2800
|
+
// than parsing prose — and the two can never disagree, because both
|
|
2801
|
+
// come from `reviewTierStack`.
|
|
2802
|
+
tiers: reviewTierStack({
|
|
2803
|
+
served,
|
|
2804
|
+
fileUrl: reviewFileUrl(out),
|
|
2805
|
+
publishedUrl: url,
|
|
2806
|
+
config,
|
|
2807
|
+
}),
|
|
2808
|
+
// The same answer `live status --json` gives, from the same function,
|
|
2809
|
+
// so the page and the command cannot disagree. Always present, unlike
|
|
2810
|
+
// the tiers' optional keys: `unavailable` is a state a consumer needs
|
|
2811
|
+
// to see rather than an absence it has to interpret.
|
|
2812
|
+
live,
|
|
2813
|
+
// Absent when there IS a served URL, so a consumer that only ever
|
|
2814
|
+
// saw a served render sees no new key.
|
|
2815
|
+
...(served ? {} : noServeBecause ? { notServed: noServeBecause } : {}),
|
|
2289
2816
|
...(serverSaid ? { server: serverSaid } : {}),
|
|
2290
2817
|
fileUrl: reviewFileUrl(out),
|
|
2291
2818
|
urlFile,
|
|
2292
2819
|
url,
|
|
2293
2820
|
notesFile: reviewNotesPath(out),
|
|
2821
|
+
// Absent stays absent, exactly as it is in the page payload: the
|
|
2822
|
+
// committing set is what a caller that did not ask always got, so
|
|
2823
|
+
// reporting it would make every existing consumer see a new key.
|
|
2824
|
+
...(data.buttons ? { buttons: data.buttons } : {}),
|
|
2294
2825
|
reviewed: Boolean(data.review),
|
|
2295
2826
|
totals: data.totals,
|
|
2296
2827
|
notes: data.notes,
|
|
@@ -2357,7 +2888,10 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2357
2888
|
(waiting.length
|
|
2358
2889
|
? ` pending: ${waiting.length} waiting\n` +
|
|
2359
2890
|
waiting
|
|
2360
|
-
.map(
|
|
2891
|
+
.map(
|
|
2892
|
+
(p) =>
|
|
2893
|
+
` ${p.code} · ${p.action || p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`,
|
|
2894
|
+
)
|
|
2361
2895
|
.join('')
|
|
2362
2896
|
: '') +
|
|
2363
2897
|
(outcomeSaid ? ` outcome: ${outcomeSaid}\n` : '') +
|
|
@@ -2373,39 +2907,45 @@ async function specEnvReview(dir, config, specArg, flags) {
|
|
|
2373
2907
|
? ''
|
|
2374
2908
|
: ` reader: ${reader.reader}${reader.why ? ` (${reader.why})` : ''}\n`) +
|
|
2375
2909
|
` page: ${out}\n` +
|
|
2376
|
-
//
|
|
2377
|
-
//
|
|
2378
|
-
//
|
|
2379
|
-
//
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2910
|
+
// THE STACK, in a fixed order, every tier named. The single `open:` line
|
|
2911
|
+
// it replaces asked the engine to pick which surface the reader could
|
|
2912
|
+
// use, and the engine cannot know — that guess failed three separate ways
|
|
2913
|
+
// in one day. Now every tier is listed, labelled, and either a URL or the
|
|
2914
|
+
// one command that turns it on.
|
|
2915
|
+
reviewTierStack({
|
|
2916
|
+
served,
|
|
2917
|
+
fileUrl: reviewFileUrl(out),
|
|
2918
|
+
publishedUrl: url,
|
|
2919
|
+
config,
|
|
2920
|
+
})
|
|
2921
|
+
.map((t) => reviewTierLine(t) + '\n')
|
|
2922
|
+
.join('') +
|
|
2923
|
+
// The runners-up sit UNDER the network tier, because that is what they are
|
|
2924
|
+
// alternatives to — and virtual adapters are gone from them entirely.
|
|
2925
|
+
(served && !served.loopback && (served.alternates || []).length
|
|
2926
|
+
? served.alternates.map((u) => ` also: ${u}\n`).join('')
|
|
2927
|
+
: '') +
|
|
2928
|
+
// WHICH TIERS THE WAIT COVERS, once, next to the stack. `local` and
|
|
2929
|
+
// `network` are two doors into one room — the page POSTs to
|
|
2930
|
+
// `location.pathname`, so both reach this server and this pending store.
|
|
2931
|
+
// `remote` writes to the artifact's own store, which nothing here can see.
|
|
2932
|
+
' the wait covers local + network; a remote verdict needs /spec-reviewed.\n' +
|
|
2933
|
+
// WHETHER IT IS ALSO RUNNING SOMEWHERE, which is the other way to judge a
|
|
2934
|
+
// change. `unavailable` prints nothing — see `liveStateLine`.
|
|
2935
|
+
(liveStateLine(live) ? `${liveStateLine(live)}\n` : '') +
|
|
2936
|
+
// The reason there is no served URL, when there is none. A `file://` link
|
|
2937
|
+
// with nothing said about it reads as the ordinary outcome, and it is not.
|
|
2938
|
+
(!served && noServeBecause ? ` not served: ${noServeBecause}\n` : '') +
|
|
2939
|
+
(served && served.loopback && config.review.allowNetwork
|
|
2940
|
+
? ` network permitted but the running server is loopback-bound — ${served.widen}\n`
|
|
2941
|
+
: '') +
|
|
2942
|
+
(served && served.started && !served.loopback
|
|
2943
|
+
? ' serving: every provisioned spec, to anyone with a URL on your network.\n' +
|
|
2944
|
+
' stop: skitterspec spec-env review serve --stop\n'
|
|
2945
|
+
: '') +
|
|
2946
|
+
// One line, and only when something was actually done on the reader's
|
|
2947
|
+
// behalf. An action nobody asked for is reported, not hidden.
|
|
2948
|
+
(serverSaid ? ` ${serverSaid}\n` : '') +
|
|
2409
2949
|
// Named on its own line so the skill never has to build the path itself.
|
|
2410
2950
|
(publishCopy ? ` publish: ${publishCopy}\n` : '') +
|
|
2411
2951
|
(url ? ` published: ${url}\n` : '') +
|
|
@@ -2481,6 +3021,61 @@ function stateDirLabel(config) {
|
|
|
2481
3021
|
return path.posix.dirname(config.registry) || '.spec-env'
|
|
2482
3022
|
}
|
|
2483
3023
|
|
|
3024
|
+
/**
|
|
3025
|
+
* The engine script's mtime in ms, or null when it cannot be read.
|
|
3026
|
+
*
|
|
3027
|
+
* WHY MTIME AND NOT A CONTENT HASH: the dist is assembled from several files,
|
|
3028
|
+
* so a hash needs a manifest to stay honest, and this answers the only question
|
|
3029
|
+
* asked — is the code on disk newer than the process serving it? WHY NOT THE
|
|
3030
|
+
* PROCESS START TIME: `ps -o lstart=` is platform-specific and needs parsing,
|
|
3031
|
+
* while this is a number we write ourselves into a file we already write.
|
|
3032
|
+
*
|
|
3033
|
+
* Null is cannot-tell and adopts. See `staleServer`.
|
|
3034
|
+
*/
|
|
3035
|
+
function scriptMtimeOf(scriptPath) {
|
|
3036
|
+
try {
|
|
3037
|
+
return fs.statSync(scriptPath).mtimeMs
|
|
3038
|
+
} catch {
|
|
3039
|
+
return null
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/**
|
|
3044
|
+
* This repo's serve token, minted once and then read.
|
|
3045
|
+
*
|
|
3046
|
+
* STORED, NOT DERIVED, and the asymmetry with the port is the point. The port
|
|
3047
|
+
* is `PORT_BASE + hash(realpath(repo)) % PORT_SPAN` because a port is not a
|
|
3048
|
+
* secret and a stable one keeps a handed-out link working. The token is the
|
|
3049
|
+
* only guard on a non-loopback bind, and a repo path is guessable by anyone on
|
|
3050
|
+
* the machine — so deriving it the same way would buy stability with the single
|
|
3051
|
+
* property it exists for. It keeps all 48 random bits; only its lifetime moved.
|
|
3052
|
+
*
|
|
3053
|
+
* WHAT WOULD FOOL THIS: a token file someone has emptied or truncated. A short
|
|
3054
|
+
* or non-hex value is treated as absent and replaced, because a malformed token
|
|
3055
|
+
* cannot guard anything — and the alternative, refusing to serve, would take
|
|
3056
|
+
* the page away over a file nobody reads.
|
|
3057
|
+
*/
|
|
3058
|
+
function repoToken(dir, config) {
|
|
3059
|
+
const file = path.resolve(dir, `${stateDirLabel(config)}/review-token`)
|
|
3060
|
+
try {
|
|
3061
|
+
const found = String(fs.readFileSync(file, 'utf8')).trim()
|
|
3062
|
+
if (/^[0-9a-f]{12}$/.test(found)) return found
|
|
3063
|
+
} catch {
|
|
3064
|
+
// Absent is the ordinary first-run state, not a problem.
|
|
3065
|
+
}
|
|
3066
|
+
const minted = mintToken()
|
|
3067
|
+
try {
|
|
3068
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
3069
|
+
fs.writeFileSync(file, minted + '\n')
|
|
3070
|
+
} catch {
|
|
3071
|
+
// UNWRITEABLE IS NOT FATAL. The server can still serve on this token for
|
|
3072
|
+
// as long as it lives; what is lost is only the survival across a restart,
|
|
3073
|
+
// which is exactly where this started. Taking the page away instead would
|
|
3074
|
+
// be a worse answer to a read-only `.spec-env`.
|
|
3075
|
+
}
|
|
3076
|
+
return minted
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2484
3079
|
// The supervised proxy process descriptor (paths relative to the checkout root).
|
|
2485
3080
|
function proxyProcFor(config, routesFileAbs) {
|
|
2486
3081
|
const sdir = stateDirLabel(config)
|
|
@@ -2615,6 +3210,27 @@ function lanAddresses(nets = require('node:os').networkInterfaces()) {
|
|
|
2615
3210
|
return rankLanAddresses(nets).map((e) => e.address)
|
|
2616
3211
|
}
|
|
2617
3212
|
|
|
3213
|
+
/**
|
|
3214
|
+
* The addresses worth OFFERING as alternatives — physical and unknown
|
|
3215
|
+
* interfaces, never virtual ones.
|
|
3216
|
+
*
|
|
3217
|
+
* The ranking already puts virtuals last, so the best guess was right; what was
|
|
3218
|
+
* wrong was listing them at all. A render offered `10.211.55.2` and
|
|
3219
|
+
* `10.37.129.2` — both Parallels adapters — as alternatives to a working link,
|
|
3220
|
+
* and no phone can route to either. An alternative that cannot work is worse
|
|
3221
|
+
* than no alternative: it reads as something to try when the first one fails.
|
|
3222
|
+
*
|
|
3223
|
+
* WHAT WOULD FOOL THIS: an interface named outside both patterns is `unknown`
|
|
3224
|
+
* and therefore KEPT, because a machine with unusual naming is more likely to
|
|
3225
|
+
* have a real address than a fake one — being wrong that way offers one dud,
|
|
3226
|
+
* where the opposite hides the only address that works.
|
|
3227
|
+
*/
|
|
3228
|
+
function offerableLanAddresses(nets = require('node:os').networkInterfaces()) {
|
|
3229
|
+
return rankLanAddresses(nets)
|
|
3230
|
+
.filter((e) => !VIRTUAL_IFACE.test(e.iface))
|
|
3231
|
+
.map((e) => e.address)
|
|
3232
|
+
}
|
|
3233
|
+
|
|
2618
3234
|
/**
|
|
2619
3235
|
* Bring the review server up, or adopt the one already running.
|
|
2620
3236
|
*
|
|
@@ -2655,11 +3271,16 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
|
|
|
2655
3271
|
if (settings && settings.port) {
|
|
2656
3272
|
if (serverScriptOk(settings)) {
|
|
2657
3273
|
const now = engineVersionFor(proc.script)
|
|
2658
|
-
const verdict = staleServer(settings.engine, now)
|
|
3274
|
+
const verdict = staleServer(settings.engine, now, settings.scriptMtime, scriptMtimeOf(proc.script))
|
|
2659
3275
|
if (verdict !== 'stale') {
|
|
2660
3276
|
const lb = settings.host === '127.0.0.1' || settings.host === 'localhost'
|
|
2661
3277
|
return {
|
|
2662
3278
|
port: settings.port,
|
|
3279
|
+
// How the RUNNING server's port was chosen, as it recorded it. A
|
|
3280
|
+
// server from before this was written has no answer; `null` says so
|
|
3281
|
+
// rather than recomputing one, because a recomputed answer could
|
|
3282
|
+
// disagree with the port actually being served.
|
|
3283
|
+
portSource: settings.portSource || null,
|
|
2663
3284
|
token: settings.token || null,
|
|
2664
3285
|
loopback: lb,
|
|
2665
3286
|
pid: running,
|
|
@@ -2697,13 +3318,29 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
|
|
|
2697
3318
|
}
|
|
2698
3319
|
}
|
|
2699
3320
|
|
|
2700
|
-
|
|
3321
|
+
// `--port` wins, then an explicit `review.servePort` number, then the
|
|
3322
|
+
// derivation. `source` is recorded in the settings file below so `--status`
|
|
3323
|
+
// can say WHY this port, rather than leaving the operator to read config.js.
|
|
3324
|
+
const resolved = resolveServePort(config, dir, port)
|
|
3325
|
+
const usePort = resolved.port
|
|
2701
3326
|
const loopback = host === '127.0.0.1' || host === 'localhost'
|
|
2702
3327
|
// The token is the ONLY guard on a non-loopback bind, so it is minted with the
|
|
2703
|
-
// bind rather than offered as an option to forget
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2706
|
-
|
|
3328
|
+
// bind rather than offered as an option to forget. It is read from the repo's
|
|
3329
|
+
// own store, so it OUTLIVES THIS PROCESS: the port beside it is a pure
|
|
3330
|
+
// function of the repo path and stable across a restart by design, and a URL
|
|
3331
|
+
// whose two halves disagree about that is a URL that dies for no reason the
|
|
3332
|
+
// reader can see. Six were handed out for one repo in a single session.
|
|
3333
|
+
//
|
|
3334
|
+
// `reuseToken` still wins where a server is being replaced on the same bind —
|
|
3335
|
+
// now redundant rather than wrong, and left alone as its own guarantee.
|
|
3336
|
+
//
|
|
3337
|
+
// EVERY BIND CARRIES IT, loopback included, and on loopback it guards nothing:
|
|
3338
|
+
// a server reachable only from this machine needs no credential. It is there
|
|
3339
|
+
// so the URL has ONE SHAPE. The bind comes from reader detection, and that
|
|
3340
|
+
// detection flipped `unknown` → `remote` inside a single session — so a
|
|
3341
|
+
// token-only-on-network rule meant the same repo's address gained and lost a
|
|
3342
|
+
// path segment underneath whoever was holding it.
|
|
3343
|
+
const token = reuseToken || repoToken(dir, config)
|
|
2707
3344
|
|
|
2708
3345
|
if (running) await stopProcess(proc, { rootDir: dir })
|
|
2709
3346
|
|
|
@@ -2728,7 +3365,8 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
|
|
|
2728
3365
|
// server refused to start on every Linux machine while macOS stayed green.
|
|
2729
3366
|
// `portsInUseOn` owns the ordering; see its comment for the verification.
|
|
2730
3367
|
const busy = await portsInUseOn(usePort, [host, '127.0.0.1'])
|
|
2731
|
-
if (busy.length)
|
|
3368
|
+
if (busy.length)
|
|
3369
|
+
return { error: 'busy', port: usePort, portSource: resolved.source, replaced, engineWas }
|
|
2732
3370
|
|
|
2733
3371
|
fs.mkdirSync(path.dirname(abs(settingsFile)), { recursive: true })
|
|
2734
3372
|
fs.writeFileSync(
|
|
@@ -2740,7 +3378,18 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
|
|
|
2740
3378
|
// — the process is alive, the file exists, and every page it renders is
|
|
2741
3379
|
// drawn by code that was replaced underneath it.
|
|
2742
3380
|
JSON.stringify(
|
|
2743
|
-
{
|
|
3381
|
+
{
|
|
3382
|
+
dir,
|
|
3383
|
+
port: usePort,
|
|
3384
|
+
portSource: resolved.source,
|
|
3385
|
+
host,
|
|
3386
|
+
token,
|
|
3387
|
+
script: proc.script,
|
|
3388
|
+
engine: engineVersionFor(proc.script),
|
|
3389
|
+
// Recorded so the NEXT adoption can tell a rebuild from a restart. A
|
|
3390
|
+
// version alone cannot: the dist is rebuilt at the same version all day.
|
|
3391
|
+
scriptMtime: scriptMtimeOf(proc.script),
|
|
3392
|
+
},
|
|
2744
3393
|
null,
|
|
2745
3394
|
2,
|
|
2746
3395
|
) + '\n',
|
|
@@ -2791,6 +3440,25 @@ async function specEnvReviewServe(dir, config, flags) {
|
|
|
2791
3440
|
const pid = readPid(abs(proc.pidFile))
|
|
2792
3441
|
const running = pid && isAlive(pid) ? pid : null
|
|
2793
3442
|
|
|
3443
|
+
// ROTATION IS THE ONLY WAY TO CHANGE A TOKEN, and it says what it costs.
|
|
3444
|
+
// Every other path reads the stored one; a silent mint is the whole bug this
|
|
3445
|
+
// replaced, so the one place that mints deliberately announces it.
|
|
3446
|
+
if (flags.rotateToken) {
|
|
3447
|
+
const file = abs(`${sdir}/review-token`)
|
|
3448
|
+
const minted = mintToken()
|
|
3449
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
3450
|
+
fs.writeFileSync(file, minted + '\n')
|
|
3451
|
+
process.stdout.write(
|
|
3452
|
+
'spec-env review serve: token rotated.\n' +
|
|
3453
|
+
' EVERY LINK ALREADY HANDED OUT IS NOW DEAD — re-render and send the new one.\n' +
|
|
3454
|
+
(running
|
|
3455
|
+
? ` the running server (pid ${running}) still answers on the old token; ` +
|
|
3456
|
+
'--stop it, then render again.\n'
|
|
3457
|
+
: ''),
|
|
3458
|
+
)
|
|
3459
|
+
return
|
|
3460
|
+
}
|
|
3461
|
+
|
|
2794
3462
|
if (flags.status) {
|
|
2795
3463
|
if (!running) {
|
|
2796
3464
|
process.stdout.write('spec-env review serve: not running.\n')
|
|
@@ -2804,8 +3472,16 @@ async function specEnvReviewServe(dir, config, flags) {
|
|
|
2804
3472
|
// ordinary answer and needs no comment; `unknown` is a server from before
|
|
2805
3473
|
// this was recorded, which is healthy and must not be accused of anything.
|
|
2806
3474
|
const verdict = staleServer(settings.engine, engineVersionFor(proc.script))
|
|
3475
|
+
// The port AND how it was chosen, so "why is this on 7742?" is answered by
|
|
3476
|
+
// the tool. A server started before `portSource` was recorded has no answer
|
|
3477
|
+
// — the line then carries the port alone rather than a guess, because an
|
|
3478
|
+
// absence is not evidence of any particular source.
|
|
3479
|
+
const reason = servePortReason(settings.portSource)
|
|
2807
3480
|
process.stdout.write(
|
|
2808
3481
|
`spec-env review serve: running (pid ${running})\n` +
|
|
3482
|
+
(settings.port
|
|
3483
|
+
? ` port: ${settings.port}${reason ? ` (${reason})` : ''}\n`
|
|
3484
|
+
: '') +
|
|
2809
3485
|
(settings.port ? ` local: ${serveUrl('127.0.0.1', settings)}\n` : '') +
|
|
2810
3486
|
(settings.engine ? ` engine: ${settings.engine}\n` : '') +
|
|
2811
3487
|
(verdict === 'stale'
|
|
@@ -2839,9 +3515,19 @@ async function specEnvReviewServe(dir, config, flags) {
|
|
|
2839
3515
|
})
|
|
2840
3516
|
|
|
2841
3517
|
if (started.error === 'busy') {
|
|
3518
|
+
// NAME `servePort`, not only `--port`. `--port` moves this run aside and
|
|
3519
|
+
// leaves every link already handed out pointing at the busy port — which is
|
|
3520
|
+
// the bug this refusal used to send people straight into. Pinning
|
|
3521
|
+
// `review.servePort` is the durable fix: it is what the next link is built
|
|
3522
|
+
// from, so the move happens once and the links follow it.
|
|
2842
3523
|
process.stdout.write(
|
|
2843
3524
|
`spec-env review serve: port ${started.port} is already in use — ` +
|
|
2844
|
-
|
|
3525
|
+
(started.portSource === 'derived'
|
|
3526
|
+
? 'two repos derived the same port. '
|
|
3527
|
+
: '') +
|
|
3528
|
+
'pin a free one in specs/.core/env.config.json ("review": { "servePort": <n> }) ' +
|
|
3529
|
+
'so the links follow, or --stop if this is an older server.\n' +
|
|
3530
|
+
' --port <n> moves this run only, and leaves existing links on the busy port.\n',
|
|
2845
3531
|
)
|
|
2846
3532
|
return
|
|
2847
3533
|
}
|
|
@@ -2888,8 +3574,13 @@ async function specEnvReviewServe(dir, config, flags) {
|
|
|
2888
3574
|
*/
|
|
2889
3575
|
function reviewServedUrls(up, addrs, folder) {
|
|
2890
3576
|
const page = (host) => `${serveUrl(host, up)}${encodeURIComponent(folder)}`
|
|
3577
|
+
// ALWAYS AVAILABLE WHEN SERVED. A server bound to 0.0.0.0 answers on loopback
|
|
3578
|
+
// too, so `local` is a real tier in both binds — it is not an alternative to
|
|
3579
|
+
// the network URL, it is the same page from the machine holding it.
|
|
3580
|
+
const loopbackUrl = page('127.0.0.1')
|
|
2891
3581
|
if (up.loopback) {
|
|
2892
3582
|
return {
|
|
3583
|
+
loopbackUrl,
|
|
2893
3584
|
url: page('127.0.0.1'),
|
|
2894
3585
|
// No runners-up: every other address on this machine is one the server
|
|
2895
3586
|
// is not listening on.
|
|
@@ -2902,8 +3593,14 @@ function reviewServedUrls(up, addrs, folder) {
|
|
|
2902
3593
|
// and can be wrong, so the alternates are offered rather than thrown away. No
|
|
2903
3594
|
// address at all means nothing to offer, and the `file://` fallback is the
|
|
2904
3595
|
// honest answer.
|
|
2905
|
-
if (!addrs.length) return null
|
|
2906
|
-
return {
|
|
3596
|
+
if (!addrs.length) return { loopbackUrl, url: null, alternates: [], loopback: false, widen: null }
|
|
3597
|
+
return {
|
|
3598
|
+
loopbackUrl,
|
|
3599
|
+
url: page(addrs[0]),
|
|
3600
|
+
alternates: addrs.slice(1).map(page),
|
|
3601
|
+
loopback: false,
|
|
3602
|
+
widen: null,
|
|
3603
|
+
}
|
|
2907
3604
|
}
|
|
2908
3605
|
|
|
2909
3606
|
/**
|
|
@@ -3057,7 +3754,7 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
|
|
|
3057
3754
|
// feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
|
|
3058
3755
|
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
3059
3756
|
// (release/abort land in a later phase).
|
|
3060
|
-
async function specEnvLive(dir, config, positional) {
|
|
3757
|
+
async function specEnvLive(dir, config, positional, flags) {
|
|
3061
3758
|
// Both verbs exist to route around the work living somewhere other than the
|
|
3062
3759
|
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
3063
3760
|
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
@@ -3076,7 +3773,7 @@ async function specEnvLive(dir, config, positional) {
|
|
|
3076
3773
|
// report cannot describe. It prints ABOVE the report, not instead of it:
|
|
3077
3774
|
// you asked a question and should still get the answer.
|
|
3078
3775
|
if (note) process.stdout.write(note)
|
|
3079
|
-
specEnvLiveStatus(dir, config, specArg)
|
|
3776
|
+
specEnvLiveStatus(dir, config, specArg, flags)
|
|
3080
3777
|
break
|
|
3081
3778
|
case 'take':
|
|
3082
3779
|
await specEnvLiveTake(dir, config, specArg)
|
|
@@ -3380,8 +4077,9 @@ async function specEnvLiveAbort(dir, config) {
|
|
|
3380
4077
|
)
|
|
3381
4078
|
}
|
|
3382
4079
|
|
|
3383
|
-
function specEnvLiveStatus(dir, config, specArg) {
|
|
4080
|
+
function specEnvLiveStatus(dir, config, specArg, flags) {
|
|
3384
4081
|
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
4082
|
+
const json = Boolean(flags && flags.json)
|
|
3385
4083
|
|
|
3386
4084
|
// Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
|
|
3387
4085
|
// skill branches on to decide whether to skip worktree provisioning and work in
|
|
@@ -3389,6 +4087,26 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
3389
4087
|
if (specArg) {
|
|
3390
4088
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
3391
4089
|
const live = !onBase && branch === spec.branch
|
|
4090
|
+
// `--json` answers with the SAME function the review render uses, so a page
|
|
4091
|
+
// and this command can never disagree about whether a spec is live. The
|
|
4092
|
+
// text form above is left exactly as it was: `/spec-start` reads its
|
|
4093
|
+
// `live: yes|no` line, and a flag must not move a seam.
|
|
4094
|
+
if (json) {
|
|
4095
|
+
process.stdout.write(
|
|
4096
|
+
JSON.stringify(
|
|
4097
|
+
{
|
|
4098
|
+
spec: spec.folder,
|
|
4099
|
+
branch: spec.branch,
|
|
4100
|
+
primary: branch || null,
|
|
4101
|
+
base: baseBranch,
|
|
4102
|
+
live: liveStateFor(spec, liveContext(dir, config, spec, { onBase, primaryBranch: branch })),
|
|
4103
|
+
},
|
|
4104
|
+
null,
|
|
4105
|
+
2,
|
|
4106
|
+
) + '\n',
|
|
4107
|
+
)
|
|
4108
|
+
return
|
|
4109
|
+
}
|
|
3392
4110
|
process.stdout.write(
|
|
3393
4111
|
`spec-env live status: ${spec.folder}\n` +
|
|
3394
4112
|
` spec: ${spec.folder} (branch ${spec.branch})\n` +
|
|
@@ -3419,6 +4137,26 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
3419
4137
|
? `${receipt.spec} (branch ${branch || '(detached)'})`
|
|
3420
4138
|
: `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
|
|
3421
4139
|
|
|
4140
|
+
if (json) {
|
|
4141
|
+
process.stdout.write(
|
|
4142
|
+
JSON.stringify(
|
|
4143
|
+
{
|
|
4144
|
+
primary: branch || null,
|
|
4145
|
+
base: baseBranch,
|
|
4146
|
+
onBase,
|
|
4147
|
+
// The three-state answer the text's `in-flight:` line carries, kept
|
|
4148
|
+
// as three states here too: a branch switched by hand leaves no
|
|
4149
|
+
// receipt, so the spec is `null` while the checkout is plainly busy.
|
|
4150
|
+
inFlight: onBase ? null : receipt && receipt.spec ? String(receipt.spec) : null,
|
|
4151
|
+
receipt: receipt || null,
|
|
4152
|
+
},
|
|
4153
|
+
null,
|
|
4154
|
+
2,
|
|
4155
|
+
) + '\n',
|
|
4156
|
+
)
|
|
4157
|
+
return
|
|
4158
|
+
}
|
|
4159
|
+
|
|
3422
4160
|
process.stdout.write(
|
|
3423
4161
|
'spec-env live:\n' +
|
|
3424
4162
|
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
@@ -3427,6 +4165,39 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
3427
4165
|
)
|
|
3428
4166
|
}
|
|
3429
4167
|
|
|
4168
|
+
/**
|
|
4169
|
+
* The `ctx` `liveStateFor` wants, probed from this repo.
|
|
4170
|
+
*
|
|
4171
|
+
* ONE BUILDER, so every caller passes the same shaped answer — the review
|
|
4172
|
+
* render and `live status --json` both come through here, which is what makes
|
|
4173
|
+
* "the page and the command agree" a property of the code rather than a habit.
|
|
4174
|
+
*
|
|
4175
|
+
* `onBase`/`primaryBranch` are the caller's, because it has usually already
|
|
4176
|
+
* asked `assertPrimaryOnMain` and asking twice can straddle a branch switch.
|
|
4177
|
+
*
|
|
4178
|
+
* An unreadable receipt is caught and dropped: it costs the holder's NAME and
|
|
4179
|
+
* never the state, which the branch answers (`liveStateFor`).
|
|
4180
|
+
*/
|
|
4181
|
+
function liveContext(dir, config, spec, { onBase, primaryBranch }) {
|
|
4182
|
+
let receipt = null
|
|
4183
|
+
try {
|
|
4184
|
+
receipt = readReceipt(dir, config)
|
|
4185
|
+
} catch {
|
|
4186
|
+
receipt = null
|
|
4187
|
+
}
|
|
4188
|
+
const front = (config.dev || []).map((d) => d.frontPort).filter((p) => typeof p === 'number')[0]
|
|
4189
|
+
return {
|
|
4190
|
+
isolated: true,
|
|
4191
|
+
onBase,
|
|
4192
|
+
primaryBranch,
|
|
4193
|
+
receipt,
|
|
4194
|
+
worktreeExists: Boolean(spec && spec.worktreePath && fs.existsSync(spec.worktreePath)),
|
|
4195
|
+
// Null where the project configured no canonical port — there is no URL to
|
|
4196
|
+
// name, and inventing one would send the reader to a closed port.
|
|
4197
|
+
url: front ? `http://${(config.proxy && config.proxy.host) || 'localhost'}:${front}` : null,
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
|
|
3430
4201
|
async function specEnv(rest) {
|
|
3431
4202
|
const [sub, ...args] = rest
|
|
3432
4203
|
let dir = process.cwd()
|
|
@@ -3444,6 +4215,7 @@ async function specEnv(rest) {
|
|
|
3444
4215
|
outcome: null,
|
|
3445
4216
|
claim: null,
|
|
3446
4217
|
drop: null,
|
|
4218
|
+
buttons: null,
|
|
3447
4219
|
json: false,
|
|
3448
4220
|
}
|
|
3449
4221
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -3454,36 +4226,49 @@ async function specEnv(rest) {
|
|
|
3454
4226
|
else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
|
|
3455
4227
|
else if (args[i] === '--branch') flags.branch = true
|
|
3456
4228
|
else if (args[i] === '--stop') flags.stop = true
|
|
4229
|
+
else if (args[i] === '--rotate-token') flags.rotateToken = true
|
|
4230
|
+
else if (args[i] === '--off') flags.off = true
|
|
3457
4231
|
else if (args[i] === '--status') flags.status = true
|
|
3458
4232
|
else if (args[i] === '--port') flags.port = args[++i]
|
|
3459
4233
|
else if (args[i] === '--host') flags.host = args[++i]
|
|
3460
4234
|
else if (args[i] === '--publish-copy') flags.publishCopy = true
|
|
4235
|
+
else if (args[i] === '--buttons') flags.buttons = args[++i]
|
|
3461
4236
|
else if (args[i] === '--out') flags.out = args[++i]
|
|
3462
4237
|
else if (args[i] === '--review') flags.review = args[++i]
|
|
3463
4238
|
else if (args[i] === '--notes') flags.notes = args[++i]
|
|
4239
|
+
else if (args[i] === '--verdict') flags.verdict = args[++i]
|
|
4240
|
+
// `--set` keeps an EMPTY STRING rather than coercing it away: empty is the
|
|
4241
|
+
// bare `/spec-remote-review`, and it means toggle. `??` so a missing value
|
|
4242
|
+
// at the end of argv is still the empty form rather than `undefined`, which
|
|
4243
|
+
// would read as the flag never having been passed.
|
|
4244
|
+
else if (args[i] === '--set') flags.set = args[++i] ?? ''
|
|
3464
4245
|
else if (args[i] === '--resolve') flags.resolve = args[++i]
|
|
3465
4246
|
else if (args[i] === '--outcome') flags.outcome = args[++i]
|
|
3466
4247
|
else if (args[i] === '--claim') flags.claim = args[++i]
|
|
3467
4248
|
else if (args[i] === '--claim-since') flags.claimSince = args[++i]
|
|
4249
|
+
else if (args[i] === '--since') flags.since = args[++i]
|
|
4250
|
+
else if (args[i] === '--timeout') flags.timeout = args[++i]
|
|
3468
4251
|
else if (args[i] === '--drop') flags.drop = args[++i]
|
|
3469
4252
|
else if (args[i] === '--json') flags.json = true
|
|
3470
4253
|
else if (args[i] === '--check') flags.check = true
|
|
3471
4254
|
else if (args[i] === '--for-command') flags.forCommand = args[++i]
|
|
3472
4255
|
else if (args[i] === '--phase') flags.phase = args[++i]
|
|
3473
4256
|
else if (args[i] === '--record-primary') flags.recordPrimary = true
|
|
4257
|
+
else if (args[i] === '--docs') flags.docs = true
|
|
3474
4258
|
else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
|
|
3475
4259
|
else positional.push(args[i])
|
|
3476
4260
|
}
|
|
3477
4261
|
dir = path.resolve(dir)
|
|
3478
|
-
// Where the caller actually is, kept before the re-anchor below.
|
|
3479
|
-
//
|
|
3480
|
-
// about the tree in front of you, and the two differ inside a
|
|
4262
|
+
// Where the caller actually is, kept before the re-anchor below. `stage` and
|
|
4263
|
+
// `review --docs` want it: every other subcommand asks about the repo, while
|
|
4264
|
+
// those two ask about the tree in front of you, and the two differ inside a
|
|
4265
|
+
// worktree.
|
|
3481
4266
|
const invokedFrom = dir
|
|
3482
4267
|
// Anchor on the primary checkout so every subcommand resolves {repo}, worktree
|
|
3483
4268
|
// paths, and the registry identically whether run from main or a worktree.
|
|
3484
4269
|
dir = resolvePrimaryCheckout(dir, gitReader(dir))
|
|
3485
4270
|
|
|
3486
|
-
const { config, present } = loadEnvConfig(dir)
|
|
4271
|
+
const { config, present, unknown } = loadEnvConfig(dir)
|
|
3487
4272
|
if (!present) {
|
|
3488
4273
|
process.stdout.write(
|
|
3489
4274
|
'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
|
|
@@ -3492,6 +4277,24 @@ async function specEnv(rest) {
|
|
|
3492
4277
|
return
|
|
3493
4278
|
}
|
|
3494
4279
|
|
|
4280
|
+
// A key the loader does not read is dropped — it always was, and still is.
|
|
4281
|
+
// What changed is that it is no longer dropped in SILENCE: a mis-typed
|
|
4282
|
+
// `review.required` leaves the gate on and a mis-typed
|
|
4283
|
+
// `teardown.deleteRemoteBranch` reverts to prompt, and the only signal either
|
|
4284
|
+
// gave was that nothing happened.
|
|
4285
|
+
//
|
|
4286
|
+
// Every spec-env subcommand passes through here, which is why it sits at this
|
|
4287
|
+
// one point rather than in a reporting verb someone might never run. It is
|
|
4288
|
+
// ADVISORY: it writes lines and changes no exit status, because a forward-compat
|
|
4289
|
+
// key and a typo are indistinguishable from here and only one of them is a
|
|
4290
|
+
// mistake (.claude/rules/negative-checks.md rule 4).
|
|
4291
|
+
//
|
|
4292
|
+
// STDERR, deliberately: `--json` subcommands write their payload to stdout, and
|
|
4293
|
+
// an advisory line on stdout would make it unparseable.
|
|
4294
|
+
for (const key of unknown || []) {
|
|
4295
|
+
process.stderr.write(`spec-env: ${ENV_CONFIG_LABEL} — unknown key "${key}" is ignored.\n`)
|
|
4296
|
+
}
|
|
4297
|
+
|
|
3495
4298
|
switch (sub) {
|
|
3496
4299
|
case 'up':
|
|
3497
4300
|
specEnvUp(dir, config, positional[0])
|
|
@@ -3538,10 +4341,22 @@ async function specEnv(rest) {
|
|
|
3538
4341
|
specEnvReviewArm(dir, config, positional[1], flags)
|
|
3539
4342
|
break
|
|
3540
4343
|
}
|
|
4344
|
+
if (positional[0] === 'waiting') {
|
|
4345
|
+
specEnvReviewWaiting(dir, config, flags)
|
|
4346
|
+
break
|
|
4347
|
+
}
|
|
4348
|
+
if (positional[0] === 'wait') {
|
|
4349
|
+
await specEnvReviewWait(dir, config, positional[1], flags)
|
|
4350
|
+
break
|
|
4351
|
+
}
|
|
3541
4352
|
if (positional[0] === 'gate') {
|
|
3542
4353
|
specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
|
|
3543
4354
|
break
|
|
3544
4355
|
}
|
|
4356
|
+
if (positional[0] === 'allow') {
|
|
4357
|
+
specEnvReviewAllow(dir, config, positional[1], flags)
|
|
4358
|
+
break
|
|
4359
|
+
}
|
|
3545
4360
|
if (positional[0] === 'skip') {
|
|
3546
4361
|
// The one positional is the REASON, not a spec: the two are
|
|
3547
4362
|
// indistinguishable as free text, and the spec is the one thing this
|
|
@@ -3549,20 +4364,26 @@ async function specEnv(rest) {
|
|
|
3549
4364
|
specEnvReviewSkip(dir, config, positional[1], flags)
|
|
3550
4365
|
break
|
|
3551
4366
|
}
|
|
3552
|
-
await specEnvReview(dir, config, positional[0], flags)
|
|
4367
|
+
await specEnvReview(dir, config, positional[0], flags, invokedFrom)
|
|
3553
4368
|
break
|
|
3554
4369
|
case 'live':
|
|
3555
|
-
await specEnvLive(dir, config, positional)
|
|
4370
|
+
await specEnvLive(dir, config, positional, flags)
|
|
3556
4371
|
break
|
|
3557
4372
|
default:
|
|
3558
4373
|
process.stdout.write(
|
|
3559
|
-
|
|
4374
|
+
`Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
|
|
3560
4375
|
' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
|
|
4376
|
+
' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
|
|
4377
|
+
' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
|
|
3561
4378
|
' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
|
|
3562
4379
|
' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
|
|
3563
4380
|
' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
|
|
3564
4381
|
' review skip "<reason>" move on without one, on the record\n' +
|
|
4382
|
+
' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
|
|
4383
|
+
' review waiting [--json] every pass waiting, across every spec\n' +
|
|
3565
4384
|
' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
|
|
4385
|
+
' review [spec] --verdict <word> send just a verdict, with nothing marked\n' +
|
|
4386
|
+
' review [spec] --buttons midrun the page offers Continue, not a commit\n' +
|
|
3566
4387
|
' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
|
|
3567
4388
|
' in is used, else the sole provisioned spec (several -> it lists them).\n' +
|
|
3568
4389
|
' A bare `live` takes that spec when the workbench is free, and prints the\n' +
|
|
@@ -3677,11 +4498,14 @@ async function run(argv) {
|
|
|
3677
4498
|
}
|
|
3678
4499
|
|
|
3679
4500
|
module.exports = {
|
|
4501
|
+
SPEC_ENV_VERBS,
|
|
3680
4502
|
run,
|
|
3681
4503
|
parse,
|
|
3682
4504
|
HELP,
|
|
3683
4505
|
unknownCommand,
|
|
3684
4506
|
rankLanAddresses,
|
|
4507
|
+
offerableLanAddresses,
|
|
4508
|
+
reviewTierStack,
|
|
3685
4509
|
serveProcFor,
|
|
3686
4510
|
serverScriptOk,
|
|
3687
4511
|
daemonScript,
|