@capagents/forgeqe 1.0.4 → 1.0.5

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.
@@ -0,0 +1,37 @@
1
+ import type { AppConfig } from "../config.ts"
2
+ import { MACHINE, requirementGap, type Probes } from "../capabilities/status.ts"
3
+ import type { Requirement } from "../capabilities/registry.ts"
4
+
5
+ /**
6
+ * The home screen's setup checklist: the few things most features rest on, each checked the way the capability
7
+ * registry checks a requirement, so the checklist and /features never disagree.
8
+ */
9
+ export type SetupItem = {
10
+ id: "model" | "agent" | "system" | "domain" | "notify"
11
+ label: string
12
+ done: boolean
13
+ /** What is missing, and how to set it up: shown when the item is chosen. */
14
+ fix?: string
15
+ /** A command that sets it up, run when the item is chosen. */
16
+ command?: string
17
+ }
18
+
19
+ const CHECKS: { id: SetupItem["id"]; label: string; requirement: Requirement; command?: string }[] = [
20
+ { id: "model", label: "Model", requirement: { kind: "live_model" } },
21
+ { id: "agent", label: "Coding agent", requirement: { kind: "coding_agent" } },
22
+ { id: "system", label: "Work items", requirement: { kind: "system", anyOf: ["azure_devops", "jira", "testrail", "zephyr", "qtest", "xray"] }, command: "/auth" },
23
+ { id: "domain", label: "Domain", requirement: { kind: "domain" } },
24
+ { id: "notify", label: "Notifications", requirement: { kind: "notify_channels" } },
25
+ ]
26
+
27
+ export function setupChecklist(cfg: AppConfig, probes: Probes = MACHINE): SetupItem[] {
28
+ return CHECKS.map((c) => {
29
+ const gap = requirementGap(c.requirement, cfg, probes)
30
+ return {
31
+ id: c.id,
32
+ label: c.label,
33
+ done: !gap,
34
+ ...(gap ? { fix: `${c.label}: ForgeQE needs ${gap.missing}. To set it up, ${gap.fix}.`, ...(c.command ? { command: c.command } : {}) } : {}),
35
+ }
36
+ })
37
+ }
@@ -0,0 +1,111 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { adoSettings } from "../cases/ado.ts"
6
+ import { http } from "../cases/http.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import type { PaletteItem } from "./palette.tsx"
9
+
10
+ /**
11
+ * Suggestions while typing: "/" completes a command, "@" a reference to something real (a recent run, a file in the
12
+ * repository, an Azure DevOps work item looked up by its id). Nothing is suggested that does not exist.
13
+ */
14
+
15
+ export type Suggestion = { kind: "command" | "run" | "file" | "item"; label: string; detail?: string; insert: string }
16
+
17
+ /** What is being completed: a command at the start of the prompt, or the "@" word being typed at its end. */
18
+ export type Trigger = { kind: "/" | "@"; token: string; start: number }
19
+
20
+ export function triggerAt(text: string): Trigger | null {
21
+ if (/^\/\S*$/.test(text)) return { kind: "/", token: text.slice(1), start: 0 }
22
+ const m = /(^|\s)@(\S*)$/.exec(text)
23
+ return m ? { kind: "@", token: m[2]!, start: m.index + m[1]!.length } : null
24
+ }
25
+
26
+ /** The prompt with the trigger's word replaced by the completion, ready for more typing. */
27
+ export function complete(text: string, t: Trigger, insert: string): string {
28
+ return `${text.slice(0, t.start)}${insert} `
29
+ }
30
+
31
+ export function commandSuggestions(items: PaletteItem[], token: string): Suggestion[] {
32
+ const seen = new Set<string>()
33
+ const out: Suggestion[] = []
34
+ for (const item of items) {
35
+ if (item.run.kind !== "command" || seen.has(item.run.text)) continue
36
+ seen.add(item.run.text)
37
+ out.push({ kind: "command", label: item.run.text, detail: item.title, insert: item.run.text })
38
+ }
39
+ const t = token.toLowerCase()
40
+ return out.filter((s) => s.label.slice(1).toLowerCase().startsWith(t)).concat(out.filter((s) => !s.label.slice(1).toLowerCase().startsWith(t) && s.label.toLowerCase().includes(t)))
41
+ }
42
+
43
+ export type RecentRun = { id: string; dir: string; goal: string }
44
+
45
+ /** The newest run folders (named by their start time) with their goals. */
46
+ export function recentRuns(outDir: string, limit = 8): RecentRun[] {
47
+ if (!existsSync(outDir)) return []
48
+ return readdirSync(outDir)
49
+ .filter((d) => /^\d{8}-\d{6}(-\d+)?$/.test(d))
50
+ .sort()
51
+ .reverse()
52
+ .slice(0, limit)
53
+ .map((id) => {
54
+ let goal = ""
55
+ try {
56
+ goal = String(JSON.parse(readFileSync(path.join(outDir, id, "run.json"), "utf8")).goal || "")
57
+ } catch {}
58
+ return { id, dir: path.join(outDir, id), goal }
59
+ })
60
+ }
61
+
62
+ const filesCache = new Map<string, string[]>()
63
+
64
+ /** The repository's tracked files (git ls-files), once per folder; none outside a git repository. */
65
+ export function repoFiles(cwd: string): string[] {
66
+ const known = filesCache.get(cwd)
67
+ if (known) return known
68
+ const r = spawnSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 20_000_000 })
69
+ const files = r.status === 0 ? r.stdout.split("\n").filter(Boolean).slice(0, 20_000) : []
70
+ filesCache.set(cwd, files)
71
+ return files
72
+ }
73
+
74
+ export type WorkItem = { id: number; title: string; type: string; project: string }
75
+
76
+ /** What `@` can refer to for the word typed after it. */
77
+ export function referenceSuggestions(token: string, src: { runs: RecentRun[]; files: string[]; item?: WorkItem | null; cwd: string }): Suggestion[] {
78
+ const t = token.toLowerCase()
79
+ const out: Suggestion[] = []
80
+ if (/^\d+$/.test(token)) {
81
+ if (src.item) out.push({ kind: "item", label: `${src.item.type} ${src.item.id}`, detail: `${src.item.title} · ${src.item.project}`, insert: `Azure DevOps ${src.item.type.toLowerCase()} ${src.item.id} ("${src.item.title}")` })
82
+ return out
83
+ }
84
+ if (!t || "run".startsWith(t) || t.startsWith("run")) {
85
+ const runs = src.runs.filter((r) => !t.startsWith("run") || t === "run" || r.id.includes(t.slice(3)))
86
+ for (const [i, r] of runs.entries()) out.push({ kind: "run", label: i === 0 ? "last run" : `run ${r.id}`, detail: r.goal || r.id, insert: `the run in ${path.relative(src.cwd, r.dir) || r.dir}` })
87
+ }
88
+ if (t.length >= 2) {
89
+ const byName = src.files.filter((f) => path.basename(f).toLowerCase().includes(t))
90
+ const byPath = src.files.filter((f) => !path.basename(f).toLowerCase().includes(t) && f.toLowerCase().includes(t))
91
+ for (const f of [...byName, ...byPath].slice(0, 8)) out.push({ kind: "file", label: path.basename(f), detail: path.dirname(f) === "." ? "" : path.dirname(f), insert: f })
92
+ }
93
+ return out.slice(0, 10)
94
+ }
95
+
96
+ const itemCache = new Map<number, WorkItem | null>()
97
+
98
+ /** An Azure DevOps work item's title, type, and project by its id; null when it does not exist or cannot be read. */
99
+ export async function lookupWorkItem(cfg: AppConfig, id: number): Promise<WorkItem | null> {
100
+ if (itemCache.has(id)) return itemCache.get(id)!
101
+ let item: WorkItem | null = null
102
+ try {
103
+ const { auth, org } = adoSettings(cfg.sources)
104
+ const { data } = await http<{ id: number; fields: Record<string, unknown> }>(`${org}/_apis/wit/workitems/${id}?fields=System.Title,System.WorkItemType,System.TeamProject&api-version=7.1`, { auth })
105
+ item = { id: data.id, title: String(data.fields["System.Title"] || ""), type: String(data.fields["System.WorkItemType"] || "Work item"), project: String(data.fields["System.TeamProject"] || "") }
106
+ } catch {
107
+ item = null
108
+ }
109
+ itemCache.set(id, item)
110
+ return item
111
+ }
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs"
2
+ import os from "node:os"
1
3
  import path from "node:path"
2
4
  import { SyntaxStyle } from "@opentui/core"
3
5
 
@@ -147,7 +149,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
147
149
  {summary.savedDir ? (
148
150
  <text>
149
151
  <span fg={theme.faint}>{"Saved "}</span>
150
- <span fg={theme.muted}>{`${summary.savedDir}/ `}</span>
152
+ <Linked text={`${summary.savedDir}/ `} color={theme.muted} />
151
153
  <span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
152
154
  </text>
153
155
  ) : null}
@@ -155,12 +157,54 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
155
157
  )
156
158
  }
157
159
 
160
+ /** A web address, or a path with at least one folder: what Linked turns into a link when it exists. */
161
+ const LINKABLE = /(https?:\/\/[^\s)"'<>]+|(?:~|\.{1,2})?\/?[\w.@-]+(?:\/[\w.@-]+)+\/?)/g
162
+
163
+ /** The target of a link: the address itself, or the file's absolute path; null for a path that does not exist. */
164
+ export function linkTarget(token: string, cwd = process.cwd()): string | null {
165
+ if (/^https?:\/\//.test(token)) return token
166
+ const file = token.startsWith("~/") ? path.join(os.homedir(), token.slice(2)) : path.resolve(cwd, token)
167
+ return existsSync(file) ? `file://${file}` : null
168
+ }
169
+
170
+ /**
171
+ * Text whose web addresses and existing file paths can be opened from the terminal (OSC 8 links: cmd/ctrl+click in
172
+ * VS Code and most terminals). A path that does not exist stays plain text, so no link leads nowhere.
173
+ */
174
+ export function Linked({ text, color }: { text: string; color: string }) {
175
+ const parts: { text: string; href?: string }[] = []
176
+ let at = 0
177
+ for (const m of text.matchAll(LINKABLE)) {
178
+ const href = linkTarget(m[0])
179
+ if (!href) continue
180
+ if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
181
+ parts.push({ text: m[0], href })
182
+ at = m.index! + m[0].length
183
+ }
184
+ if (at < text.length) parts.push({ text: text.slice(at) })
185
+ return (
186
+ <>
187
+ {parts.map((p, i) =>
188
+ p.href ? (
189
+ <a key={i} href={p.href} fg={theme.accent}>
190
+ {p.text}
191
+ </a>
192
+ ) : (
193
+ <span key={i} fg={color}>
194
+ {p.text}
195
+ </span>
196
+ ),
197
+ )}
198
+ </>
199
+ )
200
+ }
201
+
158
202
  export function Row({ label, value, color = theme.muted }: { label: string; value: string; color?: string }) {
159
203
  if (!value) return null
160
204
  return (
161
205
  <text>
162
206
  <span fg={theme.faint}>{label.padEnd(10)}</span>
163
- <span fg={color}>{value}</span>
207
+ <Linked text={value} color={color} />
164
208
  </text>
165
209
  )
166
210
  }
@@ -257,9 +301,7 @@ export function ResultCard({ card }: { card: Card }) {
257
301
  </box>
258
302
  {card.body.length ? <box style={{ height: 1 }} /> : null}
259
303
  {card.body.map((l, i) => (
260
- <text key={i} fg={lineColor(l)}>
261
- {l || " "}
262
- </text>
304
+ <text key={i}>{l ? <Linked text={l} color={lineColor(l)} /> : " "}</text>
263
305
  ))}
264
306
  </box>
265
307
  )
package/src/usage.ts CHANGED
@@ -98,7 +98,7 @@ export function usageLine(usage: Usage, cost?: Cost | null): string {
98
98
  // The meter: every model session reports here, by role
99
99
  // ---------------------------------------------------------------------------------------------
100
100
 
101
- export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge"
101
+ export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner"
102
102
 
103
103
  type Entry = { usage: Usage; cost?: Cost; priced: boolean }
104
104