@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.
- package/README.md +64 -2
- package/dist/client.js +3 -0
- package/dist/pgm.js +3 -0
- package/dist/tracking/api.js +3 -0
- package/dist/tracking/config.js +34 -1
- package/dist/tracking/feature-commands.js +73 -0
- package/dist/tracking/feature-sync.js +159 -0
- package/dist/tracking/here.js +21 -1
- package/dist/tracking/scan.js +399 -71
- package/dist/tracking/where.js +87 -12
- package/dist/tracking/workspace-commands.js +94 -0
- package/dist/tracking/worktree.js +86 -0
- package/package.json +1 -1
package/dist/tracking/scan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
const
|
|
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
|
-
|
|
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')
|
|
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) =>
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
}
|