@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/README.md
CHANGED
|
@@ -123,20 +123,81 @@ expand when the compact result already answers a direct fact.
|
|
|
123
123
|
|
|
124
124
|
## Workspace tracking
|
|
125
125
|
|
|
126
|
-
Track features, machines, workspaces and agent sessions across worktrees
|
|
126
|
+
Track features, machines, workspaces and agent sessions across worktrees.
|
|
127
|
+
For onboarding and daily use, see [docs/workspace-tracking.md](../docs/workspace-tracking.md).
|
|
127
128
|
|
|
128
129
|
```bash
|
|
129
130
|
pgm here # report the current workspace and session
|
|
130
131
|
pgm hooks install # install the Claude Code hooks (--pi installs the pi extension)
|
|
131
132
|
pgm hooks uninstall # remove the installed hooks
|
|
132
|
-
pgm where # features and workspaces grouped by stage
|
|
133
|
+
pgm where # features and workspaces grouped by stage, one summary per feature (--expand lists every workspace, --all includes done and released)
|
|
133
134
|
pgm feature add "my feature" # create a feature (also: stage, pr, link, unlink)
|
|
135
|
+
pgm feature done 1234abcd # mark the feature task done; pgm where hides it unless --all
|
|
136
|
+
pgm feature sync [1234abcd] # update PR states from GitHub with gh and apply the stage rules
|
|
134
137
|
pgm machine set --alias codebox # set this machine's alias or connect target (also: pgm machine list)
|
|
135
138
|
pgm repo set <remote> --release-on-merge true|false # record the repository for this worktree
|
|
136
139
|
pgm worktree new 1234abcd # create a worktree for a feature (UUID, 8-hex short id or title; --create creates the feature)
|
|
137
140
|
pgm scan --root ~/src # discover worktrees and record their features (needs --root <dir> or scan_roots; --yes-exact, --dry-run)
|
|
141
|
+
pgm scan --json # read-only inventory for agents: one entry per worktree, nothing is sent
|
|
142
|
+
pgm scan --apply plan.json # apply an approved plan (--dry-run validates and shows the result only)
|
|
143
|
+
pgm worktree rm <path> # remove a clean, pushed worktree and unregister it (no force flag)
|
|
144
|
+
pgm workspace prune # remove registrations of this machine whose directory is gone (--dry-run, --ignored)
|
|
138
145
|
```
|
|
139
146
|
|
|
147
|
+
The interactive `pgm scan` needs a terminal. Without one it exits with a
|
|
148
|
+
message that points to `--json`, `--apply` and `--yes-exact`.
|
|
149
|
+
|
|
150
|
+
### Scan inventory and plan
|
|
151
|
+
|
|
152
|
+
`pgm scan --json` prints:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"machine": "codebox",
|
|
157
|
+
"generated_at": "2026-11-01T10:00:00.000Z",
|
|
158
|
+
"roots": ["/home/me/workspace"],
|
|
159
|
+
"worktrees": [
|
|
160
|
+
{
|
|
161
|
+
"path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank",
|
|
162
|
+
"repo_remote": "git@github.com:owner/app.git",
|
|
163
|
+
"branch": "feat/1a2b3c4d-kw-rank",
|
|
164
|
+
"last_commit": { "date": "2026-10-30T09:12:00+00:00", "subject": "fix: ranking" },
|
|
165
|
+
"git": { "dirty_count": 0, "unpushed_count": 1, "has_upstream": true },
|
|
166
|
+
"workspace_id": null,
|
|
167
|
+
"feature": null,
|
|
168
|
+
"marker_feature_id": null,
|
|
169
|
+
"sessions": { "count": 2, "last_activity_at": "2026-10-30T10:00:00.000Z" },
|
|
170
|
+
"candidates": [
|
|
171
|
+
{ "kind": "short-id", "feature_id": "1a2b3c4d-...", "title": "Keyword rank fusion" }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`feature` is the link Postgram already has. `candidates` (marker, short-id,
|
|
179
|
+
search) are only computed for worktrees without a link. `git` is `null` when
|
|
180
|
+
git timed out (then `note` says so).
|
|
181
|
+
|
|
182
|
+
`pgm scan --apply plan.json` takes one entry per worktree path:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"entries": [
|
|
187
|
+
{ "path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank", "action": "link", "feature_id": "1a2b3c4d" },
|
|
188
|
+
{ "path": "/home/me/workspace/app.worktrees/spike", "action": "create", "title": "Spike: new cache" },
|
|
189
|
+
{ "path": "/home/me/workspace/app.worktrees/old", "action": "skip" }
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The whole plan is validated before anything is written: paths must be
|
|
195
|
+
absolute, unique, existing worktree roots outside `ignore_paths`; `link` needs
|
|
196
|
+
a feature id (UUID or 8-hex) that exists; `create` needs a title. One invalid
|
|
197
|
+
entry rejects the plan. Entries then run one by one and the result lists
|
|
198
|
+
`linked`, `created`, `skipped` or `failed` per path (exit code 1 when any
|
|
199
|
+
failed). With `--json` the result is `{ "results": [...], "failed": n }`.
|
|
200
|
+
|
|
140
201
|
New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
141
202
|
|
|
142
203
|
| Key | Default |
|
|
@@ -145,6 +206,7 @@ New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
|
145
206
|
| `scan_roots` | `[]` |
|
|
146
207
|
| `worktree_root` | `null` (meaning `<repo_root>.worktrees`) |
|
|
147
208
|
| `here_timeout_ms` | `2000` |
|
|
209
|
+
| `ignore_paths` | `[]`. Directories `pgm here`, the hooks and `pgm scan` skip. An entry matches that directory exactly; an entry ending in `/**` matches everything below it. `~` is expanded. Example: `["~/workspace"]` skips the workspace root but not the repos in it. |
|
|
148
210
|
| `resume` | per-agent-kind `with_id`/`without_id` command templates |
|
|
149
211
|
|
|
150
212
|
## Memory Roles
|
package/dist/client.js
CHANGED
|
@@ -120,6 +120,9 @@ export function createPgmClient(options) {
|
|
|
120
120
|
},
|
|
121
121
|
listTasks(input = {}) {
|
|
122
122
|
const params = new URLSearchParams();
|
|
123
|
+
if (input.kind) {
|
|
124
|
+
params.set('kind', input.kind);
|
|
125
|
+
}
|
|
123
126
|
if (input.status) {
|
|
124
127
|
params.set('status', input.status);
|
|
125
128
|
}
|
package/dist/pgm.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerHereCommand } from './tracking/here.js';
|
|
|
13
13
|
import { registerHooksCommands } from './tracking/hooks.js';
|
|
14
14
|
import { registerScanCommand } from './tracking/scan.js';
|
|
15
15
|
import { registerWhereCommand } from './tracking/where.js';
|
|
16
|
+
import { registerWorkspaceCommands } from './tracking/workspace-commands.js';
|
|
16
17
|
import { registerWorktreeCommand } from './tracking/worktree.js';
|
|
17
18
|
import { buildSyncManifest } from './sync-walk.js';
|
|
18
19
|
import { compactEdgeResponse, compactEntityListResponse, compactGraphResponse, compactSearchResponse, compactStoredEntityResponse, entityListResponseToToon, graphResponseToToon, searchResponseToToon } from './search-output.js';
|
|
@@ -615,6 +616,7 @@ taskCommand
|
|
|
615
616
|
await runWithClient(command, async (client, json) => {
|
|
616
617
|
validateToonOptions(options, json);
|
|
617
618
|
const body = await client.listTasks({
|
|
619
|
+
kind: 'task',
|
|
618
620
|
status: options.status,
|
|
619
621
|
context: options.context,
|
|
620
622
|
limit: Number(options.limit),
|
|
@@ -920,4 +922,5 @@ registerWhereCommand(program);
|
|
|
920
922
|
registerFeatureCommands(program);
|
|
921
923
|
registerWorktreeCommand(program);
|
|
922
924
|
registerScanCommand(program);
|
|
925
|
+
registerWorkspaceCommands(program);
|
|
923
926
|
await program.parseAsync(process.argv);
|
package/dist/tracking/api.js
CHANGED
|
@@ -25,6 +25,9 @@ export function createTrackingClient(options) {
|
|
|
25
25
|
patchWorkspace(id, body) {
|
|
26
26
|
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
27
27
|
},
|
|
28
|
+
deleteWorkspace(id) {
|
|
29
|
+
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
30
|
+
},
|
|
28
31
|
putMachine(alias, connectTarget) {
|
|
29
32
|
return request(options, `/api/machines/${encodeURIComponent(alias)}`, { method: 'PUT', body: { connect_target: connectTarget } });
|
|
30
33
|
},
|
package/dist/tracking/config.js
CHANGED
|
@@ -35,6 +35,23 @@ export function expandHome(value, homeDir) {
|
|
|
35
35
|
}
|
|
36
36
|
return value;
|
|
37
37
|
}
|
|
38
|
+
function trimTrailingSlashes(value) {
|
|
39
|
+
return value.length > 1 ? value.replace(/\/+$/, '') || '/' : value;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An entry matches that directory exactly. An entry ending in `/**` matches
|
|
43
|
+
* everything below the directory, but not the directory itself.
|
|
44
|
+
*/
|
|
45
|
+
export function isIgnoredPath(target, ignorePaths) {
|
|
46
|
+
const candidate = trimTrailingSlashes(target);
|
|
47
|
+
return ignorePaths.some((entry) => {
|
|
48
|
+
if (entry.endsWith('/**')) {
|
|
49
|
+
const base = trimTrailingSlashes(entry.slice(0, -3));
|
|
50
|
+
return candidate.startsWith(base === '/' ? '/' : `${base}/`);
|
|
51
|
+
}
|
|
52
|
+
return candidate === trimTrailingSlashes(entry);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
38
55
|
function isRecord(value) {
|
|
39
56
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
57
|
}
|
|
@@ -68,6 +85,14 @@ export function parseTrackingConfig(raw, input) {
|
|
|
68
85
|
const hereTimeoutMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
69
86
|
? timeout
|
|
70
87
|
: 2000;
|
|
88
|
+
const ignorePaths = [];
|
|
89
|
+
if (isRecord(raw) && Array.isArray(raw.ignore_paths)) {
|
|
90
|
+
for (const entry of raw.ignore_paths) {
|
|
91
|
+
if (typeof entry === 'string' && entry !== '') {
|
|
92
|
+
ignorePaths.push(expandHome(entry, input.homeDir));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
71
96
|
const resume = cloneResume(DEFAULT_RESUME);
|
|
72
97
|
const rawResume = isRecord(raw) && isRecord(raw.resume) ? raw.resume : {};
|
|
73
98
|
for (const kind of AGENT_KINDS) {
|
|
@@ -83,7 +108,15 @@ export function parseTrackingConfig(raw, input) {
|
|
|
83
108
|
resume[kind].without_id = kindConfig.without_id;
|
|
84
109
|
}
|
|
85
110
|
}
|
|
86
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
alias,
|
|
113
|
+
connectTarget,
|
|
114
|
+
scanRoots,
|
|
115
|
+
worktreeRoot,
|
|
116
|
+
hereTimeoutMs,
|
|
117
|
+
ignorePaths,
|
|
118
|
+
resume
|
|
119
|
+
};
|
|
87
120
|
}
|
|
88
121
|
export async function loadTrackingConfig(input) {
|
|
89
122
|
const homeDir = input?.homeDir ?? os.homedir();
|
|
@@ -6,6 +6,7 @@ import { parseCommaList, shortId } from '../shared.js';
|
|
|
6
6
|
import { runTrackingCommand } from './api.js';
|
|
7
7
|
import { UsageError, usageExit, writeMachineConfig } from './config.js';
|
|
8
8
|
import { readBootId, removeMarker, resolveLocalWorkspace, writeMarker } from './local.js';
|
|
9
|
+
import { formatSync, runFeatureSync, runGhPrState } from './feature-sync.js';
|
|
9
10
|
import { FEATURE_STAGES } from './types.js';
|
|
10
11
|
export async function runFeatureAdd(title, options, deps) {
|
|
11
12
|
const tags = parseCommaList(options.tags);
|
|
@@ -25,6 +26,57 @@ export async function runFeatureStage(id, stage, deps) {
|
|
|
25
26
|
? response
|
|
26
27
|
: [`${shortId(response.entity.id)} ${response.previous_stage ?? '-'} → ${stage}`];
|
|
27
28
|
}
|
|
29
|
+
const FULL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
const SHORT_HEX = /^[0-9a-f]{8}$/i;
|
|
31
|
+
async function resolveTaskId(id, deps) {
|
|
32
|
+
if (FULL_UUID.test(id)) {
|
|
33
|
+
return id.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
if (!SHORT_HEX.test(id)) {
|
|
36
|
+
throw new UsageError('feature id must be a full UUID or an 8-hex short id');
|
|
37
|
+
}
|
|
38
|
+
const prefix = id.toLowerCase();
|
|
39
|
+
const matches = [];
|
|
40
|
+
for (let offset = 0;; offset += 100) {
|
|
41
|
+
const page = await deps.pgm.listTasks({ limit: 100, offset });
|
|
42
|
+
for (const item of page.items) {
|
|
43
|
+
if (item.id.startsWith(prefix) && item.metadata.kind === 'feature') {
|
|
44
|
+
matches.push(item.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (page.items.length < 100) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
throw new AppError(ErrorCode.CONFLICT, `feature id ${id} is ambiguous`);
|
|
53
|
+
}
|
|
54
|
+
const [match] = matches;
|
|
55
|
+
if (match === undefined) {
|
|
56
|
+
throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
|
|
57
|
+
}
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Marks the feature's task done. `pgm where` already hides done tasks unless
|
|
62
|
+
* --all is given, so a feature needs no stage of its own for this.
|
|
63
|
+
*/
|
|
64
|
+
export async function runFeatureDone(id, deps) {
|
|
65
|
+
const taskId = await resolveTaskId(id, deps);
|
|
66
|
+
const current = await deps.pgm.recallEntity(taskId);
|
|
67
|
+
if (current.entity.metadata.kind !== 'feature') {
|
|
68
|
+
throw new UsageError(`${shortId(taskId)} is not a feature`);
|
|
69
|
+
}
|
|
70
|
+
if (current.entity.status === 'done') {
|
|
71
|
+
return deps.json
|
|
72
|
+
? { id: taskId, status: 'done', changed: false }
|
|
73
|
+
: [`${shortId(taskId)} already done`];
|
|
74
|
+
}
|
|
75
|
+
const completed = await deps.pgm.completeTask(taskId, current.entity.version);
|
|
76
|
+
return deps.json
|
|
77
|
+
? { id: taskId, status: completed.entity.status, changed: true }
|
|
78
|
+
: [`${shortId(taskId)} done`];
|
|
79
|
+
}
|
|
28
80
|
const PR_STATES = ['open', 'merged', 'closed'];
|
|
29
81
|
export async function runFeaturePr(id, url, options, deps) {
|
|
30
82
|
const state = options.state ?? 'open';
|
|
@@ -185,6 +237,27 @@ export function registerFeatureCommands(program) {
|
|
|
185
237
|
.option('--state <state>', 'open, merged or closed', 'open')).action(async (id, url, options, command) => {
|
|
186
238
|
await runTrackingCommand(command, (context) => runFeaturePr(id, url, options, featureDeps(context)));
|
|
187
239
|
});
|
|
240
|
+
usageExit(feature
|
|
241
|
+
.command('done')
|
|
242
|
+
.description('Mark a feature done so pgm where hides it (pgm where --all shows it)')
|
|
243
|
+
.argument('<id>', 'feature id (UUID or 8-hex)')).action(async (id, _options, command) => {
|
|
244
|
+
await runTrackingCommand(command, (context) => runFeatureDone(id, { pgm: context.pgm, json: context.json }));
|
|
245
|
+
});
|
|
246
|
+
usageExit(feature
|
|
247
|
+
.command('sync')
|
|
248
|
+
.description('Update PR states from GitHub (needs gh) and apply the stage rules')
|
|
249
|
+
.argument('[id]', 'only this feature (UUID or 8-hex)')).action(async (id, _options, command) => {
|
|
250
|
+
await runTrackingCommand(command, async (context) => {
|
|
251
|
+
const result = await runFeatureSync(id, {
|
|
252
|
+
client: context.client,
|
|
253
|
+
gh: (url) => runGhPrState(url)
|
|
254
|
+
});
|
|
255
|
+
if (result.errors.length > 0) {
|
|
256
|
+
process.exitCode = 1;
|
|
257
|
+
}
|
|
258
|
+
return context.json ? result : formatSync(result);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
188
261
|
usageExit(feature
|
|
189
262
|
.command('link')
|
|
190
263
|
.description('Link a workspace to a feature')
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// cli/src/tracking/feature-sync.ts: pgm feature sync — follow GitHub PR state.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { AppError, ErrorCode } from '../errors.js';
|
|
4
|
+
import { shortId } from '../shared.js';
|
|
5
|
+
import { UsageError } from './config.js';
|
|
6
|
+
const FULL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const SHORT_HEX = /^[0-9a-f]{8}$/i;
|
|
8
|
+
const GH_STATES = {
|
|
9
|
+
OPEN: 'open',
|
|
10
|
+
MERGED: 'merged',
|
|
11
|
+
CLOSED: 'closed'
|
|
12
|
+
};
|
|
13
|
+
export function parseGhState(stdout) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(stdout);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const state = parsed.state;
|
|
25
|
+
return typeof state === 'string' ? (GH_STATES[state] ?? null) : null;
|
|
26
|
+
}
|
|
27
|
+
export function classifyGhFailure(error) {
|
|
28
|
+
const stderr = error.stderr ?? '';
|
|
29
|
+
if (error.code === 'ENOENT') {
|
|
30
|
+
return { ok: false, kind: 'missing', message: 'gh is not installed' };
|
|
31
|
+
}
|
|
32
|
+
if (/gh auth login|not logged in|not logged into|authentication/i.test(stderr)) {
|
|
33
|
+
return { ok: false, kind: 'unauthenticated', message: 'gh is not logged in' };
|
|
34
|
+
}
|
|
35
|
+
const first = stderr.split('\n').find((line) => line.trim() !== '');
|
|
36
|
+
return { ok: false, kind: 'error', message: first?.trim() ?? 'gh failed' };
|
|
37
|
+
}
|
|
38
|
+
const defaultExec = (cmd, args) => new Promise((resolve, reject) => {
|
|
39
|
+
execFile(cmd, args, { timeout: 20_000 }, (error, stdout, stderr) => {
|
|
40
|
+
if (error !== null) {
|
|
41
|
+
reject(Object.assign(error, { stderr }));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
resolve({ stdout });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
export async function runGhPrState(url, exec = defaultExec) {
|
|
49
|
+
let stdout;
|
|
50
|
+
try {
|
|
51
|
+
({ stdout } = await exec('gh', ['pr', 'view', url, '--json', 'state']));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const failure = error;
|
|
55
|
+
return classifyGhFailure({ code: failure.code, stderr: failure.stderr });
|
|
56
|
+
}
|
|
57
|
+
const state = parseGhState(stdout);
|
|
58
|
+
return state === null
|
|
59
|
+
? { ok: false, kind: 'error', message: 'unexpected output from gh pr view' }
|
|
60
|
+
: { ok: true, state };
|
|
61
|
+
}
|
|
62
|
+
function collectFeatures(response) {
|
|
63
|
+
const features = [];
|
|
64
|
+
for (const group of response.groups) {
|
|
65
|
+
if ('features' in group) {
|
|
66
|
+
features.push(...group.features);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return features;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Asks gh for the state of every PR that is not merged yet and sends changes
|
|
73
|
+
* through the feature PR endpoint, so the server's derived-stage rules apply.
|
|
74
|
+
* A merged PR is final and is not asked again.
|
|
75
|
+
*/
|
|
76
|
+
export async function runFeatureSync(id, deps) {
|
|
77
|
+
const response = await deps.client.where(id === undefined
|
|
78
|
+
? { view: 'stage' }
|
|
79
|
+
: { view: 'stage', includeReleased: true, includeDone: true });
|
|
80
|
+
let features = collectFeatures(response);
|
|
81
|
+
if (id !== undefined) {
|
|
82
|
+
const wanted = id.toLowerCase();
|
|
83
|
+
if (!FULL_UUID.test(wanted) && !SHORT_HEX.test(wanted)) {
|
|
84
|
+
throw new UsageError('feature id must be a full UUID or an 8-hex short id');
|
|
85
|
+
}
|
|
86
|
+
features = features.filter((feature) => FULL_UUID.test(wanted) ? feature.id === wanted : feature.id.startsWith(wanted));
|
|
87
|
+
if (features.length === 0) {
|
|
88
|
+
throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
|
|
89
|
+
}
|
|
90
|
+
if (features.length > 1) {
|
|
91
|
+
throw new AppError(ErrorCode.CONFLICT, `feature id ${id} is ambiguous`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const result = { checked: 0, unchanged: 0, changed: [], errors: [] };
|
|
95
|
+
const answers = new Map();
|
|
96
|
+
for (const feature of features) {
|
|
97
|
+
for (const pull of feature.pull_requests) {
|
|
98
|
+
if (pull.state === 'merged') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let answer = answers.get(pull.url);
|
|
102
|
+
if (answer === undefined) {
|
|
103
|
+
answer = await deps.gh(pull.url);
|
|
104
|
+
answers.set(pull.url, answer);
|
|
105
|
+
}
|
|
106
|
+
if (!answer.ok && answer.kind === 'missing') {
|
|
107
|
+
throw new UsageError('gh (GitHub CLI) was not found on PATH. Install it from https://cli.github.com to use `pgm feature sync`.');
|
|
108
|
+
}
|
|
109
|
+
if (!answer.ok && answer.kind === 'unauthenticated') {
|
|
110
|
+
throw new UsageError('gh is not logged in. Run `gh auth login`, then retry `pgm feature sync`.');
|
|
111
|
+
}
|
|
112
|
+
result.checked += 1;
|
|
113
|
+
if (!answer.ok) {
|
|
114
|
+
result.errors.push({
|
|
115
|
+
feature_id: feature.id,
|
|
116
|
+
url: pull.url,
|
|
117
|
+
error: answer.message
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (answer.state === pull.state) {
|
|
122
|
+
result.unchanged += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const updated = await deps.client.addPullRequest(feature.id, {
|
|
127
|
+
url: pull.url,
|
|
128
|
+
state: answer.state
|
|
129
|
+
});
|
|
130
|
+
result.changed.push({
|
|
131
|
+
feature_id: feature.id,
|
|
132
|
+
url: pull.url,
|
|
133
|
+
from: pull.state,
|
|
134
|
+
to: answer.state,
|
|
135
|
+
stage_from: updated.from,
|
|
136
|
+
stage_to: updated.to
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
result.errors.push({
|
|
141
|
+
feature_id: feature.id,
|
|
142
|
+
url: pull.url,
|
|
143
|
+
error: error instanceof Error ? error.message : 'update failed'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
export function formatSync(result) {
|
|
151
|
+
const lines = result.changed.map((change) => `${shortId(change.feature_id)} ${change.url} ${change.from} → ${change.to}${change.stage_from !== change.stage_to
|
|
152
|
+
? `, stage ${change.stage_from} → ${change.stage_to}`
|
|
153
|
+
: ''}`);
|
|
154
|
+
for (const error of result.errors) {
|
|
155
|
+
lines.push(`error ${shortId(error.feature_id)} ${error.url}: ${error.error}`);
|
|
156
|
+
}
|
|
157
|
+
lines.push(`checked ${result.checked} pull ${result.checked === 1 ? 'request' : 'requests'}: ${result.changed.length} changed, ${result.unchanged} unchanged, ${result.errors.length} failed`);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
package/dist/tracking/here.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { readStdinText, resolvePgmConfig, shortId } from '../shared.js';
|
|
6
6
|
import { createTrackingClient } from './api.js';
|
|
7
|
-
import { loadTrackingConfig, usageExit } from './config.js';
|
|
7
|
+
import { isIgnoredPath, loadTrackingConfig, usageExit } from './config.js';
|
|
8
8
|
import { readBootId, readMarker, resolveLocalWorkspace } from './local.js';
|
|
9
9
|
import { AGENT_KINDS } from './types.js';
|
|
10
10
|
const HOOK_EVENTS = {
|
|
@@ -79,6 +79,12 @@ function asSessionEvent(value) {
|
|
|
79
79
|
function featureLine(feature) {
|
|
80
80
|
return `Postgram: this worktree belongs to feature ${shortId(feature.id)} "${feature.title}" (stage: ${feature.stage ?? 'building'}).`;
|
|
81
81
|
}
|
|
82
|
+
function ignoredResult(options, target) {
|
|
83
|
+
return {
|
|
84
|
+
stdout: options.hook ? '' : `ignored ${target} (ignore_paths)\n`,
|
|
85
|
+
exitCode: 0
|
|
86
|
+
};
|
|
87
|
+
}
|
|
82
88
|
export async function runHere(options, deps) {
|
|
83
89
|
let agent;
|
|
84
90
|
let sessionId;
|
|
@@ -125,6 +131,10 @@ export async function runHere(options, deps) {
|
|
|
125
131
|
return { stdout: '', exitCode: 0 };
|
|
126
132
|
}
|
|
127
133
|
const config = deps.config;
|
|
134
|
+
// Checked before any git call so an ignored directory costs nothing.
|
|
135
|
+
if (isIgnoredPath(cwd, config.ignorePaths)) {
|
|
136
|
+
return ignoredResult(options, cwd);
|
|
137
|
+
}
|
|
128
138
|
if (deps.client === null) {
|
|
129
139
|
await deps.log('missing credentials');
|
|
130
140
|
return { stdout: '', exitCode: 0 };
|
|
@@ -136,6 +146,16 @@ export async function runHere(options, deps) {
|
|
|
136
146
|
await deps.log('git timeout');
|
|
137
147
|
return { stdout: '', exitCode: 0 };
|
|
138
148
|
}
|
|
149
|
+
if (isIgnoredPath(local.path, config.ignorePaths)) {
|
|
150
|
+
return ignoredResult(options, local.path);
|
|
151
|
+
}
|
|
152
|
+
// A directory outside git holds no project code, so it is never a workspace.
|
|
153
|
+
if (local.repoRoot === null) {
|
|
154
|
+
return {
|
|
155
|
+
stdout: options.hook ? '' : `skipped ${local.path} (not a git repository)\n`,
|
|
156
|
+
exitCode: 0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
139
159
|
const marker = await deps.readMarker(local.path);
|
|
140
160
|
const bootId = await deps.bootId();
|
|
141
161
|
const session = sessionId !== null || event !== null
|