@ipul-labs/forge 1.13.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.
Files changed (113) hide show
  1. package/README.md +273 -0
  2. package/bin/README.md +210 -0
  3. package/bin/cli/analyze.js +516 -0
  4. package/bin/cli/apply.js +264 -0
  5. package/bin/cli/cifix.js +300 -0
  6. package/bin/cli/close.js +125 -0
  7. package/bin/cli/doctor.js +161 -0
  8. package/bin/cli/init.js +306 -0
  9. package/bin/cli/ink.js +57 -0
  10. package/bin/cli/issue.js +116 -0
  11. package/bin/cli/issues.js +155 -0
  12. package/bin/cli/merge.js +175 -0
  13. package/bin/cli/refine.js +253 -0
  14. package/bin/cli/repos.js +91 -0
  15. package/bin/cli/resolution.js +126 -0
  16. package/bin/cli/session.js +230 -0
  17. package/bin/cli/sessionStore.js +214 -0
  18. package/bin/cli/ship.js +762 -0
  19. package/bin/cli/utils.js +145 -0
  20. package/bin/cli/validate.js +280 -0
  21. package/bin/cli/watch.js +274 -0
  22. package/bin/forge-agent.js +174 -0
  23. package/dist/assets/index-CNTCMbW9.js +80 -0
  24. package/dist/assets/index-InpYS-zg.css +1 -0
  25. package/dist/favicon.svg +8 -0
  26. package/dist/index.html +19 -0
  27. package/dist-lib/favicon.svg +8 -0
  28. package/dist-lib/index.d.ts +1 -0
  29. package/dist-lib/style.css +1 -0
  30. package/dist-lib/ui.d.ts +2 -0
  31. package/dist-lib/ui.js +7390 -0
  32. package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
  33. package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
  34. package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
  35. package/dist-lib/views/Forge/api.d.ts +4 -0
  36. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
  37. package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
  38. package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
  39. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
  40. package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
  41. package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
  42. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
  43. package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
  44. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
  45. package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
  46. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
  47. package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
  48. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
  49. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
  50. package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
  51. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
  52. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
  53. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
  54. package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
  55. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
  56. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
  57. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
  58. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
  59. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
  60. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
  61. package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
  62. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
  63. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
  64. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
  65. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
  66. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
  67. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
  68. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
  69. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
  70. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
  71. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
  72. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
  73. package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
  74. package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
  75. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
  76. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
  77. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
  78. package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
  79. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
  80. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
  81. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
  82. package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
  83. package/dist-lib/views/Forge/types.d.ts +235 -0
  84. package/dist-lib/views/Forge/utils.d.ts +11 -0
  85. package/package.json +88 -0
  86. package/server/forge/core/ForgeAgent.js +1144 -0
  87. package/server/forge/core/agentClient.js +36 -0
  88. package/server/forge/core/branchName.js +41 -0
  89. package/server/forge/core/claudeClient.js +356 -0
  90. package/server/forge/core/configStore.js +214 -0
  91. package/server/forge/core/gitlabClient.js +340 -0
  92. package/server/forge/core/jobs.js +180 -0
  93. package/server/forge/core/k8s.js +54 -0
  94. package/server/forge/core/localRepo.js +136 -0
  95. package/server/forge/core/openclawClient.js +125 -0
  96. package/server/forge/core/prompts.js +369 -0
  97. package/server/forge/core/providers/azuredevops.js +408 -0
  98. package/server/forge/core/providers/base.js +208 -0
  99. package/server/forge/core/providers/github.js +349 -0
  100. package/server/forge/core/providers/gitlab.js +529 -0
  101. package/server/forge/core/providers/index.js +47 -0
  102. package/server/forge/core/quotaClient.js +101 -0
  103. package/server/forge/core/validator/attribute.js +84 -0
  104. package/server/forge/core/validator/clone.js +91 -0
  105. package/server/forge/core/validator/cmd.js +98 -0
  106. package/server/forge/core/validator/fs.js +9 -0
  107. package/server/forge/core/validator/overlay.js +52 -0
  108. package/server/forge/core/validator/projectType.js +87 -0
  109. package/server/forge/core/validator/runners/go.js +107 -0
  110. package/server/forge/core/validator/runners/node.js +84 -0
  111. package/server/forge/core/validator/tdd.js +105 -0
  112. package/server/forge/core/validator.js +166 -0
  113. package/server/forge/routes.js +1012 -0
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+ import React, { useEffect, useState } from "react";
3
+ import Spinner from "ink-spinner";
4
+ import { h, Box, Text, useApp, runApp } from "./ink.js";
5
+ import { getFlag, projectRoot } from "./utils.js";
6
+ import {
7
+ PHASES,
8
+ appendEvent,
9
+ readState,
10
+ resolveSessionTarget,
11
+ updateState,
12
+ } from "./sessionStore.js";
13
+
14
+ function ResolutionView({ repo, secondaries, iid, mrIid, onDone }) {
15
+ const { exit } = useApp();
16
+ const [phase, setPhase] = useState("checking"); // checking → done | error
17
+ const [verdict, setVerdict] = useState(null);
18
+ const [error, setError] = useState(null);
19
+
20
+ useEffect(() => {
21
+ if (phase === "done" || phase === "error") {
22
+ onDone({ phase, verdict, error });
23
+ exit();
24
+ }
25
+ }, [phase]);
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ (async () => {
30
+ try {
31
+ const { ForgeAgent } = await import(
32
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
33
+ );
34
+ const agent = new ForgeAgent({
35
+ project: repo,
36
+ secondaryProjects: secondaries,
37
+ });
38
+ const result = await agent.checkResolution(iid, mrIid, repo);
39
+ if (cancelled) return;
40
+ setVerdict(result);
41
+ setPhase("done");
42
+ } catch (err) {
43
+ if (cancelled) return;
44
+ setError(err);
45
+ setPhase("error");
46
+ }
47
+ })();
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, []);
52
+
53
+ if (phase === "checking") {
54
+ return h`<${Box}>
55
+ <${Text} color="cyan"><${Spinner} type="dots"/></>
56
+ <${Text}>${` Checking whether MR !${mrIid} resolves issue #${iid}…`}</>
57
+ </>`;
58
+ }
59
+
60
+ if (phase === "error") {
61
+ return h`<${Box}>
62
+ <${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
63
+ </>`;
64
+ }
65
+
66
+ const ok = verdict?.resolved === true || verdict?.ok === true;
67
+ const reasoning = verdict?.reasoning ?? verdict?.explanation ?? "";
68
+ return h`<${Box} flexDirection="column">
69
+ <${Text} color=${ok ? "green" : "red"}>${ok ? "✓ Resolved" : "✘ Not resolved"}</>
70
+ ${reasoning
71
+ ? h`<${Box} marginTop=${1}>
72
+ <${Text} dimColor>Model reasoning: </>
73
+ <${Text}>${reasoning}</>
74
+ </>`
75
+ : null}
76
+ </>`;
77
+ }
78
+
79
+ export async function cmdResolutionCheck(argv) {
80
+ const explicitIid = Number(argv[1]);
81
+ const explicitRepo = getFlag(argv, "--repo");
82
+ const target = await resolveSessionTarget({
83
+ explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
84
+ explicitRepo,
85
+ });
86
+ if (!target?.repo || !target?.iid) {
87
+ console.error(
88
+ "No session target. Pass an <iid>, set --repo, or run analyze + apply first.",
89
+ );
90
+ process.exit(1);
91
+ }
92
+ const state = await readState(target.repo, target.iid);
93
+ if (!state?.mrIid) {
94
+ console.error(
95
+ `Session ${target.repo}#${target.iid} has no MR. Run: forge-agent apply ${target.iid}`,
96
+ );
97
+ process.exit(1);
98
+ }
99
+ const repo = state.primary ?? target.repo;
100
+ const secondaries = state.secondaries ?? [];
101
+
102
+ let outcome = { phase: null };
103
+ await runApp(
104
+ h`<${ResolutionView}
105
+ repo=${repo}
106
+ secondaries=${secondaries}
107
+ iid=${target.iid}
108
+ mrIid=${state.mrIid}
109
+ onDone=${(o) => (outcome = o)} />`,
110
+ );
111
+
112
+ if (outcome.phase === "done") {
113
+ const ok = outcome.verdict?.resolved === true || outcome.verdict?.ok === true;
114
+ await updateState(target.repo, target.iid, {
115
+ phase: ok ? PHASES.RESOLVED : "resolution-rejected",
116
+ resolution: outcome.verdict ?? null,
117
+ }).catch(() => {});
118
+ await appendEvent(target.repo, target.iid, {
119
+ type: "session:resolution-check",
120
+ resolved: ok,
121
+ }).catch(() => {});
122
+ if (!ok) process.exit(2);
123
+ return;
124
+ }
125
+ process.exit(2);
126
+ }
@@ -0,0 +1,230 @@
1
+ import path from "node:path";
2
+ import React from "react";
3
+ import { h, Box, Text, runApp } from "./ink.js";
4
+ import { getFlag, projectRoot } from "./utils.js";
5
+ import {
6
+ cleanAll,
7
+ cleanSession,
8
+ getHead,
9
+ listSessions,
10
+ readAnalysis,
11
+ readState,
12
+ resolveSessionTarget,
13
+ sessionDir,
14
+ } from "./sessionStore.js";
15
+
16
+ function relTime(iso) {
17
+ if (!iso) return "";
18
+ const ms = Date.now() - new Date(iso).getTime();
19
+ if (Number.isNaN(ms) || ms < 0) return "";
20
+ const s = Math.floor(ms / 1000);
21
+ if (s < 60) return `${s}s ago`;
22
+ const m = Math.floor(s / 60);
23
+ if (m < 60) return `${m}m ago`;
24
+ const h = Math.floor(m / 60);
25
+ if (h < 24) return `${h}h ago`;
26
+ return `${Math.floor(h / 24)}d ago`;
27
+ }
28
+
29
+ function SessionsTable({ sessions, head }) {
30
+ if (sessions.length === 0) {
31
+ return h`<${Box} flexDirection="column">
32
+ <${Text} dimColor>No sessions yet.</>
33
+ <${Text}>${"Run: forge-agent analyze <iid>"}</>
34
+ </>`;
35
+ }
36
+ const rows = sessions.map((s) => {
37
+ const isHead = head && head.repo === s.repo && head.iid === s.iid;
38
+ return {
39
+ marker: isHead ? "*" : " ",
40
+ ref: `${s.repo}#${s.iid}`,
41
+ phase: s.phase ?? "?",
42
+ when: relTime(s.updatedAt),
43
+ };
44
+ });
45
+ const wRef = Math.max(3, ...rows.map((r) => r.ref.length));
46
+ const wPhase = Math.max(5, ...rows.map((r) => r.phase.length));
47
+ return h`<${Box} flexDirection="column" marginTop=${1}>
48
+ ${rows.map((r, i) =>
49
+ h`<${Box} key=${i}>
50
+ <${Text} color=${r.marker === "*" ? "green" : undefined}> ${r.marker}</>
51
+ <${Box} width=${wRef + 2}><${Text}> ${r.ref}</></>
52
+ <${Box} width=${wPhase + 2}><${Text} color="yellow">${r.phase}</></>
53
+ <${Text} dimColor>${r.when}</>
54
+ </>`,
55
+ )}
56
+ <${Box} marginTop=${1}>
57
+ <${Text} dimColor>${"* HEAD — commands without <iid> resume here"}</>
58
+ </>
59
+ </>`;
60
+ }
61
+
62
+ function SessionDetail({ state, analysisSummary }) {
63
+ return h`<${Box} flexDirection="column">
64
+ <${Box}>
65
+ <${Text} color="yellow">Session </>
66
+ <${Text}>${state.repo}#${state.iid}</>
67
+ </>
68
+ <${Box}>
69
+ <${Text} color="yellow">Phase </>
70
+ <${Text}>${state.phase ?? "?"}</>
71
+ </>
72
+ ${state.primary
73
+ ? h`<${Box}>
74
+ <${Text} color="yellow">Primary </>
75
+ <${Text}>${state.primary}</>
76
+ </>`
77
+ : null}
78
+ ${state.secondaries?.length
79
+ ? h`<${Box}>
80
+ <${Text} color="yellow">Secondary</>
81
+ <${Text}> ${state.secondaries.join(", ")}</>
82
+ </>`
83
+ : null}
84
+ ${state.branch
85
+ ? h`<${Box}>
86
+ <${Text} color="yellow">Branch </>
87
+ <${Text}>${state.branch}</>
88
+ </>`
89
+ : null}
90
+ ${state.mrIid
91
+ ? h`<${Box}>
92
+ <${Text} color="yellow">MR </>
93
+ <${Text}>!${state.mrIid}</>
94
+ </>`
95
+ : null}
96
+ ${state.lastError
97
+ ? h`<${Box}>
98
+ <${Text} color="red">Error </>
99
+ <${Text}>${state.lastError}</>
100
+ </>`
101
+ : null}
102
+ <${Box} marginTop=${1}>
103
+ <${Text} dimColor>${`Created ${relTime(state.createdAt)} · Updated ${relTime(state.updatedAt)}`}</>
104
+ </>
105
+ ${analysisSummary
106
+ ? h`<${Box} flexDirection="column" marginTop=${1}>
107
+ <${Text} dimColor>${"Cached analysis:"}</>
108
+ <${Text}>${` ${analysisSummary.changes} change(s) · ${analysisSummary.repos} repo(s) · ${analysisSummary.mrTitle ?? "(no proposed title)"}`}</>
109
+ </>`
110
+ : h`<${Box} marginTop=${1}>
111
+ <${Text} dimColor>${"No cached analysis on disk."}</>
112
+ </>`}
113
+ </>`;
114
+ }
115
+
116
+ async function listCommand(argv) {
117
+ const json = argv.includes("--json");
118
+ const sessions = await listSessions();
119
+ if (json) {
120
+ process.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`);
121
+ return;
122
+ }
123
+ const head = await getHead();
124
+ await runApp(h`<${SessionsTable} sessions=${sessions} head=${head} />`);
125
+ }
126
+
127
+ async function showCommand(argv) {
128
+ const json = argv.includes("--json");
129
+ const explicitIid = Number(argv[2]);
130
+ const explicitRepo = getFlag(argv, "--repo");
131
+ const target = await resolveSessionTarget({
132
+ explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
133
+ explicitRepo,
134
+ });
135
+ if (!target?.repo || !target?.iid) {
136
+ console.error(
137
+ "No session target. Pass an <iid>, set --repo, or run analyze first.",
138
+ );
139
+ process.exit(1);
140
+ }
141
+ const state = await readState(target.repo, target.iid);
142
+ if (!state) {
143
+ console.error(`No session at ${sessionDir(target.repo, target.iid)}`);
144
+ process.exit(1);
145
+ }
146
+ const analysis = await readAnalysis(target.repo, target.iid);
147
+ if (json) {
148
+ process.stdout.write(`${JSON.stringify({ state, analysis }, null, 2)}\n`);
149
+ return;
150
+ }
151
+ const summary = analysis
152
+ ? {
153
+ changes: analysis.changes?.length ?? 0,
154
+ repos: analysis.repos?.length ?? 0,
155
+ mrTitle: analysis.mrTitle ?? null,
156
+ }
157
+ : null;
158
+ await runApp(h`<${SessionDetail} state=${state} analysisSummary=${summary} />`);
159
+ }
160
+
161
+ async function cleanCommand(argv) {
162
+ if (argv.includes("--all")) {
163
+ await cleanAll();
164
+ process.stderr.write(`✓ Removed all sessions\n`);
165
+ return;
166
+ }
167
+ const explicitIid = Number(argv[2]);
168
+ const explicitRepo = getFlag(argv, "--repo");
169
+ const target = await resolveSessionTarget({
170
+ explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
171
+ explicitRepo,
172
+ });
173
+ if (!target?.repo || !target?.iid) {
174
+ console.error(
175
+ "No session target. Pass an <iid>, set --repo, or use --all to wipe everything.",
176
+ );
177
+ process.exit(1);
178
+ }
179
+ const removed = await cleanSession(target.repo, target.iid);
180
+ if (removed) process.stderr.write(`✓ Removed ${target.repo}#${target.iid}\n`);
181
+ else process.stderr.write(`Nothing to remove at ${target.repo}#${target.iid}\n`);
182
+ }
183
+
184
+ export async function cmdSession(argv) {
185
+ const sub = argv[1] ?? "list";
186
+ if (sub === "list" || sub === "ls") return listCommand(argv);
187
+ if (sub === "show") return showCommand(argv);
188
+ if (sub === "clean" || sub === "rm") return cleanCommand(argv);
189
+ console.error(`Unknown subcommand: ${sub}`);
190
+ console.error("Subcommands: list, show, clean");
191
+ process.exit(1);
192
+ }
193
+
194
+ /**
195
+ * Top-level `forge-agent show [<iid>]` — re-renders the cached analysis from
196
+ * the session through the same Ink view as live analyze. Resolves <iid> via
197
+ * positional arg or HEAD pointer.
198
+ */
199
+ export async function cmdShow(argv) {
200
+ const explicitIid = Number(argv[1]);
201
+ const explicitRepo = getFlag(argv, "--repo");
202
+ const json = argv.includes("--json");
203
+ const target = await resolveSessionTarget({
204
+ explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
205
+ explicitRepo,
206
+ });
207
+ if (!target?.repo || !target?.iid) {
208
+ console.error(
209
+ "No session target. Pass an <iid>, or run analyze first to seed HEAD.",
210
+ );
211
+ process.exit(1);
212
+ }
213
+ const analysis = await readAnalysis(target.repo, target.iid);
214
+ if (!analysis) {
215
+ console.error(
216
+ `No cached analysis at ${sessionDir(target.repo, target.iid)}.\n` +
217
+ `Run: forge-agent analyze ${target.iid}`,
218
+ );
219
+ process.exit(1);
220
+ }
221
+ if (json) {
222
+ process.stdout.write(`${JSON.stringify(analysis, null, 2)}\n`);
223
+ return;
224
+ }
225
+ // Reuse analyze.js's ResultBlock by importing it lazily — keeps the
226
+ // dependency cycle simple (session.js depends on the analyze view, but
227
+ // analyze.js doesn't depend on session.js's UI).
228
+ const { renderCachedResult } = await import("./analyze.js");
229
+ await renderCachedResult(analysis);
230
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Session store for the ship workflow.
3
+ *
4
+ * ~/.forge/sessions/<canonical-repo>/<iid>/
5
+ * analysis.json — last cached ForgeAgent.analyzeIssue result
6
+ * state.json — { phase, primary, secondaries, branch?, mrIid?, ... }
7
+ * events.ndjson — append-only log of every event we received
8
+ *
9
+ * ~/.forge/sessions/HEAD
10
+ * — single line: "<canonical-repo>/<iid>" of the most recently touched
11
+ * session. Used so subsequent commands without an explicit <iid> can
12
+ * resume the current unit of work, in the spirit of git's HEAD.
13
+ *
14
+ * Override the root with FORGE_SESSIONS_PATH (test isolation).
15
+ */
16
+
17
+ import { existsSync } from "node:fs";
18
+ import fs from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { canonicalRepoId } from "./utils.js";
22
+
23
+ export const PHASES = Object.freeze({
24
+ ANALYZED: "analyzed",
25
+ REFINED: "refined",
26
+ VALIDATED: "validated",
27
+ APPLIED: "applied",
28
+ WATCHING: "watching",
29
+ MERGED: "merged",
30
+ RESOLVED: "resolved",
31
+ CLOSED: "closed",
32
+ });
33
+
34
+ function rootDir() {
35
+ return (
36
+ process.env.FORGE_SESSIONS_PATH ??
37
+ path.join(os.homedir(), ".forge", "sessions")
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Resolve the on-disk directory for a session. Repo is canonicalised so
43
+ * "iplayulisten/web-app" and "gitlab:iplayulisten/web-app" map to the
44
+ * same folder.
45
+ */
46
+ export function sessionDir(repo, iid) {
47
+ return path.join(rootDir(), canonicalRepoId(String(repo)), String(iid));
48
+ }
49
+
50
+ async function ensureDir(dir) {
51
+ await fs.mkdir(dir, { recursive: true });
52
+ }
53
+
54
+ export async function writeAnalysis(repo, iid, analysis) {
55
+ const dir = sessionDir(repo, iid);
56
+ await ensureDir(dir);
57
+ await fs.writeFile(
58
+ path.join(dir, "analysis.json"),
59
+ JSON.stringify(analysis, null, 2),
60
+ "utf8",
61
+ );
62
+ }
63
+
64
+ export async function readAnalysis(repo, iid) {
65
+ const file = path.join(sessionDir(repo, iid), "analysis.json");
66
+ if (!existsSync(file)) return null;
67
+ return JSON.parse(await fs.readFile(file, "utf8"));
68
+ }
69
+
70
+ export async function readState(repo, iid) {
71
+ const file = path.join(sessionDir(repo, iid), "state.json");
72
+ if (!existsSync(file)) return null;
73
+ return JSON.parse(await fs.readFile(file, "utf8"));
74
+ }
75
+
76
+ export async function updateState(repo, iid, partial) {
77
+ const dir = sessionDir(repo, iid);
78
+ await ensureDir(dir);
79
+ const file = path.join(dir, "state.json");
80
+ const now = new Date().toISOString();
81
+ const prev = (await readState(repo, iid)) ?? {
82
+ repo: canonicalRepoId(String(repo)),
83
+ iid: Number(iid),
84
+ createdAt: now,
85
+ };
86
+ const next = { ...prev, ...partial, updatedAt: now };
87
+ await fs.writeFile(file, JSON.stringify(next, null, 2), "utf8");
88
+ return next;
89
+ }
90
+
91
+ export async function appendEvent(repo, iid, evt) {
92
+ const dir = sessionDir(repo, iid);
93
+ await ensureDir(dir);
94
+ await fs.appendFile(
95
+ path.join(dir, "events.ndjson"),
96
+ `${JSON.stringify({ ts: new Date().toISOString(), ...evt })}\n`,
97
+ "utf8",
98
+ );
99
+ }
100
+
101
+ export async function setHead(repo, iid) {
102
+ const root = rootDir();
103
+ await fs.mkdir(root, { recursive: true });
104
+ await fs.writeFile(
105
+ path.join(root, "HEAD"),
106
+ `${canonicalRepoId(String(repo))}/${iid}\n`,
107
+ "utf8",
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Return { repo, iid } of the HEAD session, or null.
113
+ */
114
+ export async function getHead() {
115
+ const file = path.join(rootDir(), "HEAD");
116
+ if (!existsSync(file)) return null;
117
+ const raw = (await fs.readFile(file, "utf8")).trim();
118
+ const lastSlash = raw.lastIndexOf("/");
119
+ if (lastSlash <= 0) return null;
120
+ const repo = raw.slice(0, lastSlash);
121
+ const iid = Number(raw.slice(lastSlash + 1));
122
+ if (!Number.isInteger(iid) || iid <= 0) return null;
123
+ return { repo, iid };
124
+ }
125
+
126
+ /**
127
+ * Walk the on-disk tree and return every session it finds. Sorted by
128
+ * updatedAt descending (most recent first).
129
+ */
130
+ export async function listSessions() {
131
+ const root = rootDir();
132
+ if (!existsSync(root)) return [];
133
+
134
+ const out = [];
135
+ const repoDirs = await safeReadDir(root);
136
+ for (const repoEntry of repoDirs) {
137
+ if (!repoEntry.isDirectory()) continue;
138
+ // Repos may be nested one level (e.g. "iplayulisten/web-app").
139
+ const repoLevel1 = path.join(root, repoEntry.name);
140
+ const repoSubdirs = await safeReadDir(repoLevel1);
141
+ for (const sub of repoSubdirs) {
142
+ if (!sub.isDirectory()) continue;
143
+ const candidatePath = path.join(repoLevel1, sub.name);
144
+
145
+ // Two layouts:
146
+ // a) repoLevel1/<iid>/state.json (single-segment repo)
147
+ // b) repoLevel1/<sub>/<iid>/state.json (two-segment repo)
148
+ if (existsSync(path.join(candidatePath, "state.json"))) {
149
+ const state = JSON.parse(
150
+ await fs.readFile(path.join(candidatePath, "state.json"), "utf8"),
151
+ );
152
+ out.push(state);
153
+ continue;
154
+ }
155
+ const iidEntries = await safeReadDir(candidatePath);
156
+ for (const iidEntry of iidEntries) {
157
+ if (!iidEntry.isDirectory()) continue;
158
+ const stateFile = path.join(candidatePath, iidEntry.name, "state.json");
159
+ if (!existsSync(stateFile)) continue;
160
+ const state = JSON.parse(await fs.readFile(stateFile, "utf8"));
161
+ out.push(state);
162
+ }
163
+ }
164
+ }
165
+
166
+ out.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
167
+ return out;
168
+ }
169
+
170
+ async function safeReadDir(dir) {
171
+ try {
172
+ return await fs.readdir(dir, { withFileTypes: true });
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+
178
+ export async function cleanSession(repo, iid) {
179
+ const dir = sessionDir(repo, iid);
180
+ if (!existsSync(dir)) return false;
181
+ await fs.rm(dir, { recursive: true, force: true });
182
+ const head = await getHead();
183
+ if (head && canonicalRepoId(head.repo) === canonicalRepoId(String(repo)) && head.iid === Number(iid)) {
184
+ await fs.rm(path.join(rootDir(), "HEAD"), { force: true });
185
+ }
186
+ return true;
187
+ }
188
+
189
+ export async function cleanAll() {
190
+ const root = rootDir();
191
+ if (!existsSync(root)) return;
192
+ await fs.rm(root, { recursive: true, force: true });
193
+ }
194
+
195
+ /**
196
+ * Resolve a session target from CLI argv. Honours an explicit positional iid
197
+ * or HEAD as fallback. Returns { repo, iid } or null.
198
+ *
199
+ * forge-agent show → HEAD
200
+ * forge-agent show 42 → primary repo + iid 42
201
+ * forge-agent show 42 --repo X → repo X + iid 42
202
+ */
203
+ export async function resolveSessionTarget({ explicitIid, explicitRepo }) {
204
+ if (Number.isInteger(explicitIid) && explicitIid > 0) {
205
+ // repo defaults to HEAD's repo if available, else explicitRepo, else
206
+ // the configStore primary. Keeps `show 42` working when there's a
207
+ // prior HEAD that was on the same repo.
208
+ if (explicitRepo) return { repo: explicitRepo, iid: explicitIid };
209
+ const head = await getHead();
210
+ if (head) return { repo: head.repo, iid: explicitIid };
211
+ return { repo: null, iid: explicitIid };
212
+ }
213
+ return await getHead();
214
+ }