spexcode 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -6,39 +6,61 @@ export type ForgeDelta =
6
6
  | { kind: 'pr'; pr: ForgePR }
7
7
  | { kind: 'remove'; target: 'issue' | 'pr'; number: number }
8
8
 
9
+ function sameMap<T>(a: Map<number, T>, b: Map<number, T>): boolean {
10
+ if (a.size !== b.size) return false
11
+ for (const [number, value] of a)
12
+ if (!b.has(number) || JSON.stringify(value) !== JSON.stringify(b.get(number))) return false
13
+ return true
14
+ }
15
+
9
16
  export class ForgeCache {
10
17
  private issues = new Map<number, ForgeIssue>()
11
18
  private prs = new Map<number, ForgePR>()
19
+ private revision = 0
12
20
 
13
21
  apply(delta: ForgeDelta): void {
14
- if (delta.kind === 'issue') this.issues.set(delta.issue.number, delta.issue)
15
- else if (delta.kind === 'pr') this.prs.set(delta.pr.number, delta.pr)
16
- else (delta.target === 'issue' ? this.issues : this.prs).delete(delta.number)
22
+ if (delta.kind === 'issue') this.set(this.issues, delta.issue)
23
+ else if (delta.kind === 'pr') this.set(this.prs, delta.pr)
24
+ else {
25
+ const target = delta.target === 'issue' ? this.issues : this.prs
26
+ if (target.delete(delta.number)) this.revision++
27
+ }
17
28
  }
18
29
 
19
30
  async reconcile(driver: ForgeDriver): Promise<void> {
20
31
  const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
21
- this.issues = new Map(issues.map((i) => [i.number, i]))
22
- this.prs = new Map(prs.map((p) => [p.number, p]))
32
+ const nextIssues = new Map(issues.map((i) => [i.number, i]))
33
+ const nextPRs = new Map(prs.map((p) => [p.number, p]))
34
+ if (!sameMap(this.issues, nextIssues) || !sameMap(this.prs, nextPRs)) this.revision++
35
+ this.issues = nextIssues
36
+ this.prs = nextPRs
23
37
  }
24
38
 
25
- // the INCREMENTAL halves reconcile() is made of: merge an updated-since issue window over the map
26
- // (an issue never leaves — a closed one just updates in place), and replace the PR set (the open-PR
27
- // list is small and self-truncating, so full replacement IS its delta).
28
39
  applyIssues(issues: ForgeIssue[]): void {
29
- for (const i of issues) this.issues.set(i.number, i)
40
+ for (const i of issues) this.set(this.issues, i)
30
41
  }
31
42
  setPRs(prs: ForgePR[]): void {
32
- this.prs = new Map(prs.map((p) => [p.number, p]))
43
+ const next = new Map(prs.map((p) => [p.number, p]))
44
+ if (!sameMap(this.prs, next)) this.revision++
45
+ this.prs = next
33
46
  }
34
47
 
35
48
  view(nodeIds: string[]): NodeLinks[] {
36
49
  return resolveLinks([...this.issues.values()], [...this.prs.values()], nodeIds)
37
50
  }
38
51
 
39
- // the raw cached set — for the one consumer (the unified Issue port, spec-cli issues.ts) that needs
40
- // EVERY cached issue, linked or not. Resolution stays the only derived view (view() above).
41
52
  state(): { issues: ForgeIssue[]; prs: ForgePR[] } {
42
53
  return { issues: [...this.issues.values()], prs: [...this.prs.values()] }
43
54
  }
55
+
56
+ stateRevision(): number {
57
+ return this.revision
58
+ }
59
+
60
+ private set<T extends ForgeIssue | ForgePR>(target: Map<number, T>, value: T): void {
61
+ if (JSON.stringify(target.get(value.number)) !== JSON.stringify(value)) {
62
+ target.set(value.number, value)
63
+ this.revision++
64
+ }
65
+ }
44
66
  }
@@ -4,7 +4,6 @@ import { FORGE_DRIVERS, forgeDriverFor, resolveForgeHost } from './drivers.js'
4
4
  import { resolveLinks, type NodeLinks } from './links.js'
5
5
  import { resolveEvalPending, type NodeEvalPending } from './needs-eval.js'
6
6
 
7
- // tiny flag reader over this command's own arg slice (everything after `forge`), so cli.ts stays routing-only.
8
7
  function flag(args: string[], name: string): string | undefined {
9
8
  const i = args.indexOf(`--${name}`)
10
9
  return i >= 0 ? args[i + 1] : undefined
@@ -14,8 +13,6 @@ const has = (args: string[], name: string) => args.includes(`--${name}`)
14
13
  async function readForge(
15
14
  args: string[],
16
15
  ): Promise<{ driver: ForgeDriver; nodeIds: string[]; issues: ForgeIssue[]; prs: ForgePR[] } | null> {
17
- // the forge is a VALUE, never a command ([[cli-surface]]): `--store <host>` names it, mirroring the
18
- // store axis every other issue verb uses.
19
16
  const host = flag(args, 'store') ?? resolveForgeHost()
20
17
  const driver = forgeDriverFor(host)
21
18
  if (!driver) {
@@ -97,9 +94,6 @@ async function evalPending(args: string[]): Promise<number> {
97
94
  return 0
98
95
  }
99
96
 
100
- // `spex issue links [--pending]` ([[cli-surface]]): the read-only forge→spec trace, folded into the issue
101
- // drawer (the forge drawer is dissolved — a forge is a value, `--store`). Bare = every linked node's open
102
- // issues/PRs; --pending narrows to the threads still awaiting an eval reading (the old eval-pending view).
103
97
  export async function runIssueLinks(args: string[]): Promise<number> {
104
98
  return has(args, 'pending') ? evalPending(args) : links(args)
105
99
  }
@@ -17,7 +17,6 @@ export const githubDriver: ForgeDriver = {
17
17
  async listIssues(): Promise<ForgeIssue[]> {
18
18
  const list = (state: string) =>
19
19
  gh<{ number: number; title: string; body: string; url: string; state: string; labels: { name: string }[]; author: { login: string } | null; createdAt: string; comments: { author: { login: string } | null; body: string; createdAt: string }[] }[]>(
20
- // comments ride the same list read (no per-issue fetch) — heavier GraphQL points, covered by the resident cache's TTL
21
20
  ['issue', 'list', '--state', state, '--limit', '200', '--json', 'number,title,body,url,state,labels,author,createdAt,comments'],
22
21
  )
23
22
  const [open, closed] = await Promise.all([list('open'), list('closed')])
@@ -26,7 +25,7 @@ export const githubDriver: ForgeDriver = {
26
25
  title: r.title,
27
26
  body: r.body ?? '',
28
27
  url: r.url,
29
- state: (r.state || '').toLowerCase(), // one canonical casing at the adapter (gh GraphQL says OPEN, REST says open)
28
+ state: (r.state || '').toLowerCase(),
30
29
  labels: (r.labels ?? []).map((l) => l.name),
31
30
  author: r.author?.login ?? '',
32
31
  createdAt: r.createdAt ?? '',
@@ -34,9 +33,6 @@ export const githubDriver: ForgeDriver = {
34
33
  }))
35
34
  },
36
35
 
37
- // the INCREMENTAL window: only issues updated since `sinceISO` (GitHub REST honors `since` = updated-at),
38
- // paged manually (100/page, stop on a short page — an incremental window is normally one page). PRs ride
39
- // the same REST endpoint flagged with `pull_request` and are filtered out — the PR list keeps its own path.
40
36
  async listIssuesSince(sinceISO: string): Promise<ForgeIssue[]> {
41
37
  type ApiRow = {
42
38
  number: number; title: string; body: string | null; html_url: string; state: string
@@ -49,8 +45,6 @@ export const githubDriver: ForgeDriver = {
49
45
  out.push(...rows)
50
46
  if (rows.length < 100) break
51
47
  }
52
- // REST's since-window carries only a comment COUNT — fetch each commented issue's thread alongside
53
- // (the window is normally a handful of issues, so this stays a handful of calls, not a re-list).
54
48
  return Promise.all(out.filter((r) => !r.pull_request).map(async (r) => ({
55
49
  number: r.number,
56
50
  title: r.title,
@@ -89,8 +83,6 @@ export const githubDriver: ForgeDriver = {
89
83
  }))
90
84
  },
91
85
 
92
- // the port's first write verb (promotion — see port.ts). `gh issue create` prints the new issue's URL on
93
- // stdout; the number is its last path segment. Fails loud like every other driver call.
94
86
  async createIssue({ title, body }: { title: string; body: string }): Promise<{ number: number; url: string }> {
95
87
  const { stdout } = await run('gh', ['issue', 'create', '--title', title, '--body', body], { maxBuffer: 1024 * 1024 })
96
88
  const url = stdout.trim().split('\n').pop() ?? ''
@@ -99,8 +91,6 @@ export const githubDriver: ForgeDriver = {
99
91
  return { number, url }
100
92
  },
101
93
 
102
- // the second write verb (a store-routed reply — see port.ts). `gh issue comment` prints the new
103
- // comment's permalink on stdout. Fails loud like every other driver call.
104
94
  async createComment({ number, body }: { number: number; body: string }): Promise<{ url: string }> {
105
95
  const { stdout } = await run('gh', ['issue', 'comment', String(number), '--body', body], { maxBuffer: 1024 * 1024 })
106
96
  const url = stdout.trim().split('\n').pop() ?? ''
@@ -118,7 +108,6 @@ export const githubDriver: ForgeDriver = {
118
108
  },
119
109
  }
120
110
 
121
- // one commented issue's thread, REST (the incremental window's companion read).
122
111
  async function listComments(number: number): Promise<ForgeComment[]> {
123
112
  const rows = await gh<{ user: { login: string } | null; body: string | null; created_at: string }[]>(
124
113
  ['api', `repos/{owner}/{repo}/issues/${number}/comments?per_page=100`],
@@ -1,11 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import type { ForgeComment, ForgeDriver, ForgeIssue, ForgePR } from '../port.js'
3
3
 
4
- // @@@gitlab context — resolved once, lazily, from the repo the command runs in.
5
- // base + project come from `git remote get-url origin` (https://host/group/proj.git and
6
- // git@host:group/proj.git forms both collapse to { base: https://host, project: group/proj });
7
- // the token is never a config value: GITLAB_TOKEN env first, else git's own credential store
8
- // (`git credential fill` — the same place a push to that host already authenticates from).
9
4
  type Ctx = { base: string; host: string; project: string; token: string }
10
5
  let ctx: Ctx | null = null
11
6
 
@@ -26,17 +21,13 @@ function gitlabCtx(): Ctx {
26
21
  }
27
22
 
28
23
  export function parseRemote(remote: string): { base: string; host: string; project: string } | null {
29
- // https://host[:port]/group/sub/project(.git)
30
24
  let m = remote.match(/^(https?:\/\/[^/]+)\/(.+?)(?:\.git)?\/?$/)
31
25
  if (m) return { base: m[1], host: m[1].replace(/^https?:\/\//, ''), project: m[2] }
32
- // git@host:group/project(.git) | ssh://git@host[:port]/group/project(.git)
33
26
  m = remote.match(/^(?:ssh:\/\/)?[\w.-]+@([\w.-]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/)
34
27
  if (m) return { base: `https://${m[1]}`, host: m[1], project: m[2] }
35
28
  return null
36
29
  }
37
30
 
38
- // ask git's credential store for the host's password (the PAT) — read-only: `fill`, never
39
- // `approve`/`reject`. Prompting is disabled so an unconfigured host fails fast instead of hanging.
40
31
  function credentialToken(host: string): string {
41
32
  try {
42
33
  const out = execFileSync('git', ['credential', 'fill'], {
@@ -63,7 +54,7 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
63
54
  return (await res.json()) as T
64
55
  }
65
56
 
66
- // full pagination for list reads stop on a short page (100/page, 20-page ceiling like github's window)
57
+ // GitLab's API has no bounded list query; stop at a short page and cap the walk.
67
58
  async function paged<T>(path: string): Promise<T[]> {
68
59
  const sep = path.includes('?') ? '&' : '?'
69
60
  const out: T[] = []
@@ -80,16 +71,13 @@ type ApiIssue = {
80
71
  labels: string[]; author: { username: string } | null; created_at: string; user_notes_count: number
81
72
  }
82
73
 
83
- // GitLab's list read cannot embed the thread (no `comments` field), so each COMMENTED issue's
84
- // notes are fetched alongside — user_notes_count already excludes system notes, so uncommented
85
- // issues (the common case) cost nothing extra; the resident cache's TTL covers the rest.
86
74
  async function toIssue(r: ApiIssue): Promise<ForgeIssue> {
87
75
  return {
88
- number: r.iid, // per-project iid, the number a human sees on the host — never the global id
76
+ number: r.iid,
89
77
  title: r.title,
90
78
  body: r.description ?? '',
91
79
  url: r.web_url,
92
- state: normalizeState(r.state), // GitLab says `opened`; the port's canonical open-state is `open`
80
+ state: normalizeState(r.state),
93
81
  labels: r.labels ?? [],
94
82
  author: r.author?.username ?? '',
95
83
  createdAt: r.created_at ?? '',
@@ -106,7 +94,7 @@ async function listNotes(iid: number): Promise<ForgeComment[]> {
106
94
  type Note = { system: boolean; author: { username: string } | null; created_at: string; body: string | null }
107
95
  const rows = await paged<Note>(`issues/${iid}/notes`)
108
96
  return rows
109
- .filter((n) => !n.system) // system notes are lifecycle noise (label/assign/close events), not discussion
97
+ .filter((n) => !n.system)
110
98
  .map((n) => ({ author: n.author?.username ?? '', createdAt: n.created_at ?? '', body: n.body ?? '' }))
111
99
  }
112
100
 
@@ -118,8 +106,6 @@ export const gitlabDriver: ForgeDriver = {
118
106
  return Promise.all(rows.map(toIssue))
119
107
  },
120
108
 
121
- // the INCREMENTAL window — GitLab's `updated_after` is the direct analog of GitHub's `since`
122
- // (updated-at ≥ the moment), and its issues endpoint never mixes MRs in, so no filtering needed.
123
109
  async listIssuesSince(sinceISO: string): Promise<ForgeIssue[]> {
124
110
  const rows = await paged<ApiIssue>(`issues?state=all&updated_after=${encodeURIComponent(sinceISO)}`)
125
111
  return Promise.all(rows.map(toIssue))
@@ -128,7 +114,6 @@ export const gitlabDriver: ForgeDriver = {
128
114
  async listPRs(): Promise<ForgePR[]> {
129
115
  type ApiMR = { iid: number; title: string; web_url: string; state: string; source_branch: string }
130
116
  const rows = await paged<ApiMR>('merge_requests?state=opened')
131
- // GitLab resolves "Closes #N" server-side — one small read per OPEN MR, no description parsing
132
117
  return Promise.all(
133
118
  rows.map(async (r) => ({
134
119
  number: r.iid,
@@ -12,22 +12,11 @@ export function forgeDriverFor(host: string): ForgeDriver | undefined {
12
12
  return FORGE_DRIVERS.find((d) => d.host === host)
13
13
  }
14
14
 
15
- // the issue stores a THIS repo's forge offers — scoped to the RESOLVED host, not every registered
16
- // driver, so a gitlab repo never gets a 'github' store in its New-issue dropdown. A resolved host
17
- // whose driver isn't registered yet contributes no store (the local store always remains).
18
15
  export function forgeIssueStores(): { id: string; label: string; kind: 'forge' }[] {
19
16
  const driver = forgeDriverFor(resolveForgeHost())
20
17
  return driver ? [{ id: driver.host, label: driver.host, kind: 'forge' }] : []
21
18
  }
22
19
 
23
- // ── host resolution ([[forge-host]]) ─────────────────────────────────────────────────────────────
24
- // WHICH forge a repo talks to is a repo fact, not a constant: derived from the origin remote, with an
25
- // explicit `forge.host` in spexcode.json / spexcode.local.json winning over the derivation, and
26
- // DEFAULT_FORGE_HOST only when nothing resolves (no repo, no origin). The resolved host may name a
27
- // driver that isn't registered yet (e.g. 'gitlab' before its driver lands) — callers degrade to an
28
- // empty forge slice via forgeDriverFor returning undefined, never to a wrong-host network call.
29
-
30
- // strip git's hook-exported env so repo discovery works from the filesystem (mirrors spec-cli git.ts)
31
20
  function gitEnv(): NodeJS.ProcessEnv {
32
21
  const env = { ...process.env }
33
22
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
@@ -40,9 +29,6 @@ function gitOut(args: string[]): string | null {
40
29
  } catch { return null }
41
30
  }
42
31
 
43
- // the `forge.host` config override — local over committed, same layering readConfig applies. Read here
44
- // (not via spec-cli) so the seam stays inside spec-forge; a malformed config file still fails LOUD,
45
- // exactly like spec-cli's readJsonConfig, rather than silently reverting the override.
46
32
  function configuredHost(): string | null {
47
33
  const root = gitOut(['rev-parse', '--show-toplevel'])
48
34
  if (!root) return null
@@ -58,7 +44,6 @@ function configuredHost(): string | null {
58
44
  return null
59
45
  }
60
46
 
61
- // hostname out of the two remote-URL shapes: a real scheme URL, or scp-like `git@host:owner/repo.git`
62
47
  function remoteHostname(url: string): string | null {
63
48
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
64
49
  try { return new URL(url).hostname || null } catch { return null }
@@ -67,9 +52,6 @@ function remoteHostname(url: string): string | null {
67
52
  return scp ? scp[1] : null
68
53
  }
69
54
 
70
- // hostname → host id. The SaaS vendors match by name; every OTHER resolvable remote is read as
71
- // self-hosted GitLab (the common self-hosted forge shape, e.g. dev.aminer.cn) — a wrong guess costs
72
- // only an empty slice (no driver ever runs against the wrong host) and `forge.host` corrects it.
73
55
  function hostFor(hostname: string): string {
74
56
  const h = hostname.toLowerCase()
75
57
  if (h.includes('github')) return 'github'
@@ -40,7 +40,6 @@ export function resolveLinks(
40
40
  return s
41
41
  }
42
42
 
43
- // PRs: branch → node (free structural link), and remember which node each PR belongs to.
44
43
  const prNode = new Map<number, string>()
45
44
  for (const pr of prs) {
46
45
  const node = branchToNode(pr.headRefName, nodeIds)
@@ -49,7 +48,6 @@ export function resolveLinks(
49
48
  slot(node).prs.set(pr.number, { ...pr, via: 'branch' })
50
49
  }
51
50
 
52
- // Issues by marker — the explicit convention. Unknown ids are dropped (no inventing nodes).
53
51
  const issueByNumber = new Map(issues.map((i) => [i.number, i]))
54
52
  for (const issue of issues) {
55
53
  for (const id of parseSpecMarkers(issue.body)) {
@@ -57,8 +55,6 @@ export function resolveLinks(
57
55
  }
58
56
  }
59
57
 
60
- // Transitive: a PR on a node branch that closes an issue links that issue to the node — unless a marker
61
- // already linked it (marker wins, so we don't overwrite the explicit source with the inferred one).
62
58
  for (const pr of prs) {
63
59
  const node = prNode.get(pr.number)
64
60
  if (!node) continue
@@ -3,7 +3,6 @@ import { resolveLinks, type LinkedIssue } from './links.js'
3
3
 
4
4
  export const NEEDS_EVAL = 'needs-eval'
5
5
 
6
- // a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-eval: foo`) is NOT a match
7
6
  const BODY_MARKER = new RegExp(`^\\s*${NEEDS_EVAL}\\s*:?\\s*$`, 'im')
8
7
 
9
8
  export function isNeedsEval(issue: ForgeIssue): boolean {
@@ -1,5 +1,3 @@
1
- // a forge issue's comment — the shape that becomes a unified Issue's Reply verbatim (author→by,
2
- // createdAt→at, body→body), so both stores' threads are one thread type downstream.
3
1
  export type ForgeComment = {
4
2
  author: string
5
3
  createdAt: string
@@ -13,12 +11,8 @@ export type ForgeIssue = {
13
11
  url: string
14
12
  state: string
15
13
  labels: string[]
16
- // who opened it and when — what lets a forge issue stand beside a local issue thread as the same
17
- // object in the unified Issue port (spec-cli issues.ts) with a `by` and a `created`.
18
14
  author: string
19
15
  createdAt: string
20
- // the discussion under it — rides the same reads (list/since), so the unified Issue port maps it
21
- // into replies[] with no second fetch path.
22
16
  comments: ForgeComment[]
23
17
  }
24
18
 
@@ -35,14 +29,8 @@ export interface ForgeDriver {
35
29
  readonly host: string
36
30
  listIssues(): Promise<ForgeIssue[]>
37
31
  listPRs(): Promise<ForgePR[]>
38
- // the port's issue write verbs — used solely by the unified Issue port (spec-cli issues.ts): promotion
39
- // creates an issue, a store-routed reply comments on one, and close advances the forge issue's own
40
- // lifecycle. The driver stays the only network toucher; the tracer never calls these; node state is never touched.
41
32
  createIssue(input: { title: string; body: string }): Promise<{ number: number; url: string }>
42
33
  createComment(input: { number: number; body: string }): Promise<{ url: string }>
43
34
  closeIssue(input: { number: number }): Promise<{ url: string }>
44
- // optional INCREMENTAL window — only issues whose updated-at ≥ sinceISO. A driver that offers it lets
45
- // the resident cache merge small deltas between periodic full reconciles instead of re-listing the
46
- // world every TTL; a driver without it simply always full-lists.
47
35
  listIssuesSince?(sinceISO: string): Promise<ForgeIssue[]>
48
36
  }
@@ -4,26 +4,14 @@ import type { ForgeIssue, ForgePR } from './port.js'
4
4
 
5
5
  const cache = new ForgeCache()
6
6
  let inFlight: Promise<void> | null = null
7
- // track last ATTEMPT, not last success, so the TTL also backs off failures — else a forge-less repo respawns `gh` every poll
8
7
  let lastAttempt = 0
9
- // tuned to the dashboard's LIVE cadence: the issues page re-polls /api/issues every ~15s and each poll
10
- // opportunistically triggers this refresh, so a TTL near the poll cadence means an externally-posted forge
11
- // issue surfaces on the board within ~one poll+cycle (~15–30s) with no page reload — the "post a github
12
- // issue → it just appears" contract. Each cycle is a tiny incremental read (one page), well inside
13
- // GitHub's rate budget; the back-off still covers a forge-less repo (a failed probe updates lastAttempt).
14
8
  const TTL_MS = 20_000
15
- // incremental-first: after the seeding full reconcile, each TTL cycle fetches only the updated-since
16
- // window (tiny — normally one page) and merges it; a periodic full reconcile stays as the backstop for
17
- // what an update window can't see (deleted/transferred issues).
18
9
  let lastIssueSync: string | null = null
19
10
  let lastFull = 0
20
11
  const FULL_MS = 30 * 60_000
21
12
 
22
13
  function refreshIfStale(now: number): void {
23
14
  if (inFlight || (lastAttempt && now - lastAttempt < TTL_MS)) return
24
- // the driver is picked per cycle from the RESOLVED host ([[forge-host]]: repo remote / forge.host
25
- // override) — a host whose driver isn't registered yet (e.g. a gitlab remote before a gitlab driver
26
- // lands) keeps the slice empty without spawning anything, never a wrong-host `gh` call or a throw
27
15
  const driver = forgeDriverFor(resolveForgeHost())
28
16
  if (!driver) return
29
17
  lastAttempt = now
@@ -40,19 +28,15 @@ function refreshIfStale(now: number): void {
40
28
  .finally(() => { inFlight = null })
41
29
  }
42
30
 
43
- // the raw cached forge set, same freshness contract as the view (instant, background reconcile) — the
44
- // server-side slice the unified Issue port (spec-cli issues.ts) merges with the local store.
45
31
  export function residentForgeState(): { issues: ForgeIssue[]; prs: ForgePR[] } {
46
32
  refreshIfStale(Date.now())
47
33
  return cache.state()
48
34
  }
49
35
 
50
- // a write must show up where it lands: after the server posts a forge comment, force one refresh past the
51
- // TTL and await it, so the next read carries the real read-back (never a local echo). Coalesces with an
52
- // in-flight cycle first — a refresh started BEFORE the write could read the pre-write world. The forced
53
- // cycle is a FULL re-list, never the incremental window: the REST since-read can lag a just-posted write,
54
- // and a lagged cycle advances the watermark PAST it — the write would then be invisible until the next
55
- // full reconcile. The full list reads the same backend the write went to, so the read-back is real.
36
+ export function residentForgeRevision(): number {
37
+ return cache.stateRevision()
38
+ }
39
+
56
40
  export async function refreshForgeNow(): Promise<void> {
57
41
  if (inFlight) await inFlight
58
42
  lastAttempt = 0