@stravica-ai/digital-operator-pa 0.1.51 → 0.1.52

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.
@@ -36,6 +36,11 @@ Usage:
36
36
 
37
37
  Notes:
38
38
  - status=done stamps \`completedAt\` to today (only if not already set).
39
+ - \`list\` defaults to the active-only scope (excludes done + archived).
40
+ An explicit \`--status <s>\` REPLACES that default and returns only
41
+ items with that status (so \`--status done\` prints done items).
42
+ \`--all\` means no status filter; \`--mine\`/\`--theirs\` compose with
43
+ either. \`--status\` wins over \`--all\` when both are given.
39
44
  - --mine resolves the PA name from operator/config/preferences.json
40
45
  (paName / agentName). Default: "pa".
41
46
  - --author sets the writer-scoped id's author token. Precedence:
@@ -228,6 +228,15 @@ export async function setWorkStatus(id, status) {
228
228
  * mine, theirs, all, paName (used for --mine resolution).
229
229
  * By default excludes done + archived (mirrors desk-list semantics).
230
230
  *
231
+ * An explicit `status` filter REPLACES the active-only default rather
232
+ * than being AND-ed with it: `--status done` returns only done items,
233
+ * `--status archived` returns only archived items, `--status in-progress`
234
+ * is unchanged (the AND was harmless for non-terminal statuses). The
235
+ * pre-0.1.52 AND made any terminal-status request unconditionally
236
+ * empty ("no work items match" for `--status done`), a confident false
237
+ * negative. `--all` still means "no status filter at all"; if both
238
+ * `--all` and `--status` are given, `--status` wins.
239
+ *
231
240
  * Reads any well-formed work item (both id forms). Skips files whose
232
241
  * filename does not parse as a work-item id at all — the same
233
242
  * skip-unparseable posture the original had, restated against the
@@ -248,10 +257,11 @@ export async function listWork(filters = {}) {
248
257
  }
249
258
  }
250
259
  let filtered = items;
251
- if (!filters.all) {
260
+ if (filters.status) {
261
+ filtered = filtered.filter((w) => w.status === filters.status);
262
+ } else if (!filters.all) {
252
263
  filtered = filtered.filter((w) => !['done', 'archived'].includes(w.status));
253
264
  }
254
- if (filters.status) filtered = filtered.filter((w) => w.status === filters.status);
255
265
  if (filters.category) filtered = filtered.filter((w) => w.category === filters.category);
256
266
  if (filters.project) filtered = filtered.filter((w) => w.projectId === filters.project);
257
267
  if (filters.owner) filtered = filtered.filter((w) => w.owner === filters.owner);
@@ -33,10 +33,14 @@ Usage:
33
33
  Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
34
34
  schema is bundled at @stravica-ai/engineering-pa-core/schema/schedule.json.
35
35
 
36
- --agent (launchd only) targets a specific agent's label prefix
37
- (com.<agent>.<id>). A schedule file's own top-level "agent" field drives
38
- install; list/remove take it explicitly since they don't read a schedule
39
- file. Defaults to "dave" everywhere — omitting it is unchanged behaviour.
36
+ --agent targets a specific agent's label / tag prefix. Install reads
37
+ the agent from the schedule file's own top-level "agent" field. Since
38
+ 0.1.52 (w-2026-09-19-dave-001), list/remove resolve the agent from the
39
+ invoking instance's "paName" in operator/config/preferences.json when
40
+ --agent is omitted, and REFUSE (exit 2) when neither source produces a
41
+ value — there is no hard-coded "dave" fallback. This is the fix for the
42
+ colleague-laptop bug where the tools would list and touch another
43
+ estate's schedules under a silent default.
40
44
 
41
45
  Host enforcement (install): a schedule's "host" field is matched against
42
46
  this machine's hostname (exact, then short-name-before-first-dot). A
@@ -85,7 +85,41 @@ function parseStdinJson(raw) {
85
85
  }
86
86
 
87
87
  export async function runInit(overrides = {}) {
88
- const paths = overrides.paths || defaultPaths({ ...overrides, allowGitRootWithoutVault: true });
88
+ // 0.1.52 fix (w-2026-09-19-dave-001): `secrets init` CREATES an age
89
+ // key. The shared resolver in core/vault/require.mjs honours
90
+ // SOPS_AGE_KEY_FILE first, so with the variable exported the previous
91
+ // init generated the new key AT the env path — a stray key landed
92
+ // outside the instance, and on a colleague laptop that carried
93
+ // another estate's SOPS_AGE_KEY_FILE it would have written the new
94
+ // key into that estate's key file. The env override is a READ-path
95
+ // escape hatch; init must generate at the instance path (solo mode)
96
+ // or the per-user path (HQ mode) regardless of the variable.
97
+ //
98
+ // Strip SOPS_AGE_KEY_FILE from the env passed to `defaultPaths` only
99
+ // — every OTHER env-derived resolution (DAVE_ROOT / PA_ROOT walk,
100
+ // etc.) still uses the caller's real env. Print a one-line notice
101
+ // when the operator had the override set and the resolved
102
+ // key path does not match it, so subsequent reads that continue to
103
+ // consult the env override are not silently pointed at the old file.
104
+ const rawEnv = overrides.env;
105
+ const envOverride = rawEnv && typeof rawEnv.SOPS_AGE_KEY_FILE === 'string' && rawEnv.SOPS_AGE_KEY_FILE.length > 0
106
+ ? rawEnv.SOPS_AGE_KEY_FILE
107
+ : null;
108
+ let envForPaths = rawEnv;
109
+ if (envOverride !== null && !overrides.paths) {
110
+ envForPaths = { ...rawEnv };
111
+ delete envForPaths.SOPS_AGE_KEY_FILE;
112
+ }
113
+ const paths = overrides.paths || defaultPaths({
114
+ ...overrides,
115
+ env: envForPaths,
116
+ allowGitRootWithoutVault: true,
117
+ });
118
+ if (envOverride !== null && envOverride !== paths.keyFile) {
119
+ console.log(
120
+ `Note: SOPS_AGE_KEY_FILE is set to ${envOverride}; init writes the new key to ${paths.keyFile}. Reads will not see the new key until the variable is unset or repointed.`,
121
+ );
122
+ }
89
123
  const argv = overrides.argv || [];
90
124
  const flags = extractInitFlags(argv);
91
125
 
@@ -207,10 +207,12 @@ Otherwise, tell the operator what is happening in one short paragraph:
207
207
 
208
208
  > Standing up your instance's own SOPS+age vault. This is what
209
209
  > credentialed tools read their keys from. The vault lives in
210
- > `.vault/`, the age private key lives in
211
- > `~/.config/stravica-pa/age-key.txt` (never committed), and the
212
- > encrypted store is safe to commit. Values you enter here never leave
213
- > your laptop.
210
+ > `.vault/`, the age private key lives inside the instance at
211
+ > `.vault/age-key.txt` (never committed), and the encrypted store is
212
+ > safe to commit. Values you enter here never leave your laptop.
213
+ > HQ-mode instances keep a per-user key at
214
+ > `~/.config/stravica-pa/age-key.txt` instead (shared across the
215
+ > estate's canonical clones).
214
216
 
215
217
  Ask, in one turn, for the values the enabled tools declare. For the
216
218
  default atlassian install:
@@ -562,7 +562,7 @@ function validateLaunchdDaveRoot(value) {
562
562
  + '(DAVE_ROOT unset at install time, falling back to the installed '
563
563
  + 'package\'s own directory, which is version-pinned and silently breaks '
564
564
  + 'on the next upgrade or package rename). Set DAVE_ROOT explicitly in '
565
- + 'the shell running `pa scheduler install`, or set the schedule\'s '
565
+ + 'the shell running `pnpm exec scheduler install`, or set the schedule\'s '
566
566
  + 'environment.DAVE_ROOT to the real operator repo root.',
567
567
  );
568
568
  e.exitCode = 2;
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * list.mjs -- List all Dave-managed scheduled tasks for a given backend
3
+ * list.mjs -- List scheduled tasks belonging to the invoking instance
4
4
  *
5
5
  * Usage: node node_modules/@stravica-ai/engineering-pa-core/src/scheduler/list.mjs [backend]
6
6
  *
7
7
  * Backends: cron, launchd, systemd, schtasks
8
8
  * If no backend is specified, auto-detects and uses the first recommended.
9
+ *
10
+ * Agent-name resolution (0.1.52, w-2026-09-19-dave-001): the header,
11
+ * launchd label prefix, cron tag, systemd unit prefix, and schtasks
12
+ * name filter ALL derive from the invoking instance's `paName`
13
+ * (operator/config/preferences.json), or an explicit `--agent`
14
+ * override. There is no hard-coded fallback: a laptop that carries
15
+ * another estate's schedules would previously see them listed under
16
+ * this instance's `dave` default, and a subsequent `scheduler remove`
17
+ * would touch the wrong estate's plists. See ./resolve-agent.mjs for
18
+ * the resolution order and the escalation on failure.
9
19
  */
10
20
 
11
21
  import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
@@ -15,54 +25,57 @@ import { execSync } from 'node:child_process';
15
25
  import { fileURLToPath } from 'node:url';
16
26
  import { detect } from './detect.mjs';
17
27
  import { launchdLabelPrefix } from './install.mjs';
28
+ import { resolveAgent } from './resolve-agent.mjs';
18
29
 
19
30
  /**
20
- * Render the leading name for the listing header. Uses the agent id when
21
- * one is supplied, else a generic label so instances without an agent id
22
- * (rare, but possible for pre-scaffold flows) still get a sensible line.
23
- *
24
- * Tag prefixes on the crontab / launchd / systemd / schtasks side are NOT
25
- * renamed here \u2014 those are load-bearing for removal on existing installs
26
- * (they are still `dave-` for backward compat).
31
+ * Render the leading name for the listing header. Uses the resolved
32
+ * agent name so both the header and the label prefix agree on which
33
+ * estate is being reported.
27
34
  *
28
- * @param {string} [agent]
35
+ * @param {string} agent
29
36
  * @returns {string}
30
37
  */
31
38
  function headerName(agent) {
32
- if (typeof agent === 'string' && agent.length > 0) return agent;
33
- return 'Schedules';
39
+ return agent;
34
40
  }
35
41
 
36
42
  /**
37
43
  * List schedules for a given backend.
38
44
  * @param {string} backend - Backend name (cron, launchd, systemd, schtasks)
39
45
  * @param {object} [opts]
40
- * @param {string} [opts.agent] - The agent id used for the header AND, on
41
- * launchd, the label prefix to filter by. Defaults to 'dave' for the
42
- * launchd label prefix (unchanged pre-feature behaviour); header defaults
43
- * to 'Schedules' when omitted.
46
+ * @param {string} [opts.agent] Explicit agent override. Falls back to
47
+ * the invoking instance's `paName` (see ./resolve-agent.mjs).
48
+ * @param {string} [opts.repoRoot] Optional instance root override
49
+ * (tests inject; production callers omit this).
44
50
  * @returns {string} Human-readable listing
45
51
  */
46
52
  export function list(backend, opts = {}) {
47
- const agent = opts.agent;
53
+ // Validate the backend BEFORE resolving the agent name: an unknown
54
+ // backend is a usage error and needs to surface as "Unknown backend:
55
+ // <x>" regardless of whether the caller has a paName resolvable on
56
+ // this host. The prior ordering (agent first) turned a usage-shaped
57
+ // failure into a scheduler-config-shaped one, hiding the real cause.
58
+ if (backend !== 'cron' && backend !== 'launchd' && backend !== 'systemd' && backend !== 'schtasks') {
59
+ throw new Error(`Unknown backend: ${backend}`);
60
+ }
61
+ const { agent } = resolveAgent({ agent: opts.agent, repoRoot: opts.repoRoot });
48
62
  switch (backend) {
49
63
  case 'cron': return listCron(agent);
50
64
  case 'launchd': return listLaunchd(agent);
51
65
  case 'systemd': return listSystemd(agent);
52
66
  case 'schtasks': return listSchtasks(agent);
53
- default:
54
- throw new Error(`Unknown backend: ${backend}`);
55
67
  }
56
68
  }
57
69
 
58
70
  function listCron(agent) {
59
- const lines = [`${headerName(agent)} schedules (cron):`, '\u2500'.repeat(37)];
71
+ const lines = [`${headerName(agent)} schedules (cron):`, '─'.repeat(37)];
60
72
  let found = false;
73
+ const tag = `# ${agent}-`;
61
74
 
62
75
  try {
63
76
  const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
64
77
  for (const line of crontab.split('\n')) {
65
- if (line.includes('# dave-')) {
78
+ if (line.includes(tag)) {
66
79
  lines.push(` ${line}`);
67
80
  found = true;
68
81
  }
@@ -76,7 +89,7 @@ function listCron(agent) {
76
89
  }
77
90
 
78
91
  function listLaunchd(agent) {
79
- const lines = [`${headerName(agent)} schedules (launchd):`, '\u2500'.repeat(37)];
92
+ const lines = [`${headerName(agent)} schedules (launchd):`, '─'.repeat(37)];
80
93
  let found = false;
81
94
 
82
95
  const prefix = launchdLabelPrefix(agent);
@@ -104,13 +117,14 @@ function listLaunchd(agent) {
104
117
  }
105
118
 
106
119
  function listSystemd(agent) {
107
- const lines = [`${headerName(agent)} schedules (systemd):`, '\u2500'.repeat(37)];
120
+ const lines = [`${headerName(agent)} schedules (systemd):`, '─'.repeat(37)];
108
121
  let found = false;
122
+ const tag = `${agent}-`;
109
123
 
110
124
  try {
111
125
  const output = execSync('systemctl --user list-timers --no-pager', { encoding: 'utf8' });
112
126
  for (const line of output.split('\n')) {
113
- if (line.includes('dave-')) {
127
+ if (line.includes(tag)) {
114
128
  lines.push(` ${line}`);
115
129
  found = true;
116
130
  }
@@ -124,13 +138,14 @@ function listSystemd(agent) {
124
138
  }
125
139
 
126
140
  function listSchtasks(agent) {
127
- const lines = [`${headerName(agent)} schedules (schtasks):`, '\u2500'.repeat(37)];
141
+ const lines = [`${headerName(agent)} schedules (schtasks):`, '─'.repeat(37)];
128
142
  let found = false;
143
+ const tag = `${agent}-`;
129
144
 
130
145
  try {
131
146
  const output = execSync('schtasks.exe /Query /FO LIST', { encoding: 'utf8' });
132
147
  for (const line of output.split('\n')) {
133
- if (line.includes('dave-')) {
148
+ if (line.includes(tag)) {
134
149
  lines.push(` ${line}`);
135
150
  found = true;
136
151
  }
@@ -184,6 +199,6 @@ if (isDirectlyInvoked()) {
184
199
  console.log(list(backend));
185
200
  } catch (err) {
186
201
  console.error(`Error: ${err.message}`);
187
- process.exit(1);
202
+ process.exit(err.exitCode || 1);
188
203
  }
189
204
  }
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * remove.mjs -- Remove a Dave-managed scheduled task
3
+ * remove.mjs -- Remove a scheduled task belonging to the invoking instance
4
4
  *
5
5
  * Usage: node node_modules/@stravica-ai/engineering-pa-core/src/scheduler/remove.mjs <schedule-id> <backend> [--delete-file]
6
6
  *
7
7
  * Removes the scheduled task identified by <schedule-id> from the
8
8
  * specified backend. Also removes the schedule JSON from schedules/
9
9
  * if --delete-file is passed.
10
+ *
11
+ * Agent-name resolution (0.1.52, w-2026-09-19-dave-001): the launchd
12
+ * label, cron tag, systemd unit, and schtasks name ALL derive from
13
+ * the invoking instance's `paName` (operator/config/preferences.json)
14
+ * or an explicit `--agent` override. Pre-0.1.52 defaulted to the
15
+ * literal `"dave"`, which on a laptop carrying another estate's
16
+ * schedules would unload/delete THAT estate's plists. There is no
17
+ * hard-coded fallback now — refusal is the safe behaviour when
18
+ * neither source resolves a name. See ./resolve-agent.mjs.
10
19
  */
11
20
 
12
21
  import { existsSync, unlinkSync, realpathSync } from 'node:fs';
@@ -16,19 +25,20 @@ import { execSync } from 'node:child_process';
16
25
  import { fileURLToPath } from 'node:url';
17
26
  import { launchdLabelPrefix } from './install.mjs';
18
27
  import { findRepoRootSync } from '../instance-root/find.mjs';
28
+ import { resolveAgent } from './resolve-agent.mjs';
19
29
 
20
30
  const __filename = fileURLToPath(import.meta.url);
21
31
  const __dirname = dirname(__filename);
22
32
 
23
33
  // ── Backend removal functions ────────────────────────────────────────
24
34
 
25
- function removeCron(schedId) {
26
- const tag = `# dave-${schedId}`;
35
+ function removeCron(schedId, agent) {
36
+ const tag = `# ${agent}-${schedId}`;
27
37
  let existing = '';
28
38
  try {
29
39
  existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
30
40
  } catch {
31
- console.log(`No cron entry found for dave-${schedId}`);
41
+ console.log(`No cron entry found for ${agent}-${schedId}`);
32
42
  return;
33
43
  }
34
44
 
@@ -36,7 +46,7 @@ function removeCron(schedId) {
36
46
  const filtered = lines.filter(line => !line.includes(tag)).join('\n').replace(/^\n+|\n+$/g, '');
37
47
 
38
48
  if (filtered === existing.replace(/^\n+|\n+$/g, '')) {
39
- console.log(`No cron entry found for dave-${schedId}`);
49
+ console.log(`No cron entry found for ${agent}-${schedId}`);
40
50
  return;
41
51
  }
42
52
 
@@ -46,7 +56,7 @@ function removeCron(schedId) {
46
56
  try { execSync('crontab -r', { stdio: 'pipe' }); } catch { /* already empty */ }
47
57
  }
48
58
 
49
- console.log(`Removed cron schedule: dave-${schedId}`);
59
+ console.log(`Removed cron schedule: ${agent}-${schedId}`);
50
60
  }
51
61
 
52
62
  function removeLaunchd(schedId, agent) {
@@ -68,8 +78,8 @@ function removeLaunchd(schedId, agent) {
68
78
  }
69
79
  }
70
80
 
71
- function removeSystemd(schedId) {
72
- const unitName = `dave-${schedId}`;
81
+ function removeSystemd(schedId, agent) {
82
+ const unitName = `${agent}-${schedId}`;
73
83
  const unitDir = join(homedir(), '.config', 'systemd', 'user');
74
84
 
75
85
  // Stop and disable
@@ -95,8 +105,8 @@ function removeSystemd(schedId) {
95
105
  }
96
106
  }
97
107
 
98
- function removeSchtasks(schedId) {
99
- const taskName = `dave-${schedId}`;
108
+ function removeSchtasks(schedId, agent) {
109
+ const taskName = `${agent}-${schedId}`;
100
110
  try {
101
111
  execSync(`schtasks.exe /Delete /TN "${taskName}" /F`, { stdio: 'pipe' });
102
112
  console.log(`Removed schtasks schedule: ${taskName}`);
@@ -108,30 +118,41 @@ function removeSchtasks(schedId) {
108
118
  // ── Main ─────────────────────────────────────────────────────────────
109
119
 
110
120
  /**
111
- * Remove a Dave-managed schedule from the specified backend.
121
+ * Remove a schedule from the specified backend for the invoking
122
+ * instance (or an explicit `--agent` override).
112
123
  *
113
124
  * Same BUG-2-class fix as install(): this used to have no return
114
125
  * statement, so the CLI wrapper's `result.ok` check was reading
115
126
  * `undefined.ok`. Every path now returns a result object.
127
+ *
116
128
  * @param {string} schedId - Schedule identifier
117
129
  * @param {string} backend - Backend name (cron, launchd, systemd, schtasks)
118
130
  * @param {object} [options]
119
131
  * @param {boolean} [options.deleteFile=false] - Also delete the schedule JSON file
120
- * @param {string} [options.agent] - Agent whose launchd label prefix to target (launchd only). Defaults to 'dave'.
121
- * @returns {{ ok: boolean, id: string, backend: string }}
132
+ * @param {string} [options.agent] - Explicit agent override. Falls
133
+ * back to the invoking instance's `paName` from preferences.json.
134
+ * @param {string} [options.repoRoot] - Optional instance root override
135
+ * (tests inject; production callers omit this).
136
+ * @returns {{ ok: boolean, id: string, backend: string, agent: string }}
122
137
  */
123
138
  export function remove(schedId, backend, options = {}) {
139
+ // See list.mjs for the same ordering rationale: backend validation
140
+ // must run BEFORE agent resolution so an unknown backend is a
141
+ // usage-shaped error, not a scheduler-config-shaped one.
142
+ if (backend !== 'cron' && backend !== 'launchd' && backend !== 'systemd' && backend !== 'schtasks') {
143
+ throw new Error(`Unknown backend: ${backend}. Supported: cron, launchd, systemd, schtasks`);
144
+ }
145
+ const { agent } = resolveAgent({ agent: options.agent, repoRoot: options.repoRoot });
146
+
124
147
  switch (backend) {
125
- case 'cron': removeCron(schedId); break;
126
- case 'launchd': removeLaunchd(schedId, options.agent); break;
127
- case 'systemd': removeSystemd(schedId); break;
128
- case 'schtasks': removeSchtasks(schedId); break;
129
- default:
130
- throw new Error(`Unknown backend: ${backend}. Supported: cron, launchd, systemd, schtasks`);
148
+ case 'cron': removeCron(schedId, agent); break;
149
+ case 'launchd': removeLaunchd(schedId, agent); break;
150
+ case 'systemd': removeSystemd(schedId, agent); break;
151
+ case 'schtasks': removeSchtasks(schedId, agent); break;
131
152
  }
132
153
 
133
154
  if (options.deleteFile) {
134
- const daveRoot = process.env.DAVE_ROOT || process.env.PA_ROOT || findRepoRootSync();
155
+ const daveRoot = process.env.DAVE_ROOT || process.env.PA_ROOT || options.repoRoot || findRepoRootSync();
135
156
  const schedFile = join(daveRoot, 'schedules', `${schedId}.json`);
136
157
  if (existsSync(schedFile)) {
137
158
  unlinkSync(schedFile);
@@ -139,7 +160,7 @@ export function remove(schedId, backend, options = {}) {
139
160
  }
140
161
  }
141
162
 
142
- return { ok: true, id: schedId, backend };
163
+ return { ok: true, id: schedId, backend, agent };
143
164
  }
144
165
 
145
166
  // ── CLI entry point ──────────────────────────────────────────────────
@@ -172,6 +193,6 @@ if (isDirectlyInvoked()) {
172
193
  remove(positional[0], positional[1], { deleteFile });
173
194
  } catch (err) {
174
195
  console.error(`Error: ${err.message}`);
175
- process.exit(1);
196
+ process.exit(err.exitCode || 1);
176
197
  }
177
198
  }
@@ -0,0 +1,56 @@
1
+ // resolve-agent.mjs — shared agent-name resolution for list + remove.
2
+ //
3
+ // Pre-0.1.52, `scheduler list` and `scheduler remove` defaulted the
4
+ // launchd label prefix (and the cron / systemd / schtasks tag prefix)
5
+ // to the literal string `"dave"`. On a laptop that carried more than
6
+ // one estate — the exact shape a colleague-hosted "demo then delete"
7
+ // solo instance sits beside another estate — this made the tools list
8
+ // and touch schedules belonging to OTHER estates. `install` had never
9
+ // had that problem because it reads the label from the schedule file's
10
+ // own `agent` field. `list` / `remove` have no schedule file at hand,
11
+ // so this helper resolves the invoking instance's `paName` from
12
+ // `operator/config/preferences.json` — the same source `install` and
13
+ // the 0.1.50 workspace header already use.
14
+ //
15
+ // Resolution order:
16
+ // 1. explicit `--agent <name>` override (unchanged);
17
+ // 2. `paName` from the invoking instance's preferences (base +
18
+ // overlay via `readPreferencesSync`);
19
+ // 3. no fallback — refuse with a message naming the preferences key
20
+ // and the `--agent` escape hatch. There is deliberately NO hard-
21
+ // coded `dave` fallback: silently defaulting is what caused this
22
+ // bug in the first place.
23
+
24
+ import { readPreferencesSync } from '../preferences.mjs';
25
+ import { findRepoRootSync } from '../instance-root/find.mjs';
26
+
27
+ /**
28
+ * Resolve the agent name to use for launchd label prefixing / cron /
29
+ * systemd / schtasks tag filtering.
30
+ *
31
+ * @param {object} [opts]
32
+ * @param {string} [opts.agent] Explicit override (from `--agent`).
33
+ * @param {string} [opts.repoRoot] Optional instance root override
34
+ * (tests inject; defaults to `findRepoRootSync()`).
35
+ * @returns {{ agent: string, source: 'explicit'|'preferences' }}
36
+ * @throws {Error} exitCode:2 when neither source resolves a value.
37
+ */
38
+ export function resolveAgent(opts = {}) {
39
+ if (typeof opts.agent === 'string' && opts.agent.length > 0) {
40
+ return { agent: opts.agent, source: 'explicit' };
41
+ }
42
+ const repoRoot = opts.repoRoot || findRepoRootSync();
43
+ const prefs = readPreferencesSync(repoRoot);
44
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0) {
45
+ return { agent: prefs.paName, source: 'preferences' };
46
+ }
47
+ const err = new Error(
48
+ 'scheduler: cannot resolve agent name. Set `paName` in '
49
+ + 'operator/config/preferences.json for this instance, or pass '
50
+ + '`--agent <name>` explicitly. No hard-coded fallback is used '
51
+ + '(0.1.52, w-2026-09-19-dave-001): a silent default would target '
52
+ + 'schedules belonging to another estate.',
53
+ );
54
+ err.exitCode = 2;
55
+ throw err;
56
+ }
@@ -294,7 +294,12 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
294
294
  }
295
295
  if (backend) {
296
296
  try {
297
- liveStatus = String(scheduler.list(backend)).trim();
297
+ // Thread the workspace's known instance root through so the
298
+ // agent-name resolver reads THIS instance's preferences.json
299
+ // (paName) rather than walking up from process.cwd(). See
300
+ // core/src/scheduler/resolve-agent.mjs (0.1.52,
301
+ // w-2026-09-19-dave-001).
302
+ liveStatus = String(scheduler.list(backend, { repoRoot })).trim();
298
303
  } catch (err) {
299
304
  schedulerError = 'scheduler list failed for backend "' + backend + '": ' + (err && err.message ? err.message : String(err));
300
305
  }
@@ -110,17 +110,44 @@ Your PA has the `rcf-lite-engineer` role baked in. It builds greenfield applicat
110
110
 
111
111
  ## Removing an instance
112
112
 
113
- Before you delete the instance directory, remove every schedule the instance installed on your operating system. Deleting the directory does not unload the launchd job (macOS) or cron entry (Linux) the scheduler registered; the loaded job then keeps firing against a path that no longer exists, and its errors turn up in the system log with no easy trace back to the retired instance.
113
+ Do everything below from inside the instance, in this order. Deleting the instance directory does not stop the workspace server, does not unload the launchd job (macOS) or cron entry (Linux) the scheduler registered, and does not remove the age key. A loaded schedule keeps firing against a path that no longer exists, its errors turn up in the system log with no easy trace back to the retired instance, and a running workspace server keeps its port bound after its own `node_modules` has been deleted.
114
114
 
115
- Two steps:
115
+ The steps are ordered so that a stop or listing that fails does not orphan a running background process. If you already deleted the instance directory before reading this, jump to the fallbacks in step 1.
116
116
 
117
- 1. From inside the instance, list its installed schedules:
117
+ 1. Stop the workspace server.
118
118
  ```
119
- pnpm exec pa scheduler list
119
+ pnpm exec pa workspace stop
120
120
  ```
121
- 2. For each id the list reports, remove it:
121
+ The stop command reads the pid and port from `.ws.pid` at the instance root, sends SIGTERM, and waits up to 5s before force-killing. Confirm with `pnpm exec pa workspace status`. If the instance directory is already gone (so `pnpm exec` is no longer runnable), find the process by port and kill it directly:
122
122
  ```
123
- pnpm exec pa scheduler remove <id> --delete-file
123
+ cat .ws.pid # if .ws.pid still exists: {"pid":<N>,"port":<P>}
124
+ lsof -i :<P> | grep LISTEN # macOS/Linux: process holding the port
125
+ pgrep -lf digital-operator-pa-workspace # match the server's spawn command
126
+ kill <pid> # SIGTERM; kill -9 <pid> if it refuses
124
127
  ```
128
+ The server responds to `GET /__api/identity` with `{"server":"digital-operator-pa-workspace","repoRoot":"...","pid":<N>,...}`, so `curl -s http://127.0.0.1:<port>/__api/identity` will identify a rogue workspace server (and name the instance directory it was started from) before you kill it.
125
129
 
126
- Only after `pa scheduler list` returns nothing is it safe to delete the instance directory.
130
+ 2. List every schedule the instance installed. Since 0.1.52 (w-2026-09-19-dave-001) the tool filters by this instance's `paName` from `operator/config/preferences.json`, so it only ever reports schedules that belong here:
131
+ ```
132
+ pnpm exec scheduler list
133
+ ```
134
+ The correct bin is `scheduler` (not `pa scheduler`; there is no `pa scheduler` subcommand). If the tool refuses because it cannot resolve a `paName`, pass `--agent <name>` explicitly.
135
+
136
+ 3. Remove every id the list reports. `--delete-file` also removes the schedule JSON:
137
+ ```
138
+ pnpm exec scheduler remove <id> --delete-file
139
+ ```
140
+ Repeat per id.
141
+
142
+ 4. Confirm nothing this instance registered is still loaded. On macOS, `launchctl list` and `crontab -l` should carry no entries with the `com.<paName>.` prefix respectively `# <paName>-` tag:
143
+ ```
144
+ launchctl list | grep "com.<paName>\." # macOS
145
+ crontab -l | grep "# <paName>-" # cron backend
146
+ systemctl --user list-timers | grep "<paName>-" # linux/systemd
147
+ ```
148
+
149
+ 5. Only now delete the instance directory:
150
+ ```
151
+ rm -rf $HOME/<pa-name>-instance
152
+ ```
153
+ The age private key lives inside the instance at `.vault/age-key.txt` and goes with the directory. If you want a portable copy of the secrets first, run `pnpm exec secrets backup` from inside the instance BEFORE step 5.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",