@stravica-ai/digital-operator-pa 0.1.41 → 0.1.43

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.
@@ -205,6 +205,13 @@ function resolveWorkspacePort(repoRoot, opts = {}) {
205
205
  return 4200;
206
206
  }
207
207
 
208
+ /**
209
+ * The `server` field the workspace's own /__api/identity endpoint returns
210
+ * (see packages/workspace/src/api.mjs). Any other body on the port means
211
+ * the port is bound by an unrelated process, not our workspace.
212
+ */
213
+ const WORKSPACE_IDENTITY_SERVER = 'digital-operator-pa-workspace';
214
+
208
215
  async function probeWorkspace(url, fetchImpl) {
209
216
  const fetchFn = fetchImpl ?? globalThis.fetch;
210
217
  if (typeof fetchFn !== 'function') {
@@ -214,8 +221,25 @@ async function probeWorkspace(url, fetchImpl) {
214
221
  const timer = setTimeout(() => controller.abort(), 1500);
215
222
  try {
216
223
  const res = await fetchFn(url, { signal: controller.signal });
224
+ // Any HTTP response has to be positively identified as OUR workspace
225
+ // before the probe reports "reachable". WSD-laptop cold install of
226
+ // 0.1.41 (2026-09-16): a docker container bound to :4200 answered
227
+ // /__api/identity with HTTP 404 and `pa status` reported the
228
+ // workspace "reachable (HTTP 404)". Identity-gate the probe so a
229
+ // foreign process on the port surfaces as "in use by another
230
+ // process" instead of a false green.
217
231
  if (res.status >= 200 && res.status < 500) {
218
- return { status: 'reachable', detail: `HTTP ${res.status}` };
232
+ let body = null;
233
+ try {
234
+ body = await res.json();
235
+ } catch { /* not JSON — falls through to the identity-mismatch branch */ }
236
+ if (body && body.server === WORKSPACE_IDENTITY_SERVER) {
237
+ return { status: 'reachable', detail: `HTTP ${res.status}` };
238
+ }
239
+ return {
240
+ status: 'in use by another process',
241
+ detail: `HTTP ${res.status}, /__api/identity did not identify as ${WORKSPACE_IDENTITY_SERVER}`,
242
+ };
219
243
  }
220
244
  return { status: 'error', detail: `HTTP ${res.status}` };
221
245
  } catch (e) {
@@ -35,6 +35,7 @@ import {
35
35
  sopsEncryptScoped,
36
36
  VaultError,
37
37
  } from '../vault.mjs';
38
+ import { runInit } from './init.mjs';
38
39
 
39
40
  // --canonical-clone / --no-canonical may appear anywhere in argv, matching
40
41
  // the --scope/--file convention in cli.mjs. Local to `set` (not global
@@ -91,7 +92,41 @@ export async function runSet(args, overrides = {}) {
91
92
  throw new VaultError('missing dependencies', 1);
92
93
  }
93
94
 
94
- const vaultState = checkVault(paths);
95
+ let vaultState = checkVault(paths);
96
+ // Self-init on `secrets set` when the legacy vault has not been
97
+ // initialised on this host yet. WSD-laptop cold install of 0.1.41
98
+ // (2026-09-16): the onboarding guide's Atlassian setup asks the
99
+ // colleague to run three `secrets set` commands, and the first one
100
+ // died with `Error: No recipients file at .vault/recipients.txt —
101
+ // Run 'secrets init' to set up the vault.` The guide's two-step
102
+ // Atlassian setup never named that mandatory extra step. `set` now
103
+ // runs the same `runInit()` code path in-process before the first
104
+ // write when the vault is uninitialised, emitting one stderr notice
105
+ // so the operator can see what happened.
106
+ //
107
+ // The self-init fires only when it is SAFE — the legacy (non-scoped)
108
+ // vault, no encrypted store already on disk (otherwise a fresh key
109
+ // would orphan the existing contents; that failure mode still surfaces
110
+ // as `missing-key` exit 2 with the "restore your key from backup"
111
+ // guidance). `missing-recipients` on a solo host is always safe (a
112
+ // present store with no recipients file would already have failed
113
+ // decryption elsewhere; the case the WSD laptop hit was "no vault at
114
+ // all yet"). `missing-key` on a solo host is also safe when the
115
+ // encrypted store is absent — pure first-run state. Scoped vaults
116
+ // (`paths.scoped`) are provisioned by scope-registration tooling and
117
+ // are never self-inited here; a `secrets set --scope <name>` against
118
+ // a missing scope surfaces the not-provisioned error unchanged.
119
+ const canSelfInit = !vaultState.ok
120
+ && !paths.scoped
121
+ && (vaultState.reason === 'missing-recipients' || vaultState.reason === 'missing-key')
122
+ && !secretsFileExists(paths);
123
+ if (canSelfInit) {
124
+ console.error(
125
+ `notice: no vault at ${paths.vaultDir}: self-initialising before first write (equivalent to running \`secrets init\`).`,
126
+ );
127
+ await runInit({ paths });
128
+ vaultState = checkVault(paths);
129
+ }
95
130
  if (!vaultState.ok) {
96
131
  console.error(vaultState.message);
97
132
  throw new VaultError(vaultState.reason || 'vault not initialised', vaultState.exitCode ?? 1);
@@ -32,7 +32,7 @@
32
32
  // omitted on `ok`. `log` is null on exits that happened before the
33
33
  // per-run log file was created.
34
34
 
35
- import { appendFile, readFile } from 'node:fs/promises';
35
+ import { appendFile, readFile, unlink } from 'node:fs/promises';
36
36
  import { basename, join } from 'node:path';
37
37
 
38
38
  import { resolvePrereqs } from './prereqs.mjs';
@@ -211,7 +211,21 @@ export async function runDistill({
211
211
  log(`distillation OK (${durationSec}s) — log: ${runLogPath}`, { stderr });
212
212
  log('changes (if any) will be committed by the next sync run', { stderr });
213
213
 
214
- // Step 4 — append success audit. Sync will pick up any file changes
214
+ // Step 4a — invalidate the retrieval artefacts so the next
215
+ // `pa knowledge` call rebuilds them and sees any doc the librarian just
216
+ // promoted. Macbook re-proof of 0.1.41 (2026-09-16): on a solo install
217
+ // there is no `library-sync-cycle:graph-rebuild` leg running behind the
218
+ // scenes, so a distilled knowledge doc stayed invisible to
219
+ // `pa knowledge search` until `.graph.json` was deleted by hand. Delete
220
+ // is safer than in-process rebuild here: the retrieval loader in
221
+ // `packages/cli/src/knowledge/graph.mjs` already self-heals on a
222
+ // missing artefact (`loadGraph:on-demand`), and a full-mode host has
223
+ // its scheduled graph-rebuild leg rebuild both files on the next cycle
224
+ // regardless. Both are untracked per the 2026-07-29 per-device ruling,
225
+ // so a deletion at commit time is invisible to git.
226
+ await invalidateRetrievalArtefacts(repoRoot, stderr);
227
+
228
+ // Step 4b — append success audit. Sync will pick up any file changes
215
229
  // the librarian produced on its next 30-minute tick.
216
230
  await writeAudit(runsLog, {
217
231
  ts: tsIso,
@@ -224,6 +238,35 @@ export async function runDistill({
224
238
  return 0;
225
239
  }
226
240
 
241
+ /**
242
+ * Delete `operator/knowledge/.graph.json` and `.backlinks.json` under
243
+ * `root` if they exist. Missing files are a no-op; any other error is
244
+ * swallowed with a stderr note so a stale artefact never masks an
245
+ * otherwise-successful distillation exit code. Exported for tests.
246
+ */
247
+ export async function invalidateRetrievalArtefacts(root, stderr = process.stderr) {
248
+ const targets = [
249
+ join(root, 'operator', 'knowledge', '.graph.json'),
250
+ join(root, 'operator', 'knowledge', '.backlinks.json'),
251
+ ];
252
+ const removed = [];
253
+ for (const path of targets) {
254
+ try {
255
+ await unlink(path);
256
+ removed.push(basename(path));
257
+ } catch (e) {
258
+ if (e && e.code === 'ENOENT') continue;
259
+ log(`warning: failed to invalidate ${basename(path)}: ${e.message}`, { stderr });
260
+ }
261
+ }
262
+ if (removed.length > 0) {
263
+ log(
264
+ `invalidated retrieval artefacts (${removed.join(', ')}); pa knowledge rebuilds on demand`,
265
+ { stderr },
266
+ );
267
+ }
268
+ }
269
+
227
270
  function isoCompactForSession(d) {
228
271
  // Matches the historical `$(date -u +%Y%m%d-%H%M)` shape used pre-Node.
229
272
  const pad = (n) => String(n).padStart(2, '0');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-core-extensions-atlassian",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Atlassian (Jira, Confluence, confluence-fetch) CLIs and library surface. Promoted into core for the alpha solo scaffold.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -9,6 +9,7 @@
9
9
  // Zero external dependencies - uses Node.js built-in fetch() and child_process.
10
10
 
11
11
  import { loadAtlassianCredentials } from '../common/secrets.mjs';
12
+ import { readBody, formatHttpError } from '../jira/client.mjs';
12
13
 
13
14
  // ---- Exit-code-aware error ----------------------------------------------
14
15
 
@@ -145,33 +146,12 @@ export function createClient(options = {}) {
145
146
  // Parse response
146
147
  let data = null;
147
148
  if (res.status !== 204) {
148
- const contentType = res.headers.get('Content-Type') || '';
149
- if (contentType.includes('application/json')) {
150
- data = await res.json();
151
- } else {
152
- const text = await res.text();
153
- if (text) data = text;
154
- }
149
+ data = await readBody(res);
155
150
  }
156
151
 
157
152
  // 4xx errors
158
153
  if (res.status >= 400) {
159
- const msgs = [];
160
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
161
- if (data && data.errors) {
162
- if (Array.isArray(data.errors)) {
163
- for (const e of data.errors) {
164
- msgs.push(e.title || e.message || JSON.stringify(e));
165
- }
166
- } else {
167
- for (const [field, msg] of Object.entries(data.errors)) {
168
- msgs.push(`${field}: ${msg}`);
169
- }
170
- }
171
- }
172
- if (data && data.message) msgs.push(data.message);
173
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
174
- throw new ConfluenceError(`Confluence API error (${res.status}): ${detail}`, 2);
154
+ throw new ConfluenceError(formatHttpError('Confluence', res.status, data), 2);
175
155
  }
176
156
 
177
157
  return { status: res.status, data, headers: res.headers };
@@ -17,6 +17,71 @@ export class JiraError extends Error {
17
17
  }
18
18
  }
19
19
 
20
+ // ---- Shared response parsing --------------------------------------------
21
+
22
+ /**
23
+ * Read a fetch Response body without ever throwing on a bad body.
24
+ *
25
+ * Atlassian sometimes returns a non-JSON body (bare text, an HTML error
26
+ * page from Cloudflare, a proxy edge string) with a `Content-Type` that
27
+ * still says `application/json`, and a raw `res.json()` on that body throws
28
+ * `SyntaxError: Unexpected token 'C', "Client mus"... is not valid JSON`,
29
+ * which surfaces as an unhandled stack trace instead of an
30
+ * "authentication failed" message. Falls back to raw text on any parse
31
+ * failure and returns null for empty bodies.
32
+ *
33
+ * @param {Response} res
34
+ * @returns {Promise<any|string|null>}
35
+ */
36
+ export async function readBody(res) {
37
+ const contentType = res.headers.get('Content-Type') || '';
38
+ const wantsJson = contentType.includes('application/json');
39
+ const raw = await res.text();
40
+ if (!raw) return null;
41
+ if (wantsJson) {
42
+ try {
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return raw.trim();
46
+ }
47
+ }
48
+ return raw.trim();
49
+ }
50
+
51
+ /**
52
+ * Turn a parsed error body into a one-line, colleague-facing HTTP
53
+ * error message. `data` may be a parsed JSON object (Atlassian error
54
+ * shape), a plain string (non-JSON body), or null.
55
+ *
56
+ * @param {string} product "Jira" or "Confluence"
57
+ * @param {number} status HTTP status
58
+ * @param {any} data Parsed body, string, or null
59
+ * @returns {string}
60
+ */
61
+ export function formatHttpError(product, status, data) {
62
+ const parts = [];
63
+ if (data && typeof data === 'object') {
64
+ if (Array.isArray(data.errorMessages)) parts.push(...data.errorMessages);
65
+ if (data.errors) {
66
+ if (Array.isArray(data.errors)) {
67
+ for (const e of data.errors) {
68
+ parts.push(e.title || e.message || JSON.stringify(e));
69
+ }
70
+ } else if (typeof data.errors === 'object') {
71
+ for (const [field, msg] of Object.entries(data.errors)) {
72
+ parts.push(`${field}: ${msg}`);
73
+ }
74
+ }
75
+ }
76
+ if (data.message && parts.length === 0) parts.push(data.message);
77
+ } else if (typeof data === 'string' && data.length > 0) {
78
+ const trimmed = data.length > 120 ? `${data.slice(0, 120)}...` : data;
79
+ parts.push(trimmed);
80
+ }
81
+ const detail = parts.length > 0 ? parts.join('; ') : `HTTP ${status}`;
82
+ return `${product} API error (${status}): ${detail}`;
83
+ }
84
+
20
85
  // ---- Credential loading -------------------------------------------------
21
86
 
22
87
  /**
@@ -142,26 +207,12 @@ export function createClient(options = {}) {
142
207
  // Parse response
143
208
  let data = null;
144
209
  if (res.status !== 204) {
145
- const contentType = res.headers.get('Content-Type') || '';
146
- if (contentType.includes('application/json')) {
147
- data = await res.json();
148
- } else {
149
- const text = await res.text();
150
- if (text) data = text;
151
- }
210
+ data = await readBody(res);
152
211
  }
153
212
 
154
213
  // 4xx errors
155
214
  if (res.status >= 400) {
156
- const msgs = [];
157
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
158
- if (data && data.errors) {
159
- for (const [field, msg] of Object.entries(data.errors)) {
160
- msgs.push(`${field}: ${msg}`);
161
- }
162
- }
163
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
164
- throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
215
+ throw new JiraError(formatHttpError('Jira', res.status, data), 2);
165
216
  }
166
217
 
167
218
  return { status: res.status, data, headers: res.headers };
@@ -309,19 +360,11 @@ export function createClient(options = {}) {
309
360
 
310
361
  let data = null;
311
362
  if (res.status !== 204) {
312
- const contentType = res.headers.get('Content-Type') || '';
313
- if (contentType.includes('application/json')) data = await res.json();
314
- else { const text = await res.text(); if (text) data = text; }
363
+ data = await readBody(res);
315
364
  }
316
365
 
317
366
  if (res.status >= 400) {
318
- const msgs = [];
319
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
320
- if (data && data.errors) {
321
- for (const [field, msg] of Object.entries(data.errors)) msgs.push(`${field}: ${msg}`);
322
- }
323
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
324
- throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
367
+ throw new JiraError(formatHttpError('Jira', res.status, data), 2);
325
368
  }
326
369
 
327
370
  return { status: res.status, data, headers: res.headers };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-scaffold",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "pa-init scaffolder + onboarding flows (solo, lead, member).",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -10,9 +10,10 @@
10
10
  //
11
11
  // Errors carry an `exitCode`. The CLI translates to `process.exit`.
12
12
 
13
- import { resolve } from 'node:path';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
14
15
 
15
- import { buildPlan, buildRenderConfig } from '../generator.mjs';
16
+ import { buildPlan, buildRenderConfig, resolveInstalledDopVersion } from '../generator.mjs';
16
17
  import {
17
18
  defaultSeam,
18
19
  promptValues,
@@ -35,7 +36,14 @@ export async function runInit(options) {
35
36
  const config = await gatherConfig(options);
36
37
 
37
38
  process.stdout.write(`\npa-init: scaffolding ${config.paName} for ${config.operatorName}\n`);
38
- process.stdout.write(` target: ${config.targetDir}\n`);
39
+ process.stdout.write(` target: ${config.targetDir}\n`);
40
+ // Announce the exact version pa-init pinned into the instance's
41
+ // package.json (or `<default>` when the resolver could not verify the
42
+ // installed umbrella version — dev-only edge case). This is the
43
+ // load-bearing signal for the "you got the right version" contract.
44
+ process.stdout.write(
45
+ ` package: @stravica-ai/digital-operator-pa@${config.pkgVersion}\n`,
46
+ );
39
47
 
40
48
  await assertTargetIsEmptyOrMissing(config.targetDir);
41
49
 
@@ -63,6 +71,27 @@ export async function runInit(options) {
63
71
  return 2;
64
72
  }
65
73
 
74
+ // Version-drift safety net (WSD-laptop cold install of 0.1.41, second-
75
+ // machine re-run of 0.1.42). The scaffold pins an exact version and
76
+ // ships a `pnpm-workspace.yaml` with `minimumReleaseAgeExclude` for
77
+ // this package, so pnpm should never resolve a different version, but
78
+ // if the guard ever misses, this prints one line naming the mismatch
79
+ // so a colleague can see it immediately instead of hitting a "Command
80
+ // X not found" on an older bin surface. Read from the SAME source
81
+ // `pa status` reads: the resolved package.json inside the instance's
82
+ // node_modules.
83
+ const scaffoldedVersion = config.pkgVersion;
84
+ const installedVersion = readInstalledDopVersion(config.targetDir);
85
+ if (scaffoldedVersion && installedVersion && installedVersion !== scaffoldedVersion) {
86
+ process.stderr.write(
87
+ `pa-init: WARNING - scaffold pinned @stravica-ai/digital-operator-pa@${scaffoldedVersion} `
88
+ + `but pnpm resolved ${installedVersion}. This usually means pnpm's `
89
+ + `minimumReleaseAge gate filtered the pinned version out; the shipped `
90
+ + `pnpm-workspace.yaml excludes @stravica-ai/digital-operator-pa from the gate `
91
+ + `which normally cures it. Run \`pnpm install\` inside ${config.targetDir} again to reconcile.\n`,
92
+ );
93
+ }
94
+
66
95
  process.stdout.write('\npa-init: running pa status …\n');
67
96
  const statusCode = await paStatus({ targetDir: config.targetDir });
68
97
  if (statusCode !== 0) {
@@ -142,6 +171,11 @@ async function gatherConfig(options) {
142
171
  operatorName: resolvedValues.operatorName,
143
172
  targetDir: resolvedValues.targetDir,
144
173
  purpose: options.purpose,
174
+ // Pin the EXACT version of `@stravica-ai/digital-operator-pa` that is
175
+ // running pa-init right now. `resolveInstalledDopVersion()` returns
176
+ // null when it cannot verify a version (dev-only edge case); the
177
+ // downstream default in `buildRenderConfig` catches that safely.
178
+ pkgVersion: resolveInstalledDopVersion() ?? undefined,
145
179
  });
146
180
  }
147
181
 
@@ -168,3 +202,26 @@ function applyValidatorsOrThrow(supplied) {
168
202
  function countEntries(plan) {
169
203
  return plan.length;
170
204
  }
205
+
206
+ /**
207
+ * Read the installed version of `@stravica-ai/digital-operator-pa` from
208
+ * the instance's own `node_modules`. Returns null if the package is
209
+ * missing or its `package.json` cannot be parsed — the caller treats
210
+ * null as "no verifiable install" and does not warn.
211
+ */
212
+ function readInstalledDopVersion(targetDir) {
213
+ const pkgPath = join(
214
+ targetDir,
215
+ 'node_modules',
216
+ '@stravica-ai',
217
+ 'digital-operator-pa',
218
+ 'package.json',
219
+ );
220
+ if (!existsSync(pkgPath)) return null;
221
+ try {
222
+ const meta = JSON.parse(readFileSync(pkgPath, 'utf8'));
223
+ return typeof meta.version === 'string' ? meta.version : null;
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
@@ -7,14 +7,92 @@
7
7
  // kind: 'dir' (mkdir -p) | 'file' (write file with contents) | 'json'
8
8
  // (stringify + write).
9
9
 
10
- import { existsSync } from 'node:fs';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
11
  import { readFile, readdir } from 'node:fs/promises';
12
+ import { createRequire } from 'node:module';
12
13
  import { dirname, join, resolve } from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
 
15
16
  import { emptyEnvelope, renderEnvelope } from '../../engineering-pa-core/src/autonomy/envelope.mjs';
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const requireFromHere = createRequire(import.meta.url);
20
+
21
+ /**
22
+ * Resolve the exact version of `@stravica-ai/digital-operator-pa` that is
23
+ * running `pa-init` right now. Two lookup paths:
24
+ *
25
+ * 1. `require.resolve('@stravica-ai/digital-operator-pa/package.json')` — hits
26
+ * when pa-init runs out of a real installed tarball
27
+ * (`node_modules/@stravica-ai/digital-operator-pa/dist/engineering-pa-scaffold/src/generator.mjs`
28
+ * → `node_modules/@stravica-ai/digital-operator-pa/package.json`).
29
+ * 2. Sibling walk to `packages/engineering-pa/package.json` — hits when
30
+ * pa-init runs out of the source monorepo (dev + CI test runs).
31
+ *
32
+ * Returns the version string, or `null` if neither path resolves. `null`
33
+ * is the "no verifiable version" outcome — the caller falls back to the
34
+ * old `^0.1.0` default so a broken lookup never breaks the scaffold.
35
+ *
36
+ * Why this exists: on the WSD-laptop cold install of 0.1.41, pnpm's
37
+ * default `minimumReleaseAge` gated the freshly-published tarball out of
38
+ * `^0.1.0` and the instance silently resolved to 0.1.39 — a version with
39
+ * no `jira` / `confluence` / `confluence-fetch` bins. Pinning the exact
40
+ * version the running scaffolder came from kills that class of drift.
41
+ */
42
+ export function resolveInstalledDopVersion() {
43
+ // Path 1: `require.resolve` from another consumer's tree — hits when
44
+ // pa-init is invoked from the bootstrap dir, where the tarball sits at
45
+ // `node_modules/@stravica-ai/digital-operator-pa/`. The scaffold module
46
+ // itself lives INSIDE the tarball, so this resolve typically fails
47
+ // when this module runs from within `dist/engineering-pa-scaffold/`;
48
+ // path 3 catches that case.
49
+ try {
50
+ const pkgPath = requireFromHere.resolve('@stravica-ai/digital-operator-pa/package.json');
51
+ const meta = JSON.parse(readFileSync(pkgPath, 'utf8'));
52
+ if (meta && typeof meta.version === 'string' && meta.version.length > 0) {
53
+ return meta.version;
54
+ }
55
+ } catch { /* fall through */ }
56
+ // Path 2: monorepo sibling walk — hits when pa-init runs directly
57
+ // from the source tree (dev + CI test runs). `packages/scaffold/src/`
58
+ // -> `packages/engineering-pa/package.json`.
59
+ const monorepoUmbrella = resolve(__dirname, '..', '..', 'engineering-pa', 'package.json');
60
+ if (existsSync(monorepoUmbrella)) {
61
+ try {
62
+ const meta = JSON.parse(readFileSync(monorepoUmbrella, 'utf8'));
63
+ if (meta && typeof meta.version === 'string' && meta.version.length > 0) {
64
+ return meta.version;
65
+ }
66
+ } catch { /* fall through */ }
67
+ }
68
+ // Path 3: packed-tarball walk — hits when this module runs from
69
+ // inside the umbrella tarball's dist tree. Layout is:
70
+ // <install>/node_modules/@stravica-ai/digital-operator-pa/package.json <-- want this
71
+ // <install>/node_modules/@stravica-ai/digital-operator-pa/dist/engineering-pa-scaffold/src/generator.mjs
72
+ // Walk upwards from `__dirname` looking for a `package.json` whose
73
+ // `name` is `@stravica-ai/digital-operator-pa`.
74
+ let cursor = __dirname;
75
+ for (let i = 0; i < 8; i += 1) {
76
+ const candidate = resolve(cursor, 'package.json');
77
+ if (existsSync(candidate)) {
78
+ try {
79
+ const meta = JSON.parse(readFileSync(candidate, 'utf8'));
80
+ if (
81
+ meta
82
+ && meta.name === '@stravica-ai/digital-operator-pa'
83
+ && typeof meta.version === 'string'
84
+ && meta.version.length > 0
85
+ ) {
86
+ return meta.version;
87
+ }
88
+ } catch { /* keep walking */ }
89
+ }
90
+ const next = resolve(cursor, '..');
91
+ if (next === cursor) break;
92
+ cursor = next;
93
+ }
94
+ return null;
95
+ }
18
96
 
19
97
  /** Path to the CLAUDE.md template (relative to this module). */
20
98
  export const CLAUDE_MD_TEMPLATE_PATH = resolve(__dirname, '..', 'templates', 'CLAUDE.md.template');
@@ -248,6 +326,47 @@ export function buildGitignore() {
248
326
  ].join('\n');
249
327
  }
250
328
 
329
+ /**
330
+ * Build the instance-level `pnpm-workspace.yaml` contents.
331
+ *
332
+ * Excludes `@stravica-ai/digital-operator-pa` from pnpm's supply-chain
333
+ * age gate (`minimumReleaseAge`) BY NAME. Everything else the instance
334
+ * ever installs still honours whatever `minimumReleaseAge` the operator
335
+ * has set. The exclude ships instead of the older `.npmrc` line because
336
+ * pnpm reads `minimumReleaseAge` / `minimumReleaseAgeExclude` from
337
+ * `pnpm-workspace.yaml` only; `.npmrc` is not honoured for these
338
+ * settings (empirically verified on pnpm 10.32 against the live
339
+ * registry: `.npmrc @stravica-ai:minimumReleaseAge=0` did not defeat a
340
+ * `minimumReleaseAge=10080` set at the same scope; a
341
+ * `pnpm-workspace.yaml` with `minimumReleaseAgeExclude: [<pkg>]` did).
342
+ *
343
+ * Rationale from the second-machine re-run of 0.1.42 (2026-09-16): the
344
+ * `.npmrc` override written into the instance was a no-op, so the
345
+ * belt-and-brace promised alongside the exact pin never bit. This file
346
+ * is the real belt-and-brace: `pnpm install` inside the instance always
347
+ * resolves the pinned version even when the operator's pnpm carries a
348
+ * non-zero `minimumReleaseAge` (pnpm 11 ships one by default).
349
+ *
350
+ * The file also marks the instance as its own pnpm workspace root,
351
+ * which is fine: instances never nest inside another pnpm workspace
352
+ * (the onboarding guide's warning covers the reverse direction).
353
+ */
354
+ export function buildInstanceWorkspaceYaml() {
355
+ return [
356
+ '# @stravica-ai/digital-operator-pa exclusion for pnpm supply-chain gating.',
357
+ '# pa-init pins the exact @stravica-ai/digital-operator-pa version in package.json,',
358
+ '# but pnpm\'s minimumReleaseAge (default 0 on pnpm 10, non-zero on some 11 defaults)',
359
+ '# can still filter that pin out during `pnpm install` and drop the instance onto a',
360
+ '# previous version with a different bin surface (WSD-laptop cold-install regression,',
361
+ '# 0.1.41 / 0.1.42). pnpm reads minimumReleaseAge only from pnpm-workspace.yaml, so',
362
+ '# the exclusion has to live here rather than in .npmrc.',
363
+ '# Non-@stravica-ai packages still honour any minimumReleaseAge set globally.',
364
+ 'minimumReleaseAgeExclude:',
365
+ ' - "@stravica-ai/digital-operator-pa"',
366
+ '',
367
+ ].join('\n');
368
+ }
369
+
251
370
  /**
252
371
  * Build the operator/config/preferences.json contents.
253
372
  *
@@ -546,6 +665,7 @@ export function buildPlanFromTemplates(config, templates) {
546
665
  plan.push({ kind: 'json', path: 'package.json', contents: pkg });
547
666
  plan.push({ kind: 'file', path: '.gitignore', contents: gitignore });
548
667
  plan.push({ kind: 'file', path: '.nvmrc', contents: '24\n' });
668
+ plan.push({ kind: 'file', path: 'pnpm-workspace.yaml', contents: buildInstanceWorkspaceYaml() });
549
669
 
550
670
  plan.push({ kind: 'json', path: '.claude/settings.json', contents: buildClaudeSettingsJson() });
551
671
  // /research slash command: rendered from the packaged core doc so the
@@ -21,17 +21,44 @@ Linux and WSL are equivalent. Install Node 24 through your platform's normal cha
21
21
 
22
22
  ```sh
23
23
  mkdir ~/pa-bootstrap && cd ~/pa-bootstrap
24
- pnpm init -y
24
+ # Two files, one heredoc each. `pnpm init -y` writes a `devEngines.packageManager`
25
+ # line pnpm 11.14 then rejects, and `-y` is silently ignored, so write a minimal
26
+ # package.json directly. The `pnpm-workspace.yaml` is the load-bearing file:
27
+ # pnpm holds packages published in the last week back by default (the
28
+ # `minimumReleaseAge` supply-chain gate; it defaults to 0 on pnpm 10 and to a
29
+ # non-zero window on some pnpm 11 defaults), and this repo releases often, so
30
+ # without an explicit exclude an unversioned `pnpm add` silently resolves down
31
+ # to a previous version with a different bin surface. pnpm reads
32
+ # `minimumReleaseAge*` settings ONLY from `pnpm-workspace.yaml` (not from
33
+ # `.npmrc`), and the exclude below trusts only this one package by name so
34
+ # every other dependency your bootstrap ever pulls still honours the operator's
35
+ # global gate.
36
+ cat > package.json <<'JSON'
37
+ { "name": "pa-bootstrap", "private": true }
38
+ JSON
39
+ cat > pnpm-workspace.yaml <<'YAML'
40
+ minimumReleaseAgeExclude:
41
+ - "@stravica-ai/digital-operator-pa"
42
+ YAML
25
43
  pnpm add @stravica-ai/digital-operator-pa
26
44
  pnpm exec pa-init --mode=solo --name=<pa-name> --operator=<your-name> \
27
45
  --target=$HOME/<pa-name>-instance --non-interactive
28
46
  cd $HOME/<pa-name>-instance
29
- pnpm install
47
+ git config user.name "Your Name" # cold laptops have no global git identity
48
+ git config user.email "you@example.com" # local to this repo; skip if you already have a global one
30
49
  git add -A && git commit -m "scaffold"
31
50
  ```
32
51
 
33
52
  Pick a `<pa-name>` in lowercase kebab-case (`alex`, `sam`, `hal-9000`). It becomes the agent directory (`agents/<pa-name>/`) and shows up in every log line.
34
53
 
54
+ `pa-init` writes the instance's `package.json` with an EXACT `@stravica-ai/digital-operator-pa` dependency (the version it just installed into `pa-bootstrap`, not a caret range) and drops a `pnpm-workspace.yaml` inside the instance that excludes `@stravica-ai/digital-operator-pa` from the same age gate. Together they belt-and-brace pnpm's supply-chain gate: without them a freshly-published tarball can silently resolve down to the previous version, which may ship a different bin surface.
55
+
56
+ **Sanity check the version.** `pa-init` prints `package: @stravica-ai/digital-operator-pa@<version>` right after the target line. If the version you see is older than the one `npm view @stravica-ai/digital-operator-pa version` reports, the exclude in your bootstrap `pnpm-workspace.yaml` did not take (rare; usually a stale pnpm store or a typo in the yaml). The `@latest` tag does NOT bypass the release-age gate, so the recovery is to pin the version npm names: `pnpm add @stravica-ai/digital-operator-pa@<version npm view prints>` inside the bootstrap dir, then re-run `pa-init`. When pnpm 11 sees a pinned version that would otherwise be gated, it adds an entry of its own to `pnpm-workspace.yaml` under `minimumReleaseAgeExclude`; the file the guide already asked you to write covers that write.
57
+
58
+ `pnpm install` in the instance runs automatically as the last step of `pa-init` (skip it with `--skip-install` for CI). The `pa status` verification also runs at the end and prints the resolved package version; a mismatch between the scaffold pin and the installed version prints a WARNING line naming both, so a broken guard surfaces immediately instead of hiding behind a "Command not found" later.
59
+
60
+ **Do not put the instance under a directory that already contains a `pnpm-workspace.yaml`.** pnpm treats the instance as a workspace member, `pnpm install` silently no-ops, and `node_modules/.bin/pa` never appears. Keep `--target` outside any pnpm workspace root (a top-level `$HOME/<pa-name>-instance`, as shown above, is safe; `~/pa-bootstrap` itself is such a workspace root now, so never nest the `--target` inside it).
61
+
35
62
  ## 2. Sanity-check the install
36
63
 
37
64
  ```sh
@@ -45,10 +72,11 @@ pnpm exec pa knowledge context # first line: library.mode = simple (variant
45
72
  ## 3. Start the workspace
46
73
 
47
74
  ```sh
48
- pnpm exec pa workspace start # opens http://localhost:4200
75
+ pnpm exec pa workspace start # opens http://localhost:4200
76
+ pnpm exec pa workspace start --port 4300 # pick a different port if 4200 is taken
49
77
  ```
50
78
 
51
- The workspace serves a viewer, a home page, and an onboarding form. Long notes from your PA land here rather than in chat.
79
+ The workspace serves a viewer, a home page, and an onboarding form. Long notes from your PA land here rather than in chat. The default port is `4200` (localhost only); the server refuses to start with `Error: Port 4200 is already in use by another process.` when the port is bound by something else (a docker container, another dev server). Pick a free port with `--port N` or set `WORKSPACE_PORT` in your environment; `pa status` reads the resolved port from `operator/config/preferences.json` so keep the two in step for the port to persist across restarts.
52
80
 
53
81
  ## 4. Start your first session
54
82
 
@@ -114,7 +142,7 @@ Your PA has the `rcf-lite-engineer` role baked in. It builds greenfield applicat
114
142
 
115
143
  The `@stravica-ai/digital-operator-pa` package ships three Atlassian CLIs on `PATH`: `jira` (issues, search, transitions, worklogs), `confluence` (spaces, pages, CQL search, mirror), and `confluence-fetch` (search + fetch + save a page under `operator/notes/`). Run `pnpm exec jira --help`, `pnpm exec confluence --help`, `pnpm exec confluence-fetch --help` for the verbs.
116
144
 
117
- Both CLIs share three credentials pointing at the same Atlassian site. Two-step setup:
145
+ Both CLIs share three credentials pointing at the same Atlassian site. Setup:
118
146
 
119
147
  ```sh
120
148
  pnpm exec secrets set JIRA_DOMAIN yoursite # for yoursite.atlassian.net
@@ -122,6 +150,10 @@ pnpm exec secrets set JIRA_EMAIL you@example.com # your Atlassian accoun
122
150
  pnpm exec secrets set JIRA_API_TOKEN <paste-token> # id.atlassian.com/manage-profile/security/api-tokens
123
151
  ```
124
152
 
153
+ `secrets set` self-initialises the vault (age keypair, `.vault/recipients.txt`, empty encrypted store) on the first write when there is nothing at `.vault/` yet. It prints a one-line notice on stderr saying so and continues. You do not need to run `secrets init` manually first. Every `secrets set` also prints one stderr line naming which vault it wrote to; on a fresh solo install with no scope configured that says the write went to the `(legacy)` pot (no scope specified, no default configured). Informational, safe to ignore. Set `SECRETS_DEFAULT_SCOPE` in your environment or configure `defaultScope` in `~/.config/stravica-pa/defaults.json` if you want a named scope; solo mode does not require one.
154
+
155
+ The age key that self-init generates lives at `~/.config/stravica-pa/age-key.txt` (one per laptop, not per instance). If that file already exists when you scaffold a second PA on the same machine, self-init reuses it, so both instances read and write the same encrypted vault content when a `.vault/` sits under both. That is the point: a single key per operator per machine. To force a fresh key, move the existing one aside (`mv ~/.config/stravica-pa/age-key.txt ~/.config/stravica-pa/age-key.txt.prev`) before the first `secrets set` in the new instance; the vault contents in the old instance will then be unreadable to the new key.
156
+
125
157
  Verify with `pnpm exec jira auth` and `pnpm exec confluence auth`; each prints the current user on success. Credentials also resolve from environment variables of the same names, so a `.env` or shell profile works as an alternative to `secrets`. The `atlassian` entry in `operator/extensions/manifest.json` records the extension is enabled and repeats the setup line.
126
158
 
127
159
  `jira mine` returns your open issues across every project the account can see. If you spend most of your Jira time in one project, set `JIRA_DEFAULT_PROJECT` (env var or a vault key of the same name) and `jira mine` narrows to it unless you pass `--project KEY` or `--all`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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",