@stravica-ai/digital-operator-pa 0.1.42 → 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.
@@ -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",
@@ -71,22 +71,24 @@ export async function runInit(options) {
71
71
  return 2;
72
72
  }
73
73
 
74
- // Version-drift safety net (WSD-laptop cold install of 0.1.41). The
75
- // scaffold pins an exact version and ships a `.npmrc` scope override,
76
- // so pnpm should never resolve a different version — but if the guard
77
- // ever misses, this prints one line naming the mismatch so a colleague
78
- // can see it immediately instead of hitting a "Command X not found" on
79
- // an older bin surface. Read from the SAME source `pa status` reads —
80
- // the resolved package.json inside the instance's node_modules.
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.
81
83
  const scaffoldedVersion = config.pkgVersion;
82
84
  const installedVersion = readInstalledDopVersion(config.targetDir);
83
85
  if (scaffoldedVersion && installedVersion && installedVersion !== scaffoldedVersion) {
84
86
  process.stderr.write(
85
- `pa-init: WARNING — scaffold pinned @stravica-ai/digital-operator-pa@${scaffoldedVersion} `
87
+ `pa-init: WARNING - scaffold pinned @stravica-ai/digital-operator-pa@${scaffoldedVersion} `
86
88
  + `but pnpm resolved ${installedVersion}. This usually means pnpm's `
87
89
  + `minimumReleaseAge gate filtered the pinned version out; the shipped `
88
- + `.npmrc scopes minimumReleaseAge=0 for @stravica-ai which normally cures it. `
89
- + `Run \`pnpm install\` inside ${config.targetDir} again to reconcile.\n`,
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`,
90
92
  );
91
93
  }
92
94
 
@@ -327,32 +327,42 @@ export function buildGitignore() {
327
327
  }
328
328
 
329
329
  /**
330
- * Build the instance-level `.npmrc` contents.
330
+ * Build the instance-level `pnpm-workspace.yaml` contents.
331
331
  *
332
- * Overrides pnpm's default `minimumReleaseAge` (in effect since pnpm 10)
333
- * FOR THE `@stravica-ai` scope only. Rationale from the WSD-laptop cold
334
- * install of 0.1.41 (2026-09-16): pnpm's supply-chain gate silently
335
- * resolves a freshly-published tarball's caret range down to the previous
336
- * version — the instance ended up on 0.1.39 with no `jira` / `confluence`
337
- * / `confluence-fetch` bins, and the guide's Atlassian step failed with
338
- * "Command jira not found" on the very version the guide had just
339
- * installed. The scaffold already pins an EXACT dependency spec (see
340
- * `buildOperatorPackageJson` and `resolveInstalledDopVersion`), but an
341
- * exact spec can still be filtered out under `minimumReleaseAge`; the
342
- * scoped override belt-and-braces the pin so `pnpm install` inside the
343
- * scaffolded instance always resolves the version the pin names.
344
- * Non-`@stravica-ai` packages continue to honour the operator's own
345
- * `minimumReleaseAge` if they have one set globally.
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).
346
353
  */
347
- export function buildInstanceNpmrc() {
354
+ export function buildInstanceWorkspaceYaml() {
348
355
  return [
349
- '# @stravica-ai scope override for pnpm supply-chain gating.',
350
- '# pa-init pins an exact @stravica-ai/digital-operator-pa version in package.json.',
351
- '# pnpm\'s default minimumReleaseAge can otherwise filter a freshly-published',
352
- '# tarball out of resolution and drop the instance onto a previous version',
353
- '# with a different bin surface (WSD-laptop cold-install regression, 0.1.41).',
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.',
354
363
  '# Non-@stravica-ai packages still honour any minimumReleaseAge set globally.',
355
- '@stravica-ai:minimumReleaseAge=0',
364
+ 'minimumReleaseAgeExclude:',
365
+ ' - "@stravica-ai/digital-operator-pa"',
356
366
  '',
357
367
  ].join('\n');
358
368
  }
@@ -655,7 +665,7 @@ export function buildPlanFromTemplates(config, templates) {
655
665
  plan.push({ kind: 'json', path: 'package.json', contents: pkg });
656
666
  plan.push({ kind: 'file', path: '.gitignore', contents: gitignore });
657
667
  plan.push({ kind: 'file', path: '.nvmrc', contents: '24\n' });
658
- plan.push({ kind: 'file', path: '.npmrc', contents: buildInstanceNpmrc() });
668
+ plan.push({ kind: 'file', path: 'pnpm-workspace.yaml', contents: buildInstanceWorkspaceYaml() });
659
669
 
660
670
  plan.push({ kind: 'json', path: '.claude/settings.json', contents: buildClaudeSettingsJson() });
661
671
  // /research slash command: rendered from the packaged core doc so the
@@ -21,11 +21,25 @@ 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 11.14 rejects the `devEngines.packageManager` line `pnpm init -y` writes,
25
- # and `-y` is silently ignored, so write a minimal package.json directly:
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.
26
36
  cat > package.json <<'JSON'
27
37
  { "name": "pa-bootstrap", "private": true }
28
38
  JSON
39
+ cat > pnpm-workspace.yaml <<'YAML'
40
+ minimumReleaseAgeExclude:
41
+ - "@stravica-ai/digital-operator-pa"
42
+ YAML
29
43
  pnpm add @stravica-ai/digital-operator-pa
30
44
  pnpm exec pa-init --mode=solo --name=<pa-name> --operator=<your-name> \
31
45
  --target=$HOME/<pa-name>-instance --non-interactive
@@ -37,11 +51,13 @@ git add -A && git commit -m "scaffold"
37
51
 
38
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.
39
53
 
40
- `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 an `.npmrc` in the instance with `@stravica-ai:minimumReleaseAge=0`. Both 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.
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.
41
57
 
42
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.
43
59
 
44
- **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).
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).
45
61
 
46
62
  ## 2. Sanity-check the install
47
63
 
@@ -136,6 +152,8 @@ pnpm exec secrets set JIRA_API_TOKEN <paste-token> # id.atlassian.com/mana
136
152
 
137
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.
138
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
+
139
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.
140
158
 
141
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.42",
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",