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.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-forge/src/cache.ts
CHANGED
|
@@ -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.
|
|
15
|
-
else if (delta.kind === 'pr') this.
|
|
16
|
-
else
|
|
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
|
-
|
|
22
|
-
|
|
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.
|
|
40
|
+
for (const i of issues) this.set(this.issues, i)
|
|
30
41
|
}
|
|
31
42
|
setPRs(prs: ForgePR[]): void {
|
|
32
|
-
|
|
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
|
}
|
package/spec-forge/src/cli.ts
CHANGED
|
@@ -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(),
|
|
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
|
-
//
|
|
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,
|
|
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),
|
|
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)
|
|
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'
|
package/spec-forge/src/links.ts
CHANGED
|
@@ -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 {
|
package/spec-forge/src/port.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|