@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 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);
@@ -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
  },
@@ -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 { alias, connectTarget, scanRoots, worktreeRoot, hereTimeoutMs, resume };
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
+ }
@@ -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