@ivotoby/postgram-cli 1.39.0 → 1.40.1

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.
@@ -1,15 +1,26 @@
1
1
  // cli/src/tracking/scan.ts: pgm scan — discover worktrees and record their features.
2
2
  import { existsSync } from 'node:fs';
3
+ import { readFile, realpath } from 'node:fs/promises';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import readline from 'node:readline';
6
7
  import { shortId } from '../shared.js';
7
8
  import { runTrackingCommand } from './api.js';
8
- import { slug, usageExit, UsageError } from './config.js';
9
- import { readMarker, resolveLocalWorkspace, writeMarker } from './local.js';
9
+ import { expandHome, isIgnoredPath, slug, usageExit, UsageError } from './config.js';
10
+ import { readMarker, resolveLocalWorkspace, runGit, writeMarker } from './local.js';
10
11
  import { discoverWorktrees, findClaudeSessions, findPiSessions } from './scan-discovery.js';
11
12
  import { syncMissing } from './where.js';
12
13
  export const BRANCH_SHORT_ID = /(?:^|\/)([0-9a-f]{8})(?:-|$)/;
14
+ /** Keeps the feature tasks of a task listing, so plain tasks are never offered or linked. */
15
+ export function featureTasks(items) {
16
+ return items
17
+ .filter((item) => item.metadata.kind === 'feature')
18
+ .map((item) => ({
19
+ id: item.id,
20
+ title: firstLineTitle(item.content),
21
+ done: item.status === 'done'
22
+ }));
23
+ }
13
24
  const DEFAULT_DEPTH = 3;
14
25
  function parseDepth(raw) {
15
26
  if (raw === undefined) {
@@ -56,7 +67,9 @@ async function buildProposals(worktree, deps, tasks, yesExact) {
56
67
  catch {
57
68
  hits = [];
58
69
  }
59
- for (const hit of hits.slice(0, 3)) {
70
+ // Search covers every task; only open features are worth proposing.
71
+ const open = new Set(tasks.filter((task) => task.done !== true).map((task) => task.id));
72
+ for (const hit of hits.filter((h) => open.has(h.id)).slice(0, 3)) {
60
73
  proposals.push({ kind: 'search', featureId: hit.id, title: hit.title });
61
74
  }
62
75
  }
@@ -69,13 +82,51 @@ function sessionsFor(worktree, claude, pi) {
69
82
  ...(pi.get(worktree.path) ?? [])
70
83
  ];
71
84
  }
72
- export async function runScan(options, deps) {
85
+ function scanRoots(options, deps) {
73
86
  const roots = options.root.length > 0
74
87
  ? options.root.map((root) => path.resolve(root))
75
88
  : deps.config.scanRoots;
76
89
  if (roots.length === 0) {
77
90
  throw new UsageError('no scan roots: pass --root <dir> or set scan_roots in ~/.pgmrc');
78
91
  }
92
+ return roots;
93
+ }
94
+ async function discoverNotIgnored(roots, depth, deps) {
95
+ const worktrees = await deps.discover(roots, depth);
96
+ return worktrees.filter((worktree) => !isIgnoredPath(worktree.path, deps.config.ignorePaths));
97
+ }
98
+ async function findSessions(paths, deps) {
99
+ const claudeDir = deps.env.CLAUDE_CONFIG_DIR ?? path.join(deps.homeDir, '.claude');
100
+ const piDir = deps.env.PI_CODING_AGENT_DIR ?? path.join(deps.homeDir, '.pi', 'agent');
101
+ const claude = await deps.claudeSessions(paths, claudeDir);
102
+ for (const note of claude.notes) {
103
+ deps.info(note);
104
+ }
105
+ const pi = await deps.piSessions(paths, piDir);
106
+ return { claude: claude.sessions, pi };
107
+ }
108
+ async function recordLocal(local, featureId, sessions, deps) {
109
+ const body = {
110
+ source: 'scan',
111
+ machine: {
112
+ alias: deps.config.alias,
113
+ hostname: deps.hostname,
114
+ boot_id: null
115
+ },
116
+ workspace: local.workspace,
117
+ feature_id: featureId,
118
+ sessions
119
+ };
120
+ await deps.client.report(body);
121
+ if ((await deps.readMarker(local.path)) !== featureId) {
122
+ await deps.writeMarker(local.path, featureId);
123
+ }
124
+ }
125
+ export async function runScan(options, deps) {
126
+ if (!options.yesExact && !deps.isTty()) {
127
+ throw new UsageError('pgm scan needs a terminal for its interactive flow. Without one, use `pgm scan --json` to list worktrees, `pgm scan --apply <plan.json>` to apply a plan, or `pgm scan --yes-exact`.');
128
+ }
129
+ const roots = scanRoots(options, deps);
79
130
  const depth = parseDepth(options.depth);
80
131
  const knownResponse = await deps.client.listWorkspaces({
81
132
  machine: deps.config.alias
@@ -88,15 +139,8 @@ export async function runScan(options, deps) {
88
139
  });
89
140
  }
90
141
  const known = new Set(knownResponse.workspaces.map((workspace) => workspace.path));
91
- const worktrees = await deps.discover(roots, depth);
92
- const paths = worktrees.map((worktree) => worktree.path);
93
- const claudeDir = deps.env.CLAUDE_CONFIG_DIR ?? path.join(deps.homeDir, '.claude');
94
- const piDir = deps.env.PI_CODING_AGENT_DIR ?? path.join(deps.homeDir, '.pi', 'agent');
95
- const claude = await deps.claudeSessions(paths, claudeDir);
96
- for (const note of claude.notes) {
97
- deps.info(note);
98
- }
99
- const pi = await deps.piSessions(paths, piDir);
142
+ const worktrees = await discoverNotIgnored(roots, depth, deps);
143
+ const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
100
144
  const tasks = await deps.tasks();
101
145
  const lines = [];
102
146
  for (const worktree of worktrees) {
@@ -158,25 +202,264 @@ export async function runScan(options, deps) {
158
202
  lines.push(`skipped ${worktree.path} (git timeout)`);
159
203
  continue;
160
204
  }
161
- const body = {
162
- source: 'scan',
163
- machine: {
164
- alias: deps.config.alias,
165
- hostname: deps.hostname,
166
- boot_id: null
167
- },
168
- workspace: local.workspace,
169
- feature_id: id,
170
- sessions: sessionsFor(worktree, claude.sessions, pi)
171
- };
172
- await deps.client.report(body);
173
- if ((await deps.readMarker(worktree.path)) !== id) {
174
- await deps.writeMarker(worktree.path, id);
175
- }
205
+ await recordLocal(local, id, sessionsFor(worktree, claude, pi), deps);
176
206
  lines.push(`recorded ${worktree.path} → ${shortId(id)}`);
177
207
  }
178
208
  return lines;
179
209
  }
210
+ const INVENTORY_CONCURRENCY = 6;
211
+ function sessionSummary(sessions) {
212
+ if (sessions.length === 0) {
213
+ return null;
214
+ }
215
+ let latest = '';
216
+ for (const session of sessions) {
217
+ const observed = session.observed_at ?? '';
218
+ if (observed > latest) {
219
+ latest = observed;
220
+ }
221
+ }
222
+ return { count: sessions.length, last_activity_at: latest };
223
+ }
224
+ /** Read-only: lists what is on disk and what Postgram knows. Sends nothing. */
225
+ export async function runScanInventory(options, deps) {
226
+ const roots = scanRoots(options, deps);
227
+ const depth = parseDepth(options.depth);
228
+ const known = new Map((await deps.client.listWorkspaces({ machine: deps.config.alias })).workspaces.map((workspace) => [workspace.path, workspace]));
229
+ const worktrees = await discoverNotIgnored(roots, depth, deps);
230
+ const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
231
+ const tasks = await deps.tasks();
232
+ const titleFor = (id) => tasks.find((task) => task.id === id)?.title ?? '';
233
+ const describe = async (worktree) => {
234
+ const [local, lastCommit] = await Promise.all([
235
+ deps.resolveWorkspace(worktree.path),
236
+ deps.lastCommit(worktree.path)
237
+ ]);
238
+ const workspace = known.get(worktree.path) ?? null;
239
+ const linkedId = workspace?.feature_id ?? null;
240
+ const marker = await deps.readMarker(worktree.path);
241
+ const candidates = (linkedId === null
242
+ ? await buildProposals(worktree, deps, tasks, false)
243
+ : []).map((proposal) => ({
244
+ kind: proposal.kind,
245
+ feature_id: proposal.featureId,
246
+ title: proposal.title
247
+ }));
248
+ const entry = {
249
+ path: worktree.path,
250
+ repo_remote: local.kind === 'ok'
251
+ ? (local.workspace.repo_remote ?? workspace?.repo_remote ?? null)
252
+ : (workspace?.repo_remote ?? null),
253
+ branch: local.kind === 'ok'
254
+ ? (local.workspace.branch ?? worktree.branch)
255
+ : worktree.branch,
256
+ last_commit: lastCommit,
257
+ git: local.kind === 'ok' ? (local.workspace.git ?? null) : null,
258
+ workspace_id: workspace?.id ?? null,
259
+ feature: linkedId !== null ? { id: linkedId, title: titleFor(linkedId) } : null,
260
+ marker_feature_id: marker,
261
+ sessions: sessionSummary(sessionsFor(worktree, claude, pi)),
262
+ candidates
263
+ };
264
+ if (local.kind === 'timeout') {
265
+ entry.note = 'git timeout';
266
+ }
267
+ return entry;
268
+ };
269
+ const entries = [];
270
+ for (let i = 0; i < worktrees.length; i += INVENTORY_CONCURRENCY) {
271
+ entries.push(...(await Promise.all(worktrees.slice(i, i + INVENTORY_CONCURRENCY).map(describe))));
272
+ }
273
+ return {
274
+ machine: deps.config.alias,
275
+ generated_at: new Date().toISOString(),
276
+ roots,
277
+ worktrees: entries
278
+ };
279
+ }
280
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
281
+ const SHORT_ID_RE = /^[0-9a-f]{8}$/i;
282
+ function isRecordValue(value) {
283
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
284
+ }
285
+ function resolveTaskId(raw, tasks) {
286
+ const lower = raw.toLowerCase();
287
+ let hits = [];
288
+ if (UUID_RE.test(lower)) {
289
+ hits = tasks.filter((task) => task.id === lower);
290
+ }
291
+ else if (SHORT_ID_RE.test(lower)) {
292
+ hits = tasks.filter((task) => task.id.startsWith(lower));
293
+ }
294
+ const hit = hits[0];
295
+ if (hit === undefined) {
296
+ return { error: `no feature matches ${raw}` };
297
+ }
298
+ if (hits.length > 1) {
299
+ return { error: `feature id ${raw} is ambiguous` };
300
+ }
301
+ return { id: hit.id };
302
+ }
303
+ /** Validates the whole plan; nothing is written while this runs. */
304
+ async function validatePlan(plan, deps) {
305
+ const rawEntries = Array.isArray(plan)
306
+ ? plan
307
+ : isRecordValue(plan) && Array.isArray(plan.entries)
308
+ ? plan.entries
309
+ : null;
310
+ if (rawEntries === null) {
311
+ throw new UsageError('plan must be {"entries": [...]} or a JSON array of entries');
312
+ }
313
+ const tasks = await deps.tasks();
314
+ const errors = [];
315
+ const valid = [];
316
+ const seen = new Set();
317
+ for (const [index, raw] of rawEntries.entries()) {
318
+ if (!isRecordValue(raw) || typeof raw.path !== 'string' || raw.path === '') {
319
+ errors.push(`entry ${index + 1}: path is required`);
320
+ continue;
321
+ }
322
+ const expanded = expandHome(raw.path, deps.homeDir);
323
+ if (!path.isAbsolute(expanded)) {
324
+ errors.push(`${raw.path}: path must be absolute`);
325
+ continue;
326
+ }
327
+ const target = path.resolve(expanded);
328
+ if (seen.has(target)) {
329
+ errors.push(`duplicate path ${target}`);
330
+ continue;
331
+ }
332
+ seen.add(target);
333
+ const action = raw.action;
334
+ if (action === 'skip') {
335
+ valid.push({ entry: { path: target, action: 'skip' } });
336
+ continue;
337
+ }
338
+ let item;
339
+ if (action === 'link') {
340
+ if (typeof raw.feature_id !== 'string' || raw.feature_id === '') {
341
+ errors.push(`${target}: link needs feature_id`);
342
+ continue;
343
+ }
344
+ const resolved = resolveTaskId(raw.feature_id, tasks);
345
+ if ('error' in resolved) {
346
+ errors.push(`${target}: ${resolved.error}`);
347
+ continue;
348
+ }
349
+ item = {
350
+ entry: { path: target, action: 'link', feature_id: raw.feature_id },
351
+ featureId: resolved.id
352
+ };
353
+ }
354
+ else if (action === 'create') {
355
+ const title = typeof raw.title === 'string' ? raw.title.trim() : '';
356
+ if (title === '') {
357
+ errors.push(`${target}: create needs a non-empty title`);
358
+ continue;
359
+ }
360
+ item = { entry: { path: target, action: 'create', title } };
361
+ }
362
+ else {
363
+ errors.push(`${target}: unknown action '${String(action)}'`);
364
+ continue;
365
+ }
366
+ if (isIgnoredPath(target, deps.config.ignorePaths)) {
367
+ errors.push(`${target}: path is in ignore_paths`);
368
+ continue;
369
+ }
370
+ if (!deps.exists(target)) {
371
+ errors.push(`${target}: path does not exist`);
372
+ continue;
373
+ }
374
+ const local = await deps.resolveWorkspace(target);
375
+ if (local.kind === 'timeout') {
376
+ errors.push(`${target}: git timed out`);
377
+ continue;
378
+ }
379
+ if (local.repoRoot === null) {
380
+ errors.push(`${target}: not a git worktree`);
381
+ continue;
382
+ }
383
+ if (local.path !== (await deps.realpath(target))) {
384
+ errors.push(`${target}: not the root of a worktree (${local.path})`);
385
+ continue;
386
+ }
387
+ if (isIgnoredPath(local.path, deps.config.ignorePaths)) {
388
+ errors.push(`${target}: path is in ignore_paths`);
389
+ continue;
390
+ }
391
+ valid.push({ ...item, local });
392
+ }
393
+ if (errors.length > 0) {
394
+ throw new UsageError(`plan rejected, nothing was written:\n${errors.map((line) => ` ${line}`).join('\n')}`);
395
+ }
396
+ return valid;
397
+ }
398
+ export async function runScanApply(plan, options, deps) {
399
+ const valid = await validatePlan(plan, deps);
400
+ const paths = valid
401
+ .filter((item) => item.entry.action !== 'skip')
402
+ .map((item) => item.entry.path);
403
+ const sessions = options.dryRun || paths.length === 0
404
+ ? { claude: new Map(), pi: new Map() }
405
+ : await findSessions(paths, deps);
406
+ const results = [];
407
+ for (const item of valid) {
408
+ const { entry } = item;
409
+ if (entry.action === 'skip') {
410
+ results.push({ path: entry.path, action: 'skip', status: 'skipped' });
411
+ continue;
412
+ }
413
+ if (options.dryRun) {
414
+ results.push({
415
+ path: entry.path,
416
+ action: entry.action,
417
+ status: entry.action === 'link' ? 'would link' : 'would create',
418
+ ...(item.featureId !== undefined ? { feature_id: item.featureId } : {})
419
+ });
420
+ continue;
421
+ }
422
+ let featureId = item.featureId;
423
+ try {
424
+ if (entry.action === 'create') {
425
+ const created = await deps.client.createFeature({ content: entry.title });
426
+ featureId = created.entity.id;
427
+ }
428
+ if (featureId === undefined || item.local === undefined) {
429
+ throw new Error('internal: unresolved plan entry');
430
+ }
431
+ await recordLocal(item.local, featureId, sessionsFor({ path: entry.path, branch: null }, sessions.claude, sessions.pi), deps);
432
+ results.push({
433
+ path: entry.path,
434
+ action: entry.action,
435
+ status: entry.action === 'link' ? 'linked' : 'created',
436
+ feature_id: featureId
437
+ });
438
+ }
439
+ catch (error) {
440
+ results.push({
441
+ path: entry.path,
442
+ action: entry.action,
443
+ status: 'failed',
444
+ ...(featureId !== undefined ? { feature_id: featureId } : {}),
445
+ error: error instanceof Error ? error.message : 'unknown error'
446
+ });
447
+ }
448
+ }
449
+ return {
450
+ results,
451
+ failed: results.filter((result) => result.status === 'failed').length
452
+ };
453
+ }
454
+ export function formatApplyResults(results) {
455
+ return results.map((result) => {
456
+ const target = result.feature_id !== undefined ? ` → ${shortId(result.feature_id)}` : '';
457
+ if (result.status === 'failed') {
458
+ return `failed ${result.path}${target}: ${result.error ?? 'unknown error'}`;
459
+ }
460
+ return `${result.status} ${result.path}${target}`;
461
+ });
462
+ }
180
463
  function firstLineTitle(content) {
181
464
  return (content ?? '').split('\n')[0]?.trim() ?? '';
182
465
  }
@@ -203,6 +486,21 @@ function askLine(question) {
203
486
  rl.on('SIGINT', () => finish(''));
204
487
  });
205
488
  }
489
+ async function readPlan(file) {
490
+ let text;
491
+ try {
492
+ text = await readFile(file, 'utf8');
493
+ }
494
+ catch (error) {
495
+ throw new UsageError(`cannot read plan ${file}: ${error instanceof Error ? error.message : 'read failed'}`);
496
+ }
497
+ try {
498
+ return JSON.parse(text);
499
+ }
500
+ catch (error) {
501
+ throw new UsageError(`plan ${file} is not valid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
502
+ }
503
+ }
206
504
  export function registerScanCommand(program) {
207
505
  const command = usageExit(program
208
506
  .command('scan')
@@ -210,52 +508,82 @@ export function registerScanCommand(program) {
210
508
  .option('--root <dir>', 'scan root (repeatable)', collectRoot, [])
211
509
  .option('--depth <n>', `directory depth below each root (default ${DEFAULT_DEPTH})`)
212
510
  .option('--yes-exact', 'accept marker and short-id proposals, skip the rest')
213
- .option('--dry-run', 'show what would be recorded without sending')).action(async () => {
511
+ .option('--dry-run', 'show what would be recorded without sending')
512
+ .option('--apply <plan.json>', 'apply an approved plan (link, create or skip per worktree path)')).action(async () => {
214
513
  const opts = command.opts();
215
- await runTrackingCommand(command, (context) => runScan({
216
- root: opts.root ?? [],
217
- ...(opts.depth !== undefined ? { depth: opts.depth } : {}),
218
- yesExact: opts.yesExact === true,
219
- dryRun: opts.dryRun === true
220
- }, {
221
- client: context.client,
222
- config: context.config,
223
- hostname: os.hostname(),
224
- env: process.env,
225
- homeDir: context.homeDir,
226
- tasks: async () => {
227
- const items = [];
228
- for (let offset = 0;; offset += 100) {
229
- const page = await context.pgm.listTasks({ limit: 100, offset });
230
- for (const item of page.items) {
231
- items.push({ id: item.id, title: firstLineTitle(item.content) });
514
+ await runTrackingCommand(command, async (context) => {
515
+ const scanOptions = {
516
+ root: opts.root ?? [],
517
+ ...(opts.depth !== undefined ? { depth: opts.depth } : {}),
518
+ yesExact: opts.yesExact === true,
519
+ dryRun: opts.dryRun === true
520
+ };
521
+ if (opts.apply !== undefined && scanOptions.yesExact) {
522
+ throw new UsageError('--apply cannot be combined with --yes-exact');
523
+ }
524
+ const deps = {
525
+ client: context.client,
526
+ config: context.config,
527
+ hostname: os.hostname(),
528
+ env: process.env,
529
+ homeDir: context.homeDir,
530
+ tasks: async () => {
531
+ const items = [];
532
+ for (let offset = 0;; offset += 100) {
533
+ const page = await context.pgm.listTasks({ limit: 100, offset });
534
+ items.push(...featureTasks(page.items));
535
+ if (page.items.length < 100) {
536
+ break;
537
+ }
232
538
  }
233
- if (page.items.length < 100) {
234
- break;
539
+ return items;
540
+ },
541
+ search: async (query) => {
542
+ const response = await context.pgm.searchEntities({
543
+ query,
544
+ type: 'task',
545
+ limit: 10
546
+ });
547
+ return response.results.map((result) => ({
548
+ id: result.entity.id,
549
+ title: firstLineTitle(result.entity.content)
550
+ }));
551
+ },
552
+ ask: askLine,
553
+ discover: discoverWorktrees,
554
+ claudeSessions: findClaudeSessions,
555
+ piSessions: findPiSessions,
556
+ resolveWorkspace: (p) => resolveLocalWorkspace(p),
557
+ readMarker,
558
+ writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
559
+ exists: existsSync,
560
+ isTty: () => process.stdin.isTTY === true,
561
+ realpath: (p) => realpath(p),
562
+ lastCommit: async (worktree) => {
563
+ const result = await runGit(['log', '-1', '--format=%cI%n%s'], {
564
+ cwd: worktree,
565
+ timeoutMs: 5000
566
+ });
567
+ if (!result.ok || result.stdout === '') {
568
+ return null;
235
569
  }
570
+ const [date = '', ...subject] = result.stdout.split('\n');
571
+ return { date, subject: subject.join('\n') };
572
+ },
573
+ info: (line) => process.stderr.write(`${line}\n`)
574
+ };
575
+ if (opts.apply !== undefined) {
576
+ const plan = await readPlan(opts.apply);
577
+ const applied = await runScanApply(plan, { dryRun: scanOptions.dryRun }, deps);
578
+ if (applied.failed > 0) {
579
+ process.exitCode = 1;
236
580
  }
237
- return items;
238
- },
239
- search: async (query) => {
240
- const response = await context.pgm.searchEntities({
241
- query,
242
- type: 'task',
243
- limit: 3
244
- });
245
- return response.results.map((result) => ({
246
- id: result.entity.id,
247
- title: firstLineTitle(result.entity.content)
248
- }));
249
- },
250
- ask: askLine,
251
- discover: discoverWorktrees,
252
- claudeSessions: findClaudeSessions,
253
- piSessions: findPiSessions,
254
- resolveWorkspace: (p) => resolveLocalWorkspace(p),
255
- readMarker,
256
- writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
257
- exists: existsSync,
258
- info: (line) => process.stderr.write(`${line}\n`)
259
- }));
581
+ return context.json ? applied : formatApplyResults(applied.results);
582
+ }
583
+ if (context.json && !scanOptions.yesExact) {
584
+ return runScanInventory(scanOptions, deps);
585
+ }
586
+ return runScan(scanOptions, deps);
587
+ });
260
588
  });
261
589
  }