@golden-frijoles/kit 0.6.0 → 0.7.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.
- package/dist/build-state.mjs +716 -0
- package/dist/cross-agent-doctor.mjs +617 -0
- package/dist/cross-review.mjs +857 -0
- package/dist/cross-review.prompt.md +100 -0
- package/dist/cross-review.security.prompt.md +92 -0
- package/dist/lib/review-guard.mjs +478 -0
- package/dist/lib/secret-guard.mjs +212 -0
- package/dist/lib/session-journal.mjs +99 -0
- package/dist/review-config.json +25 -0
- package/dist/review-route.mjs +234 -0
- package/dist/session-note.mjs +127 -0
- package/dist/session-resume.mjs +953 -0
- package/package.json +1 -1
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// session-resume.mjs — the expensive half of session continuity (README.md D1): derive the TRUE state,
|
|
3
|
+
// live, across the project's repos + the intent journal, and lead with what's surprising (D3). Never trust a
|
|
4
|
+
// stale doc or a resumed agent's pre-kill memory — this opens with real git/gh/DB reads.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// node scripts/session-resume.mjs [--json] [--root <path>] [--journal-limit N] [--all-migrations]
|
|
8
|
+
//
|
|
9
|
+
// WHICH repos: the project's one repo list — reporting.config.json's `repos` — plus its optional
|
|
10
|
+
// `checkouts` map (`{ "owner/name": "apps/web" }`, relative to --root) naming where each is checked out
|
|
11
|
+
// locally. A repo with a checkout gets the full git read; one without gets the gh read only, and the brief
|
|
12
|
+
// says so. With no reporting.config.json at all this degrades (D4) to the repo it runs in. Ported into the
|
|
13
|
+
// dobby-foundation template by plugin-audit-and-extraction S2 (story 2.3); the logic is the origin's.
|
|
14
|
+
//
|
|
15
|
+
// --root defaults to this script's own repo root, so checkouts nested inside it (e.g. apps/<name>, each
|
|
16
|
+
// its OWN git repo) resolve as siblings. Pass --root explicitly when running from a worktree of the root
|
|
17
|
+
// repo: a `git worktree add` does NOT populate gitignored nested checkouts, so without --root those repos
|
|
18
|
+
// correctly degrade to "path not found" — itself one of D4's named degradation classes.
|
|
19
|
+
//
|
|
20
|
+
// Derives (D3's "full derived state"): current branch, ahead/behind origin/main, dirty/untracked files,
|
|
21
|
+
// `git worktree list` + each worktree's own dirty state, open PRs with mergeability + CI rollup, recently
|
|
22
|
+
// merged PRs — across the SAME repos standup.mjs reads (reporting.config.json). Plus migration drift
|
|
23
|
+
// (D5): repo migration files vs the live `supabase_migrations.schema_migrations`, both directions, via
|
|
24
|
+
// `supabase migration list --linked` (a read-only CLI call — this script NEVER applies a migration, never
|
|
25
|
+
// `db push`). That check only runs against a repo that actually owns a non-empty `supabase/migrations`
|
|
26
|
+
// directory — a checkout with no migrations of its own is skipped uneventfully (not flagged as 100%
|
|
27
|
+
// drift) rather than compared against migration files that simply don't live there. A project that does
|
|
28
|
+
// not use supabase never triggers this check at all.
|
|
29
|
+
//
|
|
30
|
+
// Anomalies first (D3): a non-main branch with no open PR (in the origin project, a backend checkout
|
|
31
|
+
// left on a feature branch nobody had opened — the case that motivated this script), a dirty tree (main or any worktree), an open PR with red CI or a
|
|
32
|
+
// conflict, migration drift in EITHER direction. Then the last N journal lines
|
|
33
|
+
// (scripts/lib/session-journal.mjs, read from claude/session-journal via scripts/lib/log-branch.mjs,
|
|
34
|
+
// reused unchanged). Then the full derived state.
|
|
35
|
+
//
|
|
36
|
+
// Degrades, never dies (D4): every gather step below returns `{ available: false, reason }` on failure
|
|
37
|
+
// instead of throwing — an unauthenticated gh, a missing/unreachable repo, an empty/unfetchable journal,
|
|
38
|
+
// or an unavailable migration check each produce a partial brief that NAMES the gap, never a stack trace.
|
|
39
|
+
//
|
|
40
|
+
// Reuse, don't rebuild: scripts/lib/gh-rest.mjs (listPulls/getPullMergeability/getStatusRollup) — REST-
|
|
41
|
+
// only on purpose (GraphQL is blocked in at least one routine sandbox); scripts/lib/log-branch.mjs
|
|
42
|
+
// (readLogFromBranch) unchanged. Zero new npm deps — Node >=18 (spawnSync).
|
|
43
|
+
|
|
44
|
+
import { spawnSync } from 'node:child_process';
|
|
45
|
+
|
|
46
|
+
// The session read limit for MEMORY.md, and the working budget that keeps a session's
|
|
47
|
+
// worth of edits from crossing it. Both in bytes; see decideMemoryBudgetAnomaly.
|
|
48
|
+
export const MEMORY_HARD_LIMIT_BYTES = 25_000;
|
|
49
|
+
export const MEMORY_BUDGET_BYTES = 23_552;
|
|
50
|
+
import { existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
import { dirname, join, resolve } from 'node:path';
|
|
53
|
+
import { listPulls, getPullMergeability, getStatusRollup } from './lib/gh-rest.mjs';
|
|
54
|
+
import { readLogFromBranch } from './lib/log-branch.mjs';
|
|
55
|
+
import {
|
|
56
|
+
parseJournal,
|
|
57
|
+
lastNEntries,
|
|
58
|
+
formatEntry,
|
|
59
|
+
JOURNAL_BRANCH,
|
|
60
|
+
JOURNAL_PATH,
|
|
61
|
+
} from './lib/session-journal.mjs';
|
|
62
|
+
import { loadReportingConfig, ReportingConfigError } from './lib/reporting-config.mjs';
|
|
63
|
+
|
|
64
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
const DEFAULT_ROOT = join(__dirname, '..');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The repos to derive state for, as `{ repo, dir }` (`dir` relative to --root; null = no local checkout).
|
|
69
|
+
* From reporting.config.json — the same list the standup reads — and its optional `checkouts` map. The
|
|
70
|
+
* repo whose `origin` remote this checkout points at is `.` unless the map says otherwise. No config →
|
|
71
|
+
* just this repo (D4: degrade, never die), with the reason returned so the brief can name the gap.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveRepos({
|
|
74
|
+
root = DEFAULT_ROOT,
|
|
75
|
+
loadConfig = loadReportingConfig,
|
|
76
|
+
spawn = spawnSync,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const origin = originRepo({ root, spawn });
|
|
79
|
+
let config;
|
|
80
|
+
try {
|
|
81
|
+
config = loadConfig({ root });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (!(e instanceof ReportingConfigError)) throw e;
|
|
84
|
+
return {
|
|
85
|
+
repos: origin ? [{ repo: origin, dir: '.' }] : [],
|
|
86
|
+
note: `no usable reporting.config.json (${e.message.split('\n')[0]}) — reading only this repo`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const checkouts = config.checkouts || {};
|
|
90
|
+
return {
|
|
91
|
+
repos: config.repos.map((repo) => ({
|
|
92
|
+
repo,
|
|
93
|
+
dir: checkouts[repo] ?? (repo === origin ? '.' : null),
|
|
94
|
+
})),
|
|
95
|
+
note: null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function originRepo({ root, spawn }) {
|
|
100
|
+
const r = spawn('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' });
|
|
101
|
+
if (r.error || r.status !== 0) return null;
|
|
102
|
+
const m = String(r.stdout)
|
|
103
|
+
.trim()
|
|
104
|
+
.match(/github\.com[:/]([^/]+\/[^/.\s]+?)(?:\.git)?$/);
|
|
105
|
+
return m ? m[1] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const JOURNAL_LIMIT_DEFAULT = 10;
|
|
109
|
+
const RECENT_MERGED_LIMIT = 8;
|
|
110
|
+
// 7 days. Delivery here is measured in hours and dependabot's cadence is weekly, so a non-draft PR
|
|
111
|
+
// still open after a week has stopped being in-flight work and started being a decision nobody made.
|
|
112
|
+
// Deliberately not lower: a threshold that fires on healthy PRs gets skimmed past, and a skimmed
|
|
113
|
+
// anomaly line is worth less than no line at all.
|
|
114
|
+
const STALE_PR_DAYS = 7;
|
|
115
|
+
const MIGRATION_TIMEOUT_MS = 60000;
|
|
116
|
+
|
|
117
|
+
function lastLine(s) {
|
|
118
|
+
return (s || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================================
|
|
122
|
+
// PURE derive/format seam — unit-tested without gh/git/a database (scripts/session-resume.test.mjs).
|
|
123
|
+
// Every function below takes already-gathered facts and makes no I/O call of its own.
|
|
124
|
+
// ============================================================================================
|
|
125
|
+
|
|
126
|
+
// `git worktree list --porcelain` → an array of { path, branch, detached, locked, bare }. Blocks are
|
|
127
|
+
// separated by a blank line; `branch refs/heads/X` → X (bare ref name, no `refs/heads/` prefix).
|
|
128
|
+
export function parseWorktreeListPorcelain(text) {
|
|
129
|
+
const blocks = (text || '').trim().split(/\n\n+/).filter(Boolean);
|
|
130
|
+
return blocks.map((block) => {
|
|
131
|
+
const out = { path: null, branch: null, detached: false, locked: false, bare: false };
|
|
132
|
+
for (const line of block.split('\n')) {
|
|
133
|
+
if (line.startsWith('worktree ')) out.path = line.slice('worktree '.length).trim();
|
|
134
|
+
else if (line.startsWith('branch '))
|
|
135
|
+
out.branch = line.slice('branch '.length).replace('refs/heads/', '').trim();
|
|
136
|
+
else if (line === 'detached') out.detached = true;
|
|
137
|
+
else if (line.startsWith('locked')) out.locked = true;
|
|
138
|
+
else if (line === 'bare') out.bare = true;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `supabase migration list --linked` prints a 3-column ASCII table (Local | Remote | Time), NOT
|
|
145
|
+
// machine-readable JSON even with `-o json` (confirmed live, CLI 2.98.1 — the flag is accepted but
|
|
146
|
+
// ignored for this subcommand), so this parses the pretty table directly. A blank Local column means
|
|
147
|
+
// "applied remotely, no local file"; a blank Remote column means "local file, not applied remotely" — D5
|
|
148
|
+
// wants BOTH directions. Header/separator rows (`Local | Remote | ...`, `---|---|---`) are skipped; a
|
|
149
|
+
// data row needs at least one 14-digit timestamp version in either column.
|
|
150
|
+
export function parseMigrationListTable(text) {
|
|
151
|
+
const rows = [];
|
|
152
|
+
for (const raw of (text || '').split('\n')) {
|
|
153
|
+
if (!raw.includes('|')) continue;
|
|
154
|
+
const parts = raw.split('|').map((s) => s.trim());
|
|
155
|
+
if (parts.length < 2) continue;
|
|
156
|
+
const [local, remote] = parts;
|
|
157
|
+
if (/^local$/i.test(local) || /^-+$/.test(local.replace(/\s/g, ''))) continue;
|
|
158
|
+
const localVer = /^\d{14}$/.test(local) ? local : null;
|
|
159
|
+
const remoteVer = /^\d{14}$/.test(remote) ? remote : null;
|
|
160
|
+
if (!localVer && !remoteVer) continue;
|
|
161
|
+
rows.push({ local: localVer, remote: remoteVer });
|
|
162
|
+
}
|
|
163
|
+
// A version is APPLIED if it appears in the remote column of ANY row — not merely of its own row.
|
|
164
|
+
// Two local files may share one timestamp (we have exactly that: 20260711120000 is used by both
|
|
165
|
+
// `..._migrations_connector_enabled_flag.sql` and `..._seller_shell_on_sell_enabled_flag.sql`).
|
|
166
|
+
// `schema_migrations` is keyed by version, so it can only ever record ONE of them, and the CLI then
|
|
167
|
+
// prints the pair as two rows: `local|remote` for the recorded one and `local|<blank>` for the other.
|
|
168
|
+
//
|
|
169
|
+
// Reading that blank row naively as "unapplied" is FALSE — verified live 2026-07-26: the version is in
|
|
170
|
+
// schema_migrations and both flag rows exist in the project's own tables. Worse, it is false on the one signal
|
|
171
|
+
// that has to be trustworthy: it would send someone to re-apply an already-applied migration. So a
|
|
172
|
+
// version present anywhere in the remote column is applied, and the duplicate is reported as what it
|
|
173
|
+
// actually is — a version collision, which is a real (if minor) repo defect worth naming once.
|
|
174
|
+
const appliedVersions = new Set(rows.map((r) => r.remote).filter(Boolean));
|
|
175
|
+
const localVersions = rows.map((r) => r.local).filter(Boolean);
|
|
176
|
+
const localSet = new Set(localVersions);
|
|
177
|
+
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const duplicateLocalVersions = [];
|
|
180
|
+
for (const v of localVersions) {
|
|
181
|
+
if (seen.has(v) && !duplicateLocalVersions.includes(v)) duplicateLocalVersions.push(v);
|
|
182
|
+
seen.add(v);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
rows,
|
|
187
|
+
unappliedLocal: [...new Set(localVersions.filter((v) => !appliedVersions.has(v)))],
|
|
188
|
+
appliedNoFile: [...new Set(rows.map((r) => r.remote).filter((v) => v && !localSet.has(v)))],
|
|
189
|
+
duplicateLocalVersions,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A statusCheckRollup (scripts/lib/gh-rest.mjs's buildStatusRollup shape) has at least one hard failure —
|
|
194
|
+
// same predicate babysit-pr.mjs's decideBabysitActions uses for "failingChecks".
|
|
195
|
+
export function rollupHasFailure(rollup) {
|
|
196
|
+
return (
|
|
197
|
+
Array.isArray(rollup) &&
|
|
198
|
+
rollup.some((c) => c.conclusion === 'FAILURE' || c.conclusion === 'ERROR' || c.state === 'FAILURE')
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function rollupHasPending(rollup) {
|
|
203
|
+
return (
|
|
204
|
+
Array.isArray(rollup) &&
|
|
205
|
+
rollup.some(
|
|
206
|
+
(c) => !c.conclusion && (c.status === 'IN_PROGRESS' || c.status === 'QUEUED' || c.status === 'PENDING')
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// THE motivating anomaly (README.md, epic Why): a non-main, non-detached branch with no open PR pointing
|
|
212
|
+
// at it. `openPrs` is a TRI-STATE: an array (gh reachable — even empty is a confirmed "no PRs"), or
|
|
213
|
+
// anything else (gh unavailable — we genuinely don't know, so this must NOT claim a false positive; that
|
|
214
|
+
// case is surfaced as a GAP instead, never as a confirmed anomaly).
|
|
215
|
+
export function decideStrayBranch({ branch, detached, openPrs }) {
|
|
216
|
+
if (!branch || detached || branch === 'main') return null;
|
|
217
|
+
if (!Array.isArray(openPrs)) return null;
|
|
218
|
+
const hasPr = openPrs.some((pr) => pr.headRefName === branch);
|
|
219
|
+
if (hasPr) return null;
|
|
220
|
+
return {
|
|
221
|
+
type: 'stray-branch',
|
|
222
|
+
detail: `on branch \`${branch}\` with no open PR — leftover state from an earlier session (the case that motivated this check).`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function decideDirtyTree({ label, dirtyFiles }) {
|
|
227
|
+
if (!dirtyFiles) return null;
|
|
228
|
+
return {
|
|
229
|
+
type: 'dirty-tree',
|
|
230
|
+
detail: `${label}: ${dirtyFiles} uncommitted/untracked file${dirtyFiles === 1 ? '' : 's'}.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function decideWorktreeAnomalies(worktrees) {
|
|
235
|
+
return (worktrees || [])
|
|
236
|
+
.filter((w) => w.dirty)
|
|
237
|
+
.map((w) => ({
|
|
238
|
+
type: 'dirty-tree',
|
|
239
|
+
detail: `worktree ${w.path} (branch ${w.branch || 'detached'}): uncommitted changes.`,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A PR nobody has closed the loop on is the single most repeated failure this report exists to catch,
|
|
244
|
+
// and until now it was invisible here: `open PRs: 4` reads identically whether they were opened this
|
|
245
|
+
// morning or five weeks ago. On 2026-08-24 the backend carried four dependabot PRs aged 10–17 days and
|
|
246
|
+
// this repo had four advisory audit PRs stranded since 08-03 — the audit series had simply stopped
|
|
247
|
+
// being harvested, and nothing in any session ever said so out loud.
|
|
248
|
+
//
|
|
249
|
+
// THREE states, not two, because "I could not work out the age" is a different fact from "it is fresh":
|
|
250
|
+
// • a parseable createdAt older than the threshold → stale, with the age named
|
|
251
|
+
// • a parseable createdAt inside the threshold → nothing (the overwhelmingly common case)
|
|
252
|
+
// • createdAt absent or unparseable → an explicit unknown-age anomaly, never silence
|
|
253
|
+
//
|
|
254
|
+
// Drafts are EXCLUDED. A draft is open on purpose — flagging it would train the reader to skim past
|
|
255
|
+
// this line, which is the failure mode that made the stale PRs invisible in the first place.
|
|
256
|
+
export function decideStalePrAnomalies(open, { nowISO, thresholdDays = STALE_PR_DAYS } = {}) {
|
|
257
|
+
const now = Date.parse(nowISO);
|
|
258
|
+
if (!Number.isFinite(now)) return []; // no trustworthy clock ⇒ assert nothing, rather than assert wrongly
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const pr of open || []) {
|
|
261
|
+
const created = Date.parse(pr.createdAt ?? '');
|
|
262
|
+
if (!Number.isFinite(created)) {
|
|
263
|
+
out.push({
|
|
264
|
+
type: 'pr-age-unknown',
|
|
265
|
+
detail: `PR #${pr.number} "${pr.title}" (${pr.url}) — open, but its creation date could not be read, so its age is UNKNOWN.`,
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const days = Math.floor((now - created) / 86400000);
|
|
270
|
+
if (days < thresholdDays) continue;
|
|
271
|
+
|
|
272
|
+
// A draft needs a SHARPER test than age, not an exemption. The first cut of this guard skipped
|
|
273
|
+
// every draft on the reasoning that "a draft is open on purpose" — and that reasoning is exactly
|
|
274
|
+
// wrong for the failure this repo has already recorded: nightly smoke-fix routines open DRAFTS,
|
|
275
|
+
// and they pile up (4 stacked by 2026-08-02; six red nights were unmerged fixes, not quota).
|
|
276
|
+
// Proven again 2026-08-25 — frontend #404 sat a green, finished, 5-day-old draft that this guard
|
|
277
|
+
// skipped by design, and merging it was the fix for a nightly red.
|
|
278
|
+
//
|
|
279
|
+
// So the question is not "is it a draft" but "does it look FINISHED": a draft whose CI is green
|
|
280
|
+
// and which GitHub says is mergeable is done work nobody landed. A draft still red, still
|
|
281
|
+
// pending, or conflicting is genuinely in progress and stays silent — that is the negation this
|
|
282
|
+
// guard must keep allowing, or it goes back to crying wolf.
|
|
283
|
+
if (pr.isDraft) {
|
|
284
|
+
const looksFinished = pr.ciAvailable && !pr.ciFailing && !pr.ciPending && pr.mergeable === 'MERGEABLE';
|
|
285
|
+
if (looksFinished) {
|
|
286
|
+
out.push({
|
|
287
|
+
type: 'pr-draft-finished',
|
|
288
|
+
detail: `PR #${pr.number} "${pr.title}" (${pr.url}) is a DRAFT that has been green and mergeable for ${days} days — finished work nobody landed. Mark it ready or close it.`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
out.push({
|
|
295
|
+
type: 'pr-stale',
|
|
296
|
+
detail: `PR #${pr.number} "${pr.title}" (${pr.url}) has been open ${days} days — decide it or close it.`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Renders an age for the full-state listing. Returns 'unknown' rather than a plausible-looking 0 when
|
|
303
|
+
// the date cannot be read — the same three-state discipline decideStalePrAnomalies applies.
|
|
304
|
+
export function prAgeLabel(pr, nowISO = new Date().toISOString()) {
|
|
305
|
+
const now = Date.parse(nowISO);
|
|
306
|
+
const created = Date.parse(pr?.createdAt ?? '');
|
|
307
|
+
if (!Number.isFinite(now) || !Number.isFinite(created)) return 'unknown';
|
|
308
|
+
return `${Math.floor((now - created) / 86400000)}d`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Every anomaly is rendered as `[${a.type}] ${a.detail}`, so a decider returning any other shape prints
|
|
312
|
+
// "[undefined] undefined" — a line that looks like a bug in the reader's terminal rather than the fact it
|
|
313
|
+
// is carrying. That is not hypothetical: decideMemoryBudgetAnomaly returned {kind, text} from the day it
|
|
314
|
+
// was added (#160), so the ONE anomaly whose entire job is to say "MEMORY.md is silently truncating" was
|
|
315
|
+
// itself silently unreadable, and it printed that way at the top of every report for weeks. Found
|
|
316
|
+
// 2026-08-24 with the index 0.3 KB over budget and the warning live.
|
|
317
|
+
//
|
|
318
|
+
// Normalising the shape is the fix; this is the guard that keeps it normalised. It asserts on the
|
|
319
|
+
// POPULATION every run rather than on the one decider that was wrong.
|
|
320
|
+
export function assertRenderableAnomalies(anomalies) {
|
|
321
|
+
const bad = (anomalies || []).filter(
|
|
322
|
+
(a) => !a || typeof a.type !== 'string' || typeof a.detail !== 'string'
|
|
323
|
+
);
|
|
324
|
+
if (bad.length) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`${bad.length} anomaly/anomalies lack a string \`type\`+\`detail\` and would render as ` +
|
|
327
|
+
`"[undefined] undefined": ${JSON.stringify(bad).slice(0, 400)}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return anomalies;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function decidePrAnomalies(open) {
|
|
334
|
+
const out = [];
|
|
335
|
+
for (const pr of open || []) {
|
|
336
|
+
if (pr.mergeable === 'CONFLICTING') {
|
|
337
|
+
out.push({
|
|
338
|
+
type: 'pr-conflict',
|
|
339
|
+
detail: `PR #${pr.number} "${pr.title}" (${pr.url}) has a merge conflict.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (pr.ciFailing) {
|
|
343
|
+
out.push({ type: 'pr-ci-red', detail: `PR #${pr.number} "${pr.title}" (${pr.url}) has red CI.` });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The two directions of migration drift are NOT equally urgent, and treating them as equal broke this
|
|
350
|
+
// report's whole purpose on its first real run (2026-07-26): 36 orphans buried the 3 genuinely
|
|
351
|
+
// actionable anomalies, which is precisely the attention failure D3 exists to prevent.
|
|
352
|
+
//
|
|
353
|
+
// unappliedLocal — a file exists, the live table has no such version. DANGEROUS and per-item: code
|
|
354
|
+
// that reads a new column against an unmigrated table breaks a live path. Every one is listed.
|
|
355
|
+
//
|
|
356
|
+
// appliedNoFile ("orphans") — the live table has a version with no local file. This is the EXPECTED
|
|
357
|
+
// residue of our documented workflow, not a defect: WAYS-OF-WORKING records that the Supabase MCP
|
|
358
|
+
// `apply_migration` stamps its OWN timestamp as the version, so the row and the filename diverge
|
|
359
|
+
// unless someone realigns `schema_migrations` by hand afterwards. Most orphans are therefore old
|
|
360
|
+
// applies nobody realigned — real, but historical and never actionable at session-resume time.
|
|
361
|
+
// Collapsed to ONE counted line that still names the versions, so the information is kept and the
|
|
362
|
+
// signal is not drowned. `--all-migrations` restores the per-item listing when that is the task.
|
|
363
|
+
export function decideMigrationAnomalies({
|
|
364
|
+
repo,
|
|
365
|
+
unappliedLocal,
|
|
366
|
+
appliedNoFile,
|
|
367
|
+
duplicateLocalVersions,
|
|
368
|
+
expandOrphans = false,
|
|
369
|
+
}) {
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const v of unappliedLocal || []) {
|
|
372
|
+
out.push({
|
|
373
|
+
repo,
|
|
374
|
+
type: 'migration-unapplied',
|
|
375
|
+
detail: `migration ${v} has a file but is NOT in the live schema_migrations table.`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
for (const v of duplicateLocalVersions || []) {
|
|
379
|
+
out.push({
|
|
380
|
+
repo,
|
|
381
|
+
type: 'migration-duplicate-version',
|
|
382
|
+
detail:
|
|
383
|
+
`two or more local migration files share version ${v}. schema_migrations is keyed by version, ` +
|
|
384
|
+
`so only one can ever be recorded — the others are permanently unrecordable. The effect may still ` +
|
|
385
|
+
`be applied; check before re-running anything.`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const orphans = appliedNoFile || [];
|
|
389
|
+
if (expandOrphans) {
|
|
390
|
+
for (const v of orphans) {
|
|
391
|
+
out.push({
|
|
392
|
+
repo,
|
|
393
|
+
type: 'migration-orphan',
|
|
394
|
+
detail: `schema_migrations has version ${v} applied live with no matching local file.`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} else if (orphans.length) {
|
|
398
|
+
const shown = orphans.slice(0, 5).join(', ');
|
|
399
|
+
const more = orphans.length > 5 ? `, +${orphans.length - 5} more` : '';
|
|
400
|
+
out.push({
|
|
401
|
+
repo,
|
|
402
|
+
type: 'migration-orphan-summary',
|
|
403
|
+
detail:
|
|
404
|
+
`${orphans.length} applied version(s) have no matching local file (${shown}${more}). ` +
|
|
405
|
+
`Usually the known MCP-timestamp/filename divergence, not unshipped work — re-run with --all-migrations to list them.`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Aggregates every anomaly category, across all repos + the migration check, in D3's "anomalies first"
|
|
412
|
+
// intent. Order: per-repo (stray branch, dirty tree, worktree dirt, PR conflict, PR CI-red), then
|
|
413
|
+
// migration drift. `repoStates`/`migrationResults` are the already-gathered facts (this function does no
|
|
414
|
+
// I/O of its own — the pure seam the contract requires).
|
|
415
|
+
/**
|
|
416
|
+
* D-mem: the session memory index has a READ LIMIT, and going over it fails silently.
|
|
417
|
+
*
|
|
418
|
+
* `~/.claude/projects/<slug>/memory/MEMORY.md` is loaded into every session, but only the
|
|
419
|
+
* first ~24.4 KB of it. Past that the tail is TRUNCATED with no error anywhere — and the
|
|
420
|
+
* tail is where the "Open items owed to the product owner" section and the epic lists live, so the
|
|
421
|
+
* failure mode is an agent that cannot see what it owes and has no way to know. It reached
|
|
422
|
+
* 29.7 KB on 2026-08-19 and had been truncating for an unknown number of sessions; the only
|
|
423
|
+
* reason it surfaced was a warning appended to the file's own loaded prefix.
|
|
424
|
+
*
|
|
425
|
+
* A byte count is the whole check. It runs at session start, where the answer can still
|
|
426
|
+
* change what the session does.
|
|
427
|
+
*
|
|
428
|
+
* Three states on purpose: over budget, within budget, and UNREADABLE. A memory directory
|
|
429
|
+
* that cannot be read must never be reported as "fine" — that is the same collapse this
|
|
430
|
+
* repo bans everywhere else (AGENTS.md rule 5).
|
|
431
|
+
*/
|
|
432
|
+
export function decideMemoryBudgetAnomaly({
|
|
433
|
+
available,
|
|
434
|
+
bytes,
|
|
435
|
+
limitBytes = MEMORY_HARD_LIMIT_BYTES,
|
|
436
|
+
budgetBytes = MEMORY_BUDGET_BYTES,
|
|
437
|
+
path,
|
|
438
|
+
}) {
|
|
439
|
+
if (!available) {
|
|
440
|
+
return {
|
|
441
|
+
type: 'memory-index-unavailable',
|
|
442
|
+
detail: `Could not read the session memory index${path ? ` (${path})` : ''} — its size is UNKNOWN, not fine. If it is over ~${Math.round(limitBytes / 1024)} KB it is being silently truncated.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// `available: true` with a non-numeric size is a malformed reading, and letting it
|
|
446
|
+
// fall through would return null — "no anomaly" — from a size nobody actually knows.
|
|
447
|
+
// That is the exact collapse the three states exist to prevent, so it reports as
|
|
448
|
+
// unavailable rather than as fine.
|
|
449
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes)) {
|
|
450
|
+
return {
|
|
451
|
+
type: 'memory-index-unavailable',
|
|
452
|
+
detail: `The session memory index reported a size of \`${String(bytes)}\`${path ? ` (${path})` : ''} — UNKNOWN, not fine.`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (bytes > limitBytes) {
|
|
456
|
+
return {
|
|
457
|
+
type: 'memory-index-truncating',
|
|
458
|
+
detail: `MEMORY.md is ${(bytes / 1024).toFixed(1)} KB, over the ~${(limitBytes / 1024).toFixed(1)} KB session read limit — it IS being truncated right now, and the tail (owed items, epic lists) is not reaching this session. Tighten index lines; move detail into topic files.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (bytes > budgetBytes) {
|
|
462
|
+
return {
|
|
463
|
+
type: 'memory-index-near-limit',
|
|
464
|
+
detail: `MEMORY.md is ${(bytes / 1024).toFixed(1)} KB, over its ${(budgetBytes / 1024).toFixed(0)} KB working budget (hard truncation at ~${(limitBytes / 1024).toFixed(1)} KB). Tighten one line before adding another.`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reads the size of this project's MEMORY.md. I/O shell only — the DECISION is
|
|
472
|
+
* `decideMemoryBudgetAnomaly`, which is what the tests exercise.
|
|
473
|
+
*
|
|
474
|
+
* `deps.stat` is injected so a test never touches a real home directory. A read that
|
|
475
|
+
* throws returns `available: false`, never a zero size: "I could not check" is its own
|
|
476
|
+
* answer here, and the anomaly text says so.
|
|
477
|
+
*/
|
|
478
|
+
export function readMemoryIndex(cwd = process.cwd(), deps = {}) {
|
|
479
|
+
const stat = deps.stat || statSync;
|
|
480
|
+
const home = deps.home || process.env.HOME || '';
|
|
481
|
+
// Claude derives the project slug from the absolute path with every separator replaced
|
|
482
|
+
// by a dash — mirrored here rather than restated as a constant, so a moved checkout
|
|
483
|
+
// resolves on its own instead of silently pointing at a stale directory.
|
|
484
|
+
// The PATH DERIVATION is inside the guard too, not just the stat. `resolve(cwd)`
|
|
485
|
+
// throws a TypeError on a non-string, which would crash the whole session brief
|
|
486
|
+
// instead of degrading — and "degrade, never die" is the rule this script is built on.
|
|
487
|
+
let path = null;
|
|
488
|
+
try {
|
|
489
|
+
const slug = resolve(cwd).replace(/\//g, '-');
|
|
490
|
+
path = join(home, '.claude', 'projects', slug, 'memory', 'MEMORY.md');
|
|
491
|
+
return { available: true, bytes: stat(path).size, path };
|
|
492
|
+
} catch {
|
|
493
|
+
return { available: false, bytes: null, path };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function buildAnomalies({ repoStates, migrationResults, expandOrphans = false, memoryIndex }) {
|
|
498
|
+
const anomalies = [];
|
|
499
|
+
// First, because it is the one anomaly that changes what the rest of this report is
|
|
500
|
+
// worth: if the index is truncating, the session is missing facts it does not know it
|
|
501
|
+
// is missing. Absent `memoryIndex` means the caller did not look — not that it is fine.
|
|
502
|
+
if (memoryIndex) {
|
|
503
|
+
const mem = decideMemoryBudgetAnomaly(memoryIndex);
|
|
504
|
+
if (mem) anomalies.push(mem);
|
|
505
|
+
}
|
|
506
|
+
for (const rs of repoStates || []) {
|
|
507
|
+
if (rs.git?.available) {
|
|
508
|
+
const openPrs = rs.gh?.available ? rs.gh.open : undefined; // undefined → decideStrayBranch treats as "unknown"
|
|
509
|
+
const stray = decideStrayBranch({ branch: rs.git.branch, detached: rs.git.detached, openPrs });
|
|
510
|
+
if (stray) anomalies.push({ repo: rs.repo, ...stray });
|
|
511
|
+
const dirty = decideDirtyTree({ label: rs.dir, dirtyFiles: rs.git.dirtyFiles });
|
|
512
|
+
if (dirty) anomalies.push({ repo: rs.repo, ...dirty });
|
|
513
|
+
for (const wt of decideWorktreeAnomalies(rs.git.worktrees)) anomalies.push({ repo: rs.repo, ...wt });
|
|
514
|
+
}
|
|
515
|
+
if (rs.gh?.available) {
|
|
516
|
+
for (const a of decidePrAnomalies(rs.gh.open)) anomalies.push({ repo: rs.repo, ...a });
|
|
517
|
+
for (const a of decideStalePrAnomalies(rs.gh.open, { nowISO: new Date().toISOString() })) {
|
|
518
|
+
anomalies.push({ repo: rs.repo, ...a });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const m of migrationResults || []) {
|
|
523
|
+
if (m.available) {
|
|
524
|
+
for (const a of decideMigrationAnomalies({ ...m, expandOrphans })) anomalies.push(a);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return assertRenderableAnomalies(anomalies);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// D4: names every degraded source instead of silently dropping it. A missing repo path, an
|
|
531
|
+
// unauthenticated/unreachable gh, an unavailable migration check, and an empty/unfetchable journal all
|
|
532
|
+
// land here — distinct from `buildAnomalies`, which only reports CONFIRMED surprising states.
|
|
533
|
+
export function buildGaps({ repoStates, migrationResults, journalAvailable, journalReason }) {
|
|
534
|
+
const gaps = [];
|
|
535
|
+
for (const rs of repoStates || []) {
|
|
536
|
+
if (!rs.git?.available)
|
|
537
|
+
gaps.push(`${rs.repo}: git state unavailable — ${rs.git?.reason || 'unknown reason'}.`);
|
|
538
|
+
// A failed dirty-probe is UNKNOWN, not clean. Without this the report renders no dirty-tree
|
|
539
|
+
// anomaly and no gap — indistinguishable from a clean tree.
|
|
540
|
+
else if (rs.git.dirtyProbeFailed) {
|
|
541
|
+
gaps.push(`${rs.repo}: could not read working-tree status — dirty/clean is UNKNOWN, not clean.`);
|
|
542
|
+
}
|
|
543
|
+
if (!rs.gh?.available) {
|
|
544
|
+
gaps.push(
|
|
545
|
+
`${rs.repo}: PR/CI state unavailable — ${rs.gh?.reason || 'gh unavailable'}. Stray-branch/CI/conflict checks skipped.`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const m of migrationResults || []) {
|
|
550
|
+
if (!m.available)
|
|
551
|
+
gaps.push(`${m.repo}: migration drift check unavailable — ${m.reason || 'unknown reason'}.`);
|
|
552
|
+
}
|
|
553
|
+
if (!journalAvailable) {
|
|
554
|
+
gaps.push(
|
|
555
|
+
`journal unavailable — ${journalReason || 'claude/session-journal has no entries yet, or could not be fetched'}.`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return gaps;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Assembles the full report object — the SAME shape feeds both --json and the human renderer below, so
|
|
562
|
+
// neither can drift from the other.
|
|
563
|
+
export function buildReport({
|
|
564
|
+
repoStates,
|
|
565
|
+
migrationResults,
|
|
566
|
+
journalEntries,
|
|
567
|
+
journalAvailable,
|
|
568
|
+
journalReason,
|
|
569
|
+
journalLimit = JOURNAL_LIMIT_DEFAULT,
|
|
570
|
+
expandOrphans = false,
|
|
571
|
+
memoryIndex,
|
|
572
|
+
generatedAt,
|
|
573
|
+
}) {
|
|
574
|
+
return {
|
|
575
|
+
generatedAt: generatedAt || new Date().toISOString(),
|
|
576
|
+
anomalies: buildAnomalies({ repoStates, migrationResults, expandOrphans, memoryIndex }),
|
|
577
|
+
gaps: buildGaps({ repoStates, migrationResults, journalAvailable, journalReason }),
|
|
578
|
+
journal: {
|
|
579
|
+
available: journalAvailable,
|
|
580
|
+
reason: journalAvailable ? null : journalReason || null,
|
|
581
|
+
totalEntries: (journalEntries || []).length,
|
|
582
|
+
recent: lastNEntries(journalEntries || [], journalLimit),
|
|
583
|
+
},
|
|
584
|
+
repos: repoStates,
|
|
585
|
+
migrations: migrationResults,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// D3's exact order: (1) anomalies, (2) last N journal lines, (3) full derived state. Gaps ride right
|
|
590
|
+
// after anomalies — both are "read this before you trust anything below" content.
|
|
591
|
+
export function renderHumanReport(report) {
|
|
592
|
+
const lines = [];
|
|
593
|
+
lines.push(`Session resume — ${report.generatedAt}`);
|
|
594
|
+
lines.push('');
|
|
595
|
+
lines.push(
|
|
596
|
+
report.anomalies.length ? `⚠ ANOMALIES (${report.anomalies.length})` : '✓ No anomalies detected.'
|
|
597
|
+
);
|
|
598
|
+
for (const a of report.anomalies) {
|
|
599
|
+
lines.push(` - [${a.type}]${a.repo ? ` ${a.repo}:` : ''} ${a.detail}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (report.gaps.length) {
|
|
603
|
+
lines.push('');
|
|
604
|
+
lines.push(`Degraded sources (${report.gaps.length}) — named, not silently dropped (D4):`);
|
|
605
|
+
for (const g of report.gaps) lines.push(` - ${g}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
lines.push('');
|
|
609
|
+
lines.push(
|
|
610
|
+
`Journal — last ${report.journal.recent.length} of ${report.journal.totalEntries}${
|
|
611
|
+
report.journal.available ? '' : ' (unavailable)'
|
|
612
|
+
}:`
|
|
613
|
+
);
|
|
614
|
+
if (!report.journal.recent.length) {
|
|
615
|
+
lines.push(
|
|
616
|
+
` (none${report.journal.available ? ' yet' : `: ${report.journal.reason || 'unavailable'}`})`
|
|
617
|
+
);
|
|
618
|
+
} else {
|
|
619
|
+
for (const e of report.journal.recent) lines.push(` ${formatEntry(e)}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
lines.push('');
|
|
623
|
+
lines.push('Full derived state:');
|
|
624
|
+
for (const rs of report.repos) {
|
|
625
|
+
lines.push(`- ${rs.repo} (${rs.dir}):`);
|
|
626
|
+
if (rs.git?.available) {
|
|
627
|
+
lines.push(
|
|
628
|
+
` branch: ${rs.git.branch}${rs.git.detached ? ' (detached)' : ''}; ` +
|
|
629
|
+
`ahead ${rs.git.ahead ?? '?'} / behind ${rs.git.behind ?? '?'} vs origin/main; ` +
|
|
630
|
+
`dirty files: ${rs.git.dirtyFiles ?? '?'}`
|
|
631
|
+
);
|
|
632
|
+
for (const w of rs.git.worktrees || []) {
|
|
633
|
+
lines.push(` worktree: ${w.path} (${w.branch || 'detached'})${w.dirty ? ' [dirty]' : ''}`);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
lines.push(` git: unavailable (${rs.git?.reason || 'unknown'})`);
|
|
637
|
+
}
|
|
638
|
+
if (rs.gh?.available) {
|
|
639
|
+
lines.push(
|
|
640
|
+
` open PRs: ${(rs.gh.open || []).length}; recently merged: ${(rs.gh.recentMerged || []).length}`
|
|
641
|
+
);
|
|
642
|
+
for (const pr of rs.gh.open || []) {
|
|
643
|
+
lines.push(
|
|
644
|
+
` #${pr.number} ${pr.title} [${pr.headRefName}] age=${prAgeLabel(pr)} mergeable=${pr.mergeable} ` +
|
|
645
|
+
`ciFailing=${pr.ciFailing} ciPending=${pr.ciPending}`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
lines.push(` gh: unavailable (${rs.gh?.reason || 'unknown'})`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (report.migrations?.length) {
|
|
654
|
+
lines.push('');
|
|
655
|
+
lines.push('Migration drift (D5, read-only):');
|
|
656
|
+
for (const m of report.migrations) {
|
|
657
|
+
if (m.available) {
|
|
658
|
+
lines.push(
|
|
659
|
+
` - ${m.repo}: ${m.unappliedLocal.length} unapplied local file(s); ${m.appliedNoFile.length} applied-with-no-file.`
|
|
660
|
+
);
|
|
661
|
+
} else {
|
|
662
|
+
lines.push(` - ${m.repo}: unavailable (${m.reason})`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return lines.join('\n');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ============================================================================================
|
|
671
|
+
// I/O gather layer — every function degrades to `{ available: false, reason }` on failure (D4);
|
|
672
|
+
// none of these throw. `deps` is injectable throughout so scripts/session-resume.test.mjs can drive every
|
|
673
|
+
// degradation path with no real git/gh/network/DB.
|
|
674
|
+
// ============================================================================================
|
|
675
|
+
|
|
676
|
+
function git(args, cwd, spawn) {
|
|
677
|
+
return spawn('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function gatherRepoGit({ dir, root, existsSyncFn, spawn }) {
|
|
681
|
+
const path = join(root, dir);
|
|
682
|
+
if (!existsSyncFn(path)) return { available: false, reason: `path not found: ${path}` };
|
|
683
|
+
if (!existsSyncFn(join(path, '.git'))) return { available: false, reason: `not a git repo: ${path}` };
|
|
684
|
+
|
|
685
|
+
const branchRes = git(['rev-parse', '--abbrev-ref', 'HEAD'], path, spawn);
|
|
686
|
+
if (branchRes.error || branchRes.status !== 0) {
|
|
687
|
+
return { available: false, reason: `git rev-parse failed: ${lastLine(branchRes.stderr)}` };
|
|
688
|
+
}
|
|
689
|
+
const branch = branchRes.stdout.trim();
|
|
690
|
+
const detached = branch === 'HEAD';
|
|
691
|
+
|
|
692
|
+
const statusRes = git(['status', '--porcelain'], path, spawn);
|
|
693
|
+
// `null` here means the probe FAILED, which is not the same fact as "the tree is clean" — and
|
|
694
|
+
// decideDirtyTree treats any falsy value as "no anomaly". Cross-review caught that a failed
|
|
695
|
+
// `git status` therefore rendered as silence. The flag below turns it into a named gap, which is
|
|
696
|
+
// D4's whole point and the "unknown is not none" rule this repo already learned once.
|
|
697
|
+
const dirtyProbeFailed = statusRes.status !== 0;
|
|
698
|
+
const dirtyFiles = dirtyProbeFailed ? null : statusRes.stdout.split('\n').filter(Boolean).length;
|
|
699
|
+
|
|
700
|
+
// Best-effort, local-refs-only (no fetch — a read-only resume must not have a network side effect on
|
|
701
|
+
// every invocation); a stale origin/main tracking ref just means a stale ahead/behind count, reported
|
|
702
|
+
// as `?` below rather than guessed at.
|
|
703
|
+
let ahead = null;
|
|
704
|
+
let behind = null;
|
|
705
|
+
const abRes = git(['rev-list', '--left-right', '--count', 'origin/main...HEAD'], path, spawn);
|
|
706
|
+
if (abRes.status === 0) {
|
|
707
|
+
const parts = abRes.stdout.trim().split(/\s+/).map(Number);
|
|
708
|
+
if (parts.length === 2 && parts.every(Number.isFinite)) [behind, ahead] = parts;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const wtRes = git(['worktree', 'list', '--porcelain'], path, spawn);
|
|
712
|
+
let worktrees = [];
|
|
713
|
+
if (wtRes.status === 0) {
|
|
714
|
+
worktrees = parseWorktreeListPorcelain(wtRes.stdout)
|
|
715
|
+
.filter((w) => w.path && resolve(w.path) !== resolve(path))
|
|
716
|
+
.map((w) => {
|
|
717
|
+
const stRes = existsSyncFn(w.path) ? git(['status', '--porcelain'], w.path, spawn) : null;
|
|
718
|
+
const dirty = stRes && stRes.status === 0 ? stRes.stdout.trim().length > 0 : null;
|
|
719
|
+
return { path: w.path, branch: w.branch, detached: w.detached, dirty };
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return { available: true, path, branch, detached, ahead, behind, dirtyFiles, dirtyProbeFailed, worktrees };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function gatherRepoGh({ repo, listPullsFn, mergeFn, rollupFn }) {
|
|
727
|
+
const all = listPullsFn({ repo, state: 'all', perPage: 50 });
|
|
728
|
+
if (all === null)
|
|
729
|
+
return { available: false, reason: 'gh unavailable, unauthenticated, or the repo could not be reached' };
|
|
730
|
+
|
|
731
|
+
const open = all.filter((p) => p.state === 'OPEN');
|
|
732
|
+
const recentMerged = all.filter((p) => p.state === 'MERGED').slice(0, RECENT_MERGED_LIMIT);
|
|
733
|
+
|
|
734
|
+
const openDetailed = open.map((p) => {
|
|
735
|
+
const mergeable = mergeFn({ repo, number: p.number });
|
|
736
|
+
const rollup = p.headSha ? rollupFn({ repo, sha: p.headSha }) : null;
|
|
737
|
+
return {
|
|
738
|
+
number: p.number,
|
|
739
|
+
title: p.title,
|
|
740
|
+
url: p.url,
|
|
741
|
+
headRefName: p.headRefName,
|
|
742
|
+
createdAt: p.createdAt,
|
|
743
|
+
isDraft: p.isDraft,
|
|
744
|
+
mergeable,
|
|
745
|
+
ciAvailable: rollup !== null,
|
|
746
|
+
ciFailing: rollupHasFailure(rollup),
|
|
747
|
+
ciPending: rollupHasPending(rollup),
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
return { available: true, open: openDetailed, recentMerged };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function gatherMigrationDrift({ repos, root, existsSyncFn, readdirSyncFn, spawn }) {
|
|
755
|
+
const results = [];
|
|
756
|
+
for (const r of repos) {
|
|
757
|
+
if (r.dir == null) continue; // no local checkout — nothing to compare
|
|
758
|
+
const migDir = join(root, r.dir, 'supabase', 'migrations');
|
|
759
|
+
if (!existsSyncFn(migDir)) continue; // no migration files owned here — not this repo's concern
|
|
760
|
+
let files;
|
|
761
|
+
try {
|
|
762
|
+
files = readdirSyncFn(migDir).filter((f) => f.endsWith('.sql'));
|
|
763
|
+
} catch {
|
|
764
|
+
files = [];
|
|
765
|
+
}
|
|
766
|
+
if (!files.length) continue;
|
|
767
|
+
|
|
768
|
+
const cwd = join(root, r.dir);
|
|
769
|
+
const res = spawn('supabase', ['migration', 'list', '--linked'], {
|
|
770
|
+
cwd,
|
|
771
|
+
encoding: 'utf8',
|
|
772
|
+
timeout: MIGRATION_TIMEOUT_MS,
|
|
773
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
774
|
+
});
|
|
775
|
+
if (res.error || res.status !== 0) {
|
|
776
|
+
results.push({
|
|
777
|
+
repo: r.repo,
|
|
778
|
+
available: false,
|
|
779
|
+
reason: res.error ? res.error.message : `supabase migration list failed: ${lastLine(res.stderr)}`,
|
|
780
|
+
});
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
results.push({
|
|
784
|
+
repo: r.repo,
|
|
785
|
+
available: true,
|
|
786
|
+
fileCount: files.length,
|
|
787
|
+
...parseMigrationListTable(res.stdout),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
return results;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function gatherJournal({ root, readLogFromBranchFn }) {
|
|
794
|
+
const content = readLogFromBranchFn({ cwd: root, branch: JOURNAL_BRANCH, path: JOURNAL_PATH });
|
|
795
|
+
if (!content) {
|
|
796
|
+
return {
|
|
797
|
+
available: false,
|
|
798
|
+
reason: 'claude/session-journal has no entries yet, or could not be fetched (network/auth)',
|
|
799
|
+
entries: [],
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return { available: true, reason: null, entries: parseJournal(content) };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ============================================================================================
|
|
806
|
+
// CLI wiring
|
|
807
|
+
// ============================================================================================
|
|
808
|
+
|
|
809
|
+
export function parseArgs(argv) {
|
|
810
|
+
const out = {
|
|
811
|
+
json: false,
|
|
812
|
+
root: DEFAULT_ROOT,
|
|
813
|
+
journalLimit: JOURNAL_LIMIT_DEFAULT,
|
|
814
|
+
expandOrphans: false,
|
|
815
|
+
help: false,
|
|
816
|
+
};
|
|
817
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
818
|
+
const a = argv[i];
|
|
819
|
+
if (a === '--json') out.json = true;
|
|
820
|
+
else if (a === '--root') out.root = resolve(argv[++i]);
|
|
821
|
+
else if (a === '--journal-limit') out.journalLimit = Number(argv[++i]);
|
|
822
|
+
else if (a === '--all-migrations') out.expandOrphans = true;
|
|
823
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
824
|
+
else throw new Error(`unknown argument '${a}'`);
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function help() {
|
|
830
|
+
return [
|
|
831
|
+
'Usage: node scripts/session-resume.mjs [--json] [--root <path>] [--journal-limit N] [--all-migrations]',
|
|
832
|
+
'',
|
|
833
|
+
'Derives live state across the project repos (git/gh/migrations) and the session journal; leads with',
|
|
834
|
+
'anomalies (D3). Degrades, never dies (D4): an unauthenticated gh, a missing repo, an empty',
|
|
835
|
+
'journal, or an unavailable DB read each produce a partial brief naming the gap, never a crash.',
|
|
836
|
+
].join('\n');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// `deps` fully injectable (mirrors cross-agent-cli.mjs's runWithCodexFallback pattern) — every gather
|
|
840
|
+
// function above already degrades internally, so main() itself needs no extra try/catch to satisfy D4;
|
|
841
|
+
// the outer try/catch below is a last-resort net for a truly unexpected throw (e.g. a permissions error
|
|
842
|
+
// on a repo path), which still must produce a partial brief, never an uncaught stack trace.
|
|
843
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
844
|
+
const {
|
|
845
|
+
log = console.log,
|
|
846
|
+
warn = console.error,
|
|
847
|
+
existsSyncFn = existsSync,
|
|
848
|
+
readdirSyncFn = readdirSync,
|
|
849
|
+
spawn = spawnSync,
|
|
850
|
+
listPullsFn = listPulls,
|
|
851
|
+
mergeFn = getPullMergeability,
|
|
852
|
+
rollupFn = getStatusRollup,
|
|
853
|
+
readLogFromBranchFn = readLogFromBranch,
|
|
854
|
+
// Injected for the same reason as every other dependency in this list: a `main()`
|
|
855
|
+
// test must not stat a real home directory. Named `statFn`/`homeDir` rather than
|
|
856
|
+
// passed through as an opaque bag so the seam is visible in this one list.
|
|
857
|
+
statFn = statSync,
|
|
858
|
+
homeDir = process.env.HOME,
|
|
859
|
+
now,
|
|
860
|
+
resolveReposFn = resolveRepos,
|
|
861
|
+
} = deps;
|
|
862
|
+
|
|
863
|
+
let args;
|
|
864
|
+
try {
|
|
865
|
+
args = parseArgs(argv);
|
|
866
|
+
} catch (e) {
|
|
867
|
+
warn(`✗ ${e.message}\n\n${help()}`);
|
|
868
|
+
return 1;
|
|
869
|
+
}
|
|
870
|
+
if (args.help) {
|
|
871
|
+
log(help());
|
|
872
|
+
return 0;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const { repos, note: reposNote } = resolveReposFn({ root: args.root, spawn });
|
|
877
|
+
if (reposNote) warn(`⚠ session-resume: ${reposNote}`);
|
|
878
|
+
const repoStates = repos.map((r) => ({
|
|
879
|
+
repo: r.repo,
|
|
880
|
+
dir: r.dir,
|
|
881
|
+
git:
|
|
882
|
+
r.dir == null
|
|
883
|
+
? { available: false, reason: 'no local checkout configured (reporting.config.json → checkouts)' }
|
|
884
|
+
: gatherRepoGit({ dir: r.dir, root: args.root, existsSyncFn, spawn }),
|
|
885
|
+
gh: gatherRepoGh({ repo: r.repo, listPullsFn, mergeFn, rollupFn }),
|
|
886
|
+
}));
|
|
887
|
+
|
|
888
|
+
const migrationResults = gatherMigrationDrift({
|
|
889
|
+
repos,
|
|
890
|
+
root: args.root,
|
|
891
|
+
existsSyncFn,
|
|
892
|
+
readdirSyncFn,
|
|
893
|
+
spawn,
|
|
894
|
+
});
|
|
895
|
+
const journal = gatherJournal({ root: args.root, readLogFromBranchFn });
|
|
896
|
+
|
|
897
|
+
const report = buildReport({
|
|
898
|
+
repoStates,
|
|
899
|
+
migrationResults,
|
|
900
|
+
journalEntries: journal.entries,
|
|
901
|
+
journalAvailable: journal.available,
|
|
902
|
+
journalReason: journal.reason,
|
|
903
|
+
journalLimit: args.journalLimit,
|
|
904
|
+
expandOrphans: args.expandOrphans,
|
|
905
|
+
memoryIndex: readMemoryIndex(args.root, { stat: statFn, home: homeDir }),
|
|
906
|
+
generatedAt: (now || new Date()).toISOString(),
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
log(args.json ? JSON.stringify(report, null, 2) : renderHumanReport(report));
|
|
910
|
+
return 0;
|
|
911
|
+
} catch (e) {
|
|
912
|
+
// Last-resort net (D4: degrade, never die) — should be unreachable given every gather function above
|
|
913
|
+
// already catches its own failure mode, but a resume tool must never stack-trace on a bad day.
|
|
914
|
+
warn(
|
|
915
|
+
`⚠ session-resume: unexpected error, showing a partial/empty brief instead of crashing: ${e.message}`
|
|
916
|
+
);
|
|
917
|
+
// Honour --json even here. Cross-review caught that this path emitted the HUMAN report
|
|
918
|
+
// unconditionally, so a machine consumer asking for JSON received prose on stdout with exit 0 —
|
|
919
|
+
// it would parse-fail or, worse, be treated as an empty result.
|
|
920
|
+
const fallback = buildReport({
|
|
921
|
+
repoStates: [],
|
|
922
|
+
migrationResults: [],
|
|
923
|
+
journalEntries: [],
|
|
924
|
+
journalAvailable: false,
|
|
925
|
+
journalReason: `session-resume crashed before the journal could be read: ${e.message}`,
|
|
926
|
+
// Reported on the CRASH path too. A truncating memory index is exactly the fact
|
|
927
|
+
// you still want on the worst day — it is cheap (one stat), it is independent of
|
|
928
|
+
// everything that just failed, and an empty brief that also silently drops it
|
|
929
|
+
// leaves the session blind twice over. Its own failure is already swallowed by
|
|
930
|
+
// readMemoryIndex, so this cannot re-enter the catch.
|
|
931
|
+
memoryIndex: readMemoryIndex(args?.root, { stat: statFn, home: homeDir }),
|
|
932
|
+
});
|
|
933
|
+
log(args?.json ? JSON.stringify(fallback, null, 2) : renderHumanReport(fallback));
|
|
934
|
+
return 0;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// realpath on both sides: through a symlinked path a plain compare is false and the script exits 0 having
|
|
939
|
+
// done nothing (#189 review).
|
|
940
|
+
const isMain = (() => {
|
|
941
|
+
try {
|
|
942
|
+
return (
|
|
943
|
+
!!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
944
|
+
);
|
|
945
|
+
} catch {
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
})();
|
|
949
|
+
if (isMain) {
|
|
950
|
+
main().then((code) => {
|
|
951
|
+
process.exitCode = code;
|
|
952
|
+
});
|
|
953
|
+
}
|