@pleri/olam-cli 0.1.7
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/dist/__tests__/auth-status.test.d.ts +2 -0
- package/dist/__tests__/auth-status.test.d.ts.map +1 -0
- package/dist/__tests__/auth-status.test.js +290 -0
- package/dist/__tests__/auth-status.test.js.map +1 -0
- package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
- package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/auth-upgrade.test.js +161 -0
- package/dist/__tests__/auth-upgrade.test.js.map +1 -0
- package/dist/__tests__/create-app-urls.test.d.ts +2 -0
- package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-urls.test.js +102 -0
- package/dist/__tests__/create-app-urls.test.js.map +1 -0
- package/dist/__tests__/enter.test.d.ts +2 -0
- package/dist/__tests__/enter.test.d.ts.map +1 -0
- package/dist/__tests__/enter.test.js +90 -0
- package/dist/__tests__/enter.test.js.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.js +119 -0
- package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
- package/dist/__tests__/host-cp.test.d.ts +9 -0
- package/dist/__tests__/host-cp.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp.test.js +254 -0
- package/dist/__tests__/host-cp.test.js.map +1 -0
- package/dist/__tests__/keys.test.d.ts +9 -0
- package/dist/__tests__/keys.test.d.ts.map +1 -0
- package/dist/__tests__/keys.test.js +145 -0
- package/dist/__tests__/keys.test.js.map +1 -0
- package/dist/__tests__/logs.test.d.ts +9 -0
- package/dist/__tests__/logs.test.d.ts.map +1 -0
- package/dist/__tests__/logs.test.js +124 -0
- package/dist/__tests__/logs.test.js.map +1 -0
- package/dist/__tests__/ps.test.d.ts +2 -0
- package/dist/__tests__/ps.test.d.ts.map +1 -0
- package/dist/__tests__/ps.test.js +172 -0
- package/dist/__tests__/ps.test.js.map +1 -0
- package/dist/__tests__/status-app-urls.test.d.ts +2 -0
- package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/status-app-urls.test.js +125 -0
- package/dist/__tests__/status-app-urls.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +9 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +262 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
- package/dist/commands/__tests__/refresh.test.d.ts +13 -0
- package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
- package/dist/commands/__tests__/refresh.test.js +170 -0
- package/dist/commands/__tests__/refresh.test.js.map +1 -0
- package/dist/commands/auth-status.d.ts +43 -0
- package/dist/commands/auth-status.d.ts.map +1 -0
- package/dist/commands/auth-status.js +208 -0
- package/dist/commands/auth-status.js.map +1 -0
- package/dist/commands/auth-upgrade.d.ts +47 -0
- package/dist/commands/auth-upgrade.d.ts.map +1 -0
- package/dist/commands/auth-upgrade.js +277 -0
- package/dist/commands/auth-upgrade.js.map +1 -0
- package/dist/commands/auth.d.ts +16 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +283 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +512 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/crystallize.d.ts +8 -0
- package/dist/commands/crystallize.d.ts.map +1 -0
- package/dist/commands/crystallize.js +101 -0
- package/dist/commands/crystallize.js.map +1 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/destroy.js +54 -0
- package/dist/commands/destroy.js.map +1 -0
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +94 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/enter.d.ts +63 -0
- package/dist/commands/enter.d.ts.map +1 -0
- package/dist/commands/enter.js +206 -0
- package/dist/commands/enter.js.map +1 -0
- package/dist/commands/host-cp.d.ts +191 -0
- package/dist/commands/host-cp.d.ts.map +1 -0
- package/dist/commands/host-cp.js +797 -0
- package/dist/commands/host-cp.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +22 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +203 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/keys.d.ts +26 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +151 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/lanes.d.ts +18 -0
- package/dist/commands/lanes.d.ts.map +1 -0
- package/dist/commands/lanes.js +122 -0
- package/dist/commands/lanes.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +39 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +38 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +177 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/observe.d.ts +9 -0
- package/dist/commands/observe.d.ts.map +1 -0
- package/dist/commands/observe.js +34 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/commands/policy-check.d.ts +14 -0
- package/dist/commands/policy-check.d.ts.map +1 -0
- package/dist/commands/policy-check.js +76 -0
- package/dist/commands/policy-check.js.map +1 -0
- package/dist/commands/pr.d.ts +17 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +148 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/ps.d.ts +25 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +164 -0
- package/dist/commands/ps.js.map +1 -0
- package/dist/commands/refresh-helpers.d.ts +25 -0
- package/dist/commands/refresh-helpers.d.ts.map +1 -0
- package/dist/commands/refresh-helpers.js +56 -0
- package/dist/commands/refresh-helpers.js.map +1 -0
- package/dist/commands/refresh.d.ts +23 -0
- package/dist/commands/refresh.d.ts.map +1 -0
- package/dist/commands/refresh.js +237 -0
- package/dist/commands/refresh.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/upgrade.d.ts +67 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +358 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +198 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/commands/world-snapshot.d.ts +18 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -0
- package/dist/commands/world-snapshot.js +327 -0
- package/dist/commands/world-snapshot.js.map +1 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +51 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18007 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +32236 -0
- package/dist/output.d.ts +10 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +31 -0
- package/dist/output.js.map +1 -0
- package/host-cp/compose.yaml +126 -0
- package/host-cp/src/auth-secret-hint.mjs +45 -0
- package/host-cp/src/auth.mjs +155 -0
- package/host-cp/src/compose-worlds-sources.mjs +170 -0
- package/host-cp/src/container-secret-fetcher.mjs +163 -0
- package/host-cp/src/docker-events.mjs +184 -0
- package/host-cp/src/local-worlds-source.mjs +83 -0
- package/host-cp/src/plan-orchestrator.mjs +829 -0
- package/host-cp/src/plan-progress.mjs +282 -0
- package/host-cp/src/pr-cache.mjs +201 -0
- package/host-cp/src/pr-merge-poller.mjs +154 -0
- package/host-cp/src/process-poller.mjs +250 -0
- package/host-cp/src/proxy.mjs +245 -0
- package/host-cp/src/pylon-worlds-source.mjs +68 -0
- package/host-cp/src/redact.mjs +67 -0
- package/host-cp/src/secret-cache.mjs +104 -0
- package/host-cp/src/server.mjs +2215 -0
- package/host-cp/src/sse-gate.mjs +117 -0
- package/host-cp/src/version-status.mjs +209 -0
- package/host-cp/src/workspace-catalog.mjs +149 -0
- package/host-cp/src/world-names-store.mjs +176 -0
- package/host-cp/src/world-pr-state.mjs +97 -0
- package/host-cp/src/world-progress.mjs +322 -0
- package/host-cp/src/world-tunnel-manager.mjs +288 -0
- package/host-cp/src/worlds-db-source.mjs +191 -0
- package/host-cp/src/worlds-source.mjs +59 -0
- package/package.json +38 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan progress parser — reads phase-*-tasks.md trackers to derive
|
|
3
|
+
* phase/task state for the inbox progress bar.
|
|
4
|
+
*
|
|
5
|
+
* @module plan-progress
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const WORKING_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse simple key:value pairs from a YAML frontmatter block (---…---).
|
|
15
|
+
* Handles single-line scalar values only — enough for feature/phase keys.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} content
|
|
18
|
+
* @returns {Record<string, string>}
|
|
19
|
+
*/
|
|
20
|
+
function parseFrontmatter(content) {
|
|
21
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
22
|
+
if (!match) return {};
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const line of match[1].split('\n')) {
|
|
25
|
+
const m = line.match(/^([\w-]+):\s*(.+)$/);
|
|
26
|
+
if (m) result[m[1]] = m[2].trim();
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract task definitions from "## Task list" section.
|
|
33
|
+
* Matches headings like:
|
|
34
|
+
* ### A0 — name
|
|
35
|
+
* ### B1 step 5 — multi-part name
|
|
36
|
+
*
|
|
37
|
+
* @param {string} content
|
|
38
|
+
* @returns {Array<{id: string, name: string}>}
|
|
39
|
+
*/
|
|
40
|
+
function extractTaskDefs(content) {
|
|
41
|
+
const sectionMatch = content.match(/^## Task list\s*\n([\s\S]*)/m);
|
|
42
|
+
if (!sectionMatch) return [];
|
|
43
|
+
|
|
44
|
+
const taskSection = sectionMatch[1];
|
|
45
|
+
const tasks = [];
|
|
46
|
+
const re = /^###\s+([A-Z]\d+)\b([^\n]*)/gm;
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = re.exec(taskSection)) !== null) {
|
|
49
|
+
const id = m[1];
|
|
50
|
+
const rest = m[2].trim();
|
|
51
|
+
// Strip leading em-dash, double-hyphen, or plain hyphen separator
|
|
52
|
+
const name = rest.replace(/^\s*[—\-]{1,2}\s*/, '').trim() || id;
|
|
53
|
+
tasks.push({ id, name });
|
|
54
|
+
}
|
|
55
|
+
return tasks;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract completed task IDs from the CP0 log comment block.
|
|
60
|
+
* Matches lines like: A0 (2026-05-05): ...
|
|
61
|
+
* A2 (2026-05-05, rebase): ...
|
|
62
|
+
*
|
|
63
|
+
* @param {string} content
|
|
64
|
+
* @returns {Set<string>}
|
|
65
|
+
*/
|
|
66
|
+
function extractCp0Completed(content) {
|
|
67
|
+
const completed = new Set();
|
|
68
|
+
const logMatch = content.match(/<!--\s*CP0 log[\s\S]*?-->/);
|
|
69
|
+
if (!logMatch) return completed;
|
|
70
|
+
|
|
71
|
+
const re = /^([A-Z]\d+)\s*\(/gm;
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = re.exec(logMatch[0])) !== null) {
|
|
74
|
+
completed.add(m[1]);
|
|
75
|
+
}
|
|
76
|
+
return completed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract completed task IDs from an item-format Status table.
|
|
81
|
+
* Matches rows like: | A1 | Tool loader index | done |
|
|
82
|
+
*
|
|
83
|
+
* @param {string} content
|
|
84
|
+
* @returns {Set<string>}
|
|
85
|
+
*/
|
|
86
|
+
function extractItemTableCompleted(content) {
|
|
87
|
+
const completed = new Set();
|
|
88
|
+
// No `m` flag — `$` must mean end-of-string so the lazy quantifier captures
|
|
89
|
+
// the whole table, not just the first line.
|
|
90
|
+
const statusMatch = content.match(/## Status\s*\n([\s\S]*?)(?=\n##\s|$)/);
|
|
91
|
+
if (!statusMatch) return completed;
|
|
92
|
+
|
|
93
|
+
const re = /^\|\s*([A-Z]\d+)\s*\|[^|]+\|\s*done\s*\|/gim;
|
|
94
|
+
let m;
|
|
95
|
+
while ((m = re.exec(statusMatch[1])) !== null) {
|
|
96
|
+
completed.add(m[1]);
|
|
97
|
+
}
|
|
98
|
+
return completed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract the authoritative done count from a count-format Status table.
|
|
103
|
+
* Matches rows like: | done | 3 |
|
|
104
|
+
*
|
|
105
|
+
* @param {string} content
|
|
106
|
+
* @returns {number|null}
|
|
107
|
+
*/
|
|
108
|
+
function extractDoneCount(content) {
|
|
109
|
+
const m = content.match(/\|\s*done\s*\|\s*(\d+)\s*\|/i);
|
|
110
|
+
return m ? parseInt(m[1], 10) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the feature slug from a branch name or by scanning docs/plans/.
|
|
115
|
+
*
|
|
116
|
+
* Strategy:
|
|
117
|
+
* 1. Strip "feat/" prefix + optional "-phase-X" suffix from branch.
|
|
118
|
+
* 2. Exact match against plans subdirectory names.
|
|
119
|
+
* 3. Prefix match (branch slug starts with a plan dir name).
|
|
120
|
+
* 4. Fallback: most-recently-modified plans dir that has phase trackers.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} repoPath - path to the git checkout
|
|
123
|
+
* @param {string|null} branch
|
|
124
|
+
* @returns {string|null}
|
|
125
|
+
*/
|
|
126
|
+
function resolveFeatureSlug(repoPath, branch) {
|
|
127
|
+
const plansDir = path.join(repoPath, 'docs', 'plans');
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = readdirSync(plansDir, { withFileTypes: true })
|
|
131
|
+
.filter((d) => d.isDirectory())
|
|
132
|
+
.map((d) => d.name);
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (branch) {
|
|
138
|
+
// Strip feat/ prefix, any nested path, and trailing -phase-X suffix
|
|
139
|
+
const slug = branch
|
|
140
|
+
.replace(/^feat\//, '')
|
|
141
|
+
.replace(/\/.*$/, '')
|
|
142
|
+
.replace(/-phase-[a-z]$/, '');
|
|
143
|
+
|
|
144
|
+
// Exact match
|
|
145
|
+
if (entries.includes(slug)) return slug;
|
|
146
|
+
|
|
147
|
+
// Prefix match (slug starts with a plan dir name)
|
|
148
|
+
const prefixMatch = entries.find((d) => slug.startsWith(d));
|
|
149
|
+
if (prefixMatch) return prefixMatch;
|
|
150
|
+
|
|
151
|
+
// Branch provided but no name match — don't guess
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// No branch: fallback to most-recently-modified dir with phase tracker files
|
|
156
|
+
let newest = null;
|
|
157
|
+
let newestMtime = 0;
|
|
158
|
+
for (const dir of entries) {
|
|
159
|
+
const dirPath = path.join(plansDir, dir);
|
|
160
|
+
try {
|
|
161
|
+
const files = readdirSync(dirPath);
|
|
162
|
+
if (!files.some((f) => /^phase-[a-z]-tasks\.md$/.test(f))) continue;
|
|
163
|
+
const mtime = statSync(dirPath).mtimeMs;
|
|
164
|
+
if (mtime > newestMtime) {
|
|
165
|
+
newestMtime = mtime;
|
|
166
|
+
newest = dir;
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// skip unreadable entries
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return newest;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse a single phase tracker file into phase/task state.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} filePath
|
|
180
|
+
* @param {boolean} isRecentlyActive - whether the world had recent activity
|
|
181
|
+
* @param {{ workingMarked: boolean }} state - mutable flag shared across phases
|
|
182
|
+
* @returns {{ id: string, name: string, status: string, tasks: Array }|null}
|
|
183
|
+
*/
|
|
184
|
+
function parseTrackerFile(filePath, isRecentlyActive, state) {
|
|
185
|
+
let content;
|
|
186
|
+
try {
|
|
187
|
+
content = readFileSync(filePath, 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const fm = parseFrontmatter(content);
|
|
193
|
+
|
|
194
|
+
// Phase ID: frontmatter "phase" field or filename "phase-X-tasks.md"
|
|
195
|
+
const phaseId =
|
|
196
|
+
fm.phase ||
|
|
197
|
+
path.basename(filePath).match(/^phase-([a-z])-tasks\.md$/)?.[1] ||
|
|
198
|
+
'?';
|
|
199
|
+
|
|
200
|
+
const phaseName = `Phase ${phaseId.toUpperCase()}`;
|
|
201
|
+
|
|
202
|
+
const taskDefs = extractTaskDefs(content);
|
|
203
|
+
if (taskDefs.length === 0) return null;
|
|
204
|
+
|
|
205
|
+
// Collect completions from all sources
|
|
206
|
+
const cp0Completed = extractCp0Completed(content);
|
|
207
|
+
const itemTableCompleted = extractItemTableCompleted(content);
|
|
208
|
+
const doneCount = extractDoneCount(content);
|
|
209
|
+
|
|
210
|
+
// Merge CP0 log + item-table; count-format overrides if present
|
|
211
|
+
const mergedCompleted = new Set([...cp0Completed, ...itemTableCompleted]);
|
|
212
|
+
|
|
213
|
+
const tasks = taskDefs.map((t, i) => {
|
|
214
|
+
const isComplete =
|
|
215
|
+
doneCount !== null
|
|
216
|
+
? i < doneCount // count format is authoritative
|
|
217
|
+
: mergedCompleted.has(t.id);
|
|
218
|
+
|
|
219
|
+
if (isComplete) return { id: t.id, name: t.name, status: 'complete' };
|
|
220
|
+
|
|
221
|
+
// First pending task across all phases = candidate for "working"
|
|
222
|
+
if (!state.workingMarked) {
|
|
223
|
+
state.workingMarked = true;
|
|
224
|
+
return {
|
|
225
|
+
id: t.id,
|
|
226
|
+
name: t.name,
|
|
227
|
+
status: isRecentlyActive ? 'working' : 'pending',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { id: t.id, name: t.name, status: 'pending' };
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const allComplete = tasks.every((t) => t.status === 'complete');
|
|
235
|
+
const anyWorking = tasks.some((t) => t.status === 'working');
|
|
236
|
+
const phaseStatus = allComplete ? 'complete' : anyWorking ? 'working' : 'pending';
|
|
237
|
+
|
|
238
|
+
return { id: phaseId, name: phaseName, status: phaseStatus, tasks };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read plan progress from a world's git checkout.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} repoPath - absolute path to the git checkout
|
|
245
|
+
* @param {string|null} branch - current branch name (e.g. "feat/foo-phase-a")
|
|
246
|
+
* @param {{ lastActivityAtMs?: number|null }} [opts]
|
|
247
|
+
* @returns {{ feature: string, phases: Array }|null}
|
|
248
|
+
* null when no plan tracker is found (caller falls back to legacy bar)
|
|
249
|
+
*/
|
|
250
|
+
export function readPlanProgress(repoPath, branch, { lastActivityAtMs = null } = {}) {
|
|
251
|
+
const feature = resolveFeatureSlug(repoPath, branch);
|
|
252
|
+
if (!feature) return null;
|
|
253
|
+
|
|
254
|
+
const plansDir = path.join(repoPath, 'docs', 'plans', feature);
|
|
255
|
+
let phaseFiles;
|
|
256
|
+
try {
|
|
257
|
+
phaseFiles = readdirSync(plansDir)
|
|
258
|
+
.filter((f) => /^phase-[a-z]-tasks\.md$/.test(f))
|
|
259
|
+
.sort();
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (phaseFiles.length === 0) return null;
|
|
265
|
+
|
|
266
|
+
const isRecentlyActive =
|
|
267
|
+
lastActivityAtMs != null
|
|
268
|
+
? Date.now() - lastActivityAtMs <= WORKING_THRESHOLD_MS
|
|
269
|
+
: false;
|
|
270
|
+
|
|
271
|
+
const state = { workingMarked: false };
|
|
272
|
+
|
|
273
|
+
const phases = phaseFiles
|
|
274
|
+
.map((file) =>
|
|
275
|
+
parseTrackerFile(path.join(plansDir, file), isRecentlyActive, state),
|
|
276
|
+
)
|
|
277
|
+
.filter(Boolean);
|
|
278
|
+
|
|
279
|
+
if (phases.length === 0) return null;
|
|
280
|
+
|
|
281
|
+
return { feature, phases };
|
|
282
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache for GitHub PR data with TTL and concurrent-fetch coalescing.
|
|
3
|
+
*
|
|
4
|
+
* @module pr-cache
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const GH_API_BASE = 'https://api.github.com';
|
|
8
|
+
const TTL_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse owner, repo, and PR number from a GitHub PR URL.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} prUrl e.g. https://github.com/owner/repo/pull/123
|
|
14
|
+
* @returns {{ owner: string, repo: string, number: number } | null}
|
|
15
|
+
*/
|
|
16
|
+
function parsePrUrl(prUrl) {
|
|
17
|
+
const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reduce an array of check runs into a single status string.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{conclusion: string|null, status: string}>} checkRuns
|
|
26
|
+
* @returns {'pending'|'passing'|'failing'|null}
|
|
27
|
+
*/
|
|
28
|
+
function reduceCheckRuns(checkRuns) {
|
|
29
|
+
if (!checkRuns || checkRuns.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
let hasFailure = false;
|
|
32
|
+
let hasPending = false;
|
|
33
|
+
|
|
34
|
+
for (const run of checkRuns) {
|
|
35
|
+
const conclusion = run.conclusion;
|
|
36
|
+
const status = run.status;
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
conclusion === 'failure' ||
|
|
40
|
+
conclusion === 'timed_out' ||
|
|
41
|
+
conclusion === 'action_required'
|
|
42
|
+
) {
|
|
43
|
+
hasFailure = true;
|
|
44
|
+
} else if (
|
|
45
|
+
status === 'queued' ||
|
|
46
|
+
status === 'in_progress' ||
|
|
47
|
+
conclusion === null
|
|
48
|
+
) {
|
|
49
|
+
hasPending = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (hasFailure) return 'failing';
|
|
54
|
+
if (hasPending) return 'pending';
|
|
55
|
+
return 'passing';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} PrCacheEntry
|
|
60
|
+
* @property {number} fetchedAt
|
|
61
|
+
* @property {'open'|'merged'|'closed'|null} prState
|
|
62
|
+
* @property {number|null} prNumber
|
|
63
|
+
* @property {'pending'|'passing'|'failing'|null} prChecks
|
|
64
|
+
* @property {Promise<PrData>|null} promise
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} PrData
|
|
69
|
+
* @property {'open'|'merged'|'closed'|null} state
|
|
70
|
+
* @property {number|null} number
|
|
71
|
+
* @property {'pending'|'passing'|'failing'|null} checks
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch PR data from GitHub API.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} prUrl
|
|
78
|
+
* @param {() => Promise<string|null>} getToken
|
|
79
|
+
* @returns {Promise<PrData>}
|
|
80
|
+
*/
|
|
81
|
+
async function fetchPrData(prUrl, getToken) {
|
|
82
|
+
const parsed = parsePrUrl(prUrl);
|
|
83
|
+
if (!parsed) return { state: null, number: null, checks: null };
|
|
84
|
+
|
|
85
|
+
const token = await getToken();
|
|
86
|
+
/** @type {HeadersInit} */
|
|
87
|
+
const headers = { Accept: 'application/vnd.github+json' };
|
|
88
|
+
if (token) headers['Authorization'] = `token ${token}`;
|
|
89
|
+
|
|
90
|
+
// Fetch PR metadata
|
|
91
|
+
const prResp = await fetch(
|
|
92
|
+
`${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`,
|
|
93
|
+
{ headers, signal: AbortSignal.timeout(10_000) },
|
|
94
|
+
);
|
|
95
|
+
if (!prResp.ok) {
|
|
96
|
+
return { state: null, number: parsed.number, checks: null };
|
|
97
|
+
}
|
|
98
|
+
const prData = await prResp.json();
|
|
99
|
+
|
|
100
|
+
let state = prData.state ?? null;
|
|
101
|
+
if (state === 'closed' && prData.merged_at) state = 'merged';
|
|
102
|
+
|
|
103
|
+
const sha = prData.head?.sha ?? null;
|
|
104
|
+
if (!sha) {
|
|
105
|
+
return { state, number: parsed.number, checks: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fetch check runs for the head SHA
|
|
109
|
+
let checks = null;
|
|
110
|
+
try {
|
|
111
|
+
const checksResp = await fetch(
|
|
112
|
+
`${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/commits/${sha}/check-runs`,
|
|
113
|
+
{ headers, signal: AbortSignal.timeout(10_000) },
|
|
114
|
+
);
|
|
115
|
+
if (checksResp.ok) {
|
|
116
|
+
const checksData = await checksResp.json();
|
|
117
|
+
const runs = Array.isArray(checksData.check_runs) ? checksData.check_runs : [];
|
|
118
|
+
checks = reduceCheckRuns(runs);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Non-fatal — return null checks
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { state, number: parsed.number, checks };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a PR data cache with TTL and concurrent-fetch coalescing.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null> }}
|
|
131
|
+
*/
|
|
132
|
+
export function createPrCache() {
|
|
133
|
+
/** @type {Map<string, PrCacheEntry>} */
|
|
134
|
+
const cache = new Map();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get PR data for a URL, using cache if fresh or coalescing concurrent fetches.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} prUrl
|
|
140
|
+
* @param {() => Promise<string|null>} getToken
|
|
141
|
+
* @returns {Promise<PrData|null>}
|
|
142
|
+
*/
|
|
143
|
+
async function getPr(prUrl, getToken) {
|
|
144
|
+
if (!prUrl) return null;
|
|
145
|
+
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const entry = cache.get(prUrl);
|
|
148
|
+
|
|
149
|
+
// Fresh cache hit
|
|
150
|
+
if (entry && !entry.promise && now - entry.fetchedAt < TTL_MS) {
|
|
151
|
+
return { state: entry.prState, number: entry.prNumber, checks: entry.prChecks };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// In-flight fetch — coalesce
|
|
155
|
+
if (entry && entry.promise) {
|
|
156
|
+
try {
|
|
157
|
+
return await entry.promise;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Stale or missing — start new fetch
|
|
164
|
+
const promise = fetchPrData(prUrl, getToken).then(
|
|
165
|
+
(data) => {
|
|
166
|
+
cache.set(prUrl, {
|
|
167
|
+
fetchedAt: Date.now(),
|
|
168
|
+
prState: data.state,
|
|
169
|
+
prNumber: data.number,
|
|
170
|
+
prChecks: data.checks,
|
|
171
|
+
promise: null,
|
|
172
|
+
});
|
|
173
|
+
return data;
|
|
174
|
+
},
|
|
175
|
+
(err) => {
|
|
176
|
+
// Clear promise on error so next call retries
|
|
177
|
+
const current = cache.get(prUrl);
|
|
178
|
+
if (current && current.promise) {
|
|
179
|
+
cache.set(prUrl, { ...current, promise: null });
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
cache.set(prUrl, {
|
|
186
|
+
fetchedAt: entry ? entry.fetchedAt : 0,
|
|
187
|
+
prState: entry ? entry.prState : null,
|
|
188
|
+
prNumber: entry ? entry.prNumber : null,
|
|
189
|
+
prChecks: entry ? entry.prChecks : null,
|
|
190
|
+
promise,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
return await promise;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { getPr };
|
|
201
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR merge poller for auto-destroying worlds whose PR has merged.
|
|
3
|
+
*
|
|
4
|
+
* State machine per world:
|
|
5
|
+
* open -> merged (on GitHub reports merged)
|
|
6
|
+
* merged -> merged_destroyed (after grace period, if auto_destroy_on_merge)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const GH_API_BASE = 'https://api.github.com';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse owner, repo, and PR number from a GitHub PR URL.
|
|
13
|
+
* @param {string} prUrl e.g. https://github.com/org/repo/pull/123
|
|
14
|
+
* @returns {{ owner: string, repo: string, number: number } | null}
|
|
15
|
+
*/
|
|
16
|
+
function parsePrUrl(prUrl) {
|
|
17
|
+
const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{
|
|
24
|
+
* prStateStore: import('./world-pr-state.mjs').ReturnType<typeof createWorldPrStateStore>,
|
|
25
|
+
* getGhToken: () => Promise<string|null>,
|
|
26
|
+
* destroyWorld: (worldId: string) => Promise<void>,
|
|
27
|
+
* pollIntervalMs?: number,
|
|
28
|
+
* gracePeriodMs?: number,
|
|
29
|
+
* }} opts
|
|
30
|
+
*/
|
|
31
|
+
export function createPrMergePoller({
|
|
32
|
+
prStateStore,
|
|
33
|
+
getGhToken,
|
|
34
|
+
destroyWorld,
|
|
35
|
+
pollIntervalMs = 300_000,
|
|
36
|
+
gracePeriodMs = 600_000,
|
|
37
|
+
}) {
|
|
38
|
+
let intervalId = null;
|
|
39
|
+
let disabled = false;
|
|
40
|
+
let warnedOnce = false;
|
|
41
|
+
// Track in-flight grace timers so stop() can clear them
|
|
42
|
+
const graceTimers = new Map();
|
|
43
|
+
|
|
44
|
+
async function destroyAndMark(worldId) {
|
|
45
|
+
const entry = prStateStore.get(worldId);
|
|
46
|
+
const prUrl = entry?.pr_url ?? '(unknown)';
|
|
47
|
+
const mergedAt = entry?.pr_merged_at ?? '(unknown)';
|
|
48
|
+
console.log(
|
|
49
|
+
`[pr-merge-poller] auto-destroyed world ${worldId}: PR ${prUrl} merged at ${mergedAt}, destroyed at ${new Date().toISOString()}`,
|
|
50
|
+
);
|
|
51
|
+
try {
|
|
52
|
+
await destroyWorld(worldId);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`[pr-merge-poller] destroyWorld failed for ${worldId}:`, err.message);
|
|
55
|
+
}
|
|
56
|
+
prStateStore.set(worldId, { pr_state: 'merged_destroyed' });
|
|
57
|
+
graceTimers.delete(worldId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scheduleGrace(worldId, entry) {
|
|
61
|
+
if (graceTimers.has(worldId)) return; // already scheduled
|
|
62
|
+
const id = setTimeout(() => {
|
|
63
|
+
destroyAndMark(worldId).catch((err) => {
|
|
64
|
+
console.error(`[pr-merge-poller] destroyAndMark error for ${worldId}:`, err.message);
|
|
65
|
+
});
|
|
66
|
+
}, gracePeriodMs);
|
|
67
|
+
graceTimers.set(worldId, id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkPr(worldId, entry, ghToken) {
|
|
71
|
+
const parsed = parsePrUrl(entry.pr_url);
|
|
72
|
+
if (!parsed) {
|
|
73
|
+
console.warn(`[pr-merge-poller] cannot parse PR URL for ${worldId}: ${entry.pr_url}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const apiUrl = `${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`;
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
const resp = await fetch(apiUrl, {
|
|
80
|
+
headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' },
|
|
81
|
+
});
|
|
82
|
+
if (!resp.ok) {
|
|
83
|
+
console.warn(`[pr-merge-poller] GH API ${resp.status} for ${worldId}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
data = await resp.json();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn(`[pr-merge-poller] fetch failed for ${worldId}:`, err.message);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isMerged = data.state === 'closed' && data.merged_at != null;
|
|
93
|
+
if (!isMerged) return;
|
|
94
|
+
|
|
95
|
+
prStateStore.set(worldId, {
|
|
96
|
+
pr_state: 'merged',
|
|
97
|
+
pr_merged_at: data.merged_at,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (entry.auto_destroy_on_merge === false) return;
|
|
101
|
+
|
|
102
|
+
scheduleGrace(worldId, prStateStore.get(worldId));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pollOnce() {
|
|
106
|
+
const ghToken = await getGhToken();
|
|
107
|
+
if (!ghToken) {
|
|
108
|
+
if (!warnedOnce) {
|
|
109
|
+
console.warn(
|
|
110
|
+
'pr-merge-poller: no GH token found (GH_TOKEN/GITHUB_TOKEN env or /gh-config/hosts.yml); PR polling disabled',
|
|
111
|
+
);
|
|
112
|
+
warnedOnce = true;
|
|
113
|
+
}
|
|
114
|
+
disabled = true;
|
|
115
|
+
stop();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const worlds = prStateStore.getWorldsToWatch();
|
|
120
|
+
for (const entry of worlds) {
|
|
121
|
+
const { worldId, ...rest } = entry;
|
|
122
|
+
if (rest.pr_state === 'open') {
|
|
123
|
+
await checkPr(worldId, rest, ghToken);
|
|
124
|
+
} else if (rest.pr_state === 'merged') {
|
|
125
|
+
// Resume grace timer for merged entries that survived a restart
|
|
126
|
+
if (rest.auto_destroy_on_merge !== false) {
|
|
127
|
+
scheduleGrace(worldId, rest);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function start() {
|
|
134
|
+
if (intervalId !== null || disabled) return;
|
|
135
|
+
intervalId = setInterval(() => {
|
|
136
|
+
pollOnce().catch((err) => {
|
|
137
|
+
console.error('[pr-merge-poller] pollOnce error:', err.message);
|
|
138
|
+
});
|
|
139
|
+
}, pollIntervalMs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stop() {
|
|
143
|
+
if (intervalId !== null) {
|
|
144
|
+
clearInterval(intervalId);
|
|
145
|
+
intervalId = null;
|
|
146
|
+
}
|
|
147
|
+
for (const id of graceTimers.values()) {
|
|
148
|
+
clearTimeout(id);
|
|
149
|
+
}
|
|
150
|
+
graceTimers.clear();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { start, stop };
|
|
154
|
+
}
|