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

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.
@@ -15,6 +15,9 @@ import {
15
15
  computeDistillationHealth,
16
16
  renderHealthLine,
17
17
  } from '../../../engineering-pa-core/src/distill/health.mjs';
18
+ import { vaultState } from '../../../engineering-pa-core/src/vault/require.mjs';
19
+ import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
20
+ import { resolveDefaultScope } from '../secrets/vault.mjs';
18
21
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
19
22
  import { doOnboard } from './onboard.mjs';
20
23
 
@@ -126,9 +129,11 @@ async function doStatus(opts = {}) {
126
129
  const out = opts.stdout ?? process.stdout;
127
130
  const err = opts.stderr ?? process.stderr;
128
131
  const cwd = opts.cwd ?? process.cwd();
132
+ const env = opts.env ?? process.env;
133
+ const home = opts.home;
129
134
  const repoRoot = opts.repoRoot
130
- ?? process.env.DAVE_REPO_ROOT
131
- ?? process.env.PA_REPO_ROOT
135
+ ?? env.DAVE_REPO_ROOT
136
+ ?? env.PA_REPO_ROOT
132
137
  ?? cwd;
133
138
  const workspacePort = resolveWorkspacePort(repoRoot, opts);
134
139
 
@@ -199,7 +204,46 @@ async function doStatus(opts = {}) {
199
204
  // Workspace-not-running is OK — operators may run pa status before
200
205
  // starting the workspace server.
201
206
 
202
- // 4. Distillation health — schedule installed?, last run, staging
207
+ // 4. Vault state — three states rendered on one line (initialised /
208
+ // not needed / NOT INITIALISED). Load-bearing because 0.1.50 shipped
209
+ // a scaffold that created an empty `.vault/` for every colleague and
210
+ // then never initialised it: a jira/confluence call after onboard
211
+ // hit `secrets get` against a vault that did not exist, and the
212
+ // failure mode was silent misdirection. `not needed` renders when no
213
+ // enabled tool in operator/extensions/manifest.json declares any
214
+ // secret keys (via packages/core/src/vault/tool-needs.mjs).
215
+ out.write('\nVault:\n');
216
+ try {
217
+ const needs = enabledToolsNeedingSecrets(repoRoot);
218
+ // 0.1.51 fix round (w-2026-09-18-dave-007): consult the SAME
219
+ // default-scope resolver the credentialed tools use so the Vault
220
+ // line can never say `initialised` while `secrets get` would miss.
221
+ // In solo mode the resolver returns scope=null (its defaults.json
222
+ // and identity.json branches are gated off), so `vaultPaths`
223
+ // targets the instance's own legacy vault — matching what init
224
+ // seeded. In HQ mode a resolved scope steers `vaultState` at the
225
+ // scoped file the tools will actually read.
226
+ const scoped = resolveDefaultScope({ cwd: repoRoot, env, home });
227
+ const state = vaultState({ repoRoot, home, env, scope: scoped.scope || undefined });
228
+ if (state.ok) {
229
+ const n = state.keyCount == null ? '?' : state.keyCount;
230
+ const scopeSuffix = scoped.scope ? `; scope '${scoped.scope}' from ${scoped.source}` : '';
231
+ out.write(` initialised (${n} keys${scopeSuffix})\n`);
232
+ } else if (needs.length === 0) {
233
+ out.write(' not needed (no enabled tool declares secret needs)\n');
234
+ } else if (state.state === 'partial') {
235
+ const gaps = state.missing.join(', ');
236
+ const toolLabels = needs.map((t) => t.toolId).join(', ');
237
+ out.write(` NOT INITIALISED (partial; missing: ${gaps}) - enabled tools needing secrets: ${toolLabels}. Run: pnpm exec secrets init\n`);
238
+ } else {
239
+ const toolLabels = needs.map((t) => t.toolId).join(', ');
240
+ out.write(` NOT INITIALISED - enabled tools needing secrets: ${toolLabels}. Run: pnpm exec secrets init\n`);
241
+ }
242
+ } catch (e) {
243
+ out.write(` vault probe failed: ${e?.message || e}\n`);
244
+ }
245
+
246
+ // 5. Distillation health — schedule installed?, last run, staging
203
247
  // count. Load-bearing because the whole self-learning loop dies
204
248
  // silently otherwise (the estate has already been bitten: on the
205
249
  // WSD-laptop cold-install proof of 0.1.41, staged observations piled
@@ -7,12 +7,15 @@
7
7
  // step is done it stamps `completedAt` with an ISO string (UTC, second-precision)
8
8
  // and preserves the 2-space indent of the file so subsequent diffs stay clean.
9
9
  //
10
- // Mandatory steps (as of 0.1.50): librarianSchedule. See
11
- // packages/core/docs/commands/onboard.md §Completion.
10
+ // Mandatory steps (as of 0.1.51): librarianSchedule, plus vault when
11
+ // any enabled tool declares a secrets need (see
12
+ // packages/core/src/vault/tool-needs.mjs::enabledToolsNeedingSecrets).
13
+ // See packages/core/docs/commands/onboard.md §Completion.
12
14
 
13
15
  import { readFile, writeFile } from 'node:fs/promises';
14
16
  import { join } from 'node:path';
15
17
 
18
+ import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
16
19
  import { findRepoRoot } from './repo-root.mjs';
17
20
 
18
21
  const HELP = `pa onboard - Onboarding walkthrough state
@@ -30,13 +33,31 @@ Exit codes:
30
33
  0 ok | 1 usage or a mandatory step is pending | 2 IO failure
31
34
  `;
32
35
 
33
- const MANDATORY_STEPS = [
36
+ const BASE_MANDATORY_STEPS = [
34
37
  {
35
38
  id: 'librarianSchedule',
36
39
  remediation: 'run /onboard and complete the librarianSchedule step (installs the mandatory distillation sweep)',
37
40
  },
38
41
  ];
39
42
 
43
+ // The vault step is conditionally mandatory: required only when at
44
+ // least one enabled tool declares a secrets need. See
45
+ // packages/core/src/vault/tool-needs.mjs. A `notNeeded` value on this
46
+ // step is a clean pass through the gate; `pending` refuses completion.
47
+ function mandatoryStepsFor(steps, enabledNeedingSecrets) {
48
+ const list = [...BASE_MANDATORY_STEPS];
49
+ const vault = steps ? steps.vault : undefined;
50
+ if (vault === 'notNeeded') return list;
51
+ if (enabledNeedingSecrets && enabledNeedingSecrets.length > 0) {
52
+ const toolLabels = enabledNeedingSecrets.map((t) => t.toolId).join(', ');
53
+ list.push({
54
+ id: 'vault',
55
+ remediation: `run /onboard and complete the vault step (initialise the SOPS+age vault used by: ${toolLabels})`,
56
+ });
57
+ }
58
+ return list;
59
+ }
60
+
40
61
  export async function doOnboard(argv) {
41
62
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
42
63
  process.stdout.write(HELP);
@@ -95,9 +116,18 @@ export async function onboardComplete(argv, deps = {}) {
95
116
  return 2;
96
117
  }
97
118
  const steps = parsed.steps || {};
119
+ const enabledNeedingSecrets = deps.enabledToolsNeedingSecrets
120
+ ? deps.enabledToolsNeedingSecrets(repoRoot)
121
+ : enabledToolsNeedingSecrets(repoRoot);
122
+ const mandatory = mandatoryStepsFor(steps, enabledNeedingSecrets);
98
123
  const pending = [];
99
- for (const step of MANDATORY_STEPS) {
100
- if (steps[step.id] !== 'done') pending.push(step);
124
+ for (const step of mandatory) {
125
+ // `notNeeded` on the vault step passes; every other terminal value
126
+ // that is not `done` is pending. Skipped is never valid for a
127
+ // mandatory step (mirrors librarianSchedule's contract).
128
+ if (steps[step.id] === 'done') continue;
129
+ if (step.id === 'vault' && steps.vault === 'notNeeded') continue;
130
+ pending.push(step);
101
131
  }
102
132
  if (pending.length > 0) {
103
133
  for (const step of pending) {
@@ -22,7 +22,10 @@ import { resolveScopePaths, VaultError } from './vault.mjs';
22
22
  const HELP = `secrets — SOPS+age secrets management
23
23
 
24
24
  Usage:
25
- secrets init Initialise the vault (generate keys, create empty secrets file)
25
+ secrets init Initialise the vault (generate keys, create empty secrets file).
26
+ --empty: explicit no-seed init (current default shape).
27
+ --stdin-json: after init, seed KEYs from a JSON object
28
+ read on stdin (values never echoed; refuses a TTY stdin).
26
29
  secrets get KEY Retrieve a secret (add --exists for a present/absent check that never prints the value)
27
30
  secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
28
31
  Since 0.1.20, canonical-first dual-write is
@@ -53,7 +56,18 @@ Usage:
53
56
  --values is required — decrypt-only is never
54
57
  a valid pass (see below). Never prints values.
55
58
 
56
- Private key: ~/.config/stravica-pa/age-key.txt (never committed)
59
+ Age private key resolution (0.1.51, w-2026-09-18-dave-007). One shared
60
+ order every verb + pa status + the atlassian resolver consult:
61
+ 1. $SOPS_AGE_KEY_FILE Explicit override, always wins.
62
+ 2. <repo>/.vault/age-key.txt Solo-mode key, gitignored. Wins when
63
+ present in either mode.
64
+ 3. ~/.config/stravica-pa/age-key.txt
65
+ Per-user key. Used only when the
66
+ instance is in HQ mode
67
+ (library.mode: remote). Solo mode
68
+ with no in-instance key resolves back
69
+ to #2, init writes there.
70
+
57
71
  Public key: .vault/recipients.txt (committed to repo)
58
72
  Encrypted store: .vault/secrets.enc.json (committed to repo)
59
73
  SOPS config: .vault/.sops.yaml (committed to repo)
@@ -330,7 +344,7 @@ export async function run(argv, overrides = {}) {
330
344
  const cmdOverrides = paths ? { ...overrides, paths } : overrides;
331
345
 
332
346
  switch (cmd) {
333
- case 'init': return runInit(cmdOverrides);
347
+ case 'init': return runInit({ ...cmdOverrides, argv: cmdArgs });
334
348
  case 'get': return runGet(cmdArgs, cmdOverrides);
335
349
  case 'set': return runSet(cmdArgs, cmdOverrides);
336
350
  case 'list': return runList(cmdOverrides);
@@ -353,6 +367,15 @@ export async function run(argv, overrides = {}) {
353
367
  }
354
368
 
355
369
  export async function main(argv, overrides = {}) {
370
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): env defaulted at the
371
+ // BIN entry so every downstream defaultPaths / resolveScopePaths /
372
+ // resolveKeyFile call sees the operator's real environment
373
+ // (SOPS_AGE_KEY_FILE, DAVE_ROOT, etc). The vault.mjs resolvers do NOT
374
+ // default env from process.env themselves — that leak was breaking
375
+ // scoped-fixture tests when the developer's shell carried
376
+ // SOPS_AGE_KEY_FILE. run() is exported for tests to call with an
377
+ // explicit env; main() is what the packaged bin invokes.
378
+ if (overrides.env === undefined) overrides = { ...overrides, env: process.env };
356
379
  try {
357
380
  const code = await run(argv, overrides);
358
381
  process.exit(code ?? 0);
@@ -1,6 +1,23 @@
1
1
  // init — generate age keypair, write recipients, write SOPS config,
2
2
  // create an empty encrypted secrets file if none exists.
3
+ //
4
+ // Two non-interactive paths are exposed for the guided onboarding
5
+ // vault step AND the e2e test to share ONE code path:
6
+ //
7
+ // secrets init --empty
8
+ // Explicit "create the key and the empty store, no seeded keys".
9
+ // The default behaviour has always produced this shape; the flag
10
+ // is a self-documenting affirmation the caller wanted it.
11
+ //
12
+ // secrets init --stdin-json
13
+ // Read {"KEY": "value", ...} from stdin and seed those keys after
14
+ // the vault is created (never writes to canonical clones — a fresh
15
+ // init lands values in the workspace vault only). Values are never
16
+ // echoed to stdout or stderr and never appear in any log line the
17
+ // tool writes; the tool refuses if stdin is a TTY (no accidental
18
+ // paste-into-prompt where the value would land in shell history).
3
19
 
20
+ import { readFileSync } from 'node:fs';
4
21
  import {
5
22
  defaultPaths,
6
23
  checkDeps,
@@ -14,12 +31,93 @@ import {
14
31
  writeSopsConfig,
15
32
  keyFileExists,
16
33
  secretsFileExists,
34
+ sopsDecrypt,
17
35
  sopsEncrypt,
18
36
  VaultError,
19
37
  } from '../vault.mjs';
20
38
 
39
+ const KEY_NAME_RE = /^[A-Z0-9_]+$/;
40
+
41
+ function extractInitFlags(argv) {
42
+ const out = { empty: false, stdinJson: false };
43
+ for (const a of argv || []) {
44
+ if (a === '--empty') out.empty = true;
45
+ else if (a === '--stdin-json') out.stdinJson = true;
46
+ else if (a === '-h' || a === '--help') out.help = true;
47
+ else out.unknown = a;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function readStdinSync() {
53
+ // Read raw bytes from stdin synchronously. Never printed anywhere.
54
+ try {
55
+ return readFileSync(0, 'utf8');
56
+ } catch (err) {
57
+ throw new VaultError(`Error: could not read stdin for --stdin-json: ${err && err.message ? err.message : err}`, 1);
58
+ }
59
+ }
60
+
61
+ function parseStdinJson(raw) {
62
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
63
+ throw new VaultError('Error: --stdin-json received empty input; expected a JSON object of KEY/value pairs.', 1);
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ } catch (err) {
69
+ throw new VaultError(`Error: --stdin-json input is not valid JSON: ${err && err.message ? err.message : err}`, 1);
70
+ }
71
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
72
+ throw new VaultError('Error: --stdin-json input must be a JSON OBJECT of KEY/value pairs.', 1);
73
+ }
74
+ const out = {};
75
+ for (const [key, value] of Object.entries(parsed)) {
76
+ if (!KEY_NAME_RE.test(key)) {
77
+ throw new VaultError(`Error: --stdin-json key "${key}" must match ${KEY_NAME_RE} (upper snake case).`, 1);
78
+ }
79
+ if (typeof value !== 'string') {
80
+ throw new VaultError(`Error: --stdin-json value for "${key}" must be a string.`, 1);
81
+ }
82
+ out[key] = value;
83
+ }
84
+ return out;
85
+ }
86
+
21
87
  export async function runInit(overrides = {}) {
22
88
  const paths = overrides.paths || defaultPaths({ ...overrides, allowGitRootWithoutVault: true });
89
+ const argv = overrides.argv || [];
90
+ const flags = extractInitFlags(argv);
91
+
92
+ if (flags.help) {
93
+ console.log([
94
+ 'Usage: secrets init [--empty | --stdin-json]',
95
+ '',
96
+ ' --empty Create the key and an empty encrypted store (default shape).',
97
+ ' --stdin-json After init, seed KEYs from a JSON object read on stdin.',
98
+ ' Refuses when stdin is a TTY. Values are never echoed.',
99
+ ].join('\n'));
100
+ return 0;
101
+ }
102
+
103
+ if (flags.empty && flags.stdinJson) {
104
+ console.error('Error: --empty and --stdin-json are mutually exclusive.');
105
+ throw new VaultError('mutually exclusive flags', 1);
106
+ }
107
+
108
+ // Read stdin BEFORE running the init side effects — a malformed
109
+ // payload should not leave a partially-initialised vault on disk when
110
+ // the caller was going to seed it in one shot.
111
+ let seedPairs = null;
112
+ if (flags.stdinJson) {
113
+ const stdinStream = overrides.stdinStream || process.stdin;
114
+ if (stdinStream && stdinStream.isTTY && !overrides.allowTtyStdin) {
115
+ console.error('Error: --stdin-json requires piped JSON on stdin; refusing to prompt (would land the value in shell history).');
116
+ throw new VaultError('stdin is a TTY', 1);
117
+ }
118
+ const raw = overrides.stdinRead ? overrides.stdinRead() : readStdinSync();
119
+ seedPairs = parseStdinJson(raw);
120
+ }
23
121
 
24
122
  const missing = await checkDeps();
25
123
  if (missing.length > 0) {
@@ -33,6 +131,13 @@ export async function runInit(overrides = {}) {
33
131
  ensureKeyDir(paths);
34
132
 
35
133
  if (keyFileExists(paths)) {
134
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): the shared resolver
135
+ // routes solo instances to `<repoRoot>/.vault/age-key.txt` and HQ
136
+ // instances to `~/.config/stravica-pa/age-key.txt`. A pre-existing
137
+ // key at the resolved path is intentional reuse (init is
138
+ // idempotent). A foreign per-user key on a fresh solo laptop no
139
+ // longer meets this check — the solo resolver returns the instance
140
+ // path, which does not exist, and init generates a NEW key there.
36
141
  console.log(`Age private key already exists at ${paths.keyFile}`);
37
142
  } else {
38
143
  await ageKeygen(paths);
@@ -59,6 +164,29 @@ export async function runInit(overrides = {}) {
59
164
  console.log('Encrypted secrets file already exists.');
60
165
  }
61
166
 
167
+ // Seed non-interactively. Merge (do not replace) any keys that were
168
+ // already in the store — this verb is idempotent by design and a
169
+ // re-run with a new key/value should not clobber the operator's
170
+ // existing set. Values are never echoed; the summary line at the end
171
+ // prints only key NAMES and the pair count.
172
+ if (seedPairs && Object.keys(seedPairs).length > 0) {
173
+ let data = {};
174
+ if (secretsFileExists(paths)) {
175
+ try {
176
+ data = await sopsDecrypt(paths, paths.secretsFile);
177
+ } catch (err) {
178
+ console.error(`Error: could not decrypt ${paths.secretsFile} to merge seeded keys: ${err && err.message ? err.message : err}`);
179
+ throw new VaultError('seed merge decrypt failed', 2);
180
+ }
181
+ }
182
+ for (const [k, v] of Object.entries(seedPairs)) data[k] = v;
183
+ await sopsEncrypt(paths, pubKey, data);
184
+ const names = Object.keys(seedPairs).sort();
185
+ console.log(`Seeded ${names.length} key(s): ${names.join(', ')}`);
186
+ } else if (flags.stdinJson) {
187
+ console.log('--stdin-json input carried no keys; vault created empty.');
188
+ }
189
+
62
190
  console.log('');
63
191
  console.log('Vault initialised.');
64
192
  console.log(` Private key: ${paths.keyFile} (NEVER commit this)`);
@@ -21,6 +21,7 @@
21
21
  // "device-side vault mutations must round-trip to canonical immediately").
22
22
 
23
23
  import { readFileSync } from 'node:fs';
24
+ import { isSoloMode } from '../../../../engineering-pa-core/src/vault/mode.mjs';
24
25
  import {
25
26
  resolveCommandPaths,
26
27
  resolveScopePaths,
@@ -153,19 +154,38 @@ export async function runSet(args, overrides = {}) {
153
154
  // callers that build paths themselves and only pass home via
154
155
  // overrides, then to homedir() for the real CLI at run time.
155
156
  const canonicalHome = paths.home ?? overrides.home;
156
- const resolved = overrides.resolveCanonicalCloneTarget
157
- ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
158
- : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
159
- if (resolved.file) {
160
- effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
157
+
158
+ // Solo-mode gate (0.1.51, w-2026-09-18-dave-007). Auto-discovery of
159
+ // a canonical clone via ~/.config/stravica-pa/defaults.json or the
160
+ // ~/stravica-canonical/*/vault/<scope>.enc.json convention is the
161
+ // co-existence hazard the fix closes: on a colleague laptop that
162
+ // already carries ANOTHER instance's vault, a solo `secrets set`
163
+ // must never silently dual-write across the estate boundary.
164
+ // Explicit --canonical-clone PATH still wins (above); the gate only
165
+ // suppresses the AUTO-discovery tier. HQ mode (preferences.library.
166
+ // mode === 'remote') keeps today's behaviour because there the auto-
167
+ // discovery IS the desired behaviour — the operator owns every vault
168
+ // the scan can find. See packages/core/src/vault/mode.mjs.
169
+ const soloGate = overrides.isSoloMode
170
+ ? overrides.isSoloMode({ repoRoot: paths.root })
171
+ : isSoloMode({ repoRoot: paths.root });
172
+ if (soloGate) {
173
+ console.error(`notice: solo mode - canonical-clone auto-discovery is off for scope '${paths.scope}'. Workspace-only write. Pass --canonical-clone PATH to force a dual-write.`);
161
174
  } else {
162
- // Absence notice — never a failure. Explains WHY nothing resolved so
163
- // an operator on a host without a canonical layout knows whether to
164
- // provision one, pass --no-canonical to silence, or ignore.
165
- const tail = resolved.tried && resolved.tried.length > 0
166
- ? ` (tried: ${resolved.tried.join('; ')})`
167
- : '';
168
- console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
175
+ const resolved = overrides.resolveCanonicalCloneTarget
176
+ ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
177
+ : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
178
+ if (resolved.file) {
179
+ effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
180
+ } else {
181
+ // Absence notice — never a failure. Explains WHY nothing resolved so
182
+ // an operator on a host without a canonical layout knows whether to
183
+ // provision one, pass --no-canonical to silence, or ignore.
184
+ const tail = resolved.tried && resolved.tried.length > 0
185
+ ? ` (tried: ${resolved.tried.join('; ')})`
186
+ : '';
187
+ console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
188
+ }
169
189
  }
170
190
  }
171
191
 
@@ -16,6 +16,8 @@ import { join, dirname, resolve, basename, sep } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { mkdtempSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
+ import { isSoloMode } from '../../../engineering-pa-core/src/vault/mode.mjs';
20
+ import { resolveKeyFile as coreResolveKeyFile } from '../../../engineering-pa-core/src/vault/require.mjs';
19
21
 
20
22
  // ── Path resolution ──────────────────────────────────────────────────
21
23
 
@@ -155,8 +157,34 @@ export function defaultPaths(overrides = {}) {
155
157
  allowGitRootWithoutVault: overrides.allowGitRootWithoutVault,
156
158
  }).root;
157
159
  const home = overrides.home ?? homedir();
160
+ // Pass caller-supplied env only. The resolver treats undefined env as
161
+ // empty (no SOPS_AGE_KEY_FILE consulted) so `defaultPaths` under a
162
+ // test harness that never passed `env` is never subject to a leaked
163
+ // env-override from the developer's shell. Real CLI invocations
164
+ // thread `env: process.env` in via `main()` in cli.mjs.
165
+ const env = overrides.env;
158
166
  const vaultDir = overrides.vaultDir ?? join(root, '.vault');
159
- const keyDir = overrides.keyDir ?? join(home, '.config', 'stravica-pa');
167
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): the key file resolves
168
+ // through the ONE shared resolver in core/vault so init, get, set,
169
+ // status, requireVault, and the atlassian resolver all agree on the
170
+ // active key path. Solo mode → in-instance `.vault/age-key.txt`;
171
+ // HQ mode → per-user `~/.config/stravica-pa/age-key.txt`; explicit
172
+ // `SOPS_AGE_KEY_FILE` env override always wins. Callers that hand-
173
+ // craft `overrides.keyFile` or `overrides.keyDir` bypass the resolver
174
+ // entirely so the existing test helpers keep their isolated key dir.
175
+ let keyFile;
176
+ let keyDir;
177
+ if (overrides.keyFile) {
178
+ keyFile = overrides.keyFile;
179
+ keyDir = overrides.keyDir ?? dirname(keyFile);
180
+ } else if (overrides.keyDir) {
181
+ keyDir = overrides.keyDir;
182
+ keyFile = join(keyDir, 'age-key.txt');
183
+ } else {
184
+ const keyResolved = coreResolveKeyFile({ repoRoot: root, home, env, mode: overrides.mode });
185
+ keyFile = keyResolved.keyFile;
186
+ keyDir = dirname(keyFile);
187
+ }
160
188
  return {
161
189
  root,
162
190
  vaultDir,
@@ -164,7 +192,7 @@ export function defaultPaths(overrides = {}) {
164
192
  recipientsFile: join(vaultDir, 'recipients.txt'),
165
193
  sopsConfig: join(vaultDir, '.sops.yaml'),
166
194
  keyDir,
167
- keyFile: join(keyDir, 'age-key.txt'),
195
+ keyFile,
168
196
  // Retained on the paths object so downstream consumers (canonical-
169
197
  // clone auto-discovery in commands/set.mjs) can honour the SAME home
170
198
  // the caller injected, instead of falling back to the real user's
@@ -208,12 +236,26 @@ function scopeError(message, exitCode = 1) {
208
236
  */
209
237
  export function resolveScopePaths(opts = {}) {
210
238
  const home = opts.home ?? homedir();
211
- const keyDir = opts.keyDir ?? join(home, '.config', 'stravica-pa');
212
- const keyFile = opts.keyFile ?? join(keyDir, 'age-key.txt');
239
+ // See defaultPaths above — env is threaded from the CLI entry point,
240
+ // never defaulted to process.env at this layer, so a test harness
241
+ // that never passed env does not inherit SOPS_AGE_KEY_FILE.
242
+ const env = opts.env;
213
243
 
214
244
  if (opts.file) {
215
245
  const secretsFile = resolve(opts.file);
216
246
  const vaultDir = dirname(secretsFile);
247
+ // --file bypasses workspace resolution: there is no instance to
248
+ // detect solo/HQ mode from. The env override still wins; otherwise
249
+ // fall back to the per-user path (unchanged 0.1.50 behaviour). A
250
+ // caller that wants the in-instance key file explicitly can pass
251
+ // `keyFile` here or via SOPS_AGE_KEY_FILE.
252
+ const envOverride = env && typeof env.SOPS_AGE_KEY_FILE === 'string' && env.SOPS_AGE_KEY_FILE.length > 0
253
+ ? env.SOPS_AGE_KEY_FILE
254
+ : null;
255
+ const keyFile = opts.keyFile
256
+ ?? envOverride
257
+ ?? join(opts.keyDir ?? join(home, '.config', 'stravica-pa'), 'age-key.txt');
258
+ const keyDir = opts.keyDir ?? dirname(keyFile);
217
259
  return {
218
260
  root: vaultDir,
219
261
  vaultDir,
@@ -237,6 +279,10 @@ export function resolveScopePaths(opts = {}) {
237
279
  }
238
280
 
239
281
  const { root } = resolveWorkspaceRoot({ cwd: opts.cwd, env: opts.env, home: opts.home });
282
+ // 0.1.51 fix round 2: the ONE shared resolver, same as defaultPaths.
283
+ const keyResolved = coreResolveKeyFile({ repoRoot: root, home, env, mode: opts.mode });
284
+ const keyFile = opts.keyFile ?? keyResolved.keyFile;
285
+ const keyDir = opts.keyDir ?? dirname(keyFile);
240
286
  const workspaceDir = join(root, '.vault', 'scopes');
241
287
  const workspaceFile = join(workspaceDir, `${opts.scope}.enc.json`);
242
288
  const canonicalDir = join(root, 'vault');
@@ -524,6 +570,43 @@ export function resolveDefaultScope(opts = {}) {
524
570
  return { scope: env.SECRETS_DEFAULT_SCOPE.trim(), source: 'env', detail: 'SECRETS_DEFAULT_SCOPE env var' };
525
571
  }
526
572
 
573
+ // Solo-mode gate (0.1.51 fix round, w-2026-09-18-dave-007). On a
574
+ // colleague laptop that carries ANOTHER estate's `defaultScope` in
575
+ // ~/.config/stravica-pa/defaults.json (or an agents/<name>/identity.json
576
+ // authored by a teammate), a fresh solo instance's `secrets init` seeds
577
+ // the legacy `.vault/secrets.enc.json` while a subsequent bare
578
+ // `secrets get` follows the resolver into `.vault/scopes/<scope>.enc.json`
579
+ // — a scope this instance never created — and the credentialed tools
580
+ // fail on "could not read JIRA_DOMAIN". In solo mode we therefore
581
+ // ignore both the defaults.json AND identity.json branches so init,
582
+ // get, set, status and the atlassian resolver all agree on the
583
+ // instance's own legacy vault. HQ mode (`library.mode === 'remote'`)
584
+ // is unchanged: the scoped estate vault IS what tools should target.
585
+ //
586
+ // Explicit `SECRETS_DEFAULT_SCOPE` (above) always wins in every mode
587
+ // — an env-var override is a caller decision and never gets rewritten.
588
+ //
589
+ // repoRoot resolution is silent: an explicit `opts.repoRoot` wins,
590
+ // else a quiet walk from cwd looks for the vault-bearing workspace
591
+ // marker (`.vault/` or `vault/*.enc.json`). No stderr notice, no
592
+ // throw — a caller outside any workspace (unusual, but possible in
593
+ // ad-hoc test setups) falls through to the pre-gate behaviour so the
594
+ // rest of the resolver stays testable in isolation.
595
+ let repoRoot = opts.repoRoot ?? null;
596
+ if (!repoRoot) {
597
+ try {
598
+ const { root: walked } = walkForWorkspace(cwd);
599
+ if (walked) repoRoot = walked;
600
+ } catch {
601
+ repoRoot = null;
602
+ }
603
+ }
604
+ const soloModeImpl = opts.isSoloMode ?? isSoloMode;
605
+ const inSoloMode = repoRoot ? soloModeImpl({ repoRoot }) : false;
606
+ if (inSoloMode) {
607
+ return { scope: null, source: null, detail: null };
608
+ }
609
+
527
610
  const defaults = readDefaultsDefaultScope(home);
528
611
  if (defaults.scope) {
529
612
  return { scope: defaults.scope, source: 'defaults', detail: defaults.file };
@@ -10,7 +10,7 @@ Phase 1 Task 4 ports the following from the predecessor `Stravica/dave` `core/`
10
10
 
11
11
  - `desk` CLI + work-item/project/spawn-log schemas
12
12
  - Briefing-contract framework + standards docs
13
- - Canonical roles (librarian, tool-developer, system-admin, agent-ops)
13
+ - Canonical roles (librarian, tool-developer, system-admin, agent-ops, agentic-workflow-designer)
14
14
  - Knowledge retrieval + distillation engine
15
15
  - Scheduler primitives
16
16
  - Memory-sync mechanism