@ivotoby/postgram-cli 1.38.3 → 1.40.0

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
@@ -121,6 +121,94 @@ provenance, decisions, dependencies, blockers, ownership, involvement,
121
121
  discussion participants, connected context, or ambiguous search hits. Do not
122
122
  expand when the compact result already answers a direct fact.
123
123
 
124
+ ## Workspace tracking
125
+
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).
128
+
129
+ ```bash
130
+ pgm here # report the current workspace and session
131
+ pgm hooks install # install the Claude Code hooks (--pi installs the pi extension)
132
+ pgm hooks uninstall # remove the installed hooks
133
+ pgm where # features and workspaces grouped by stage, one summary per feature (--expand lists every workspace, --all includes done and released)
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
137
+ pgm machine set --alias codebox # set this machine's alias or connect target (also: pgm machine list)
138
+ pgm repo set <remote> --release-on-merge true|false # record the repository for this worktree
139
+ pgm worktree new 1234abcd # create a worktree for a feature (UUID, 8-hex short id or title; --create creates the feature)
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)
145
+ ```
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
+
201
+ New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
202
+
203
+ | Key | Default |
204
+ |-----|---------|
205
+ | `machine` | `{ "alias": <hostname>, "connect_target": <alias> }` |
206
+ | `scan_roots` | `[]` |
207
+ | `worktree_root` | `null` (meaning `<repo_root>.worktrees`) |
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. |
210
+ | `resume` | per-agent-kind `with_id`/`without_id` command templates |
211
+
124
212
  ## Memory Roles
125
213
 
126
214
  Store durable memory:
package/dist/client.js CHANGED
@@ -12,7 +12,7 @@ function normalizeErrorBody(body) {
12
12
  }
13
13
  return body;
14
14
  }
15
- async function request(options, path, requestOptions = {}) {
15
+ export async function request(options, path, requestOptions = {}) {
16
16
  const headers = {
17
17
  Authorization: `Bearer ${options.apiKey}`,
18
18
  ...(requestOptions.body === undefined
@@ -28,6 +28,9 @@ async function request(options, path, requestOptions = {}) {
28
28
  if (requestOptions.body !== undefined) {
29
29
  init.body = JSON.stringify(requestOptions.body);
30
30
  }
31
+ if (requestOptions.signal !== undefined) {
32
+ init.signal = requestOptions.signal;
33
+ }
31
34
  const response = await fetch(`${options.apiUrl}${path}`, init);
32
35
  if (!response.ok) {
33
36
  const body = normalizeErrorBody(await parseResponse(response));
package/dist/pgm.js CHANGED
@@ -8,6 +8,13 @@ import { pipeline } from 'node:stream/promises';
8
8
  import { createPgmClient } from './client.js';
9
9
  import { handleCliFailure, isJsonMode, parseCommaList, parseJsonObject, printHuman, printJson, readStdinText, resolvePgmConfig, shortId } from './shared.js';
10
10
  import { AppError, ErrorCode } from './errors.js';
11
+ import { registerFeatureCommands } from './tracking/feature-commands.js';
12
+ import { registerHereCommand } from './tracking/here.js';
13
+ import { registerHooksCommands } from './tracking/hooks.js';
14
+ import { registerScanCommand } from './tracking/scan.js';
15
+ import { registerWhereCommand } from './tracking/where.js';
16
+ import { registerWorkspaceCommands } from './tracking/workspace-commands.js';
17
+ import { registerWorktreeCommand } from './tracking/worktree.js';
11
18
  import { buildSyncManifest } from './sync-walk.js';
12
19
  import { compactEdgeResponse, compactEntityListResponse, compactGraphResponse, compactSearchResponse, compactStoredEntityResponse, entityListResponseToToon, graphResponseToToon, searchResponseToToon } from './search-output.js';
13
20
  function readCliVersion() {
@@ -908,4 +915,11 @@ program
908
915
  return lines;
909
916
  });
910
917
  });
918
+ registerHereCommand(program);
919
+ registerHooksCommands(program);
920
+ registerWhereCommand(program);
921
+ registerFeatureCommands(program);
922
+ registerWorktreeCommand(program);
923
+ registerScanCommand(program);
924
+ registerWorkspaceCommands(program);
911
925
  await program.parseAsync(process.argv);
@@ -0,0 +1,142 @@
1
+ // cli/src/tracking/api.ts
2
+ import os from 'node:os';
3
+ import { createPgmClient, request } from '../client.js';
4
+ import { handleCliFailure, isJsonMode, printHuman, printJson, resolvePgmConfig } from '../shared.js';
5
+ import { UsageError, loadTrackingConfig } from './config.js';
6
+ function toQuery(params) {
7
+ const query = new URLSearchParams();
8
+ for (const [key, value] of Object.entries(params)) {
9
+ query.set(key, value);
10
+ }
11
+ const text = query.toString();
12
+ return text ? `?${text}` : '';
13
+ }
14
+ export function createTrackingClient(options) {
15
+ return {
16
+ report(body, reportOptions) {
17
+ return request(options, '/api/workspaces/report', {
18
+ method: 'POST',
19
+ body,
20
+ ...(reportOptions?.timeoutMs !== undefined
21
+ ? { signal: AbortSignal.timeout(reportOptions.timeoutMs) }
22
+ : {})
23
+ });
24
+ },
25
+ patchWorkspace(id, body) {
26
+ return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'PATCH', body });
27
+ },
28
+ deleteWorkspace(id) {
29
+ return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
30
+ },
31
+ putMachine(alias, connectTarget) {
32
+ return request(options, `/api/machines/${encodeURIComponent(alias)}`, { method: 'PUT', body: { connect_target: connectTarget } });
33
+ },
34
+ listMachines() {
35
+ return request(options, '/api/machines');
36
+ },
37
+ putRepository(remote, releaseOnMerge) {
38
+ return request(options, '/api/repositories', {
39
+ method: 'PUT',
40
+ body: { remote, release_on_merge: releaseOnMerge }
41
+ });
42
+ },
43
+ createFeature(input) {
44
+ return request(options, '/api/features', {
45
+ method: 'POST',
46
+ body: input
47
+ });
48
+ },
49
+ setStage(id, stage) {
50
+ return request(options, `/api/features/${encodeURIComponent(id)}/stage`, { method: 'POST', body: { stage } });
51
+ },
52
+ addPullRequest(id, input) {
53
+ return request(options, `/api/features/${encodeURIComponent(id)}/pull-requests`, {
54
+ method: 'POST',
55
+ body: input
56
+ });
57
+ },
58
+ lookup(machine, path) {
59
+ return request(options, `/api/workspaces/lookup${toQuery({ machine, path })}`);
60
+ },
61
+ listWorkspaces(query) {
62
+ const params = {};
63
+ if (query.machine !== undefined) {
64
+ params.machine = query.machine;
65
+ }
66
+ if (query.repo !== undefined) {
67
+ params.repo = query.repo;
68
+ }
69
+ if (query.featureId !== undefined) {
70
+ params.feature_id = query.featureId;
71
+ }
72
+ if (query.unassigned) {
73
+ params.unassigned = 'true';
74
+ }
75
+ if (query.includeEnded) {
76
+ params.include_ended = 'true';
77
+ }
78
+ return request(options, `/api/workspaces${toQuery(params)}`);
79
+ },
80
+ where(query) {
81
+ const params = {};
82
+ if (query.view !== undefined) {
83
+ params.view = query.view;
84
+ }
85
+ if (query.machine !== undefined) {
86
+ params.machine = query.machine;
87
+ }
88
+ if (query.repo !== undefined) {
89
+ params.repo = query.repo;
90
+ }
91
+ if (query.includeReleased) {
92
+ params.include_released = 'true';
93
+ }
94
+ if (query.includeDone) {
95
+ params.include_done = 'true';
96
+ }
97
+ if (query.includeEnded) {
98
+ params.include_ended = 'true';
99
+ }
100
+ return request(options, `/api/where${toQuery(params)}`);
101
+ }
102
+ };
103
+ }
104
+ export async function runTrackingCommand(command, handler) {
105
+ const json = isJsonMode(command);
106
+ let result;
107
+ try {
108
+ const homeDir = os.homedir();
109
+ const config = await loadTrackingConfig();
110
+ const credentials = await resolvePgmConfig();
111
+ result = await handler({
112
+ json,
113
+ config,
114
+ client: createTrackingClient(credentials),
115
+ pgm: createPgmClient(credentials),
116
+ homeDir
117
+ });
118
+ }
119
+ catch (error) {
120
+ if (error instanceof UsageError) {
121
+ process.stderr.write(`${error.message}\n`);
122
+ process.exitCode = 2;
123
+ return;
124
+ }
125
+ await handleCliFailure(error, json);
126
+ return;
127
+ }
128
+ if (result === undefined) {
129
+ return;
130
+ }
131
+ if (Array.isArray(result)) {
132
+ if (json) {
133
+ printJson(result);
134
+ }
135
+ else {
136
+ printHuman(result.map(String));
137
+ }
138
+ }
139
+ else {
140
+ printJson(result);
141
+ }
142
+ }
@@ -0,0 +1,181 @@
1
+ // cli/src/tracking/config.ts
2
+ import { chmod, readFile, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { AGENT_KINDS } from './types.js';
6
+ export const DEFAULT_RESUME = {
7
+ 'claude-code': { with_id: 'claude --resume {session_id}', without_id: 'claude' },
8
+ pi: { with_id: 'pi --session {session_id}', without_id: 'pi' },
9
+ talon: { with_id: null, without_id: null },
10
+ other: { with_id: null, without_id: null }
11
+ };
12
+ export class UsageError extends Error {
13
+ }
14
+ export function shq(value) {
15
+ return `'${value.replaceAll("'", `'\\''`)}'`;
16
+ }
17
+ export function slug(title) {
18
+ return (title
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .slice(0, 40)
23
+ .replace(/-+$/, '') || 'feature');
24
+ }
25
+ export function defaultAlias(hostname) {
26
+ const head = hostname.toLowerCase().split('.')[0] ?? '';
27
+ return head.replace(/[^a-z0-9._-]/g, '-') || 'machine';
28
+ }
29
+ export function expandHome(value, homeDir) {
30
+ if (value === '~') {
31
+ return homeDir;
32
+ }
33
+ if (value.startsWith('~/')) {
34
+ return path.join(homeDir, value.slice(2));
35
+ }
36
+ return value;
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
+ }
55
+ function isRecord(value) {
56
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
57
+ }
58
+ function nonEmptyString(value) {
59
+ return typeof value === 'string' && value !== '' ? value : null;
60
+ }
61
+ function cloneResume(templates) {
62
+ const result = {};
63
+ for (const kind of AGENT_KINDS) {
64
+ const base = templates[kind];
65
+ result[kind] = { with_id: base.with_id, without_id: base.without_id };
66
+ }
67
+ return result;
68
+ }
69
+ export function parseTrackingConfig(raw, input) {
70
+ const machine = isRecord(raw) ? (isRecord(raw.machine) ? raw.machine : {}) : {};
71
+ const alias = nonEmptyString(machine.alias) ?? defaultAlias(input.hostname);
72
+ const connectTarget = nonEmptyString(machine.connect_target) ?? alias;
73
+ const scanRoots = [];
74
+ if (isRecord(raw) && Array.isArray(raw.scan_roots)) {
75
+ for (const entry of raw.scan_roots) {
76
+ if (typeof entry === 'string') {
77
+ scanRoots.push(expandHome(entry, input.homeDir));
78
+ }
79
+ }
80
+ }
81
+ const worktreeRoot = isRecord(raw) && typeof raw.worktree_root === 'string'
82
+ ? expandHome(raw.worktree_root, input.homeDir)
83
+ : null;
84
+ const timeout = isRecord(raw) ? raw.here_timeout_ms : undefined;
85
+ const hereTimeoutMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
86
+ ? timeout
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
+ }
96
+ const resume = cloneResume(DEFAULT_RESUME);
97
+ const rawResume = isRecord(raw) && isRecord(raw.resume) ? raw.resume : {};
98
+ for (const kind of AGENT_KINDS) {
99
+ const kindConfig = rawResume[kind];
100
+ if (!isRecord(kindConfig)) {
101
+ continue;
102
+ }
103
+ if (typeof kindConfig.with_id === 'string' || kindConfig.with_id === null) {
104
+ resume[kind].with_id = kindConfig.with_id;
105
+ }
106
+ if (typeof kindConfig.without_id === 'string' ||
107
+ kindConfig.without_id === null) {
108
+ resume[kind].without_id = kindConfig.without_id;
109
+ }
110
+ }
111
+ return {
112
+ alias,
113
+ connectTarget,
114
+ scanRoots,
115
+ worktreeRoot,
116
+ hereTimeoutMs,
117
+ ignorePaths,
118
+ resume
119
+ };
120
+ }
121
+ export async function loadTrackingConfig(input) {
122
+ const homeDir = input?.homeDir ?? os.homedir();
123
+ const hostname = input?.hostname ?? os.hostname();
124
+ let raw = {};
125
+ try {
126
+ const text = await readFile(path.join(homeDir, '.pgmrc'), 'utf8');
127
+ raw = JSON.parse(text);
128
+ }
129
+ catch {
130
+ raw = {};
131
+ }
132
+ return parseTrackingConfig(raw, { homeDir, hostname });
133
+ }
134
+ export async function writeMachineConfig(input) {
135
+ const homeDir = input.homeDir ?? os.homedir();
136
+ const rcPath = path.join(homeDir, '.pgmrc');
137
+ let doc = {};
138
+ let text = null;
139
+ try {
140
+ text = await readFile(rcPath, 'utf8');
141
+ }
142
+ catch (error) {
143
+ const code = error instanceof Error && 'code' in error
144
+ ? error.code
145
+ : undefined;
146
+ if (code !== 'ENOENT') {
147
+ throw error;
148
+ }
149
+ }
150
+ if (text !== null) {
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(text);
154
+ }
155
+ catch (error) {
156
+ throw new UsageError(`~/.pgmrc contains invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
157
+ }
158
+ if (!isRecord(parsed)) {
159
+ throw new UsageError('~/.pgmrc must be a JSON object');
160
+ }
161
+ doc = parsed;
162
+ }
163
+ const machine = isRecord(doc.machine) ? doc.machine : {};
164
+ const previousAlias = nonEmptyString(machine.alias);
165
+ const alias = nonEmptyString(input.alias) ??
166
+ previousAlias ??
167
+ defaultAlias(os.hostname());
168
+ const connectTarget = nonEmptyString(input.connectTarget) ??
169
+ nonEmptyString(machine.connect_target) ??
170
+ alias;
171
+ doc.machine = { ...machine, alias, connect_target: connectTarget };
172
+ await writeFile(rcPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
173
+ await chmod(rcPath, 0o600);
174
+ return { previousAlias, alias, connectTarget };
175
+ }
176
+ export function usageExit(command) {
177
+ command.exitOverride((error) => {
178
+ process.exit(error.exitCode === 0 ? 0 : 2);
179
+ });
180
+ return command;
181
+ }