@stravica-ai/digital-operator-pa 0.1.13 → 0.1.16

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.
@@ -0,0 +1,301 @@
1
+ // envelope.mjs — pure functions for autonomy-envelope frontmatter parsing +
2
+ // rendering. Zero IO. All filesystem side effects live in cli.mjs.
3
+ //
4
+ // The envelope is a markdown file with a YAML frontmatter block carrying the
5
+ // machine-readable state. We ship our own minimal YAML reader/writer here
6
+ // (limited to the shape the schema defines) to avoid pulling in a YAML dep
7
+ // for such a narrow surface. Any file whose frontmatter falls outside the
8
+ // documented shape is rejected as invalid.
9
+
10
+ export const VALID_STATES = ['fullyApproved', 'checkBeforeCommitting', 'needsFullSupervision'];
11
+
12
+ const STATE_LABELS = {
13
+ fullyApproved: 'fully approved',
14
+ checkBeforeCommitting: 'check before committing',
15
+ needsFullSupervision: 'needs full supervision',
16
+ };
17
+
18
+ /**
19
+ * Parse an autonomy-envelope markdown file. Returns
20
+ * { frontmatter, body }
21
+ * where `frontmatter` matches the schema shape and `body` is the markdown
22
+ * after the closing `---`. Throws on malformed input; the error carries an
23
+ * `exitCode` for the CLI to propagate.
24
+ */
25
+ export function parseEnvelope(source) {
26
+ if (typeof source !== 'string' || source.length === 0) {
27
+ throwUsage('envelope file is empty');
28
+ }
29
+ const lines = source.split(/\r?\n/);
30
+ if (lines[0].trim() !== '---') {
31
+ throwParse('envelope must start with YAML frontmatter delimited by ---');
32
+ }
33
+ let closeIdx = -1;
34
+ for (let i = 1; i < lines.length; i++) {
35
+ if (lines[i].trim() === '---') {
36
+ closeIdx = i;
37
+ break;
38
+ }
39
+ }
40
+ if (closeIdx < 0) {
41
+ throwParse('unterminated YAML frontmatter (missing closing ---)');
42
+ }
43
+ const fmLines = lines.slice(1, closeIdx);
44
+ const bodyLines = lines.slice(closeIdx + 1);
45
+ const frontmatter = parseFrontmatter(fmLines);
46
+ return { frontmatter, body: bodyLines.join('\n') };
47
+ }
48
+
49
+ /**
50
+ * Parse a limited-shape YAML block. Supports:
51
+ * key: value (string)
52
+ * key: (empty list start)
53
+ * - key: value (list item — object)
54
+ * key: value
55
+ * Numbers, booleans, and nested maps beyond one level are NOT supported —
56
+ * the schema doesn't need them. Values are always treated as strings.
57
+ */
58
+ function parseFrontmatter(lines) {
59
+ const out = {};
60
+ let i = 0;
61
+ while (i < lines.length) {
62
+ const raw = lines[i];
63
+ if (raw.trim() === '' || raw.trim().startsWith('#')) { i++; continue; }
64
+ const match = raw.match(/^([a-zA-Z][a-zA-Z0-9]*):(?:\s*(.*))?$/);
65
+ if (!match) {
66
+ throwParse(`could not parse frontmatter line ${i + 1}: ${raw}`);
67
+ }
68
+ const key = match[1];
69
+ const value = (match[2] ?? '').trim();
70
+ if (value === '') {
71
+ // Expect either a list (starts with "- " at next non-empty indented
72
+ // line) or an empty value.
73
+ const listItems = [];
74
+ let j = i + 1;
75
+ while (j < lines.length) {
76
+ const peek = lines[j];
77
+ if (peek.trim() === '' || peek.trim().startsWith('#')) { j++; continue; }
78
+ if (!/^\s+-\s/.test(peek)) break;
79
+ const item = readListItem(lines, j);
80
+ listItems.push(item.value);
81
+ j = item.next;
82
+ }
83
+ out[key] = listItems;
84
+ i = j;
85
+ } else {
86
+ out[key] = stripQuotes(value);
87
+ i++;
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function readListItem(lines, start) {
94
+ const first = lines[start];
95
+ const indentMatch = first.match(/^(\s+)-\s/);
96
+ if (!indentMatch) {
97
+ throwParse(`expected list item at line ${start + 1}: ${first}`);
98
+ }
99
+ const itemIndent = indentMatch[1] + ' '; // continuation is deeper than "- "
100
+ const item = {};
101
+ // The "- key: value" first line is the first key on the item.
102
+ const firstBody = first.slice(indentMatch[0].length);
103
+ const firstMatch = firstBody.match(/^([a-zA-Z][a-zA-Z0-9]*):\s*(.*)$/);
104
+ if (!firstMatch) {
105
+ throwParse(`could not parse list item at line ${start + 1}: ${first}`);
106
+ }
107
+ item[firstMatch[1]] = stripQuotes(firstMatch[2].trim());
108
+ let j = start + 1;
109
+ while (j < lines.length) {
110
+ const peek = lines[j];
111
+ if (peek.trim() === '' || peek.trim().startsWith('#')) { j++; continue; }
112
+ // Terminate when indent doesn't extend the item (either new list item
113
+ // at the same "- " indent, or dedent back to a top-level key).
114
+ if (peek.startsWith(itemIndent)) {
115
+ const bodyLine = peek.slice(itemIndent.length);
116
+ const m = bodyLine.match(/^([a-zA-Z][a-zA-Z0-9]*):\s*(.*)$/);
117
+ if (!m) {
118
+ throwParse(`could not parse list item continuation at line ${j + 1}: ${peek}`);
119
+ }
120
+ item[m[1]] = stripQuotes(m[2].trim());
121
+ j++;
122
+ } else {
123
+ break;
124
+ }
125
+ }
126
+ return { value: item, next: j };
127
+ }
128
+
129
+ function stripQuotes(value) {
130
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
131
+ return value.slice(1, -1);
132
+ }
133
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
134
+ return value.slice(1, -1);
135
+ }
136
+ return value;
137
+ }
138
+
139
+ /**
140
+ * Render an envelope object (frontmatter shape) back to markdown. Body is
141
+ * regenerated: a current-state table + a history log preserved from prior
142
+ * body sections. This keeps the file readable + deterministic.
143
+ */
144
+ export function renderEnvelope(frontmatter, history = []) {
145
+ const fm = renderFrontmatter(frontmatter);
146
+ const body = renderBody(frontmatter, history);
147
+ return `---\n${fm}---\n\n${body}\n`;
148
+ }
149
+
150
+ function renderFrontmatter(fm) {
151
+ const lines = [];
152
+ lines.push(`agent: ${quoteIfNeeded(fm.agent)}`);
153
+ lines.push(`owner: ${quoteIfNeeded(fm.owner)}`);
154
+ lines.push(`updated: ${quoteIfNeeded(fm.updated)}`);
155
+ lines.push('taskTypes:');
156
+ const items = fm.taskTypes ?? [];
157
+ for (const item of items) {
158
+ lines.push(` - id: ${quoteIfNeeded(item.id)}`);
159
+ lines.push(` label: ${quoteIfNeeded(item.label)}`);
160
+ lines.push(` state: ${quoteIfNeeded(item.state)}`);
161
+ if (item.stateSince) lines.push(` stateSince: ${quoteIfNeeded(item.stateSince)}`);
162
+ if (item.operatorNote) lines.push(` operatorNote: ${quoteIfNeeded(item.operatorNote)}`);
163
+ }
164
+ return lines.join('\n') + '\n';
165
+ }
166
+
167
+ function quoteIfNeeded(value) {
168
+ if (value == null) return '""';
169
+ const str = String(value);
170
+ // Quote values that could be misparsed as YAML control sequences.
171
+ if (/^[\s"'#\-\[\{,&*!|>%@`]/.test(str)) return JSON.stringify(str);
172
+ if (/[:#]\s/.test(str)) return JSON.stringify(str);
173
+ if (str.trim() !== str) return JSON.stringify(str);
174
+ return str;
175
+ }
176
+
177
+ function renderBody(fm, history) {
178
+ const lines = [];
179
+ lines.push(`# Autonomy envelope — ${fm.agent}`);
180
+ lines.push('');
181
+ lines.push('## Current state');
182
+ lines.push('');
183
+ if (!fm.taskTypes || fm.taskTypes.length === 0) {
184
+ lines.push('_No task types configured. Use `pa autonomy set <agent> <task-type> <state>` to add one._');
185
+ } else {
186
+ lines.push('| Task type | State | Since | Note |');
187
+ lines.push('|---|---|---|---|');
188
+ for (const item of fm.taskTypes) {
189
+ const since = (item.stateSince ?? '').slice(0, 10);
190
+ const note = (item.operatorNote ?? '—').replace(/\|/g, '\\|');
191
+ lines.push(`| ${escapeCell(item.label)} | ${STATE_LABELS[item.state] ?? item.state} | ${since || '—'} | ${note} |`);
192
+ }
193
+ }
194
+ lines.push('');
195
+ lines.push('## History');
196
+ lines.push('');
197
+ const entries = [...history].sort((a, b) => (b.at ?? '').localeCompare(a.at ?? ''));
198
+ if (entries.length === 0) {
199
+ lines.push('_No history yet._');
200
+ } else {
201
+ for (const entry of entries) {
202
+ lines.push(`### ${entry.at} — ${entry.taskTypeId} → ${entry.state}`);
203
+ if (entry.note) {
204
+ lines.push('');
205
+ lines.push(`Operator note: ${entry.note}`);
206
+ }
207
+ lines.push('');
208
+ }
209
+ }
210
+ return lines.join('\n');
211
+ }
212
+
213
+ function escapeCell(str) {
214
+ return String(str).replace(/\|/g, '\\|');
215
+ }
216
+
217
+ /**
218
+ * Extract history entries from a rendered body. Returns an array of
219
+ * { at, taskTypeId, state, note }
220
+ * entries. Best-effort — malformed sections are skipped, not raised.
221
+ */
222
+ export function extractHistory(body) {
223
+ const entries = [];
224
+ if (typeof body !== 'string') return entries;
225
+ const lines = body.split(/\r?\n/);
226
+ let i = 0;
227
+ while (i < lines.length) {
228
+ const line = lines[i];
229
+ const match = line.match(/^###\s+(\S+)\s+—\s+([a-z0-9][a-z0-9-]*)\s+→\s+([A-Za-z]+)\s*$/);
230
+ if (!match) { i++; continue; }
231
+ const at = match[1];
232
+ const taskTypeId = match[2];
233
+ const state = match[3];
234
+ let note = null;
235
+ let j = i + 1;
236
+ while (j < lines.length && lines[j].trim() === '') j++;
237
+ if (j < lines.length && lines[j].startsWith('Operator note:')) {
238
+ note = lines[j].slice('Operator note:'.length).trim();
239
+ }
240
+ entries.push({ at, taskTypeId, state, note });
241
+ i = j + 1;
242
+ }
243
+ return entries;
244
+ }
245
+
246
+ /**
247
+ * Validate a frontmatter object against the schema. Throws on invalid;
248
+ * returns nothing on valid.
249
+ */
250
+ export function validateFrontmatter(fm) {
251
+ if (!fm || typeof fm !== 'object') throwParse('frontmatter must be an object');
252
+ requireString(fm, 'agent');
253
+ requireString(fm, 'owner');
254
+ requireString(fm, 'updated');
255
+ if (!Array.isArray(fm.taskTypes)) throwParse('taskTypes must be an array');
256
+ for (const [i, item] of fm.taskTypes.entries()) {
257
+ if (!item || typeof item !== 'object') throwParse(`taskTypes[${i}] must be an object`);
258
+ requireString(item, `taskTypes[${i}].id`, item.id);
259
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(item.id)) throwParse(`taskTypes[${i}].id '${item.id}' must match ^[a-z0-9][a-z0-9-]*$`);
260
+ requireString(item, `taskTypes[${i}].label`, item.label);
261
+ requireString(item, `taskTypes[${i}].state`, item.state);
262
+ if (!VALID_STATES.includes(item.state)) {
263
+ throwParse(`taskTypes[${i}].state '${item.state}' must be one of ${VALID_STATES.join(', ')}`);
264
+ }
265
+ }
266
+ }
267
+
268
+ function requireString(obj, field, value) {
269
+ const v = value ?? obj?.[field];
270
+ if (typeof v !== 'string' || v.length === 0) {
271
+ throwParse(`${field} must be a non-empty string`);
272
+ }
273
+ }
274
+
275
+ function throwParse(msg) {
276
+ const err = new Error(msg);
277
+ err.exitCode = 2;
278
+ throw err;
279
+ }
280
+
281
+ function throwUsage(msg) {
282
+ const err = new Error(msg);
283
+ err.exitCode = 1;
284
+ throw err;
285
+ }
286
+
287
+ export function nowIso() {
288
+ return new Date().toISOString();
289
+ }
290
+
291
+ export function emptyEnvelope(agent, owner) {
292
+ return {
293
+ frontmatter: {
294
+ agent,
295
+ owner,
296
+ updated: nowIso(),
297
+ taskTypes: [],
298
+ },
299
+ history: [],
300
+ };
301
+ }
@@ -10,6 +10,9 @@
10
10
  "bin": {
11
11
  "pa-init": "bin/pa-init"
12
12
  },
13
+ "dependencies": {
14
+ "@stravica-ai/engineering-pa-core": "workspace:*"
15
+ },
13
16
  "private": true,
14
17
  "license": "MIT",
15
18
  "engines": {
@@ -11,6 +11,8 @@ import { readFile } from 'node:fs/promises';
11
11
  import { dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
+ import { emptyEnvelope, renderEnvelope } from '../../engineering-pa-core/src/autonomy/envelope.mjs';
15
+
14
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
17
 
16
18
  /** Path to the CLAUDE.md template (relative to this module). */
@@ -254,6 +256,25 @@ export function buildJournalMd(config) {
254
256
  ].join('\n');
255
257
  }
256
258
 
259
+ /**
260
+ * Build the agents/{paName}/autonomy-envelope.md placeholder. The
261
+ * envelope tracks per-task-type approval state; ships as a stub so the
262
+ * CLAUDE.md template's reference resolves on day one and the agent
263
+ * baseline audit (`pa agent audit`) passes on a fresh scaffold.
264
+ *
265
+ * Uses the autonomy package's own `emptyEnvelope` + `renderEnvelope`
266
+ * seeders so the stub always round-trips through `parseEnvelope` — no
267
+ * risk of `pa autonomy get/set/list` throwing on a fresh scaffold
268
+ * because the seeder shape drifted from the parser's contract.
269
+ * `generatedAt` (a fixed ISO from the runner) pins `updated:` so the
270
+ * scaffold plan is deterministic.
271
+ */
272
+ export function buildAutonomyEnvelopeMd(config) {
273
+ const { frontmatter } = emptyEnvelope(config.paName, config.operatorName);
274
+ frontmatter.updated = config.generatedAt;
275
+ return renderEnvelope(frontmatter, []);
276
+ }
277
+
257
278
  /** Build the agents/{paName}/next-session.md placeholder. */
258
279
  export function buildNextSessionMd(config) {
259
280
  return [
@@ -303,6 +324,7 @@ export function buildPlanFromTemplates(config, templates) {
303
324
  const profile = buildProfileMd(config);
304
325
  const journal = buildJournalMd(config);
305
326
  const nextSession = buildNextSessionMd(config);
327
+ const autonomyEnvelope = buildAutonomyEnvelopeMd(config);
306
328
 
307
329
  const dirs = [
308
330
  'operator',
@@ -339,6 +361,7 @@ export function buildPlanFromTemplates(config, templates) {
339
361
  plan.push({ kind: 'file', path: `agents/${paName}/CLAUDE.md`, contents: claudeMd });
340
362
  plan.push({ kind: 'file', path: `agents/${paName}/journal.md`, contents: journal });
341
363
  plan.push({ kind: 'file', path: `agents/${paName}/next-session.md`, contents: nextSession });
364
+ plan.push({ kind: 'file', path: `agents/${paName}/autonomy-envelope.md`, contents: autonomyEnvelope });
342
365
  plan.push({ kind: 'json', path: `agents/${paName}/spawn-log.json`, contents: spawnLog });
343
366
 
344
367
  // Empty-directory markers — git won't track empty dirs.
@@ -339,19 +339,24 @@
339
339
  navItems = document.querySelectorAll('.nav-item');
340
340
  })
341
341
  .then(function() { navigate(pathFromHash()); });
342
- // Load dynamic branding from API
342
+ // Load dynamic branding from API. Subtitle prefers the config-driven role
343
+ // title (preferences.json → pa.roleTitle, surfaced as data.paRoleTitle) so
344
+ // each estate can show its PA's role (e.g. "System Architect",
345
+ // "Forward-Deployed Lead Operator"); absent config falls back to the product
346
+ // name "Digital Operator", pixel-identical to the pre-config behaviour.
343
347
  fetch('/__api/overview').then(function(r) { return r.json(); }).then(function(data) {
344
348
  var brandName = document.getElementById('brand-name');
345
349
  var brandSub = document.getElementById('brand-subtitle');
350
+ var subtitle = data.paRoleTitle || 'Digital Operator';
346
351
  if (data.paName) {
347
352
  var name = data.paName;
348
353
  var capName = name.charAt(0).toUpperCase() + name.slice(1);
349
354
  brandName.textContent = capName;
350
- document.title = capName + ' — Digital Operator';
355
+ document.title = capName + ' — ' + subtitle;
351
356
  } else {
352
357
  brandName.textContent = 'Workspace';
353
358
  }
354
- brandSub.textContent = 'Digital Operator';
359
+ brandSub.textContent = subtitle;
355
360
  }).catch(function() {
356
361
  document.getElementById('brand-name').textContent = 'Workspace';
357
362
  document.getElementById('brand-subtitle').textContent = 'Digital Operator';
@@ -846,13 +846,22 @@ function buildOverview(projects, work, repoRoot) {
846
846
  const status = item.status || 'unknown';
847
847
  workByStatus[status] = (workByStatus[status] || 0) + 1;
848
848
  }
849
- // Read PA name from preferences if available
849
+ // Read PA name + optional display role title from preferences if available.
850
+ // paRoleTitle is a NEW, dedicated display field (`pa.roleTitle`) rather than
851
+ // an alias for the existing `pa.role` category token — `pa.role` defaults to
852
+ // 'PA' from the scaffold and would silently replace the 'Digital Operator'
853
+ // subtitle on every existing estate. paRoleTitle is null unless explicitly
854
+ // configured, so absent config keeps the pre-existing subtitle fallback.
850
855
  let paName = null;
856
+ let paRoleTitle = null;
851
857
  try {
852
858
  const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
853
859
  if (existsSync(prefsFile)) {
854
860
  const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
855
861
  paName = prefs.paName || null;
862
+ if (prefs.pa && typeof prefs.pa.roleTitle === 'string' && prefs.pa.roleTitle.length) {
863
+ paRoleTitle = prefs.pa.roleTitle;
864
+ }
856
865
  }
857
866
  } catch (e) { /* non-fatal */ }
858
867
  // Read operator name and role from profile
@@ -868,6 +877,7 @@ function buildOverview(projects, work, repoRoot) {
868
877
  } catch (e) { /* non-fatal */ }
869
878
  return {
870
879
  paName: paName,
880
+ paRoleTitle: paRoleTitle,
871
881
  operatorName: operatorName,
872
882
  operatorRole: operatorRole,
873
883
  projectCount: projectCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
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",
@@ -53,6 +53,6 @@
53
53
  "scripts": {
54
54
  "build": "node build.mjs",
55
55
  "test": "node --test --test-isolation=none test/*.test.mjs",
56
- "dry-publish": "node build.mjs && pnpm publish --dry-run --no-git-checks"
56
+ "dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
57
57
  }
58
58
  }