@stravica-ai/digital-operator-pa 0.1.19 → 0.1.20

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.
@@ -8,7 +8,7 @@ Dave uses SOPS with age encryption for secrets management. Secrets are encrypted
8
8
 
9
9
  Implemented: `init`, `get`, `set`, `list`, `delete`, `rotate`, `status`, `backup`, `vaults`, `find` (alias `which`), `verify`. No npm dependencies — shells out to `sops` and `age` via `node:child_process.spawn`.
10
10
 
11
- `set` additionally supports `--canonical-clone PATH` (write a canonical clone's vault file first, then the resolved workspace copy — aborts before touching the workspace copy if the canonical write fails). `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
11
+ **Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20.** A plain `secrets set` on a scoped vault writes canonical FIRST and workspace SECOND whenever a canonical clone for the scope is discoverable on this host (via `~/.config/stravica-pa/defaults.json`'s `canonicalClone` key or the `~/stravica-canonical/*/vault/<scope>.enc.json` estate convention). Absence of a canonical clone is a stderr NOTICE, never a failure. Explicit `--canonical-clone PATH` still wins over auto-discovery (and bootstraps a missing file); `--no-canonical` opts out entirely and suppresses the notice. `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
12
12
 
13
13
  `get`/`set` run without `--scope`/`--file` now resolve a **default scope** — `$SECRETS_DEFAULT_SCOPE` env var, then `"defaultScope"` in `~/.config/stravica-pa/defaults.json`, then the calling PA's own `agents/<name>/identity.json`, then the legacy single-file vault as a last resort. Every such invocation prints a one-line stderr notice naming the target vault BEFORE the operation runs, including the legacy-fallback case ("no scope specified and no default configured") — see `secrets --help` → "Default scope" for the full precedence and example notices.
14
14
 
@@ -67,11 +67,17 @@ node core/tools/secrets/bin/secrets status
67
67
  # Open a browser with a QR code of the master key for backup
68
68
  node core/tools/secrets/bin/secrets backup
69
69
 
70
- # Write a canonical clone's vault FIRST, then the workspace copy second.
71
- # Aborts (exit 2) before touching the workspace copy if the canonical
72
- # write fails (e.g. the existing canonical store can't be decrypted).
70
+ # Force a specific canonical file (wins over auto-discovery; bootstraps
71
+ # a missing file). Aborts (exit 2) before touching the workspace copy if
72
+ # the canonical write fails (e.g. the existing canonical store can't be
73
+ # decrypted). Auto-discovery is on by default since 0.1.20 — this flag
74
+ # is only for pointing at a specific target.
73
75
  node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /path/to/canonical/vault/scope.enc.json
74
76
 
77
+ # Opt out of canonical dual-write entirely (suppresses the absence
78
+ # notice too). Mutually exclusive with --canonical-clone.
79
+ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical
80
+
75
81
  # Byte-level comparison of DECRYPTED values against a reference vault.
76
82
  # --values is required — there is no decrypt-only verification mode
77
83
  # (decrypt-only false-passes a vault that decrypts cleanly but holds the
@@ -79,29 +85,66 @@ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /p
79
85
  node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
80
86
  ```
81
87
 
82
- ### Canonical-first writes (`set --canonical-clone`)
88
+ ### Canonical-first writes
89
+
90
+ Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
91
+ a plain `secrets set` on a scoped vault writes canonical FIRST and the
92
+ workspace SECOND whenever a canonical clone for the scope is discoverable
93
+ on this host. The abort-before-workspace ordering is identical to the
94
+ explicit-flag path it grew out of: if the canonical write fails for any
95
+ reason (existing canonical store undecryptable, sops encrypt failure),
96
+ the command exits 2 and the workspace copy is never touched.
97
+
98
+ **Resolution order** (first hit wins; every miss recorded in the absence
99
+ notice's tried-tier detail):
100
+
101
+ 1. **Explicit `--canonical-clone PATH`** — always wins, bootstraps a
102
+ missing file (this is the flag's historical behaviour).
103
+ 2. **`"canonicalClone"` in `~/.config/stravica-pa/defaults.json`** — the
104
+ same file (already read for `workspaceRoot` and `defaultScope`); the
105
+ CLI never writes it. Two shapes supported:
106
+ - **Object (scope → file map):**
107
+ ```json
108
+ { "canonicalClone": { "seo-ops": "/Users/thefoot/stravica-canonical/stravica-operator-barry/vault/seo-ops.enc.json" } }
109
+ ```
110
+ - **String (root dir)** — derives `<root>/vault/<scope>.enc.json`,
111
+ which fits the estate's operator-repo layout:
112
+ ```json
113
+ { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
114
+ ```
115
+ 3. **Estate convention** — `~/stravica-canonical/<repo>/vault/<scope>.enc.json`
116
+ (glob shape `~/stravica-canonical/*/vault/…enc.json`). This reuses the
117
+ root the rest of the estate already uses (see
118
+ `library-sync-cycle`'s `--repos-root` default and
119
+ `dex-cutover-driver`'s `reposRoot`). Exactly one match wins; ambiguity
120
+ (multiple matches) or zero matches skips this tier.
121
+
122
+ **Absence** is a stderr notice, never a failure — a host with no
123
+ canonical layout keeps writing to workspace only:
124
+
125
+ ```
126
+ notice: no canonical clone configured for scope 'seo-ops' on this host — workspace-only write (tried: …)
127
+ ```
128
+
129
+ The `tried:` tail names every tier that missed and why, so an operator on
130
+ a host that ought to have a canonical knows exactly where to configure it
131
+ (or that `--no-canonical` is the intended answer for a host that never
132
+ had one).
83
133
 
84
- Without the flag, `set` behaves exactly as before — it writes only the
85
- resolved workspace copy. With `--canonical-clone PATH`, `set`:
134
+ **Flags:**
86
135
 
87
- 1. Resolves `PATH` the same way `--file` resolves any other vault (its
88
- own `.sops.yaml` expected alongside it), reusing this host's own key
89
- material (`keyDir`/`keyFile`) — the canonical clone and the workspace
90
- copy are expected to share recipients in practice (same operator,
91
- same device).
92
- 2. Decrypts the existing canonical store (if any) and writes the new key
93
- into it. **If this fails for any reason — the existing canonical
94
- store can't be decrypted, or the encrypt itself fails — `set` aborts
95
- with exit 2 and the workspace copy is never touched.**
96
- 3. Only on canonical success does it proceed to the existing
97
- decrypt-mutate-encrypt cycle against the resolved workspace copy.
136
+ - `--canonical-clone PATH` — force a specific canonical file (wins over
137
+ auto-discovery; bootstraps a missing file). Backwards-compatible with
138
+ every prior release.
139
+ - `--no-canonical` — opt out of dual-write entirely, and suppress the
140
+ absence notice. Mutually exclusive with `--canonical-clone`.
98
141
 
99
142
  This closes the "device-side vault mutation only round-trips halfway"
100
- class: a device-side `secrets set` that updates the workspace copy but
101
- never reaches canonical leaves the two vaults silently diverged until the
102
- next down-path materialisation clobbers the device's fresher value.
103
- `--canonical-clone` makes the canonical write the FIRST, gating step —
104
- the workspace never ends up ahead of canonical.
143
+ class BY DEFAULT rather than opt-in: any workspace write that a
144
+ canonical layout could catch, it now does — no flag required.
145
+
146
+ Legacy vaults (no scope) never trigger canonical resolution — the notice
147
+ would be pure noise for a fallback that has no natural canonical mirror.
105
148
 
106
149
  ### Value-compare verification (`verify --values --against`)
107
150
 
@@ -25,10 +25,17 @@ Usage:
25
25
  secrets init Initialise the vault (generate keys, create empty secrets file)
26
26
  secrets get KEY Retrieve a secret
27
27
  secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
28
- --canonical-clone PATH: write the canonical
29
- clone's vault file FIRST, then this workspace
30
- copy. If the canonical write fails, aborts
31
- before touching the workspace copy at all.
28
+ Since 0.1.20, canonical-first dual-write is
29
+ the DEFAULT for scoped writes when a canonical
30
+ clone for the scope is discoverable on this
31
+ host. See "Canonical-first writes" below.
32
+ --canonical-clone PATH: force a specific
33
+ canonical clone's vault file (bootstraps a
34
+ missing file; wins over auto-discovery).
35
+ --no-canonical: opt out of the dual-write and
36
+ the absence notice.
37
+ If the canonical write fails, aborts before
38
+ touching the workspace copy at all.
32
39
  secrets list List all secret key names (not values)
33
40
  secrets delete KEY Remove a secret
34
41
  secrets rotate Re-encrypt with current recipients
@@ -111,16 +118,30 @@ Default scope (get/set only, when neither --scope nor --file is given):
111
118
  note: writing to '(legacy)' pot — no scope specified and no default configured. Use --scope <name> or configure a default.
112
119
 
113
120
  Canonical-first writes (set only):
114
- --canonical-clone PATH Write PATH (a canonical clone's encrypted
115
- vault file) FIRST, then the resolved
116
- workspace copy SECOND. If the canonical
117
- write fails for any reason (existing store
118
- undecryptable, sops encrypt failure), the
119
- command aborts with exit 2 BEFORE touching
120
- the workspace copy at all — a device-side
121
- vault mutation never round-trips only
122
- halfway. Without this flag, 'set' behaves
123
- exactly as before (workspace copy only).
121
+ Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20 —
122
+ a plain 'secrets set' on a scoped vault writes canonical FIRST and
123
+ workspace SECOND whenever a canonical clone for the scope is
124
+ discoverable on this host. Discovery order, first hit wins:
125
+ 1. Explicit --canonical-clone PATH (always wins, bootstraps a
126
+ missing file).
127
+ 2. "canonicalClone" in ~/.config/stravica-pa/defaults.json — either:
128
+ object (scope → file map):
129
+ { "canonicalClone": { "seo-ops": "/abs/vault/seo-ops.enc.json" } }
130
+ string (root dir), derives <root>/vault/<scope>.enc.json:
131
+ { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
132
+ 3. Estate convention: ~/stravica-canonical/*/vault/<scope>.enc.json
133
+ (exactly one match; ambiguity or zero matches skips this tier).
134
+ Absence is a NOTICE, never a failure — a host with no canonical layout
135
+ keeps writing to workspace only, with a one-line stderr line naming why
136
+ nothing resolved. If any canonical write fails (existing store
137
+ undecryptable, sops encrypt failure), the command aborts with exit 2
138
+ BEFORE touching the workspace copy — the ordering invariant is
139
+ identical to the flag path since it always was.
140
+
141
+ --canonical-clone PATH Force PATH as the canonical target (wins over
142
+ auto-discovery; bootstraps a missing file).
143
+ --no-canonical Opt out of dual-write entirely, and suppress
144
+ the absence notice.
124
145
 
125
146
  Value-compare verification:
126
147
  secrets verify --values --against <path>
@@ -139,7 +160,7 @@ const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vau
139
160
  Commands:
140
161
  init Initialise the vault (generate keys, create config)
141
162
  get KEY Retrieve a secret value
142
- set KEY VALUE Store or update a secret (--canonical-clone PATH: write canonical first)
163
+ set KEY VALUE Store or update a secret (canonical-first by default; --no-canonical to opt out)
143
164
  list List all secret key names
144
165
  delete KEY Remove a secret
145
166
  rotate Re-encrypt with current recipients
@@ -2,18 +2,29 @@
2
2
  // Passing `-` as VALUE reads the secret from stdin (trailing newline stripped),
3
3
  // which keeps the value out of argv / process listings / shell history.
4
4
  //
5
- // --canonical-clone PATH: write the canonical clone's vault file FIRST,
6
- // then this workspace copy SECOND. If the canonical write fails for any
7
- // reason, the command aborts (exit 2) BEFORE touching the workspace copy
8
- // at all — a device-side vault mutation never round-trips only halfway
9
- // (see up-path-single-writer-ownership's vault follow-up: "device-side
10
- // vault mutations must round-trip to canonical immediately"). Without the
11
- // flag, behaviour is exactly as before (workspace copy only).
5
+ // Canonical-first dual-write is the DEFAULT for scoped writes since 0.1.20:
6
+ // when a canonical clone for the target scope is discoverable on this host
7
+ // (via ~/.config/stravica-pa/defaults.json "canonicalClone" or the
8
+ // ~/stravica-canonical/*/vault/<scope>.enc.json convention — see
9
+ // resolveCanonicalCloneTarget in ../vault.mjs), set writes canonical FIRST
10
+ // and workspace SECOND, with the same abort-before-workspace ordering the
11
+ // explicit --canonical-clone flag has always had. Absence of a canonical
12
+ // clone is a NOTICE, never a failure (a host that never had a canonical
13
+ // keeps working); --no-canonical opts out (and suppresses the notice).
14
+ // Explicit --canonical-clone PATH still wins over any auto-discovery.
15
+ //
16
+ // Ordering invariant (pinned by tests): if the canonical write fails for
17
+ // any reason (existing canonical store undecryptable, sops encrypt
18
+ // failure), the command aborts with exit 2 BEFORE touching the workspace
19
+ // copy at all — a device-side vault mutation never round-trips only
20
+ // halfway (see up-path-single-writer-ownership's vault follow-up:
21
+ // "device-side vault mutations must round-trip to canonical immediately").
12
22
 
13
23
  import { readFileSync } from 'node:fs';
14
24
  import {
15
25
  resolveCommandPaths,
16
26
  resolveScopePaths,
27
+ resolveCanonicalCloneTarget,
17
28
  checkDeps,
18
29
  depInstallMessage,
19
30
  checkVault,
@@ -25,19 +36,21 @@ import {
25
36
  VaultError,
26
37
  } from '../vault.mjs';
27
38
 
28
- // --canonical-clone may appear anywhere in argv, matching the --scope/
29
- // --file convention in cli.mjs. Local to `set` (not a global flag) since
30
- // no other command writes to a second target.
31
- function extractCanonicalCloneFlag(argv) {
39
+ // --canonical-clone / --no-canonical may appear anywhere in argv, matching
40
+ // the --scope/--file convention in cli.mjs. Local to `set` (not global
41
+ // flags) since no other command writes to a second target.
42
+ function extractCanonicalFlags(argv) {
32
43
  const rest = [];
33
44
  let canonicalClone;
45
+ let noCanonical = false;
34
46
  for (let i = 0; i < argv.length; i += 1) {
35
47
  const a = argv[i];
36
48
  if (a === '--canonical-clone') { canonicalClone = argv[i + 1]; i += 1; continue; }
37
49
  if (a.startsWith('--canonical-clone=')) { canonicalClone = a.slice('--canonical-clone='.length); continue; }
50
+ if (a === '--no-canonical') { noCanonical = true; continue; }
38
51
  rest.push(a);
39
52
  }
40
- return { canonicalClone, rest };
53
+ return { canonicalClone, noCanonical, rest };
41
54
  }
42
55
 
43
56
  export async function runSet(args, overrides = {}) {
@@ -45,15 +58,20 @@ export async function runSet(args, overrides = {}) {
45
58
  // target vault) whenever --scope/--file wasn't given upstream in
46
59
  // cli.mjs — see vault.mjs's default-scope resolution section.
47
60
  const paths = resolveCommandPaths(overrides);
48
- const { canonicalClone, rest } = extractCanonicalCloneFlag(args);
61
+ const { canonicalClone, noCanonical, rest } = extractCanonicalFlags(args);
49
62
  const key = rest[0];
50
63
  let value = rest[1];
51
64
 
52
65
  if (!key || value == null || value === '') {
53
- console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH] (use `-` as VALUE to read from stdin)');
66
+ console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (use `-` as VALUE to read from stdin)');
54
67
  throw new VaultError('usage', 1);
55
68
  }
56
69
 
70
+ if (canonicalClone && noCanonical) {
71
+ console.error('Error: --canonical-clone and --no-canonical are mutually exclusive.');
72
+ throw new VaultError('mutually exclusive canonical flags', 1);
73
+ }
74
+
57
75
  if (value === '-') {
58
76
  const readStdin = overrides.readStdin ?? (() => readFileSync(0, 'utf-8'));
59
77
  const raw = readStdin();
@@ -79,16 +97,54 @@ export async function runSet(args, overrides = {}) {
79
97
  throw new VaultError(vaultState.reason || 'vault not initialised', vaultState.exitCode ?? 1);
80
98
  }
81
99
 
100
+ // Canonical-first resolution order (BEFORE any workspace write):
101
+ // 1. Explicit --canonical-clone PATH → wins, always. Bootstraps a
102
+ // missing canonical file (matches the flag's historical behaviour).
103
+ // 2. --no-canonical → skip canonical entirely, no notice, no discovery.
104
+ // 3. Scoped write (paths.scope set) → auto-discover via
105
+ // resolveCanonicalCloneTarget. Hit → dual-write canonical-first,
106
+ // SAME output lines as the explicit-flag path. Miss → workspace-only
107
+ // PLUS a one-line stderr notice naming why nothing resolved.
108
+ // 4. Legacy fallback (no scope) → no canonical resolution, no notice —
109
+ // legacy has no natural canonical mirror.
110
+ let effectiveCanonical = null;
111
+ let canonicalPathsFromOverride = overrides.canonicalPaths || null;
112
+ if (canonicalClone) {
113
+ effectiveCanonical = { file: canonicalClone, source: 'flag' };
114
+ } else if (!noCanonical && paths.scope) {
115
+ // Honour the paths' own `home` first — it carries the caller's
116
+ // injected home end-to-end so test isolation cannot leak into the
117
+ // real ~/stravica-canonical/ scan. Falls back to overrides.home for
118
+ // callers that build paths themselves and only pass home via
119
+ // overrides, then to homedir() for the real CLI at run time.
120
+ const canonicalHome = paths.home ?? overrides.home;
121
+ const resolved = overrides.resolveCanonicalCloneTarget
122
+ ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
123
+ : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
124
+ if (resolved.file) {
125
+ effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
126
+ } else {
127
+ // Absence notice — never a failure. Explains WHY nothing resolved so
128
+ // an operator on a host without a canonical layout knows whether to
129
+ // provision one, pass --no-canonical to silence, or ignore.
130
+ const tail = resolved.tried && resolved.tried.length > 0
131
+ ? ` (tried: ${resolved.tried.join('; ')})`
132
+ : '';
133
+ console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
134
+ }
135
+ }
136
+
82
137
  // Canonical FIRST. Resolved the same way `--file` already works for
83
138
  // every other scope-aware command — an arbitrary path, its own
84
139
  // .sops.yaml expected alongside it, reusing THIS host's key material
85
140
  // (same recipients in practice for a workspace/canonical-clone pair).
86
141
  // Any failure here (existing canonical store undecryptable, sops
87
142
  // encrypt failure) throws and propagates straight out of runSet —
88
- // the workspace write below is never reached.
89
- if (canonicalClone) {
90
- const canonicalPaths = overrides.canonicalPaths || resolveScopePaths({
91
- file: canonicalClone,
143
+ // the workspace write below is never reached. Ordering pinned by
144
+ // canonical-clone.test.mjs — do not reorder.
145
+ if (effectiveCanonical) {
146
+ const canonicalPaths = canonicalPathsFromOverride || resolveScopePaths({
147
+ file: effectiveCanonical.file,
92
148
  keyDir: paths.keyDir,
93
149
  keyFile: paths.keyFile,
94
150
  });
@@ -165,6 +165,11 @@ export function defaultPaths(overrides = {}) {
165
165
  sopsConfig: join(vaultDir, '.sops.yaml'),
166
166
  keyDir,
167
167
  keyFile: join(keyDir, 'age-key.txt'),
168
+ // Retained on the paths object so downstream consumers (canonical-
169
+ // clone auto-discovery in commands/set.mjs) can honour the SAME home
170
+ // the caller injected, instead of falling back to the real user's
171
+ // homedir() and cross-contaminating test isolation.
172
+ home,
168
173
  };
169
174
  }
170
175
 
@@ -220,6 +225,7 @@ export function resolveScopePaths(opts = {}) {
220
225
  scope: opts.scope ?? null,
221
226
  scoped: true,
222
227
  layout: 'file',
228
+ home,
223
229
  };
224
230
  }
225
231
 
@@ -260,6 +266,7 @@ export function resolveScopePaths(opts = {}) {
260
266
  scope: opts.scope,
261
267
  scoped: true,
262
268
  layout: 'workspace',
269
+ home,
263
270
  };
264
271
  }
265
272
 
@@ -275,6 +282,7 @@ export function resolveScopePaths(opts = {}) {
275
282
  scope: opts.scope,
276
283
  scoped: true,
277
284
  layout: 'canonical',
285
+ home,
278
286
  };
279
287
  }
280
288
 
@@ -332,6 +340,138 @@ function readDefaultsDefaultScope(home) {
332
340
  }
333
341
  }
334
342
 
343
+ // ── Canonical-clone target resolution (set only) ─────────────────────
344
+ //
345
+ // When `set` runs on a scoped vault WITHOUT --canonical-clone (and WITHOUT
346
+ // --no-canonical), auto-discover the canonical clone's vault file for the
347
+ // resolved scope. Resolution order (each tier tried in turn, first hit
348
+ // wins; every miss recorded in `tried` for the absence-notice detail):
349
+ //
350
+ // 1. "canonicalClone" in ~/.config/stravica-pa/defaults.json — two
351
+ // shapes supported:
352
+ // - object (scope → file map):
353
+ // { "canonicalClone": { "seo-ops": "/abs/path/seo-ops.enc.json" } }
354
+ // - string (root dir): derives <root>/vault/<scope>.enc.json:
355
+ // { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
356
+ // This CLI never writes defaults.json.
357
+ // 2. Estate convention discovery under ~/stravica-canonical/*/vault/
358
+ // <scope>.enc.json — the ~/stravica-canonical/ layout is the
359
+ // established root for canonical clones on every host (see
360
+ // library-sync-cycle's default --repos-root, dex-cutover-driver's
361
+ // reposRoot, and the routine references across projects/). Exactly
362
+ // one match wins; zero matches or multiple matches skips this tier
363
+ // (multiple is recorded as ambiguity so the notice explains WHY the
364
+ // auto-write did not happen).
365
+ //
366
+ // A resolved-but-nonexistent file (config points at a path that isn't
367
+ // there) is treated as a miss with a `tried` line, not a bootstrap — the
368
+ // auto-write path only ever writes to canonicals that already exist.
369
+ // Explicit --canonical-clone still bootstraps missing files (it always did).
370
+
371
+ function readDefaultsCanonicalClone(home) {
372
+ const file = defaultsFilePath(home);
373
+ if (!existsSync(file)) return { file, shape: null, value: null, reason: 'file not present' };
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(readFileSync(file, 'utf-8'));
377
+ } catch {
378
+ return { file, shape: null, value: null, reason: 'file is not valid JSON' };
379
+ }
380
+ if (!parsed || typeof parsed !== 'object' || !('canonicalClone' in parsed)) {
381
+ return { file, shape: null, value: null, reason: 'canonicalClone key not set' };
382
+ }
383
+ const v = parsed.canonicalClone;
384
+ if (typeof v === 'string' && v.trim() !== '') {
385
+ return { file, shape: 'root', value: resolve(v.trim()), reason: null };
386
+ }
387
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
388
+ return { file, shape: 'map', value: v, reason: null };
389
+ }
390
+ return { file, shape: null, value: null, reason: 'canonicalClone must be a non-empty string or a scope→file object' };
391
+ }
392
+
393
+ // Directory scan for the estate convention: ~/stravica-canonical/<repo>/
394
+ // vault/<scope>.enc.json (glob-shape ~/stravica-canonical/*/vault/…enc.json;
395
+ // slash-star notation avoided in this comment because JSDoc closes on */).
396
+ // Returns { file, source, detail } on exactly-one hit, or
397
+ // { file: null, reason } describing the miss (none / ambiguous / root
398
+ // absent / unreadable). Never throws — a scan failure is a miss, not a
399
+ // secrets-set failure.
400
+ function discoverConventionalCanonical(home, scope) {
401
+ const root = join(home, 'stravica-canonical');
402
+ if (!existsSync(root)) {
403
+ return { file: null, reason: `no ${root} directory` };
404
+ }
405
+ let entries;
406
+ try {
407
+ entries = readdirSync(root, { withFileTypes: true });
408
+ } catch (err) {
409
+ return { file: null, reason: `${root} unreadable (${err && err.code ? err.code : 'error'})` };
410
+ }
411
+ const hits = [];
412
+ for (const ent of entries) {
413
+ if (!ent.isDirectory()) continue;
414
+ const candidate = join(root, ent.name, 'vault', `${scope}.enc.json`);
415
+ if (existsSync(candidate)) hits.push(candidate);
416
+ }
417
+ if (hits.length === 1) {
418
+ return { file: hits[0], source: 'convention', detail: `~/stravica-canonical/*/vault/${scope}.enc.json` };
419
+ }
420
+ if (hits.length === 0) {
421
+ return { file: null, reason: `no ~/stravica-canonical/*/vault/${scope}.enc.json` };
422
+ }
423
+ return { file: null, reason: `ambiguous — ${hits.length} matches under ~/stravica-canonical: ${hits.join(', ')}` };
424
+ }
425
+
426
+ /**
427
+ * Resolve the canonical clone's vault file for `scope`. Returns
428
+ * { file, source, detail, tried } on hit — file is an absolute path,
429
+ * source is 'defaults-map' |
430
+ * 'defaults-root' | 'convention'.
431
+ * { file: null, tried } on miss — tried is a list of the
432
+ * per-tier miss reasons, in
433
+ * priority order.
434
+ * Never throws. Never a failure — absence is a notice case, not an error.
435
+ * Scope is required; callers must not invoke this on the legacy vault
436
+ * fall-through (no scope → no natural canonical mirror).
437
+ */
438
+ export function resolveCanonicalCloneTarget(opts = {}) {
439
+ const home = opts.home ?? homedir();
440
+ const scope = opts.scope;
441
+ const tried = [];
442
+ if (!scope) return { file: null, tried: ['no scope resolved (legacy fallback)'] };
443
+
444
+ const defaults = readDefaultsCanonicalClone(home);
445
+ if (defaults.shape === 'map') {
446
+ const entry = defaults.value[scope];
447
+ if (typeof entry === 'string' && entry.trim() !== '') {
448
+ const file = resolve(entry.trim());
449
+ if (existsSync(file)) {
450
+ return { file, source: 'defaults-map', detail: `${defaults.file} → canonicalClone["${scope}"]`, tried };
451
+ }
452
+ tried.push(`${defaults.file} → canonicalClone["${scope}"] = ${file} (path not present)`);
453
+ } else {
454
+ tried.push(`${defaults.file} → canonicalClone map has no entry for "${scope}"`);
455
+ }
456
+ } else if (defaults.shape === 'root') {
457
+ const file = join(defaults.value, 'vault', `${scope}.enc.json`);
458
+ if (existsSync(file)) {
459
+ return { file, source: 'defaults-root', detail: `${defaults.file} → canonicalClone root ${defaults.value}`, tried };
460
+ }
461
+ tried.push(`${defaults.file} → canonicalClone root ${defaults.value} → ${file} (path not present)`);
462
+ } else {
463
+ tried.push(`${defaults.file} canonicalClone: ${defaults.reason}`);
464
+ }
465
+
466
+ const conv = discoverConventionalCanonical(home, scope);
467
+ if (conv.file) {
468
+ return { file: conv.file, source: conv.source, detail: conv.detail, tried };
469
+ }
470
+ tried.push(`convention: ${conv.reason}`);
471
+
472
+ return { file: null, tried };
473
+ }
474
+
335
475
  /**
336
476
  * Walk up from `cwd` for the nearest ancestor directory that sits under
337
477
  * an "agents" path segment AND contains identity.json. Returns the file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",