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

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Operator-facing CLIs — pa, secrets, scheduler, sync, smoke, launch.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -49,6 +49,11 @@ Usage:
49
49
  and the excluded amount is shown.
50
50
  [--exclude-stubs] Skip null-domain nodes
51
51
  pa knowledge path <a> <b> Shortest path between two docs
52
+ pa knowledge context Print the effective CLAUDE.md rule
53
+ [--json] variants selected by \`library.mode\`
54
+ (remote-library spec §5.1, §5.2).
55
+ Refuses cleanly on structurally
56
+ invalid REMOTE config.
52
57
  pa knowledge eval [--fixture P] Score the retrieval eval set (hit@1/5,
53
58
  [--out D] orphan gauge, delta vs last run);
54
59
  [--full] persists to
@@ -93,6 +98,10 @@ export async function run(argv) {
93
98
  case 'domain': return await doDomain(rest);
94
99
  case 'orphans': return await doOrphans(rest);
95
100
  case 'path': return await doPath(rest);
101
+ case 'context': {
102
+ const { runContext } = await import('./context.mjs');
103
+ return await runContext(rest);
104
+ }
96
105
  case 'eval': {
97
106
  const { run: runEval } = await import('./eval.mjs');
98
107
  return await runEval(rest);
@@ -0,0 +1,248 @@
1
+ // context.mjs — `pa knowledge context` verb.
2
+ //
3
+ // Prints the CLAUDE.md rule variants that are LIVE for this session,
4
+ // selected by `library.mode` in the merged operator preferences.
5
+ //
6
+ // The two-mode switch is specified in the `@stravica-ai/remote-library`
7
+ // spec §5.1 and §5.2 (build items 14 + 15). Item 14 landed the wiring —
8
+ // `resolveLibraryConfig` + `getLibraryVariant` in `packages/core/src/
9
+ // library-config.mjs` — and the `plan.libraryVariant` seam on
10
+ // `buildSessionPlan`. Item 15 (this file, plus stravica-pa's CLAUDE.md
11
+ // variant markers) closes the loop: agents read the CLAUDE.md variants
12
+ // on boot and invoke `pa knowledge context` to know WHICH is live.
13
+ //
14
+ // Rule-variant TEXT is duplicated between this module and the operator
15
+ // repo's CLAUDE.md. Both sources cite spec §5.2 verbatim. The
16
+ // duplication is a feature: each stands alone; if one drifts, running
17
+ // `pa knowledge context` alongside CLAUDE.md flags the drift visually.
18
+ //
19
+ // Refuse-boot semantics (spec §5.1): a structurally invalid REMOTE
20
+ // config (mode="remote" with missing endpoint/key, or malformed key
21
+ // shape) surfaces the underlying `LibraryConfigError` on stderr and
22
+ // exits 1. `pa knowledge context` is deliberately usable as a
23
+ // diagnostic when the boot path itself is refusing to open — an
24
+ // operator can invoke it AFTER the `pa session` refusal to inspect
25
+ // exactly what the mode-resolver saw.
26
+ //
27
+ // Runtime unreachability of the remote endpoint is NOT surfaced here:
28
+ // this verb never performs a network call. That is deliberate — spec
29
+ // §5.1 rules runtime unreachability non-fatal at boot; probing here
30
+ // would drag the boot doctrine sideways.
31
+
32
+ import { LibraryConfigError, getLibraryVariant, resolveLibraryConfig } from '../../../engineering-pa-core/src/library-config.mjs';
33
+ import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
34
+
35
+ import { getRepoRoot } from '../pa/repo-root.mjs';
36
+
37
+ // --- rule-variant text (spec §5.2 verbatim) ------------------------------
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.
44
+ export const RULE_VARIANTS = [
45
+ {
46
+ bucket: 4,
47
+ ruleNumber: 2,
48
+ ruleTitle: 'Repo is single source of truth',
49
+ simple: 'Markdown for human-readable, JSON for machine-readable indexes.',
50
+ remote: 'The write path is `library put`. The pulled repo is a read-only projection of the event log. Never edit files inside library repos directly; write via `library put`.',
51
+ },
52
+ {
53
+ bucket: 4,
54
+ ruleNumber: 7,
55
+ ruleTitle: 'The Librarian sweeps staging during distillation',
56
+ simple: 'Each agent stages to its own staging dir; the Librarian routes observations to canonical destinations. Don\'t route observations directly.',
57
+ remote: '`library flush` sweeps local staging into the event log. The service applies the events to the git projection.',
58
+ },
59
+ ];
60
+
61
+ const USAGE = `pa knowledge context — print the effective CLAUDE.md rule variants for this session
62
+
63
+ Usage:
64
+ pa knowledge context Print the live rule variants (human-readable)
65
+ pa knowledge context --format json Emit the same envelope as JSON
66
+
67
+ The variant is selected by \`library.mode\` in the merged operator preferences
68
+ (base file + preferences.local.json overlay). Absent field defaults to "simple"
69
+ per spec §5.1.
70
+
71
+ A structurally invalid REMOTE config (mode="remote" with missing endpoint or
72
+ key, or a malformed key shape) is REFUSED here with the same message the
73
+ \`pa session\` boot path uses — this verb is safe to run as a diagnostic when
74
+ \`pa session\` itself is refusing to open.
75
+
76
+ Runtime endpoint unreachability is not probed here (spec §5.1: runtime
77
+ unreachability is non-fatal at boot; individual \`library\` verbs fail closed
78
+ and alert).
79
+
80
+ Exit codes (tooling standard §6):
81
+ 0 ok | 1 usage/config error | 2 unexpected internal failure
82
+ `;
83
+
84
+ /**
85
+ * Build the envelope for the resolved library variant. Pure: takes an
86
+ * already-resolved config, returns the printable / JSON-emittable shape.
87
+ *
88
+ * @param {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}} config
89
+ * @returns {{
90
+ * verb: 'context',
91
+ * libraryMode: 'simple'|'remote',
92
+ * libraryVariant: 'simple'|'remote',
93
+ * remoteEndpoint: string|null,
94
+ * ruleVariants: Array<{bucket:number, ruleNumber:number, ruleTitle:string, effective:string, mode:'simple'|'remote'}>,
95
+ * specReference: string,
96
+ * }}
97
+ */
98
+ export function buildContextEnvelope(config) {
99
+ const variant = getLibraryVariant(config);
100
+ return {
101
+ verb: 'context',
102
+ libraryMode: config.mode,
103
+ libraryVariant: variant,
104
+ remoteEndpoint: config.mode === 'remote' ? config.remoteEndpoint : null,
105
+ ruleVariants: RULE_VARIANTS.map((r) => ({
106
+ bucket: r.bucket,
107
+ ruleNumber: r.ruleNumber,
108
+ ruleTitle: r.ruleTitle,
109
+ effective: variant === 'remote' ? r.remote : r.simple,
110
+ mode: variant,
111
+ })),
112
+ specReference: '@stravica-ai/remote-library spec §5.1 (mode switch), §5.2 (rule variants)',
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Human-readable renderer. Emits one block per rule variant so an
118
+ * operator can eyeball which text is live.
119
+ * @param {ReturnType<typeof buildContextEnvelope>} envelope
120
+ * @returns {string}
121
+ */
122
+ export function formatContextTable(envelope) {
123
+ const lines = [];
124
+ lines.push(`library.mode = ${envelope.libraryMode} (variant token: ${envelope.libraryVariant})`);
125
+ if (envelope.libraryMode === 'remote') {
126
+ lines.push(`remoteEndpoint: ${envelope.remoteEndpoint}`);
127
+ }
128
+ lines.push('');
129
+ lines.push('Effective CLAUDE.md rule variants (agents/dave/CLAUDE.md, Bucket 4):');
130
+ lines.push('');
131
+ for (const r of envelope.ruleVariants) {
132
+ lines.push(` Rule ${r.bucket}.${r.ruleNumber} — ${r.ruleTitle} [${r.mode}]`);
133
+ lines.push(` ${r.effective}`);
134
+ lines.push('');
135
+ }
136
+ lines.push(`Spec: ${envelope.specReference}`);
137
+ lines.push('');
138
+ return lines.join('\n');
139
+ }
140
+
141
+ // --- verb entry ---------------------------------------------------------
142
+
143
+ function parseFlags(args) {
144
+ const flags = {};
145
+ const positional = [];
146
+ for (let i = 0; i < args.length; i += 1) {
147
+ const a = args[i];
148
+ if (a === '-h' || a === '--help') { flags.help = true; continue; }
149
+ if (a === '--json') { flags.format = 'json'; continue; }
150
+ if (a === '--format') {
151
+ const peek = args[i + 1];
152
+ if (peek == null || peek.startsWith('--')) {
153
+ const err = new Error('--format requires an argument (table|json)');
154
+ err.exitCode = 1;
155
+ throw err;
156
+ }
157
+ flags.format = peek;
158
+ i += 1;
159
+ continue;
160
+ }
161
+ if (a.startsWith('--format=')) {
162
+ flags.format = a.slice('--format='.length);
163
+ continue;
164
+ }
165
+ positional.push(a);
166
+ }
167
+ if (positional.length > 0) {
168
+ const err = new Error(`unexpected argument: ${positional[0]}`);
169
+ err.exitCode = 1;
170
+ throw err;
171
+ }
172
+ if (flags.format != null && flags.format !== 'table' && flags.format !== 'json') {
173
+ const err = new Error(`unknown --format: ${flags.format} (expected table | json)`);
174
+ err.exitCode = 1;
175
+ throw err;
176
+ }
177
+ return flags;
178
+ }
179
+
180
+ /**
181
+ * Verb entrypoint. Seams allow tests to inject a fake preferences reader,
182
+ * fake repo-root resolver, and capture streams.
183
+ *
184
+ * @param {string[]} argv args after `context`
185
+ * @param {object} [seam]
186
+ * @param {() => Promise<string>} [seam.getRepoRoot]
187
+ * @param {(root:string) => Promise<object|null>} [seam.readPreferences]
188
+ * @param {NodeJS.WritableStream} [seam.stdout]
189
+ * @param {NodeJS.WritableStream} [seam.stderr]
190
+ */
191
+ export async function runContext(argv, seam = {}) {
192
+ const stdout = seam.stdout ?? process.stdout;
193
+ const stderr = seam.stderr ?? process.stderr;
194
+ const getRoot = seam.getRepoRoot ?? getRepoRoot;
195
+ const readPrefs = seam.readPreferences ?? readPreferences;
196
+
197
+ let flags;
198
+ try {
199
+ flags = parseFlags(argv);
200
+ } catch (err) {
201
+ stderr.write(`[pa knowledge context] ${err.message}\n`);
202
+ stderr.write(USAGE);
203
+ return err.exitCode ?? 1;
204
+ }
205
+
206
+ if (flags.help) {
207
+ stdout.write(USAGE);
208
+ return 0;
209
+ }
210
+
211
+ let repoRoot;
212
+ try {
213
+ repoRoot = await getRoot();
214
+ } catch (err) {
215
+ stderr.write(`[pa knowledge context] ${err.message ?? err}\n`);
216
+ return err.exitCode ?? 2;
217
+ }
218
+
219
+ let prefs;
220
+ try {
221
+ prefs = await readPrefs(repoRoot);
222
+ } catch (err) {
223
+ stderr.write(`[pa knowledge context] failed to read preferences at ${repoRoot}: ${err.message ?? err}\n`);
224
+ return 2;
225
+ }
226
+
227
+ let config;
228
+ try {
229
+ config = resolveLibraryConfig(prefs);
230
+ } catch (err) {
231
+ if (err instanceof LibraryConfigError) {
232
+ stderr.write(`[pa knowledge context] ${err.message}\n`);
233
+ return err.exitCode ?? 1;
234
+ }
235
+ stderr.write(`[pa knowledge context] ${err.message ?? err}\n`);
236
+ return 2;
237
+ }
238
+
239
+ const envelope = buildContextEnvelope(config);
240
+ if (flags.format === 'json') {
241
+ stdout.write(JSON.stringify(envelope, null, 2) + '\n');
242
+ } else {
243
+ stdout.write(formatContextTable(envelope));
244
+ }
245
+ return 0;
246
+ }
247
+
248
+ export { USAGE };
@@ -24,7 +24,7 @@ Usage:
24
24
  pa project <verb> Project state writes (create | status | doc add/rm | list)
25
25
  pa work <verb> Work-item state writes (create | status | list)
26
26
  pa gsc <verb> Google Search Console automation (add-property | verify-via-cf-dns | submit-sitemap | sitemap-status | pull-analytics | list-properties)
27
- pa knowledge <verb> Query the operator's knowledge graph (related | search | domain | orphans | path)
27
+ pa knowledge <verb> Query the operator's knowledge graph (related | search | domain | orphans | path | context)
28
28
  pa research <verb> Deep-research session store (run | list | show | gates | prune | mark | paths)
29
29
  pa autonomy <verb> Per-agent per-task-type approval state (init | get | set | list)
30
30
  pa environment [verb] Session-boot environment cache (default | refresh | extended)
@@ -24,6 +24,24 @@ export async function writeJson(path, value) {
24
24
  await writeFile(path, body, 'utf8');
25
25
  }
26
26
 
27
+ /**
28
+ * Write a JSON file with O_CREAT|O_EXCL semantics. Refuses to overwrite
29
+ * an existing file (throws a Node error with `code === 'EEXIST'`).
30
+ *
31
+ * Same shape rules as `writeJson`: 2-space indent, trailing newline,
32
+ * parent directories created on demand. Callers that follow an
33
+ * allocate-then-write pattern use this to turn a same-author write race
34
+ * into an EEXIST they can retry at NNN+1, rather than the plain flag
35
+ * `'w'` behaviour where the second write silently clobbered the first.
36
+ * Same primitive `escalate.mjs` in `librarian-pr-intake` uses for the
37
+ * librarian's own allocator — the two writers now share one race model.
38
+ */
39
+ export async function writeJsonExclusive(path, value) {
40
+ await mkdir(dirname(path), { recursive: true });
41
+ const body = JSON.stringify(value, null, 2) + '\n';
42
+ await writeFile(path, body, { encoding: 'utf8', flag: 'wx' });
43
+ }
44
+
27
45
  /**
28
46
  * Ensure a directory exists. Returns the absolute path.
29
47
  */
@@ -22,6 +22,11 @@ import { spawn } from 'node:child_process';
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
 
24
24
  import { mergePreferences } from '../../../engineering-pa-core/src/preferences.mjs';
25
+ import {
26
+ LibraryConfigError,
27
+ getLibraryVariant,
28
+ resolveLibraryConfig,
29
+ } from '../../../engineering-pa-core/src/library-config.mjs';
25
30
 
26
31
  /**
27
32
  * Resolve the operator repo root with the priority documented in the brief:
@@ -172,6 +177,28 @@ export async function buildSessionPlan(repoRoot, seam = {}) {
172
177
  );
173
178
  }
174
179
 
180
+ // Item 14: resolve the `library.*` block and select the CLAUDE.md
181
+ // rule-variant token. `resolveLibraryConfig` throws
182
+ // LibraryConfigError on mode=remote misconfiguration (spec §5.1 —
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.
188
+ let libraryConfig;
189
+ try {
190
+ libraryConfig = resolveLibraryConfig(merged);
191
+ } catch (err) {
192
+ if (err instanceof LibraryConfigError) {
193
+ throw new SessionError(
194
+ `${err.message} (preferences at ${preferencesPath})`,
195
+ err.exitCode ?? 1,
196
+ );
197
+ }
198
+ throw err;
199
+ }
200
+ const libraryVariant = getLibraryVariant(libraryConfig);
201
+
175
202
  return {
176
203
  repoRoot,
177
204
  preferencesPath,
@@ -179,6 +206,8 @@ export async function buildSessionPlan(repoRoot, seam = {}) {
179
206
  agentDir,
180
207
  command: 'claude',
181
208
  args: ['--dangerously-skip-permissions'],
209
+ libraryConfig,
210
+ libraryVariant,
182
211
  };
183
212
  }
184
213
 
@@ -26,7 +26,22 @@ import {
26
26
  } from '../../../engineering-pa-core/src/workItemId/index.mjs';
27
27
 
28
28
  import { getRepoRoot } from './repo-root.mjs';
29
- import { readJson, writeJson, ensureDir, listJsonFiles, today } from './io.mjs';
29
+ import {
30
+ readJson,
31
+ writeJson,
32
+ writeJsonExclusive,
33
+ ensureDir,
34
+ listJsonFiles,
35
+ today,
36
+ } from './io.mjs';
37
+
38
+ // Upper bound on the O_CREAT|O_EXCL retry loop in `createWork`. Same
39
+ // value the librarian-pr-intake allocator uses (`escalate.mjs`); both
40
+ // close the same class of same-author race between allocator scan and
41
+ // exclusive write. 99 is comfortably above any realistic burst — 999 is
42
+ // the per-author-per-day NNN ceiling so a runaway loop still terminates
43
+ // well below it.
44
+ const MAX_ALLOCATE_RETRIES = 99;
30
45
 
31
46
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
32
47
 
@@ -63,29 +78,22 @@ function validatePriority(priority) {
63
78
  }
64
79
 
65
80
  /**
66
- * Highest NNN already allocated for `dateStr` in THIS author's per-day
67
- * sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author segment)
68
- * do not count toward any author's sequence — each writer starts from
69
- * 001 on the day writer-scoping takes effect and existing legacy items
70
- * simply occupy a separate, older space.
81
+ * Highest NNN currently visible on disk for `dateStr` in THIS author's
82
+ * per-day sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author
83
+ * segment) do not count toward any author's sequence — each writer
84
+ * starts from 001 on the day writer-scoping takes effect and existing
85
+ * legacy items simply occupy a separate, older space.
71
86
  *
72
- * If 999 is already taken, throws — callers should treat as an
73
- * operational failure and report. In practice this is unreachable
74
- * because NNN is per-author-per-day, not shared.
87
+ * The value is a starting point for `createWork`'s allocate-write-retry
88
+ * loop, not a final answer: between the scan and the exclusive write,
89
+ * another same-author `pa work create` may have landed a same-NNN file
90
+ * (same-author write race, structurally possible when two operator
91
+ * sessions run concurrently on shared work/). The O_CREAT|O_EXCL retry
92
+ * in `createWork` is what closes that residual race.
75
93
  */
76
- async function nextWorkNumber(repoRoot, dateStr, author) {
77
- const dir = join(repoRoot, 'work');
78
- const files = await listJsonFiles(dir);
79
- const max = maxNForAuthorOnDate(files, dateStr, author);
80
- const next = max + 1;
81
- if (next > 999) {
82
- const err = new Error(
83
- `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
84
- );
85
- err.exitCode = 2;
86
- throw err;
87
- }
88
- return next;
94
+ async function scanMaxNInWorkDir(workDir, dateStr, author) {
95
+ const files = await listJsonFiles(workDir);
96
+ return maxNForAuthorOnDate(files, dateStr, author);
89
97
  }
90
98
 
91
99
  /**
@@ -101,6 +109,15 @@ async function nextWorkNumber(repoRoot, dateStr, author) {
101
109
  * with an actionable message naming all three sources — a mint with no
102
110
  * author identity is the exact defect R1 exists to close, so we refuse
103
111
  * to fall back to any hard-coded default.
112
+ *
113
+ * Write is O_CREAT|O_EXCL via `writeJsonExclusive`. If two same-author
114
+ * `createWork` invocations race (same estate, same day, two operator
115
+ * sessions), the allocator scan may hand both callers the same base
116
+ * NNN; the first exclusive write lands and the second gets EEXIST,
117
+ * which the retry loop below resolves by trying NNN+1, NNN+2, ... up
118
+ * to `MAX_ALLOCATE_RETRIES`. Cross-writer collisions (different author
119
+ * tokens) are already structurally impossible after R1
120
+ * (w-2026-08-12-001) — this closes the residual same-author window.
104
121
  */
105
122
  export async function createWork(opts) {
106
123
  if (!opts.title) {
@@ -127,28 +144,48 @@ export async function createWork(opts) {
127
144
  await ensureDir(workDir);
128
145
 
129
146
  const dateStr = today();
130
- const nnn = await nextWorkNumber(repoRoot, dateStr, author);
131
- const id = buildWorkItemId({ date: dateStr, author, nnn });
132
- const path = join(workDir, `${id}.json`);
133
-
134
- const item = {
135
- id,
136
- projectId: opts.projectId || null,
137
- title: opts.title,
138
- category: opts.category || 'chore',
139
- status: 'draft',
140
- priority: opts.priority == null ? 2 : opts.priority,
141
- owner: opts.owner || 'system-architect',
142
- assignee: opts.assignee || opts.owner || 'system-architect',
143
- created: dateStr,
144
- updated: dateStr,
145
- completedAt: null,
146
- summary: opts.summary || '',
147
- blocked: null,
148
- links: [],
149
- };
150
- await writeJson(path, item);
151
- return { id, path };
147
+ const baseN = await scanMaxNInWorkDir(workDir, dateStr, author);
148
+
149
+ for (let offset = 1; offset <= MAX_ALLOCATE_RETRIES; offset += 1) {
150
+ const nnn = baseN + offset;
151
+ if (nnn > 999) {
152
+ const err = new Error(
153
+ `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
154
+ );
155
+ err.exitCode = 2;
156
+ throw err;
157
+ }
158
+ const id = buildWorkItemId({ date: dateStr, author, nnn });
159
+ const path = join(workDir, `${id}.json`);
160
+ const item = {
161
+ id,
162
+ projectId: opts.projectId || null,
163
+ title: opts.title,
164
+ category: opts.category || 'chore',
165
+ status: 'draft',
166
+ priority: opts.priority == null ? 2 : opts.priority,
167
+ owner: opts.owner || 'system-architect',
168
+ assignee: opts.assignee || opts.owner || 'system-architect',
169
+ created: dateStr,
170
+ updated: dateStr,
171
+ completedAt: null,
172
+ summary: opts.summary || '',
173
+ blocked: null,
174
+ links: [],
175
+ };
176
+ try {
177
+ await writeJsonExclusive(path, item);
178
+ return { id, path };
179
+ } catch (e) {
180
+ if (e && e.code === 'EEXIST') continue;
181
+ throw e;
182
+ }
183
+ }
184
+ const err = new Error(
185
+ `pa work create: gave up after ${MAX_ALLOCATE_RETRIES} EEXIST retries from base NNN ${baseN + 1} in ${dateStr} author=${author}`,
186
+ );
187
+ err.exitCode = 2;
188
+ throw err;
152
189
  }
153
190
 
154
191
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Type-agnostic platform spine — desk CLI, distill, memory-sync, schemas, briefing-contract framework, knowledge engine, scheduler primitives.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -0,0 +1,122 @@
1
+ // library-config.mjs — resolve + validate the `library.*` block in
2
+ // operator preferences.
3
+ //
4
+ // This module supplies the READ + REFUSE-BOOT half of the two-mode
5
+ // switch specified in `@stravica-ai/remote-library` spec §5.1:
6
+ //
7
+ // - `library.mode` is EXPLICIT (`"simple"` | `"remote"`); never auto-
8
+ // detected. Absent defaults to `"simple"`.
9
+ // - `mode="remote"` with missing/empty `remoteEndpoint` or `remoteKey`
10
+ // REFUSES to boot the library subsystem — no silent fallback to
11
+ // SIMPLE (falling back is a data-loss path).
12
+ //
13
+ // This lands as a token-selection module for build item 14 (see the
14
+ // spec's work-item breakdown §11). The CLAUDE.md variant CONTENT and
15
+ // the `pa knowledge context` verb both key off `getLibraryVariant`
16
+ // and will land in item 15; the wiring here is stable so item 15 slots
17
+ // content into the existing shape without changing this file.
18
+ //
19
+ // The reader is DELIBERATELY pure — it consumes an already-merged
20
+ // preferences object. Callers pair it with `readPreferences` /
21
+ // `readPreferencesSync` from ./preferences.mjs so the overlay-aware
22
+ // path stays a single implementation.
23
+
24
+ /**
25
+ * Default when `library.mode` is absent from preferences.
26
+ * @type {"simple"|"remote"}
27
+ */
28
+ export const DEFAULT_LIBRARY_MODE = 'simple';
29
+
30
+ /**
31
+ * Set of accepted `library.mode` values (spec §5.1).
32
+ */
33
+ const VALID_MODES = new Set(['simple', 'remote']);
34
+
35
+ /**
36
+ * Sentinel raised on invalid `library.*` configuration. Carries a
37
+ * `code` for machine-consumer disambiguation and an `exitCode` so
38
+ * CLI wrappers can surface it without a switch.
39
+ */
40
+ export class LibraryConfigError extends Error {
41
+ /**
42
+ * @param {string} message
43
+ * @param {string} code e.g. 'invalid-mode' | 'remote-config-missing' | 'remote-key-shape'
44
+ * @param {number} [exitCode] defaults to 1 (usage / configuration error)
45
+ */
46
+ constructor(message, code, exitCode = 1) {
47
+ super(message);
48
+ this.name = 'LibraryConfigError';
49
+ this.code = code;
50
+ this.exitCode = exitCode;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve the `library` block from a merged preferences object.
56
+ * Returns `{ mode }` for SIMPLE mode and
57
+ * `{ mode: 'remote', remoteEndpoint, remoteKey }` for REMOTE mode.
58
+ * Throws {@link LibraryConfigError} on invalid shape or on REMOTE
59
+ * mode with missing required fields.
60
+ *
61
+ * @param {object|null} preferences merged preferences (base + overlay)
62
+ * @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}}
63
+ */
64
+ export function resolveLibraryConfig(preferences) {
65
+ const raw =
66
+ preferences && typeof preferences === 'object' && !Array.isArray(preferences)
67
+ ? preferences.library
68
+ : null;
69
+ const block = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
70
+
71
+ const mode = block.mode ?? DEFAULT_LIBRARY_MODE;
72
+
73
+ if (!VALID_MODES.has(mode)) {
74
+ throw new LibraryConfigError(
75
+ `library.mode must be "simple" or "remote", got ${JSON.stringify(mode)}`,
76
+ 'invalid-mode',
77
+ );
78
+ }
79
+
80
+ if (mode === 'simple') {
81
+ return { mode };
82
+ }
83
+
84
+ // REMOTE mode — validate required fields (spec §5.1 refuse-boot).
85
+ const missing = [];
86
+ const endpoint = block.remoteEndpoint;
87
+ const key = block.remoteKey;
88
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') missing.push('library.remoteEndpoint');
89
+ if (typeof key !== 'string' || key.trim() === '') missing.push('library.remoteKey');
90
+ if (missing.length > 0) {
91
+ throw new LibraryConfigError(
92
+ `library.mode="remote" but required field(s) missing/empty: ${missing.join(', ')}. ` +
93
+ `Refusing to boot the library subsystem (spec §5.1: no silent fallback to SIMPLE).`,
94
+ 'remote-config-missing',
95
+ );
96
+ }
97
+
98
+ // Cheap shape check on remoteKey (`<subkeyId>:<secret>`). The server
99
+ // does the authoritative check; catching a paste error early spares a
100
+ // network round-trip and a generic 401.
101
+ if (!/^[^:\s]+:[^\s]+$/.test(key)) {
102
+ throw new LibraryConfigError(
103
+ 'library.remoteKey must be of shape "<subkeyId>:<secret>" (no whitespace, colon separator).',
104
+ 'remote-key-shape',
105
+ );
106
+ }
107
+
108
+ return { mode, remoteEndpoint: endpoint, remoteKey: key };
109
+ }
110
+
111
+ /**
112
+ * Return the CLAUDE.md rule-variant token for a resolved library config.
113
+ * Item 15 keys off this string when composing the effective CLAUDE.md
114
+ * and when `pa knowledge context` prints the resolved wording.
115
+ *
116
+ * @param {{mode: 'simple'|'remote'}} config the value returned by resolveLibraryConfig
117
+ * @returns {'simple'|'remote'}
118
+ */
119
+ export function getLibraryVariant(config) {
120
+ const mode = config?.mode ?? DEFAULT_LIBRARY_MODE;
121
+ return mode === 'remote' ? 'remote' : 'simple';
122
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-scaffold",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "pa-init scaffolder + onboarding flows (solo, lead, member).",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -184,6 +184,13 @@ export function buildPreferencesJson(config) {
184
184
  pa: { name: capitalise(config.paName), role: 'PA' },
185
185
  communicationStyle: config.communicationStyle,
186
186
  workspacePort: 4200,
187
+ // `@stravica-ai/remote-library` spec §5.1 — mode is EXPLICIT, never
188
+ // auto-detected. Fresh installs land on SIMPLE (single-operator, one
189
+ // local librarian sweep). Flipping to `"remote"` also requires
190
+ // `remoteEndpoint` and `remoteKey` (see spec §5.1); the `pa session`
191
+ // boot path refuses to launch when `mode="remote"` and either field
192
+ // is missing.
193
+ library: { mode: 'simple' },
187
194
  generatedAt: config.generatedAt,
188
195
  };
189
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.22",
3
+ "version": "0.1.25",
4
4
  "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",