@bli-cockpit/cli 0.1.28 → 0.1.30
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/README.md +10 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +4 -3
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +67 -2
- package/dist/commands/local.js +310 -11
- package/dist/commands/session-sync.js +40 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/local-state.js +29 -10
- package/dist/repo-identity.js +15 -10
- package/dist/upload.js +115 -25
- package/package.json +2 -2
|
@@ -26,6 +26,8 @@ export function parseLocalArgs(argv) {
|
|
|
26
26
|
return parseStartArgs(argv.slice(1));
|
|
27
27
|
case "sync":
|
|
28
28
|
return parseSyncArgs(argv.slice(1));
|
|
29
|
+
case "backfill":
|
|
30
|
+
return parseBackfillArgs(argv.slice(1));
|
|
29
31
|
case "status":
|
|
30
32
|
return parseStatusArgs(argv.slice(1));
|
|
31
33
|
case "sessions":
|
|
@@ -206,6 +208,7 @@ function parseStartArgs(args) {
|
|
|
206
208
|
"--workspace",
|
|
207
209
|
"--branch",
|
|
208
210
|
"--ticket",
|
|
211
|
+
"--clear-ticket",
|
|
209
212
|
"--topic",
|
|
210
213
|
"--topic-summary",
|
|
211
214
|
"--intent",
|
|
@@ -237,6 +240,11 @@ function parseStartArgs(args) {
|
|
|
237
240
|
],
|
|
238
241
|
});
|
|
239
242
|
assertNoPositionals(values.positionals, "start");
|
|
243
|
+
const activeTicketId = optionalNonEmpty(values.flags.get("--ticket"));
|
|
244
|
+
const clearTicket = values.booleans.has("--clear-ticket");
|
|
245
|
+
if (activeTicketId && clearTicket) {
|
|
246
|
+
throw new Error("--ticket and --clear-ticket cannot be combined.");
|
|
247
|
+
}
|
|
240
248
|
const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
|
|
241
249
|
const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
|
|
242
250
|
const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
|
|
@@ -252,7 +260,8 @@ function parseStartArgs(args) {
|
|
|
252
260
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
253
261
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
254
262
|
branch: optionalNonEmpty(values.flags.get("--branch")),
|
|
255
|
-
activeTicketId
|
|
263
|
+
activeTicketId,
|
|
264
|
+
clearTicket,
|
|
256
265
|
topicLabel,
|
|
257
266
|
topicSummaryRedacted,
|
|
258
267
|
workIntent,
|
|
@@ -297,6 +306,52 @@ function parseSyncArgs(args) {
|
|
|
297
306
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
298
307
|
};
|
|
299
308
|
}
|
|
309
|
+
function parseBackfillArgs(args) {
|
|
310
|
+
const values = parseNamedArgs(args, {
|
|
311
|
+
allowedFlags: [
|
|
312
|
+
"--home",
|
|
313
|
+
"--repo",
|
|
314
|
+
"--workspace",
|
|
315
|
+
"--since-days",
|
|
316
|
+
"--all",
|
|
317
|
+
"--source",
|
|
318
|
+
"--dry-run",
|
|
319
|
+
"--max-files",
|
|
320
|
+
"--yes",
|
|
321
|
+
"--json",
|
|
322
|
+
],
|
|
323
|
+
valueFlags: [
|
|
324
|
+
"--home",
|
|
325
|
+
"--repo",
|
|
326
|
+
"--workspace",
|
|
327
|
+
"--since-days",
|
|
328
|
+
"--source",
|
|
329
|
+
"--max-files",
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
assertNoPositionals(values.positionals, "backfill");
|
|
333
|
+
const source = values.flags.get("--source");
|
|
334
|
+
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
335
|
+
throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
|
|
336
|
+
}
|
|
337
|
+
const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
|
|
338
|
+
const all = values.booleans.has("--all");
|
|
339
|
+
if (all && sinceDays !== undefined) {
|
|
340
|
+
throw new Error("--since-days and --all cannot be combined.");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
kind: "backfill",
|
|
344
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
345
|
+
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
346
|
+
source,
|
|
347
|
+
sinceDays,
|
|
348
|
+
all,
|
|
349
|
+
dryRun: values.booleans.has("--dry-run"),
|
|
350
|
+
maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
|
|
351
|
+
yes: values.booleans.has("--yes"),
|
|
352
|
+
json: values.booleans.has("--json"),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
300
355
|
function parseStatusArgs(args) {
|
|
301
356
|
const values = parseNamedArgs(args, {
|
|
302
357
|
allowedFlags: [
|
|
@@ -332,6 +387,8 @@ function parseSessionsArgs(args) {
|
|
|
332
387
|
"--repo",
|
|
333
388
|
"--workspace",
|
|
334
389
|
"--source",
|
|
390
|
+
"--since-days",
|
|
391
|
+
"--all",
|
|
335
392
|
"--json",
|
|
336
393
|
"--max-depth",
|
|
337
394
|
"--max-repos",
|
|
@@ -341,6 +398,7 @@ function parseSessionsArgs(args) {
|
|
|
341
398
|
"--repo",
|
|
342
399
|
"--workspace",
|
|
343
400
|
"--source",
|
|
401
|
+
"--since-days",
|
|
344
402
|
"--max-depth",
|
|
345
403
|
"--max-repos",
|
|
346
404
|
],
|
|
@@ -348,13 +406,20 @@ function parseSessionsArgs(args) {
|
|
|
348
406
|
assertNoPositionals(values.positionals, "sessions");
|
|
349
407
|
const source = values.flags.get("--source");
|
|
350
408
|
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
351
|
-
throw new Error("--source must be 'codex' or 'claude'.");
|
|
409
|
+
throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
|
|
410
|
+
}
|
|
411
|
+
const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
|
|
412
|
+
const all = values.booleans.has("--all");
|
|
413
|
+
if (all && sinceDays !== undefined) {
|
|
414
|
+
throw new Error("--since-days and --all cannot be combined.");
|
|
352
415
|
}
|
|
353
416
|
return {
|
|
354
417
|
kind: "sessions",
|
|
355
418
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
356
419
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
357
420
|
source,
|
|
421
|
+
sinceDays,
|
|
422
|
+
all,
|
|
358
423
|
json: values.booleans.has("--json"),
|
|
359
424
|
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
360
425
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
package/dist/commands/local.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createCollectorServer } from "../server.js";
|
|
6
6
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
7
|
+
import { runBackfillCommand } from "./backfill.js";
|
|
8
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
7
9
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
8
10
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
9
11
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
10
|
-
import {
|
|
12
|
+
import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
11
13
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
14
|
+
import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
12
15
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
13
16
|
import { discoverGitWorktrees } from "../repo-identity.js";
|
|
14
17
|
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
@@ -23,6 +26,7 @@ export const rootCommandNames = new Set([
|
|
|
23
26
|
"logout",
|
|
24
27
|
"start",
|
|
25
28
|
"sync",
|
|
29
|
+
"backfill",
|
|
26
30
|
"status",
|
|
27
31
|
"sessions",
|
|
28
32
|
"serve",
|
|
@@ -61,6 +65,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
61
65
|
return await runStart(command, io);
|
|
62
66
|
case "sync":
|
|
63
67
|
return await runSync(command, io);
|
|
68
|
+
case "backfill":
|
|
69
|
+
return await runBackfillCommand(command, io);
|
|
64
70
|
case "status":
|
|
65
71
|
return await runStatus(command, io);
|
|
66
72
|
case "sessions":
|
|
@@ -91,10 +97,11 @@ export function localCommandHelp(command) {
|
|
|
91
97
|
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
92
98
|
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
93
99
|
" cockpit logout",
|
|
94
|
-
" cockpit start [--ticket <id
|
|
100
|
+
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
95
101
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
102
|
+
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
|
|
96
103
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
97
|
-
" cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
104
|
+
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
98
105
|
" cockpit serve [--port <port>] [--workspace <path>]",
|
|
99
106
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
100
107
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
@@ -169,10 +176,11 @@ function localSubcommandHelp(command) {
|
|
|
169
176
|
[
|
|
170
177
|
"start",
|
|
171
178
|
[
|
|
172
|
-
"Usage: cockpit start [--ticket <id
|
|
179
|
+
"Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
|
|
173
180
|
"",
|
|
174
181
|
"Starts local ambient capture. Parent folders start each child git worktree.",
|
|
175
|
-
"Add --ticket only when the work already has a visible ticket.",
|
|
182
|
+
"Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
|
|
183
|
+
"Use --clear-ticket to intentionally return the context to general ambient capture.",
|
|
176
184
|
"Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
|
|
177
185
|
"Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
|
|
178
186
|
"Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
|
|
@@ -197,6 +205,18 @@ function localSubcommandHelp(command) {
|
|
|
197
205
|
"--max-depth and --max-repos.",
|
|
198
206
|
],
|
|
199
207
|
],
|
|
208
|
+
[
|
|
209
|
+
"backfill",
|
|
210
|
+
[
|
|
211
|
+
"Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
|
|
212
|
+
"",
|
|
213
|
+
"Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
|
|
214
|
+
"Omit --source to scan both sources; there is no --source all literal.",
|
|
215
|
+
"The non-all window is capped at the collector session paired_at timestamp.",
|
|
216
|
+
"--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
|
|
217
|
+
"--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
|
|
218
|
+
],
|
|
219
|
+
],
|
|
200
220
|
[
|
|
201
221
|
"status",
|
|
202
222
|
[
|
|
@@ -209,12 +229,13 @@ function localSubcommandHelp(command) {
|
|
|
209
229
|
[
|
|
210
230
|
"sessions",
|
|
211
231
|
[
|
|
212
|
-
"Usage: cockpit sessions [--source codex|claude] [--workspace <path>] [--json]",
|
|
232
|
+
"Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
|
|
213
233
|
"",
|
|
214
234
|
"Read-only: re-runs Codex + Claude session attribution and prints each",
|
|
215
235
|
"session's id, source, state, reason, scores, signals, and per-sidecar",
|
|
216
236
|
"skip reasons. No upload, no cursor writes. Answers \"why is session X",
|
|
217
237
|
"missing?\" locally — counts and labels only, never paths or content.",
|
|
238
|
+
"--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
|
|
218
239
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
219
240
|
],
|
|
220
241
|
],
|
|
@@ -637,6 +658,8 @@ function writeOnboardLiveStatus(io, command, roots, options) {
|
|
|
637
658
|
writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
|
|
638
659
|
writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
|
|
639
660
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
|
|
661
|
+
if (options.backfillHint)
|
|
662
|
+
writeLine(io.stdout, options.backfillHint);
|
|
640
663
|
writeLine(io.stdout, "Next: cockpit status");
|
|
641
664
|
}
|
|
642
665
|
function backgroundSyncLine(result) {
|
|
@@ -659,6 +682,7 @@ async function runOnboard(command, io) {
|
|
|
659
682
|
let rootsResult = null;
|
|
660
683
|
let agentRules = null;
|
|
661
684
|
let autostart = null;
|
|
685
|
+
let backfillHint = null;
|
|
662
686
|
try {
|
|
663
687
|
if (!command.json) {
|
|
664
688
|
writeLine(io.stdout, "Cockpit harvest onboarding");
|
|
@@ -666,6 +690,7 @@ async function runOnboard(command, io) {
|
|
|
666
690
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
667
691
|
}
|
|
668
692
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
693
|
+
backfillHint = await onboardBackfillHint(command.homeDir);
|
|
669
694
|
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
670
695
|
const interactive = !command.json && isInteractiveStdin(io);
|
|
671
696
|
rootsResult = await resolveOnboardingRoots({
|
|
@@ -755,6 +780,7 @@ async function runOnboard(command, io) {
|
|
|
755
780
|
root_resolution: rootsResult,
|
|
756
781
|
agent_rules: agentRules,
|
|
757
782
|
autostart,
|
|
783
|
+
backfill_hint: backfillHint,
|
|
758
784
|
mode: "multi_repo",
|
|
759
785
|
repos: multi.results,
|
|
760
786
|
codex_sessions: multi.codex_sessions,
|
|
@@ -768,6 +794,7 @@ async function runOnboard(command, io) {
|
|
|
768
794
|
agentRules,
|
|
769
795
|
autostart,
|
|
770
796
|
initialSyncOk: true,
|
|
797
|
+
backfillHint,
|
|
771
798
|
});
|
|
772
799
|
}
|
|
773
800
|
return multi.ok ? 0 : 1;
|
|
@@ -827,6 +854,7 @@ async function runOnboard(command, io) {
|
|
|
827
854
|
root_resolution: rootsResult,
|
|
828
855
|
agent_rules: agentRules,
|
|
829
856
|
autostart,
|
|
857
|
+
backfill_hint: backfillHint,
|
|
830
858
|
codex_sessions: run.summary,
|
|
831
859
|
}, null, 2));
|
|
832
860
|
return 0;
|
|
@@ -852,6 +880,7 @@ async function runOnboard(command, io) {
|
|
|
852
880
|
agentRules,
|
|
853
881
|
autostart,
|
|
854
882
|
initialSyncOk: true,
|
|
883
|
+
backfillHint,
|
|
855
884
|
});
|
|
856
885
|
return 0;
|
|
857
886
|
}
|
|
@@ -980,6 +1009,8 @@ function cursorStatusLine(sync) {
|
|
|
980
1009
|
}
|
|
981
1010
|
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
982
1011
|
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
1012
|
+
const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
|
|
1013
|
+
const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
|
|
983
1014
|
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
984
1015
|
if (Array.isArray(repoRoot)) {
|
|
985
1016
|
const discovered = [];
|
|
@@ -1186,6 +1217,7 @@ async function runStart(command, io) {
|
|
|
1186
1217
|
repoRoot: worktree.repo_root,
|
|
1187
1218
|
branch: command.branch,
|
|
1188
1219
|
activeTicketId: command.activeTicketId,
|
|
1220
|
+
clearTicket: command.clearTicket,
|
|
1189
1221
|
topicLabel: command.topicLabel,
|
|
1190
1222
|
topicSummaryRedacted: command.topicSummaryRedacted,
|
|
1191
1223
|
workIntent: command.workIntent,
|
|
@@ -1221,6 +1253,20 @@ async function runStart(command, io) {
|
|
|
1221
1253
|
return 0;
|
|
1222
1254
|
}
|
|
1223
1255
|
async function runSync(command, io) {
|
|
1256
|
+
const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
|
|
1257
|
+
if (backfillLock.held) {
|
|
1258
|
+
if (command.json) {
|
|
1259
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1260
|
+
status: "live_sync_paused_during_backfill",
|
|
1261
|
+
reason: "live sync paused during backfill",
|
|
1262
|
+
held_since: backfillLock.held_since,
|
|
1263
|
+
}, null, 2));
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
writeLine(io.stdout, "live sync paused during backfill");
|
|
1267
|
+
}
|
|
1268
|
+
return 0;
|
|
1269
|
+
}
|
|
1224
1270
|
// Single-flight: a launchd timer and a manual sync must not interleave the
|
|
1225
1271
|
// cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
|
|
1226
1272
|
const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
|
|
@@ -1299,6 +1345,7 @@ async function runSyncLocked(command, io) {
|
|
|
1299
1345
|
return 1;
|
|
1300
1346
|
}
|
|
1301
1347
|
async function runStatus(command, io) {
|
|
1348
|
+
const backfillCursor = await inspectBackfillCursor(command.homeDir);
|
|
1302
1349
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1303
1350
|
if (worktrees.length > 1) {
|
|
1304
1351
|
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
@@ -1309,10 +1356,11 @@ async function runStatus(command, io) {
|
|
|
1309
1356
|
head_sha: worktree.head_sha,
|
|
1310
1357
|
})));
|
|
1311
1358
|
if (command.json) {
|
|
1312
|
-
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
|
|
1359
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
|
|
1313
1360
|
return 0;
|
|
1314
1361
|
}
|
|
1315
1362
|
writeLine(io.stdout, "Cockpit parent status");
|
|
1363
|
+
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
1316
1364
|
for (const status of statuses) {
|
|
1317
1365
|
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
1318
1366
|
}
|
|
@@ -1320,7 +1368,7 @@ async function runStatus(command, io) {
|
|
|
1320
1368
|
}
|
|
1321
1369
|
const status = await inspectLocalCollectorStatus(command);
|
|
1322
1370
|
if (command.json) {
|
|
1323
|
-
writeLine(io.stdout, JSON.stringify(status, null, 2));
|
|
1371
|
+
writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
|
|
1324
1372
|
return 0;
|
|
1325
1373
|
}
|
|
1326
1374
|
writeLine(io.stdout, "Cockpit local status");
|
|
@@ -1337,6 +1385,7 @@ async function runStatus(command, io) {
|
|
|
1337
1385
|
writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
|
|
1338
1386
|
writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
|
|
1339
1387
|
writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
|
|
1388
|
+
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
1340
1389
|
for (const detail of status.details)
|
|
1341
1390
|
writeLine(io.stdout, `- ${detail}`);
|
|
1342
1391
|
return 0;
|
|
@@ -1349,6 +1398,251 @@ function displayWorkLabel(status) {
|
|
|
1349
1398
|
return `${status.work_label} (${status.work_id})`;
|
|
1350
1399
|
return status.work_label ?? status.work_id ?? "no active work context";
|
|
1351
1400
|
}
|
|
1401
|
+
async function fileExists(filePath) {
|
|
1402
|
+
try {
|
|
1403
|
+
await access(filePath);
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async function onboardBackfillHint(homeDir) {
|
|
1411
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
1412
|
+
if (await fileExists(backfillCompletionMarkerPath(paths)))
|
|
1413
|
+
return null;
|
|
1414
|
+
const hasArchivedSessions = await hasAnyJsonlFile([
|
|
1415
|
+
path.join(homeDir ?? os.homedir(), ".codex", "archived_sessions"),
|
|
1416
|
+
]);
|
|
1417
|
+
return hasArchivedSessions
|
|
1418
|
+
? "Backfill: archived sessions found. Run `cockpit backfill --all` to include older local history."
|
|
1419
|
+
: null;
|
|
1420
|
+
}
|
|
1421
|
+
async function inspectBackfillCursor(homeDir) {
|
|
1422
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
1423
|
+
const marker = await readBackfillCompletionMarker(paths);
|
|
1424
|
+
if (marker) {
|
|
1425
|
+
return {
|
|
1426
|
+
state: "done",
|
|
1427
|
+
remaining_count: 0,
|
|
1428
|
+
updated_at: marker.cursor.updated_at,
|
|
1429
|
+
completed_at: marker.completed_at,
|
|
1430
|
+
sources: summarizeBackfillCursorSources(marker.cursor),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
const cursor = await readBackfillCursor(paths);
|
|
1434
|
+
if (!cursor.updated_at) {
|
|
1435
|
+
return {
|
|
1436
|
+
state: "never_run",
|
|
1437
|
+
remaining_count: 0,
|
|
1438
|
+
updated_at: null,
|
|
1439
|
+
completed_at: null,
|
|
1440
|
+
sources: summarizeBackfillCursorSources(cursor),
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
return {
|
|
1444
|
+
state: "remaining",
|
|
1445
|
+
remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
|
|
1446
|
+
updated_at: cursor.updated_at,
|
|
1447
|
+
completed_at: null,
|
|
1448
|
+
sources: summarizeBackfillCursorSources(cursor),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
async function readBackfillCompletionMarker(paths) {
|
|
1452
|
+
try {
|
|
1453
|
+
const raw = JSON.parse(await readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
1454
|
+
if (typeof raw.completed_at !== "string")
|
|
1455
|
+
return null;
|
|
1456
|
+
const cursor = raw.cursor && typeof raw.cursor === "object"
|
|
1457
|
+
? normalizeBackfillCursor(raw.cursor)
|
|
1458
|
+
: await readBackfillCursor(paths);
|
|
1459
|
+
return { completed_at: raw.completed_at, cursor };
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function normalizeBackfillCursor(value) {
|
|
1466
|
+
const record = value;
|
|
1467
|
+
return {
|
|
1468
|
+
schema_version: "cockpit-backfill-cursor.v1",
|
|
1469
|
+
updated_at: typeof record.updated_at === "string" && record.updated_at
|
|
1470
|
+
? record.updated_at
|
|
1471
|
+
: null,
|
|
1472
|
+
sources: {
|
|
1473
|
+
codex: {
|
|
1474
|
+
oldest_mtime_ms_processed: typeof record.sources?.codex?.oldest_mtime_ms_processed === "number"
|
|
1475
|
+
? record.sources.codex.oldest_mtime_ms_processed
|
|
1476
|
+
: null,
|
|
1477
|
+
oldest_mtime_processed: typeof record.sources?.codex?.oldest_mtime_processed === "string"
|
|
1478
|
+
? record.sources.codex.oldest_mtime_processed
|
|
1479
|
+
: null,
|
|
1480
|
+
state_counts: numberRecord(record.sources?.codex?.state_counts),
|
|
1481
|
+
reason_counts: numberRecord(record.sources?.codex?.reason_counts),
|
|
1482
|
+
},
|
|
1483
|
+
claude_code: {
|
|
1484
|
+
oldest_mtime_ms_processed: typeof record.sources?.claude_code?.oldest_mtime_ms_processed === "number"
|
|
1485
|
+
? record.sources.claude_code.oldest_mtime_ms_processed
|
|
1486
|
+
: null,
|
|
1487
|
+
oldest_mtime_processed: typeof record.sources?.claude_code?.oldest_mtime_processed === "string"
|
|
1488
|
+
? record.sources.claude_code.oldest_mtime_processed
|
|
1489
|
+
: null,
|
|
1490
|
+
state_counts: numberRecord(record.sources?.claude_code?.state_counts),
|
|
1491
|
+
reason_counts: numberRecord(record.sources?.claude_code?.reason_counts),
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function numberRecord(value) {
|
|
1497
|
+
if (!value || typeof value !== "object")
|
|
1498
|
+
return {};
|
|
1499
|
+
const out = {};
|
|
1500
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
1501
|
+
if (typeof raw === "number" && Number.isFinite(raw))
|
|
1502
|
+
out[key] = raw;
|
|
1503
|
+
}
|
|
1504
|
+
return out;
|
|
1505
|
+
}
|
|
1506
|
+
function summarizeBackfillCursorSources(cursor) {
|
|
1507
|
+
return {
|
|
1508
|
+
codex: summarizeBackfillSource(cursor.sources.codex),
|
|
1509
|
+
claude_code: summarizeBackfillSource(cursor.sources.claude_code),
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function summarizeBackfillSource(source) {
|
|
1513
|
+
return {
|
|
1514
|
+
oldest_mtime_processed: source.oldest_mtime_processed,
|
|
1515
|
+
observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
|
|
1516
|
+
state_counts: source.state_counts,
|
|
1517
|
+
reason_counts: source.reason_counts,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function backfillCursorLine(status) {
|
|
1521
|
+
switch (status.state) {
|
|
1522
|
+
case "done":
|
|
1523
|
+
return `done (${status.completed_at ?? "completion marker present"})`;
|
|
1524
|
+
case "never_run":
|
|
1525
|
+
return "never run";
|
|
1526
|
+
case "remaining":
|
|
1527
|
+
return `${status.remaining_count} remaining`;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
async function countRemainingBackfillSessionFiles(homeDir, cursor) {
|
|
1531
|
+
const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
|
|
1532
|
+
const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
|
|
1533
|
+
return codex + claude;
|
|
1534
|
+
}
|
|
1535
|
+
async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
|
|
1536
|
+
let count = 0;
|
|
1537
|
+
await walkFiles(roots, async (filePath, entryName) => {
|
|
1538
|
+
if (!entryName.endsWith(".jsonl"))
|
|
1539
|
+
return;
|
|
1540
|
+
const info = await stat(filePath).catch(() => null);
|
|
1541
|
+
if (!info?.isFile())
|
|
1542
|
+
return;
|
|
1543
|
+
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
1544
|
+
count += 1;
|
|
1545
|
+
});
|
|
1546
|
+
return count;
|
|
1547
|
+
}
|
|
1548
|
+
async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
|
|
1549
|
+
let count = 0;
|
|
1550
|
+
await walkFiles([projectsDir], async (filePath, entryName) => {
|
|
1551
|
+
if (!entryName.endsWith(".jsonl"))
|
|
1552
|
+
return;
|
|
1553
|
+
if (filePath.includes(`${path.sep}subagents${path.sep}`))
|
|
1554
|
+
return;
|
|
1555
|
+
const info = await stat(filePath).catch(() => null);
|
|
1556
|
+
if (!info?.isFile())
|
|
1557
|
+
return;
|
|
1558
|
+
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
1559
|
+
count += 1;
|
|
1560
|
+
});
|
|
1561
|
+
return count;
|
|
1562
|
+
}
|
|
1563
|
+
async function hasAnyJsonlFile(roots) {
|
|
1564
|
+
let found = false;
|
|
1565
|
+
await walkFiles(roots, async (_filePath, entryName) => {
|
|
1566
|
+
if (entryName.endsWith(".jsonl"))
|
|
1567
|
+
found = true;
|
|
1568
|
+
}, () => found);
|
|
1569
|
+
return found;
|
|
1570
|
+
}
|
|
1571
|
+
async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
1572
|
+
const stack = [...roots];
|
|
1573
|
+
while (stack.length > 0 && !shouldStop()) {
|
|
1574
|
+
const current = stack.pop();
|
|
1575
|
+
if (!current)
|
|
1576
|
+
continue;
|
|
1577
|
+
let entries;
|
|
1578
|
+
try {
|
|
1579
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
for (const entry of entries) {
|
|
1585
|
+
if (shouldStop())
|
|
1586
|
+
return;
|
|
1587
|
+
const full = path.join(current, entry.name);
|
|
1588
|
+
if (entry.isDirectory()) {
|
|
1589
|
+
stack.push(full);
|
|
1590
|
+
}
|
|
1591
|
+
else if (entry.isFile()) {
|
|
1592
|
+
await onFile(full, entry.name);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
async function sessionsScanWindow(command, now) {
|
|
1598
|
+
if (command.all) {
|
|
1599
|
+
return {
|
|
1600
|
+
mode: "all",
|
|
1601
|
+
since_days: null,
|
|
1602
|
+
started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
|
|
1603
|
+
.toISOString(),
|
|
1604
|
+
paired_at: await readPairedAt(command.homeDir),
|
|
1605
|
+
since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
|
|
1606
|
+
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
if (command.sinceDays !== undefined) {
|
|
1610
|
+
const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
|
|
1611
|
+
const pairedAt = await readPairedAt(command.homeDir);
|
|
1612
|
+
const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
|
|
1613
|
+
const startedAtMs = Number.isFinite(pairedMs)
|
|
1614
|
+
? Math.max(requestedMs, pairedMs)
|
|
1615
|
+
: requestedMs;
|
|
1616
|
+
return {
|
|
1617
|
+
mode: "since_days",
|
|
1618
|
+
since_days: command.sinceDays,
|
|
1619
|
+
started_at: new Date(startedAtMs).toISOString(),
|
|
1620
|
+
paired_at: pairedAt,
|
|
1621
|
+
since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
|
|
1622
|
+
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
return {
|
|
1626
|
+
mode: "default",
|
|
1627
|
+
since_days: null,
|
|
1628
|
+
started_at: null,
|
|
1629
|
+
paired_at: await readPairedAt(command.homeDir),
|
|
1630
|
+
since_minutes: null,
|
|
1631
|
+
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
async function readPairedAt(homeDir) {
|
|
1635
|
+
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
1636
|
+
return typeof session?.paired_at === "string" ? session.paired_at : null;
|
|
1637
|
+
}
|
|
1638
|
+
function sessionsWindowLine(window) {
|
|
1639
|
+
if (window.mode === "all")
|
|
1640
|
+
return "all local history";
|
|
1641
|
+
if (window.mode === "since_days") {
|
|
1642
|
+
return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
|
|
1643
|
+
}
|
|
1644
|
+
return "default scan window";
|
|
1645
|
+
}
|
|
1352
1646
|
/**
|
|
1353
1647
|
* Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
|
|
1354
1648
|
* prints why each session is or is not collected. Per-session reasons otherwise
|
|
@@ -1360,6 +1654,7 @@ async function runSessions(command, io) {
|
|
|
1360
1654
|
const now = new Date();
|
|
1361
1655
|
const homeDir = command.homeDir ?? os.homedir();
|
|
1362
1656
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1657
|
+
const window = await sessionsScanWindow(command, now);
|
|
1363
1658
|
const wantCodex = command.source !== "claude";
|
|
1364
1659
|
const wantClaude = command.source !== "codex";
|
|
1365
1660
|
const codex = wantCodex
|
|
@@ -1367,8 +1662,8 @@ async function runSessions(command, io) {
|
|
|
1367
1662
|
sessionsDirs: defaultCodexSessionDirs(homeDir),
|
|
1368
1663
|
worktrees,
|
|
1369
1664
|
now,
|
|
1370
|
-
sinceMinutes:
|
|
1371
|
-
limit:
|
|
1665
|
+
sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
|
|
1666
|
+
limit: window.limit,
|
|
1372
1667
|
})
|
|
1373
1668
|
: null;
|
|
1374
1669
|
const claude = wantClaude
|
|
@@ -1376,6 +1671,8 @@ async function runSessions(command, io) {
|
|
|
1376
1671
|
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
1377
1672
|
worktrees,
|
|
1378
1673
|
now,
|
|
1674
|
+
sinceMinutes: window.since_minutes ?? undefined,
|
|
1675
|
+
limit: window.mode === "default" ? undefined : window.limit,
|
|
1379
1676
|
})
|
|
1380
1677
|
: null;
|
|
1381
1678
|
// Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
|
|
@@ -1410,6 +1707,7 @@ async function runSessions(command, io) {
|
|
|
1410
1707
|
}));
|
|
1411
1708
|
if (command.json) {
|
|
1412
1709
|
writeLine(io.stdout, JSON.stringify({
|
|
1710
|
+
window,
|
|
1413
1711
|
...(codex
|
|
1414
1712
|
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
1415
1713
|
: {}),
|
|
@@ -1426,6 +1724,7 @@ async function runSessions(command, io) {
|
|
|
1426
1724
|
return 0;
|
|
1427
1725
|
}
|
|
1428
1726
|
writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
|
|
1727
|
+
writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
|
|
1429
1728
|
for (const row of codexRows) {
|
|
1430
1729
|
writeLine(io.stdout, sessionRowLine(row));
|
|
1431
1730
|
}
|