@stravica-ai/digital-operator-pa 0.1.25 → 0.1.27

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.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Operator-facing CLIs — pa, secrets, scheduler, sync, smoke, launch.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -29,18 +29,19 @@
29
29
  // §5.1 rules runtime unreachability non-fatal at boot; probing here
30
30
  // would drag the boot doctrine sideways.
31
31
 
32
- import { LibraryConfigError, getLibraryVariant, resolveLibraryConfig } from '../../../engineering-pa-core/src/library-config.mjs';
32
+ import { LibraryConfigError, getLibraryVariant, resolveLibraryConfigWithRefs } from '../../../engineering-pa-core/src/library-config.mjs';
33
33
  import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
34
34
 
35
35
  import { getRepoRoot } from '../pa/repo-root.mjs';
36
36
 
37
37
  // --- rule-variant text (spec §5.2 verbatim) ------------------------------
38
38
  //
39
- // Every entry mirrors an existing rule in `agents/dave/CLAUDE.md` under
40
- // Bucket 4 (Knowledge & memory). `bucket` + `ruleNumber` + `ruleTitle`
41
- // let the JSON envelope be indexed against the CLAUDE.md file without
42
- // string-matching the file body. `simple` is the historic wording; the
43
- // REMOTE deltas come from remote-library spec §5.2.
39
+ // Every entry mirrors an existing rule in the operator's
40
+ // `agents/<paName>/CLAUDE.md` under Bucket 4 (Knowledge & memory).
41
+ // `bucket` + `ruleNumber` + `ruleTitle` let the JSON envelope be indexed
42
+ // against the CLAUDE.md file without string-matching the file body.
43
+ // `simple` is the historic wording; the REMOTE deltas come from
44
+ // remote-library spec §5.2.
44
45
  export const RULE_VARIANTS = [
45
46
  {
46
47
  bucket: 4,
@@ -83,25 +84,41 @@ Exit codes (tooling standard §6):
83
84
 
84
85
  /**
85
86
  * Build the envelope for the resolved library variant. Pure: takes an
86
- * already-resolved config, returns the printable / JSON-emittable shape.
87
+ * already-resolved config plus the resolved paName, returns the
88
+ * printable / JSON-emittable shape.
89
+ *
90
+ * `paName` is the boot-resolved operator name (from merged preferences,
91
+ * same source `buildSessionPlan` uses). When null (diagnostic call with
92
+ * absent preferences), the rendered path degrades to the `<paName>`
93
+ * placeholder so the diagnostic still emits without lying about which
94
+ * operator's file the variants target.
87
95
  *
88
96
  * @param {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}} config
97
+ * @param {{paName?: string|null}} [opts]
89
98
  * @returns {{
90
99
  * verb: 'context',
91
100
  * libraryMode: 'simple'|'remote',
92
101
  * libraryVariant: 'simple'|'remote',
93
102
  * remoteEndpoint: string|null,
103
+ * paName: string|null,
104
+ * agentClaudePath: string,
94
105
  * ruleVariants: Array<{bucket:number, ruleNumber:number, ruleTitle:string, effective:string, mode:'simple'|'remote'}>,
95
106
  * specReference: string,
96
107
  * }}
97
108
  */
98
- export function buildContextEnvelope(config) {
109
+ export function buildContextEnvelope(config, opts = {}) {
99
110
  const variant = getLibraryVariant(config);
111
+ const paName = typeof opts.paName === 'string' && opts.paName.length > 0
112
+ ? opts.paName
113
+ : null;
114
+ const agentClaudePath = `agents/${paName ?? '<paName>'}/CLAUDE.md`;
100
115
  return {
101
116
  verb: 'context',
102
117
  libraryMode: config.mode,
103
118
  libraryVariant: variant,
104
119
  remoteEndpoint: config.mode === 'remote' ? config.remoteEndpoint : null,
120
+ paName,
121
+ agentClaudePath,
105
122
  ruleVariants: RULE_VARIANTS.map((r) => ({
106
123
  bucket: r.bucket,
107
124
  ruleNumber: r.ruleNumber,
@@ -126,7 +143,7 @@ export function formatContextTable(envelope) {
126
143
  lines.push(`remoteEndpoint: ${envelope.remoteEndpoint}`);
127
144
  }
128
145
  lines.push('');
129
- lines.push('Effective CLAUDE.md rule variants (agents/dave/CLAUDE.md, Bucket 4):');
146
+ lines.push(`Effective CLAUDE.md rule variants (${envelope.agentClaudePath}, Bucket 4):`);
130
147
  lines.push('');
131
148
  for (const r of envelope.ruleVariants) {
132
149
  lines.push(` Rule ${r.bucket}.${r.ruleNumber} — ${r.ruleTitle} [${r.mode}]`);
@@ -226,7 +243,7 @@ export async function runContext(argv, seam = {}) {
226
243
 
227
244
  let config;
228
245
  try {
229
- config = resolveLibraryConfig(prefs);
246
+ config = resolveLibraryConfigWithRefs(prefs, { repoRoot });
230
247
  } catch (err) {
231
248
  if (err instanceof LibraryConfigError) {
232
249
  stderr.write(`[pa knowledge context] ${err.message}\n`);
@@ -236,7 +253,16 @@ export async function runContext(argv, seam = {}) {
236
253
  return 2;
237
254
  }
238
255
 
239
- const envelope = buildContextEnvelope(config);
256
+ // paName is the boot-resolved operator name. Sourced from the same
257
+ // merged preferences `buildSessionPlan` consumes — no new config, no
258
+ // second read. Absent prefs (readPreferences === null) keeps paName
259
+ // null and the envelope renders the `<paName>` placeholder so the
260
+ // diagnostic remains usable when the boot path itself is refusing.
261
+ const paName = (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0)
262
+ ? prefs.paName
263
+ : null;
264
+
265
+ const envelope = buildContextEnvelope(config, { paName });
240
266
  if (flags.format === 'json') {
241
267
  stdout.write(JSON.stringify(envelope, null, 2) + '\n');
242
268
  } else {
@@ -25,7 +25,7 @@ import { mergePreferences } from '../../../engineering-pa-core/src/preferences.m
25
25
  import {
26
26
  LibraryConfigError,
27
27
  getLibraryVariant,
28
- resolveLibraryConfig,
28
+ resolveLibraryConfigWithRefs,
29
29
  } from '../../../engineering-pa-core/src/library-config.mjs';
30
30
 
31
31
  /**
@@ -178,16 +178,17 @@ export async function buildSessionPlan(repoRoot, seam = {}) {
178
178
  }
179
179
 
180
180
  // Item 14: resolve the `library.*` block and select the CLAUDE.md
181
- // rule-variant token. `resolveLibraryConfig` throws
181
+ // rule-variant token. `resolveLibraryConfigWithRefs` throws
182
182
  // LibraryConfigError on mode=remote misconfiguration (spec §5.1 —
183
183
  // refuse to boot the library subsystem, no silent fallback to
184
- // SIMPLE). Item 15 will consume `libraryVariant` when it renders the
185
- // effective CLAUDE.md and when `pa knowledge context` prints the
186
- // resolved wording; the plan carries the token forward so item 15
187
- // slots content in without changing the wiring here.
184
+ // SIMPLE), and additionally resolves a `library.remoteKey` carrying
185
+ // the full-scope vault reference form (`<scope>/<KEY_NAME>`) into
186
+ // its underlying literal via the `secrets` CLI. Item 15 consumes
187
+ // `libraryVariant` when it renders the effective CLAUDE.md and when
188
+ // `pa knowledge context` prints the resolved wording.
188
189
  let libraryConfig;
189
190
  try {
190
- libraryConfig = resolveLibraryConfig(merged);
191
+ libraryConfig = resolveLibraryConfigWithRefs(merged, { repoRoot });
191
192
  } catch (err) {
192
193
  if (err instanceof LibraryConfigError) {
193
194
  throw new SessionError(
File without changes
File without changes
File without changes
File without changes
@@ -16,10 +16,31 @@
16
16
  // and will land in item 15; the wiring here is stable so item 15 slots
17
17
  // content into the existing shape without changing this file.
18
18
  //
19
- // The reader is DELIBERATELY pure — it consumes an already-merged
20
- // preferences object. Callers pair it with `readPreferences` /
19
+ // `resolveLibraryConfig` is DELIBERATELY pure — it consumes an already-
20
+ // merged preferences object. Callers pair it with `readPreferences` /
21
21
  // `readPreferencesSync` from ./preferences.mjs so the overlay-aware
22
22
  // path stays a single implementation.
23
+ //
24
+ // `resolveRemoteKeyRef` sits alongside it as an OPT-IN vault-fetching
25
+ // resolver: `library.remoteKey` values that look like the full-scope
26
+ // reference form (`<scope>/<KEY_NAME>` per Baz's w-2026-07-15-023
27
+ // ruling) resolve to their vaulted literal via the `secrets` CLI so
28
+ // canonical stravica-operator-barry never has to carry a literal
29
+ // subkey in a git-tracked file. Literal values pass through untouched.
30
+ // The wrapper `resolveLibraryConfigWithRefs(preferences, {repoRoot})`
31
+ // composes the two so callers get one call: read → ref-resolve →
32
+ // validate, refuse-boot on any failure.
33
+ //
34
+ // Written into the platform in w-2026-08-21-005 after the 2026-08-21T11:05Z
35
+ // library-sync-cycle wipe: the canonical file had no library block, a
36
+ // materialisation tick clobbered HQ's local plaintext block, and the
37
+ // options collapsed to (a) put a literal secret in canonical (rejected
38
+ // by policy — git history is forever), (b) hold sync-cycle indefinitely,
39
+ // or (c) ship this resolver. The estate picked (c).
40
+
41
+ import { readFileSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+ import { execFileSync } from 'node:child_process';
23
44
 
24
45
  /**
25
46
  * Default when `library.mode` is absent from preferences.
@@ -120,3 +141,134 @@ export function getLibraryVariant(config) {
120
141
  const mode = config?.mode ?? DEFAULT_LIBRARY_MODE;
121
142
  return mode === 'remote' ? 'remote' : 'simple';
122
143
  }
144
+
145
+ /**
146
+ * Full-scope vault reference form for `library.remoteKey`, per Baz's
147
+ * ruling on w-2026-07-15-023: `<scope>/<KEY_NAME>` (e.g.
148
+ * `hq-estate/REMOTE_LIBRARY_SUBKEY_DAVE`). Distinct from the literal-
149
+ * subkey shape (`<subkeyId>:<secret>`) so the shape validator downstream
150
+ * still refuses garbage. Any value that neither matches this ref shape
151
+ * nor the literal shape is passed through and refused by the shape
152
+ * validator.
153
+ */
154
+ export const REMOTE_KEY_REF_RE = /^([a-z0-9-]+)\/([A-Z][A-Z0-9_]*)$/;
155
+
156
+ /**
157
+ * Resolve a `library.remoteKey` value that carries the full-scope vault
158
+ * reference form (`<scope>/<KEY_NAME>`) into the underlying literal
159
+ * `<subkeyId>:<secret>` by calling the estate `secrets` CLI. Literal
160
+ * values (containing a colon) and anything else pass through untouched;
161
+ * the shape validator in `resolveLibraryConfig` is authoritative on shape.
162
+ *
163
+ * Canonical stravica-operator-barry cannot carry the literal subkey (a
164
+ * sync-cycle materialisation would round-trip a plaintext secret through
165
+ * a git-tracked file). The reference lives in canonical; the vault holds
166
+ * the value; this function is the bridge. See w-2026-08-21-005 for the
167
+ * incident that made this the durable pattern.
168
+ *
169
+ * @param {string} refOrLiteral the raw remoteKey value from preferences
170
+ * @param {object} opts
171
+ * @param {string} opts.repoRoot repo root; used to locate the secrets bin
172
+ * @param {(args:{scope:string,key:string,repoRoot:string})=>string} [opts.secretsGet]
173
+ * injectable resolver (tests)
174
+ * @returns {string} the resolved literal (never printed)
175
+ */
176
+ export function resolveRemoteKeyRef(refOrLiteral, { repoRoot, secretsGet } = {}) {
177
+ if (typeof refOrLiteral !== 'string') return refOrLiteral;
178
+ const m = REMOTE_KEY_REF_RE.exec(refOrLiteral);
179
+ if (!m) return refOrLiteral;
180
+ const [, scope, key] = m;
181
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
182
+ throw new LibraryConfigError(
183
+ `library.remoteKey vault reference "${refOrLiteral}" cannot be resolved: ` +
184
+ `no repoRoot supplied. Callers must pass repoRoot when preferences carry a scope/KEY ref.`,
185
+ 'remote-key-ref-unresolved',
186
+ );
187
+ }
188
+ const fetcher = secretsGet || defaultSecretsGet;
189
+ let resolved;
190
+ try {
191
+ resolved = fetcher({ scope, key, repoRoot });
192
+ } catch (err) {
193
+ throw new LibraryConfigError(
194
+ `library.remoteKey vault reference "${refOrLiteral}" failed to resolve ` +
195
+ `(scope=${scope} key=${key}): ${err && err.message ? err.message : err}. ` +
196
+ `Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
197
+ 'remote-key-ref-unresolved',
198
+ );
199
+ }
200
+ if (typeof resolved !== 'string' || resolved.length === 0) {
201
+ throw new LibraryConfigError(
202
+ `library.remoteKey vault reference "${refOrLiteral}" resolved to empty value ` +
203
+ `(scope=${scope} key=${key}). ` +
204
+ `Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
205
+ 'remote-key-ref-unresolved',
206
+ );
207
+ }
208
+ // secrets get can leave a trailing newline depending on subshell; strip
209
+ // trailing whitespace once at the boundary so the shape validator sees
210
+ // the clean payload.
211
+ return resolved.replace(/\s+$/, '');
212
+ }
213
+
214
+ /**
215
+ * Read + ref-resolve + validate in one call. Wraps `resolveLibraryConfig`
216
+ * with `resolveRemoteKeyRef` so callers get: (1) full-scope vault refs
217
+ * automatically resolved via the injected `secretsGet` (or the built-in
218
+ * `secrets` CLI); (2) the same shape/refuse-boot guarantees on the
219
+ * resolved literal. Preserves the pure `resolveLibraryConfig` for
220
+ * caller sites that never see refs.
221
+ *
222
+ * @param {object|null} preferences merged preferences (base + overlay)
223
+ * @param {object} opts
224
+ * @param {string} opts.repoRoot required when preferences may carry a
225
+ * scope/KEY ref; ignored otherwise
226
+ * @param {(args:{scope:string,key:string,repoRoot:string})=>string} [opts.secretsGet]
227
+ * @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}}
228
+ */
229
+ export function resolveLibraryConfigWithRefs(preferences, opts = {}) {
230
+ if (
231
+ preferences &&
232
+ typeof preferences === 'object' &&
233
+ !Array.isArray(preferences) &&
234
+ preferences.library &&
235
+ typeof preferences.library === 'object' &&
236
+ !Array.isArray(preferences.library) &&
237
+ preferences.library.mode === 'remote' &&
238
+ typeof preferences.library.remoteKey === 'string'
239
+ ) {
240
+ const original = preferences.library.remoteKey;
241
+ const resolved = resolveRemoteKeyRef(original, opts);
242
+ if (resolved !== original) {
243
+ preferences = {
244
+ ...preferences,
245
+ library: { ...preferences.library, remoteKey: resolved },
246
+ };
247
+ }
248
+ }
249
+ return resolveLibraryConfig(preferences);
250
+ }
251
+
252
+ /**
253
+ * Default vault fetcher: invokes the `secrets` bin from the installed
254
+ * `@stravica-ai/digital-operator-pa` package under `repoRoot`. Uses
255
+ * direct `node <bin>` invocation (not `pnpm exec`) to sidestep the
256
+ * pnpm-11 stdout footgun that has bitten sam-tasks and others. Value
257
+ * is never logged.
258
+ */
259
+ function defaultSecretsGet({ scope, key, repoRoot }) {
260
+ const secretsBin = join(
261
+ repoRoot,
262
+ 'node_modules',
263
+ '@stravica-ai',
264
+ 'digital-operator-pa',
265
+ 'bin',
266
+ 'secrets',
267
+ );
268
+ const out = execFileSync('node', [secretsBin, 'get', key, '--scope', scope], {
269
+ cwd: repoRoot,
270
+ encoding: 'utf8',
271
+ stdio: ['ignore', 'pipe', 'pipe'],
272
+ });
273
+ return out;
274
+ }
File without changes
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.25",
4
- "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
3
+ "version": "0.1.27",
4
+ "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
@@ -49,11 +49,10 @@
49
49
  "engines": {
50
50
  "node": ">=24"
51
51
  },
52
+ "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
52
53
  "scripts": {
53
54
  "build": "node build.mjs",
54
- "prepack": "node build.mjs",
55
55
  "test": "node --test --test-isolation=none test/*.test.mjs",
56
56
  "dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
57
- },
58
- "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
59
- }
57
+ }
58
+ }