@worca/app 1.4.0-rc.1 → 1.5.0-rc.2

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -0,0 +1,383 @@
1
+ // src/core/metrics/prs.mjs
2
+ // Pull-request states for the delivery timeline (docs/team-metrics.md "Timeline"). A run record
3
+ // is written when the run ends, before its PR exists, so the merge is joined in at read time from
4
+ // three sources, most trusted first:
5
+ // 1. PR event files on the worca-metrics branch (`.worca-metrics/prs/<number>.json`), written
6
+ // by the optional GitHub Action (docs/team-metrics-pr-events.md);
7
+ // 2. the GitHub CLI: one batched GraphQL query per 30 branches, cached in
8
+ // ~/.worca-cc/metrics/pr-cache.json (a merged PR is never asked about again);
9
+ // 3. this machine's own pipelines table (pr_url / pr_state, no dates).
10
+ // Every source is optional. Without gh and without the Action the page still renders; the
11
+ // status block says which source was missing so the page can explain the gap. Never throws.
12
+ import { execFile } from 'node:child_process';
13
+ import { lstat, readdir, readFile, realpath, mkdir, writeFile, rename } from 'node:fs/promises';
14
+ import { join, sep } from 'node:path';
15
+ import { METRICS_DIR, metricsRoot, worktreePath } from './sync.mjs';
16
+ import { prepare } from '../db.mjs';
17
+
18
+ export const PR_EVENTS_DIR = 'prs';
19
+ export const MAX_EVENT_FILE_BYTES = 64 * 1024;
20
+ export const GH_BATCH = 30;
21
+ export const MAX_LOOKUPS = 600; // per request: the page asks for the window it shows
22
+ export const OPEN_TTL_MS = 10 * 60_000; // an open PR (or none yet) is re-asked after this
23
+ export const NONE_OLD_TTL_MS = 24 * 3_600_000; // …or after a day, once the run is a month old
24
+ export const GH_STATUS_TTL_MS = 5 * 60_000;
25
+ const DAY = 86_400_000;
26
+ const STATES = new Set(['OPEN', 'MERGED', 'CLOSED']);
27
+
28
+ function defaultRun(cmd, args, { timeout = 30_000 } = {}) {
29
+ return new Promise((resolve) => {
30
+ try {
31
+ execFile(cmd, args, { timeout, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
32
+ resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || err?.message || ''), code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, missing: err?.code === 'ENOENT' });
33
+ });
34
+ } catch (err) { resolve({ ok: false, stdout: '', stderr: String(err?.message || err), code: -1, missing: err?.code === 'ENOENT' }); }
35
+ });
36
+ }
37
+
38
+ let _run = defaultRun;
39
+ let _now = () => Date.now();
40
+ let _ghStatus = null; // { state, detail, at }
41
+
42
+ const isoOrNull = (v) => (typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null);
43
+ const lower = (s) => String(s || '').toLowerCase();
44
+ /** github.com slugs are exactly "owner/repo"; every other host keeps its host segment. */
45
+ export const isGithubSlug = (slug) => typeof slug === 'string' && /^[a-z0-9._-]+\/[a-z0-9._-]+$/i.test(slug);
46
+
47
+ /** "https://github.com/o/r/pull/12" → { repo: 'o/r', number: 12 } | null */
48
+ export function parsePrUrl(url) {
49
+ const m = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i.exec(String(url || ''));
50
+ return m ? { repo: `${m[1]}/${m[2]}`, number: Number(m[3]) } : null;
51
+ }
52
+
53
+ /** One PR event file (v1) → a normalised PR, or null when it is not one. */
54
+ export function parsePrEvent(text) {
55
+ let v;
56
+ try { v = JSON.parse(text); } catch { return null; }
57
+ if (!v || typeof v !== 'object' || v.v !== 1 || v.kind !== 'pr') return null;
58
+ if (typeof v.repo !== 'string' || !Number.isInteger(v.number) || typeof v.head !== 'string') return null;
59
+ const state = String(v.state || '').toUpperCase();
60
+ if (!STATES.has(state)) return null;
61
+ return {
62
+ repo: v.repo, number: v.number, url: typeof v.url === 'string' ? v.url : null,
63
+ title: typeof v.title === 'string' ? v.title.slice(0, 200) : null,
64
+ head: v.head, base: typeof v.base === 'string' ? v.base : null, state,
65
+ author: typeof v.author === 'string' && v.author ? v.author.slice(0, 100) : null,
66
+ authorName: typeof v.authorName === 'string' && v.authorName ? v.authorName.slice(0, 200) : null,
67
+ authorKey: typeof v.authorKey === 'string' && /^[0-9a-f]{16}$/.test(v.authorKey) ? v.authorKey : null,
68
+ createdAt: isoOrNull(v.createdAt), mergedAt: isoOrNull(v.mergedAt), closedAt: isoOrNull(v.closedAt),
69
+ updatedAt: isoOrNull(v.updatedAt), via: 'action',
70
+ };
71
+ }
72
+
73
+ /** Every PR event under a metrics worktree. Same guards as the run records (read.mjs). */
74
+ export async function readPrEventsFromDir(dir) {
75
+ const out = [];
76
+ for (const p of [join(dir, METRICS_DIR), join(dir, METRICS_DIR, PR_EVENTS_DIR)]) {
77
+ const st = await lstat(p).catch(() => null);
78
+ if (!st || !st.isDirectory()) return out;
79
+ }
80
+ const root = join(dir, METRICS_DIR, PR_EVENTS_DIR);
81
+ const rootReal = await realpath(root).catch(() => null);
82
+ if (!rootReal) return out;
83
+ let ents;
84
+ try { ents = await readdir(root, { withFileTypes: true }); } catch { return out; }
85
+ for (const d of ents) {
86
+ if (!d.isFile() || !/^\d+\.json$/.test(d.name)) continue;
87
+ const p = join(root, d.name);
88
+ const st = await lstat(p).catch(() => null);
89
+ const real = st ? await realpath(p).catch(() => null) : null;
90
+ if (!st || !st.isFile() || st.size > MAX_EVENT_FILE_BYTES || !real || !real.startsWith(rootReal + sep)) continue;
91
+ const ev = parsePrEvent(await readFile(p, 'utf8').catch(() => ''));
92
+ if (ev) out.push(ev);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // ---- gh ------------------------------------------------------------------------------------
98
+
99
+ /** 'ok' | 'missing' (no gh on PATH) | 'unauthenticated'. Memoised for a few minutes. */
100
+ export async function ghStatus() {
101
+ const now = _now();
102
+ if (_ghStatus && now - _ghStatus.at < GH_STATUS_TTL_MS) return _ghStatus;
103
+ const r = await _run('gh', ['auth', 'status', '--hostname', 'github.com'], { timeout: 15_000 });
104
+ const state = r.ok ? 'ok' : r.missing ? 'missing' : 'unauthenticated';
105
+ _ghStatus = { state, detail: r.ok ? null : String(r.stderr || '').trim().split('\n')[0] || null, at: now };
106
+ return _ghStatus;
107
+ }
108
+
109
+ const gqlString = (s) => JSON.stringify(String(s));
110
+ const PR_FIELDS = 'number url title state createdAt mergedAt closedAt headRefName baseRefName';
111
+
112
+ /** One GraphQL document for up to GH_BATCH {repo, branch} lookups, aliased q0…qN. */
113
+ export function buildBranchQuery(lookups) {
114
+ const parts = lookups.map((l, i) => {
115
+ const [owner, name] = l.repo.split('/');
116
+ return `q${i}: repository(owner: ${gqlString(owner)}, name: ${gqlString(name)}) { pullRequests(headRefName: ${gqlString(l.branch)}, first: 5, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ${PR_FIELDS} } } }`;
117
+ });
118
+ return `query {\n${parts.join('\n')}\n}`;
119
+ }
120
+
121
+ function fromGhNode(repo, n) {
122
+ if (!n || !Number.isInteger(n.number) || !STATES.has(n.state)) return null;
123
+ return { repo, number: n.number, url: n.url || null, title: n.title || null, head: n.headRefName || null, base: n.baseRefName || null, state: n.state, createdAt: isoOrNull(n.createdAt), mergedAt: isoOrNull(n.mergedAt), closedAt: isoOrNull(n.closedAt), via: 'gh' };
124
+ }
125
+
126
+ /**
127
+ * @returns {Promise<{results: Map<string, object[]>, error: string|null}>} key "repo#branch"
128
+ * (lower-cased repo) → PRs. A lookup GitHub could not answer (no access, unknown repo) is
129
+ * left out, so it is asked again next time rather than cached as "no PR".
130
+ */
131
+ export async function lookupBranchesViaGh(lookups) {
132
+ const results = new Map();
133
+ let error = null;
134
+ for (let i = 0; i < lookups.length; i += GH_BATCH) {
135
+ const batch = lookups.slice(i, i + GH_BATCH);
136
+ const r = await _run('gh', ['api', 'graphql', '-f', `query=${buildBranchQuery(batch)}`], { timeout: 30_000 });
137
+ let body = null;
138
+ try { body = JSON.parse(r.stdout || 'null'); } catch { body = null; }
139
+ // gh exits non-zero when ANY alias errors (an unknown repo), but still prints the partial data.
140
+ if (!body || typeof body !== 'object' || !body.data) { error ||= String(r.stderr || 'gh api graphql failed').trim().split('\n')[0]; continue; }
141
+ batch.forEach((l, j) => {
142
+ const repoNode = body.data[`q${j}`];
143
+ if (!repoNode) return;
144
+ const nodes = Array.isArray(repoNode.pullRequests?.nodes) ? repoNode.pullRequests.nodes : [];
145
+ results.set(`${lower(l.repo)}#${l.branch}`, nodes.map((n) => fromGhNode(l.repo, n)).filter(Boolean));
146
+ });
147
+ }
148
+ return { results, error };
149
+ }
150
+
151
+ // ---- cache ---------------------------------------------------------------------------------
152
+
153
+ export function cachePath() { return join(metricsRoot(), 'pr-cache.json'); }
154
+
155
+ async function loadCache() {
156
+ try {
157
+ const v = JSON.parse(await readFile(cachePath(), 'utf8'));
158
+ return v && v.v === 1 && v.entries && typeof v.entries === 'object' ? v : { v: 1, entries: {} };
159
+ } catch { return { v: 1, entries: {} }; }
160
+ }
161
+
162
+ async function saveCache(cache) {
163
+ try {
164
+ await mkdir(metricsRoot(), { recursive: true });
165
+ const tmp = `${cachePath()}.${process.pid}.tmp`;
166
+ await writeFile(tmp, JSON.stringify(cache));
167
+ await rename(tmp, cachePath());
168
+ } catch { /* a cache that cannot be written is only slower next time */ }
169
+ }
170
+
171
+ /** A cached answer is final once a PR merged; otherwise it is re-asked after a TTL. */
172
+ export function cacheFresh(entry, { now = _now(), runEndedMs = null } = {}) {
173
+ if (!entry || !Array.isArray(entry.prs) || !Number.isFinite(entry.checkedAt)) return false;
174
+ if (entry.prs.some((p) => p.state === 'MERGED')) return true;
175
+ const old = runEndedMs != null && now - runEndedMs > 30 * DAY;
176
+ const ttl = entry.prs.length === 0 && old ? NONE_OLD_TTL_MS : entry.prs.every((p) => p.state === 'CLOSED') && entry.prs.length ? NONE_OLD_TTL_MS : OPEN_TTL_MS;
177
+ return now - entry.checkedAt < ttl;
178
+ }
179
+
180
+ // ---- local pipelines -----------------------------------------------------------------------
181
+
182
+ function localPrs(ids) {
183
+ const out = new Map();
184
+ if (!ids.length) return out;
185
+ try {
186
+ for (let i = 0; i < ids.length; i += 500) {
187
+ const chunk = ids.slice(i, i + 500);
188
+ const rows = prepare(`SELECT id, pr_url, pr_number, pr_state FROM pipelines WHERE pr_url IS NOT NULL AND id IN (${chunk.map(() => '?').join(',')})`).all(...chunk);
189
+ for (const row of rows) {
190
+ const parsed = parsePrUrl(row.pr_url);
191
+ const state = STATES.has(String(row.pr_state || '').toUpperCase()) ? String(row.pr_state).toUpperCase() : 'OPEN';
192
+ out.set(row.id, { repo: parsed?.repo || null, number: row.pr_number ?? parsed?.number ?? null, url: row.pr_url, title: null, state, createdAt: null, mergedAt: null, closedAt: null, via: 'local' });
193
+ }
194
+ }
195
+ } catch { /* no DB (a CLI-less reader): skip */ }
196
+ return out;
197
+ }
198
+
199
+ // ---- resolve -------------------------------------------------------------------------------
200
+
201
+ const RANK = { OPEN: 0, CLOSED: 1, MERGED: 2 };
202
+ const VIA_RANK = { local: 1, gh: 2, action: 3 };
203
+ /** The better of two answers for one PR: a more final state, then a more trusted source. */
204
+ function better(a, b) {
205
+ if (!a) return b;
206
+ if (RANK[b.state] !== RANK[a.state]) return RANK[b.state] > RANK[a.state] ? b : a;
207
+ return (VIA_RANK[b.via] ?? 0) >= (VIA_RANK[a.via] ?? 0) ? { ...a, ...Object.fromEntries(Object.entries(b).filter(([, v]) => v != null)) } : a;
208
+ }
209
+
210
+ function sanitizeLookup(x) {
211
+ if (!x || typeof x.id !== 'string' || !x.id || x.id.length > 64) return null;
212
+ const repos = Array.isArray(x.repos) ? x.repos.filter((r) => typeof r === 'string' && r && r.length <= 200).slice(0, 20) : [];
213
+ const branch = typeof x.branch === 'string' && x.branch && x.branch.length <= 255 ? x.branch : null;
214
+ const pr = x.pr && typeof x.pr === 'object' ? { url: typeof x.pr.url === 'string' ? x.pr.url : null, number: Number.isInteger(x.pr.number) ? x.pr.number : null } : null;
215
+ const endedMs = typeof x.endedAt === 'string' ? Date.parse(x.endedAt) : NaN;
216
+ return { id: x.id, repos, branch, pr, endedMs: Number.isFinite(endedMs) ? endedMs : null };
217
+ }
218
+
219
+ /**
220
+ * @param {object} p
221
+ * @param {object[]} p.runs [{ id, repos, branch, pr, endedAt }] from prLookupFor() (shared/timeline).
222
+ * @param {string[]} p.sinks metrics slugs whose worktrees may hold PR events.
223
+ * @param {boolean} [p.useGh]
224
+ * @returns {Promise<{ prs: Object<string, object[]|null>, status: object }>}
225
+ */
226
+ export async function resolveRunPrs({ runs = [], sinks = [], useGh = true } = {}) {
227
+ const now = _now();
228
+ const lookups = runs.map(sanitizeLookup).filter(Boolean).slice(0, MAX_LOOKUPS);
229
+ const status = { gh: 'unused', ghDetail: null, ghError: null, actionRepos: [], unsupportedRepos: [], checked: 0 };
230
+
231
+ // 1. Action events, indexed by repo#branch and repo#number.
232
+ const byBranch = new Map();
233
+ const byNumber = new Map();
234
+ const actionRepos = new Set();
235
+ for (const slug of sinks) {
236
+ let dir;
237
+ try { dir = worktreePath(slug); } catch { continue; }
238
+ for (const ev of await readPrEventsFromDir(dir)) {
239
+ const repo = lower(ev.repo);
240
+ actionRepos.add(repo);
241
+ const bk = `${repo}#${ev.head}`;
242
+ if (!byBranch.has(bk)) byBranch.set(bk, []);
243
+ byBranch.get(bk).push(ev);
244
+ byNumber.set(`${repo}#${ev.number}`, ev);
245
+ }
246
+ }
247
+ status.actionRepos = [...actionRepos];
248
+
249
+ // 2. Local pipelines (this machine's own runs).
250
+ const local = localPrs(lookups.map((l) => l.id));
251
+
252
+ // 3. gh, for the repo#branch pairs the Action has not answered.
253
+ const cache = await loadCache();
254
+ const need = new Map(); // key → { repo, branch, endedMs }
255
+ const unsupported = new Set();
256
+ for (const l of lookups) {
257
+ if (!l.branch) continue;
258
+ for (const repo of l.repos) {
259
+ const key = `${lower(repo)}#${l.branch}`;
260
+ if (byBranch.has(key)) continue;
261
+ if (!isGithubSlug(repo)) { if (!actionRepos.has(lower(repo))) unsupported.add(repo); continue; }
262
+ if (cacheFresh(cache.entries[key], { now, runEndedMs: l.endedMs })) continue;
263
+ const prev = need.get(key);
264
+ if (!prev || (l.endedMs ?? 0) > (prev.endedMs ?? 0)) need.set(key, { repo, branch: l.branch, endedMs: l.endedMs });
265
+ }
266
+ }
267
+ status.unsupportedRepos = [...unsupported];
268
+ if (useGh && need.size) {
269
+ const gh = await ghStatus();
270
+ status.gh = gh.state;
271
+ status.ghDetail = gh.detail;
272
+ if (gh.state === 'ok') {
273
+ const { results, error } = await lookupBranchesViaGh([...need.values()]);
274
+ status.ghError = error;
275
+ status.checked = results.size;
276
+ for (const [key, prs] of results) cache.entries[key] = { prs, checkedAt: now };
277
+ if (results.size) await saveCache(cache);
278
+ }
279
+ }
280
+
281
+ // Join per run.
282
+ const prs = {};
283
+ for (const l of lookups) {
284
+ const found = new Map(); // repo#number → pr
285
+ let known = false;
286
+ const add = (p) => {
287
+ if (!p) return;
288
+ const k = `${lower(p.repo)}#${p.number ?? p.url}`;
289
+ found.set(k, better(found.get(k), p));
290
+ };
291
+ // The record's own `pr` carries no state, so on its own it proves nothing about a merge:
292
+ // it only helps to find the PR's event when the Action has written one.
293
+ if (l.pr) {
294
+ const parsed = parsePrUrl(l.pr.url);
295
+ const repo = parsed?.repo || l.repos[0] || null;
296
+ const number = l.pr.number ?? parsed?.number ?? null;
297
+ const ev = repo && number != null ? byNumber.get(`${lower(repo)}#${number}`) : null;
298
+ if (ev) { add(ev); known = true; }
299
+ }
300
+ if (local.has(l.id)) add(local.get(l.id));
301
+ if (l.branch) {
302
+ for (const repo of l.repos) {
303
+ const key = `${lower(repo)}#${l.branch}`;
304
+ const evs = byBranch.get(key);
305
+ const cached = cache.entries[key];
306
+ if (evs) { known = true; evs.forEach(add); }
307
+ else if (cached && Array.isArray(cached.prs)) { known = true; cached.prs.forEach((p) => add({ ...p, via: 'gh' })); }
308
+ }
309
+ }
310
+ // A PR known only from this machine's table is still a real PR, but "no PR" is only claimed
311
+ // when the Action or GitHub actually answered for the run's branch.
312
+ prs[l.id] = found.size ? [...found.values()] : known ? [] : null;
313
+ }
314
+ return { prs, status };
315
+ }
316
+
317
+ export const MAX_LISTED_EVENTS = 5000;
318
+
319
+ /**
320
+ * Every PR the Action recorded for the scope's repositories whose life overlaps [from, to): the
321
+ * Timeline shows the ones no recorded run points at as work done outside Worca. Only the
322
+ * Action's files can answer this — gh is asked per branch, never "list everything".
323
+ * @param {object} p
324
+ * @param {string[]} p.sinks metrics slugs whose worktrees hold the events.
325
+ * @param {string[]} p.repos the scope's repositories (slugs); events of other repos are dropped.
326
+ * @returns {Promise<{ prs: object[], truncated: boolean, actionRepos: string[] }>}
327
+ */
328
+ export async function listPrEvents({ sinks = [], repos = [], from = null, to = null } = {}) {
329
+ const now = _now();
330
+ const want = new Set(repos.map(lower));
331
+ const lo = Number.isFinite(from) ? from : -Infinity;
332
+ const hi = Number.isFinite(to) ? to : Infinity;
333
+ const seen = new Set();
334
+ const out = [];
335
+ const actionRepos = new Set();
336
+ for (const slug of sinks) {
337
+ let dir;
338
+ try { dir = worktreePath(slug); } catch { continue; }
339
+ for (const ev of await readPrEventsFromDir(dir)) {
340
+ const repo = lower(ev.repo);
341
+ if (want.size && !want.has(repo)) continue;
342
+ actionRepos.add(repo);
343
+ const key = `${repo}#${ev.number}`;
344
+ if (seen.has(key)) continue;
345
+ const start = Date.parse(ev.createdAt ?? '');
346
+ const end = Date.parse(ev.mergedAt ?? ev.closedAt ?? '') || now;
347
+ if (!Number.isFinite(start) || start >= hi || end < lo) continue;
348
+ seen.add(key);
349
+ out.push(ev);
350
+ }
351
+ }
352
+ out.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
353
+ return { prs: out.slice(0, MAX_LISTED_EVENTS), truncated: out.length > MAX_LISTED_EVENTS, actionRepos: [...actionRepos] };
354
+ }
355
+
356
+ // ---- the GitHub Action ---------------------------------------------------------------------
357
+
358
+ export const PR_WORKFLOW_PATH = '.github/workflows/worca-metrics-pr-events.yml';
359
+ export const PR_WORKFLOW_TEMPLATE = new URL('./pr-events-workflow.yml', import.meta.url);
360
+
361
+ export async function prWorkflowText() { return readFile(PR_WORKFLOW_TEMPLATE, 'utf8'); }
362
+
363
+ /**
364
+ * Write the PR-events workflow into a project (`worca metrics pr-workflow`). Never overwrites a
365
+ * file that differs unless `force`: the team may have edited it.
366
+ * @returns {Promise<{ path: string, status: 'created'|'updated'|'unchanged'|'differs' }>}
367
+ */
368
+ export async function installPrWorkflow(projectDir, { force = false } = {}) {
369
+ const text = await prWorkflowText();
370
+ const path = join(projectDir, ...PR_WORKFLOW_PATH.split('/'));
371
+ const current = await readFile(path, 'utf8').catch(() => null);
372
+ if (current === text) return { path, status: 'unchanged' };
373
+ if (current != null && !force) return { path, status: 'differs' };
374
+ await mkdir(join(projectDir, '.github', 'workflows'), { recursive: true });
375
+ await writeFile(path, text);
376
+ return { path, status: current == null ? 'created' : 'updated' };
377
+ }
378
+
379
+ export const _testing = {
380
+ setRunner(fn) { _run = typeof fn === 'function' ? fn : defaultRun; _ghStatus = null; },
381
+ setNow(fn) { _now = typeof fn === 'function' ? fn : () => Date.now(); },
382
+ reset() { _run = defaultRun; _now = () => Date.now(); _ghStatus = null; },
383
+ };
@@ -212,35 +212,7 @@ function syncFor(slug, prefs, pending) {
212
212
  * refresh:{requested,fetched,limited,retryInMs}, fetchError:string|null}>}
213
213
  */
214
214
  export async function readScope(scope, { refresh = false, defer = false } = {}) {
215
- const sources = [];
216
- let meta;
217
- let rateProjectDir = null;
218
- if (scope.kind === 'project') {
219
- const p = (await listProjects()).find((x) => x.key === scope.id);
220
- if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
221
- rateProjectDir = p.path;
222
- const sink = await resolveProjectSink(p.path);
223
- if (!sink.ok) {
224
- const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
225
- throw Object.assign(new Error(sink.detail || `team metrics are not enabled for ${p.name}`), { code });
226
- }
227
- const ownSlug = sink.from;
228
- sources.push({ slug: sink.slug, projectDir: sink.projectDir, keep: (r) => r.target?.kind === 'project' && r.target.project === ownSlug });
229
- meta = { kind: 'project', id: p.key, name: p.name, slug: ownSlug, recordedIn: sink.delegated ? sink.slug : null };
230
- } else {
231
- const ws = await readWorkspace(scope.id);
232
- if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
233
- const name = ws.name.toLowerCase();
234
- for (const path of ws.projectPaths) {
235
- const prefs = (await discoverProject(path).catch(() => null)) || readTeamMetricsPrefs(projectKey(path));
236
- if (!recordsLocally(prefs)) continue; // only members that record locally (config read, not a marker)
237
- sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
238
- }
239
- if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
240
- rateProjectDir = ws.metricsProject || null;
241
- const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
242
- meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
243
- }
215
+ const { sources, meta, rateProjectDir } = await scopeSources(scope);
244
216
  const seen = new Set();
245
217
  const records = [];
246
218
  const stats = { files: 0, malformed: 0, unknownV: 0 };
@@ -276,6 +248,44 @@ export async function readScope(scope, { refresh = false, defer = false } = {})
276
248
  return { scope: meta, records, stats, sinks: sources.map((s) => s.slug), sync, refresh: refreshInfo, fetchError, rateProjectDir };
277
249
  }
278
250
 
251
+ /**
252
+ * The metrics branches a scope reads (no git, no record parsing): a project's own sink (or the
253
+ * one it delegates to), or every workspace member that records locally.
254
+ * @returns {Promise<{sources: {slug, projectDir, keep}[], meta: object, rateProjectDir: string|null}>}
255
+ */
256
+ export async function scopeSources(scope) {
257
+ const sources = [];
258
+ let meta;
259
+ let rateProjectDir = null;
260
+ if (scope.kind === 'project') {
261
+ const p = (await listProjects()).find((x) => x.key === scope.id);
262
+ if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
263
+ rateProjectDir = p.path;
264
+ const sink = await resolveProjectSink(p.path);
265
+ if (!sink.ok) {
266
+ const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
267
+ throw Object.assign(new Error(sink.detail || `team metrics are not enabled for ${p.name}`), { code });
268
+ }
269
+ const ownSlug = sink.from;
270
+ sources.push({ slug: sink.slug, projectDir: sink.projectDir, keep: (r) => r.target?.kind === 'project' && r.target.project === ownSlug });
271
+ meta = { kind: 'project', id: p.key, name: p.name, slug: ownSlug, recordedIn: sink.delegated ? sink.slug : null };
272
+ } else {
273
+ const ws = await readWorkspace(scope.id);
274
+ if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
275
+ const name = ws.name.toLowerCase();
276
+ for (const path of ws.projectPaths) {
277
+ const prefs = (await discoverProject(path).catch(() => null)) || readTeamMetricsPrefs(projectKey(path));
278
+ if (!recordsLocally(prefs)) continue; // only members that record locally (config read, not a marker)
279
+ sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
280
+ }
281
+ if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
282
+ rateProjectDir = ws.metricsProject || null;
283
+ const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
284
+ meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
285
+ }
286
+ return { sources, meta, rateProjectDir };
287
+ }
288
+
279
289
  /** Scope list + Projects/Workspaces status in one call (Scope select, Projects cells, ws cards, Stats hint). */
280
290
  export async function listScopes({ discover = false } = {}) {
281
291
  const recordsBySink = new Map();
@@ -11,7 +11,7 @@ import { readPrState } from '../artifacts.mjs';
11
11
  import { RESULTS_FILE } from '../results.mjs';
12
12
  import { UI_PHASE } from '../../shared/graph/manifest.mjs';
13
13
  import {
14
- projectSlug, gitUserName, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
14
+ projectSlug, gitUserName, gitUserEmail, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
15
15
  } from './sync.mjs';
16
16
  import { writeRunLedger } from './ledger.mjs';
17
17
  import { readPolicyState } from '../policy/state.mjs';
@@ -231,6 +231,9 @@ export function buildRunRecord(snap, { attribution = 'git-user', now = new Date(
231
231
  deletions: num(g.deletions),
232
232
  },
233
233
  actor: attribution === 'none' ? null : cleanText(snap.actor),
234
+ // Additive: a stable, host-neutral key for the person (personKey of the git email), so the
235
+ // Timeline can match a run's actor to the author of a pull request made outside Worca.
236
+ ...(attribution !== 'none' && PERSON_KEY_RE.test(String(snap.actorKey || '')) ? { actorKey: snap.actorKey } : {}),
234
237
  // Present ONLY on runs that saw a policy: records of policy-less runs stay byte-identical to v1.
235
238
  ...((p) => (p ? { policy: p } : {}))(buildPolicy(snap.policy, attribution)),
236
239
  ...((h) => (h ? { human: h } : {}))(buildHuman(agentSteps, snap.humanHours)),
@@ -372,7 +375,11 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
372
375
  lastPause: iv.lastPauseReason
373
376
  ? { reason: iv.lastPauseReason, detail: iv.lastPauseDetail ?? null }
374
377
  : harness.pauseReason ? { reason: harness.pauseReason, detail: harness.pauseDetail ?? null } : null,
375
- actor: await gitUserName(harness.projectDir),
378
+ // The person who started the run (identity.mjs) when one is known; else, as before, the
379
+ // checkout's git user (a local install, the CLI). attribution:'none' still drops both.
380
+ actor: actorForRecord(harness.state && harness.state.startedBy) ?? await gitUserName(harness.projectDir),
381
+ // Only for the git user: a person named by startedBy (identity.mjs) has no email here.
382
+ actorKey: actorForRecord(harness.state && harness.state.startedBy) ? null : personKey(await gitUserEmail(harness.projectDir)),
376
383
  // The run's policy state (pipelines.policy_state) as the gates and the resume flow left it;
377
384
  // `unattended` is the harness's own auto flag, which the record needs even when nothing else
378
385
  // was written (a --yes run that stayed under every cap still carries no state row).
@@ -380,6 +387,27 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
380
387
  };
381
388
  }
382
389
 
390
+ export const PERSON_KEY_RE = /^[0-9a-f]{16}$/;
391
+ /** Emails that name a machine, not a person: Worca's own run commits (run-harness.mjs) and the
392
+ * metrics fallback identity. The PR-events Action skips the same ones. */
393
+ const MACHINE_EMAIL_RE = /@local$/i;
394
+
395
+ /**
396
+ * A person's host-neutral key: sha256("worca:" + lower-cased email), first 16 hex. The same on
397
+ * every machine and every host (the PR-events Action computes it from commit authors), and no
398
+ * plain address is stored. null for no email or a machine identity.
399
+ */
400
+ export function personKey(email) {
401
+ const e = typeof email === 'string' ? email.trim().toLowerCase() : '';
402
+ if (!e || !e.includes('@') || MACHINE_EMAIL_RE.test(e)) return null;
403
+ return createHash('sha256').update(`worca:${e}`).digest('hex').slice(0, 16);
404
+ }
405
+
406
+ /** A recorded person (not 'local', not empty) for the metrics actor, or null to fall back. */
407
+ export function actorForRecord(startedBy) {
408
+ return typeof startedBy === 'string' && startedBy.trim() && startedBy !== 'local' ? startedBy.trim() : null;
409
+ }
410
+
383
411
  let _recorder = null;
384
412
  let scheduleFlush = realScheduleFlush;
385
413
 
@@ -9,6 +9,19 @@ import { randomBytes } from 'node:crypto';
9
9
  import { worcaHome, listProjects } from '../projects.mjs';
10
10
  import { projectKey, canonicalProjectRoot } from '../store.mjs';
11
11
  import { listRemotes, parseRemoteUrl } from '../git-info.mjs';
12
+ import { githubEnv, readGithubCredentials, stripGithubCredentials } from '../github-credentials.mjs';
13
+
14
+ const NETWORK_GIT = new Set(['fetch', 'push', 'ls-remote', 'clone', 'pull']);
15
+
16
+ /** "owner/name" of origin on github.com, only in App mode (the one mode that needs it). */
17
+ async function originRepo(cwd) {
18
+ if (readGithubCredentials().mode !== 'app' || !cwd) return null;
19
+ try {
20
+ const url = await new Promise((ok) => execFile('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 10_000 }, (e, out) => ok(e ? '' : String(out).trim())));
21
+ const p = parseRemoteUrl(url);
22
+ return p && p.host === 'github.com' ? `${p.owner}/${p.repo}` : null;
23
+ } catch { return null; }
24
+ }
12
25
  import { readTeamMetricsPrefs, writeTeamMetricsPrefs } from '../config.mjs';
13
26
  import { readWorkspace, listWorkspaces, isGitRepo } from '../workspaces.mjs';
14
27
  import { withLock } from './lock.mjs';
@@ -69,8 +82,19 @@ function hookFreeArgs() {
69
82
  return ['-c', `core.hooksPath=${dir}`, '-c', 'commit.gpgsign=false', '-c', 'push.gpgSign=false'];
70
83
  }
71
84
 
72
- function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
73
- const base = { ...process.env };
85
+ async function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
86
+ // The write credential (it reads too): metrics and policy branches are fetched and pushed.
87
+ // In App mode a fresh token per call; a failed mint degrades to no credential (git then
88
+ // reports the auth failure the sync already handles) and is logged once per call.
89
+ // Local commands (commit, worktree, read-tree…) get no credential at all.
90
+ let base;
91
+ if (NETWORK_GIT.has(args[0])) {
92
+ const cred = await githubEnv('write', { repo: await originRepo(cwd) });
93
+ if (cred.error) console.warn(`[worca] metrics/policy git ${args[0]}: ${cred.error}`);
94
+ base = cred.env;
95
+ } else {
96
+ base = stripGithubCredentials(process.env);
97
+ }
74
98
  for (const k of STRIP_ENV) delete base[k];
75
99
  return new Promise((done) => {
76
100
  execFile('git', [...hookFreeArgs(), ...args], {
@@ -148,6 +172,12 @@ export async function gitUserName(dir) {
148
172
  return r.ok ? r.stdout.trim() || null : null;
149
173
  }
150
174
 
175
+ /** `git config user.email` — only ever hashed into a record (record.mjs personKey), never stored. */
176
+ export async function gitUserEmail(dir) {
177
+ const r = await _git(dir, ['config', 'user.email'], { timeoutMs: 10_000 });
178
+ return r.ok ? r.stdout.trim() || null : null;
179
+ }
180
+
151
181
  /** `-c` identity args for metrics commits (§4.5: git user, or Worca under attribution:none).
152
182
  * A name without an email would make `git commit` fail, so both must be set to use the git user.
153
183
  * Exported for the team-policy branch (policy/sync.mjs), which commits under the same rules. */
@@ -515,7 +545,7 @@ async function validateDelegateTarget(delegateTo, ownSlug) {
515
545
  * mode 'delegate' → create a marker branch {delegateTo}; or join; with change:true rewrite an existing marker.
516
546
  * @returns {Promise<{action:'created'|'joined'|'changed', slug:string, config:object|null}>}
517
547
  */
518
- export async function enableTeamMetrics(projectDir, { mode = 'here', attribution = 'git-user', delegateTo = null, change = false, now = new Date() } = {}) {
548
+ export async function enableTeamMetrics(projectDir, { mode = 'here', attribution = 'git-user', delegateTo = null, change = false, now = new Date(), by = null } = {}) {
519
549
  if (mode !== 'here' && mode !== 'delegate') throw metricsError('BAD_REQUEST', `mode must be "here" or "delegate"`);
520
550
  const { slug, hasOrigin } = await projectSlug(projectDir);
521
551
  if (!hasOrigin) throw metricsError('NO_ORIGIN', 'this project has no origin remote — team metrics push to origin, so there is nowhere to record');
@@ -526,7 +556,8 @@ export async function enableTeamMetrics(projectDir, { mode = 'here', attribution
526
556
  // every member's marker still carried the developer's real git name, and "Route all members"
527
557
  // (which passes no attribution at all) did it for the whole workspace in one click.
528
558
  const attr = mode === 'delegate' ? delegate.attribution : normAttribution(attribution);
529
- const user = await gitUserName(projectDir);
559
+ // The person who asked (identity.mjs) when known; else the git user. attribution:'none' still drops it.
560
+ const user = (typeof by === 'string' && by.trim() && by !== 'local' ? by.trim() : null) ?? await gitUserName(projectDir);
530
561
  const config = mode === 'delegate'
531
562
  // enabledBy follows the same policy as the commit identity (decision 13): anonymising the
532
563
  // marker's author while leaving the developer's name in its JSON body would defeat the point.