@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.
- package/dist/engineering-pa-cli/src/pa/cli.mjs +25 -1
- package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +36 -1
- package/dist/engineering-pa-core/src/distill/run.mjs +45 -2
- package/dist/engineering-pa-core-extensions-atlassian/package.json +1 -1
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +3 -23
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +69 -26
- package/dist/engineering-pa-scaffold/package.json +1 -1
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +60 -3
- package/dist/engineering-pa-scaffold/src/generator.mjs +121 -1
- package/docs/onboarding-solo.md +37 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Errors carry an `exitCode`. The CLI translates to `process.exit`.
|
|
12
12
|
|
|
13
|
-
import {
|
|
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:
|
|
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
|
package/docs/onboarding-solo.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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