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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +47 -3
  2. package/dist/engineering-pa-cli/src/pa/onboard.mjs +35 -5
  3. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +5 -0
  4. package/dist/engineering-pa-cli/src/pa/work.mjs +12 -2
  5. package/dist/engineering-pa-cli/src/scheduler/cli.mjs +8 -4
  6. package/dist/engineering-pa-cli/src/secrets/cli.mjs +26 -3
  7. package/dist/engineering-pa-cli/src/secrets/commands/init.mjs +163 -1
  8. package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +32 -12
  9. package/dist/engineering-pa-cli/src/secrets/vault.mjs +87 -4
  10. package/dist/engineering-pa-core/README.md +1 -1
  11. package/dist/engineering-pa-core/docs/commands/onboard.md +78 -13
  12. package/dist/engineering-pa-core/docs/pa-operating-model.md +1 -0
  13. package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +173 -0
  14. package/dist/engineering-pa-core/src/scheduler/install.mjs +1 -1
  15. package/dist/engineering-pa-core/src/scheduler/list.mjs +41 -26
  16. package/dist/engineering-pa-core/src/scheduler/remove.mjs +43 -22
  17. package/dist/engineering-pa-core/src/scheduler/resolve-agent.mjs +56 -0
  18. package/dist/engineering-pa-core/src/vault/mode.mjs +50 -0
  19. package/dist/engineering-pa-core/src/vault/require.mjs +306 -0
  20. package/dist/engineering-pa-core/src/vault/tool-needs.mjs +74 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +1 -1
  22. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +60 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +7 -1
  24. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +7 -1
  25. package/dist/engineering-pa-scaffold/src/generator.mjs +10 -0
  26. package/dist/engineering-pa-workspace/src/api.mjs +6 -1
  27. package/docs/onboarding-solo.md +46 -2
  28. package/docs/what-it-can-do.md +1 -0
  29. package/package.json +1 -1
@@ -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) {
@@ -36,6 +36,11 @@ Usage:
36
36
 
37
37
  Notes:
38
38
  - status=done stamps \`completedAt\` to today (only if not already set).
39
+ - \`list\` defaults to the active-only scope (excludes done + archived).
40
+ An explicit \`--status <s>\` REPLACES that default and returns only
41
+ items with that status (so \`--status done\` prints done items).
42
+ \`--all\` means no status filter; \`--mine\`/\`--theirs\` compose with
43
+ either. \`--status\` wins over \`--all\` when both are given.
39
44
  - --mine resolves the PA name from operator/config/preferences.json
40
45
  (paName / agentName). Default: "pa".
41
46
  - --author sets the writer-scoped id's author token. Precedence:
@@ -228,6 +228,15 @@ export async function setWorkStatus(id, status) {
228
228
  * mine, theirs, all, paName (used for --mine resolution).
229
229
  * By default excludes done + archived (mirrors desk-list semantics).
230
230
  *
231
+ * An explicit `status` filter REPLACES the active-only default rather
232
+ * than being AND-ed with it: `--status done` returns only done items,
233
+ * `--status archived` returns only archived items, `--status in-progress`
234
+ * is unchanged (the AND was harmless for non-terminal statuses). The
235
+ * pre-0.1.52 AND made any terminal-status request unconditionally
236
+ * empty ("no work items match" for `--status done`), a confident false
237
+ * negative. `--all` still means "no status filter at all"; if both
238
+ * `--all` and `--status` are given, `--status` wins.
239
+ *
231
240
  * Reads any well-formed work item (both id forms). Skips files whose
232
241
  * filename does not parse as a work-item id at all — the same
233
242
  * skip-unparseable posture the original had, restated against the
@@ -248,10 +257,11 @@ export async function listWork(filters = {}) {
248
257
  }
249
258
  }
250
259
  let filtered = items;
251
- if (!filters.all) {
260
+ if (filters.status) {
261
+ filtered = filtered.filter((w) => w.status === filters.status);
262
+ } else if (!filters.all) {
252
263
  filtered = filtered.filter((w) => !['done', 'archived'].includes(w.status));
253
264
  }
254
- if (filters.status) filtered = filtered.filter((w) => w.status === filters.status);
255
265
  if (filters.category) filtered = filtered.filter((w) => w.category === filters.category);
256
266
  if (filters.project) filtered = filtered.filter((w) => w.projectId === filters.project);
257
267
  if (filters.owner) filtered = filtered.filter((w) => w.owner === filters.owner);
@@ -33,10 +33,14 @@ Usage:
33
33
  Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
34
34
  schema is bundled at @stravica-ai/engineering-pa-core/schema/schedule.json.
35
35
 
36
- --agent (launchd only) targets a specific agent's label prefix
37
- (com.<agent>.<id>). A schedule file's own top-level "agent" field drives
38
- install; list/remove take it explicitly since they don't read a schedule
39
- file. Defaults to "dave" everywhere — omitting it is unchanged behaviour.
36
+ --agent targets a specific agent's label / tag prefix. Install reads
37
+ the agent from the schedule file's own top-level "agent" field. Since
38
+ 0.1.52 (w-2026-09-19-dave-001), list/remove resolve the agent from the
39
+ invoking instance's "paName" in operator/config/preferences.json when
40
+ --agent is omitted, and REFUSE (exit 2) when neither source produces a
41
+ value — there is no hard-coded "dave" fallback. This is the fix for the
42
+ colleague-laptop bug where the tools would list and touch another
43
+ estate's schedules under a silent default.
40
44
 
41
45
  Host enforcement (install): a schedule's "host" field is matched against
42
46
  this machine's hostname (exact, then short-name-before-first-dot). A
@@ -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,127 @@ 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
- const paths = overrides.paths || defaultPaths({ ...overrides, allowGitRootWithoutVault: true });
88
+ // 0.1.52 fix (w-2026-09-19-dave-001): `secrets init` CREATES an age
89
+ // key. The shared resolver in core/vault/require.mjs honours
90
+ // SOPS_AGE_KEY_FILE first, so with the variable exported the previous
91
+ // init generated the new key AT the env path — a stray key landed
92
+ // outside the instance, and on a colleague laptop that carried
93
+ // another estate's SOPS_AGE_KEY_FILE it would have written the new
94
+ // key into that estate's key file. The env override is a READ-path
95
+ // escape hatch; init must generate at the instance path (solo mode)
96
+ // or the per-user path (HQ mode) regardless of the variable.
97
+ //
98
+ // Strip SOPS_AGE_KEY_FILE from the env passed to `defaultPaths` only
99
+ // — every OTHER env-derived resolution (DAVE_ROOT / PA_ROOT walk,
100
+ // etc.) still uses the caller's real env. Print a one-line notice
101
+ // when the operator had the override set and the resolved
102
+ // key path does not match it, so subsequent reads that continue to
103
+ // consult the env override are not silently pointed at the old file.
104
+ const rawEnv = overrides.env;
105
+ const envOverride = rawEnv && typeof rawEnv.SOPS_AGE_KEY_FILE === 'string' && rawEnv.SOPS_AGE_KEY_FILE.length > 0
106
+ ? rawEnv.SOPS_AGE_KEY_FILE
107
+ : null;
108
+ let envForPaths = rawEnv;
109
+ if (envOverride !== null && !overrides.paths) {
110
+ envForPaths = { ...rawEnv };
111
+ delete envForPaths.SOPS_AGE_KEY_FILE;
112
+ }
113
+ const paths = overrides.paths || defaultPaths({
114
+ ...overrides,
115
+ env: envForPaths,
116
+ allowGitRootWithoutVault: true,
117
+ });
118
+ if (envOverride !== null && envOverride !== paths.keyFile) {
119
+ console.log(
120
+ `Note: SOPS_AGE_KEY_FILE is set to ${envOverride}; init writes the new key to ${paths.keyFile}. Reads will not see the new key until the variable is unset or repointed.`,
121
+ );
122
+ }
123
+ const argv = overrides.argv || [];
124
+ const flags = extractInitFlags(argv);
125
+
126
+ if (flags.help) {
127
+ console.log([
128
+ 'Usage: secrets init [--empty | --stdin-json]',
129
+ '',
130
+ ' --empty Create the key and an empty encrypted store (default shape).',
131
+ ' --stdin-json After init, seed KEYs from a JSON object read on stdin.',
132
+ ' Refuses when stdin is a TTY. Values are never echoed.',
133
+ ].join('\n'));
134
+ return 0;
135
+ }
136
+
137
+ if (flags.empty && flags.stdinJson) {
138
+ console.error('Error: --empty and --stdin-json are mutually exclusive.');
139
+ throw new VaultError('mutually exclusive flags', 1);
140
+ }
141
+
142
+ // Read stdin BEFORE running the init side effects — a malformed
143
+ // payload should not leave a partially-initialised vault on disk when
144
+ // the caller was going to seed it in one shot.
145
+ let seedPairs = null;
146
+ if (flags.stdinJson) {
147
+ const stdinStream = overrides.stdinStream || process.stdin;
148
+ if (stdinStream && stdinStream.isTTY && !overrides.allowTtyStdin) {
149
+ console.error('Error: --stdin-json requires piped JSON on stdin; refusing to prompt (would land the value in shell history).');
150
+ throw new VaultError('stdin is a TTY', 1);
151
+ }
152
+ const raw = overrides.stdinRead ? overrides.stdinRead() : readStdinSync();
153
+ seedPairs = parseStdinJson(raw);
154
+ }
23
155
 
24
156
  const missing = await checkDeps();
25
157
  if (missing.length > 0) {
@@ -33,6 +165,13 @@ export async function runInit(overrides = {}) {
33
165
  ensureKeyDir(paths);
34
166
 
35
167
  if (keyFileExists(paths)) {
168
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): the shared resolver
169
+ // routes solo instances to `<repoRoot>/.vault/age-key.txt` and HQ
170
+ // instances to `~/.config/stravica-pa/age-key.txt`. A pre-existing
171
+ // key at the resolved path is intentional reuse (init is
172
+ // idempotent). A foreign per-user key on a fresh solo laptop no
173
+ // longer meets this check — the solo resolver returns the instance
174
+ // path, which does not exist, and init generates a NEW key there.
36
175
  console.log(`Age private key already exists at ${paths.keyFile}`);
37
176
  } else {
38
177
  await ageKeygen(paths);
@@ -59,6 +198,29 @@ export async function runInit(overrides = {}) {
59
198
  console.log('Encrypted secrets file already exists.');
60
199
  }
61
200
 
201
+ // Seed non-interactively. Merge (do not replace) any keys that were
202
+ // already in the store — this verb is idempotent by design and a
203
+ // re-run with a new key/value should not clobber the operator's
204
+ // existing set. Values are never echoed; the summary line at the end
205
+ // prints only key NAMES and the pair count.
206
+ if (seedPairs && Object.keys(seedPairs).length > 0) {
207
+ let data = {};
208
+ if (secretsFileExists(paths)) {
209
+ try {
210
+ data = await sopsDecrypt(paths, paths.secretsFile);
211
+ } catch (err) {
212
+ console.error(`Error: could not decrypt ${paths.secretsFile} to merge seeded keys: ${err && err.message ? err.message : err}`);
213
+ throw new VaultError('seed merge decrypt failed', 2);
214
+ }
215
+ }
216
+ for (const [k, v] of Object.entries(seedPairs)) data[k] = v;
217
+ await sopsEncrypt(paths, pubKey, data);
218
+ const names = Object.keys(seedPairs).sort();
219
+ console.log(`Seeded ${names.length} key(s): ${names.join(', ')}`);
220
+ } else if (flags.stdinJson) {
221
+ console.log('--stdin-json input carried no keys; vault created empty.');
222
+ }
223
+
62
224
  console.log('');
63
225
  console.log('Vault initialised.');
64
226
  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 };