@stravica-ai/digital-operator-pa 0.1.14 → 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,418 @@
1
+ #!/usr/bin/env node
2
+ // migrate-doc-paths.mjs — one-shot data migration for the repo-relative
3
+ // doc-path contract (Baz ruling 2026-08-06, w-2026-08-06-007).
4
+ //
5
+ // Background:
6
+ // `pa project doc add <id> --path <p>` used to store <p> verbatim with
7
+ // no validation. Operators frequently supplied project-relative paths
8
+ // (`docs/foo.md`, `equity-analysis.md`), which then 404'd in the
9
+ // workspace viewer because the viewer's file API resolves paths against
10
+ // the repo root. Ruling: repo-relative wins (see doc-path.mjs header).
11
+ //
12
+ // What this does:
13
+ // For every entry in projects/*\/project.json:docs (both flat-array and
14
+ // grouped shape), apply the same normalisation rule as the runtime CLI:
15
+ // - resolves as-is under repoRoot -> untouched
16
+ // - resolves only under projects/<id>/ -> rewrite path to the joined form
17
+ // - resolves neither way -> REPORT only (never rewrite, never delete)
18
+ //
19
+ // Shape preservation:
20
+ // Entries may carry {label, path}, {label, path, status}, {group, label,
21
+ // path}, or {title, path, role} (bowmark-legal). This script only ever
22
+ // rewrites the `path` string on entries where the join rule triggers.
23
+ // Every other key stays exactly as it was, in its original insertion
24
+ // order. Docs shape (flat vs grouped) is preserved — the script does not
25
+ // promote flat arrays to grouped.
26
+ //
27
+ // Idempotence:
28
+ // After a successful --apply run, a second dry-run reports 0 joins.
29
+ //
30
+ // Usage:
31
+ // node <script> [--repo <path>] [--apply] [--json]
32
+ //
33
+ // --repo <path> Operator repo root. Defaults to $DAVE_REPO_ROOT /
34
+ // $PA_REPO_ROOT, else walks upward from cwd for .git.
35
+ // --apply Actually write the mutated project.json files. Default
36
+ // is dry-run (no writes).
37
+ // --json Emit the per-entry report as JSON on stdout instead of
38
+ // a human-readable log.
39
+ //
40
+ // Exit codes (tooling-standard §6):
41
+ // 0 ok (regardless of unresolvables — they are reported, not gated).
42
+ // 1 usage error / invalid --repo / project.json parse failure.
43
+ // 2 filesystem write failure.
44
+
45
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
46
+ import { existsSync } from 'node:fs';
47
+ import { dirname, join, resolve, relative, isAbsolute, sep, posix } from 'node:path';
48
+
49
+ // -- args --------------------------------------------------------------------
50
+
51
+ function parseArgs(argv) {
52
+ const out = { repo: null, apply: false, json: false, help: false };
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === '-h' || a === '--help') out.help = true;
56
+ else if (a === '--apply') out.apply = true;
57
+ else if (a === '--json') out.json = true;
58
+ else if (a === '--repo') {
59
+ out.repo = argv[i + 1];
60
+ i += 1;
61
+ } else if (a.startsWith('--repo=')) {
62
+ out.repo = a.slice('--repo='.length);
63
+ } else {
64
+ throw usageError(`unknown argument: ${a}`);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const USAGE = `migrate-doc-paths — normalise project.json doc entries to repo-relative paths.
71
+
72
+ Usage:
73
+ node migrate-doc-paths.mjs [--repo <path>] [--apply] [--json]
74
+
75
+ Options:
76
+ --repo <path> Operator repo root. Defaults to $DAVE_REPO_ROOT / $PA_REPO_ROOT,
77
+ else walks upward from the current directory for a .git marker.
78
+ --apply Actually write the changes. Default is dry-run.
79
+ --json Emit the per-entry report as JSON on stdout.
80
+ -h, --help Show this help.
81
+
82
+ Behaviour:
83
+ For every projects/*\/project.json:docs entry, apply the same normalisation
84
+ rule as \`pa project doc add\`:
85
+ - path resolves under repoRoot -> untouched
86
+ - path resolves only under projects/<id>/ -> rewrite to the joined form
87
+ - neither resolves -> REPORT only (no rewrite, no delete)
88
+
89
+ Entry shape is preserved exactly — only the \`path\` string is ever mutated.
90
+ Docs shape (flat array vs grouped object) is preserved.
91
+
92
+ Exit codes:
93
+ 0 ok | 1 usage/invalid input | 2 filesystem write failure.
94
+ `;
95
+
96
+ function usageError(msg) {
97
+ const err = new Error(msg);
98
+ err.exitCode = 1;
99
+ return err;
100
+ }
101
+
102
+ // -- repo root discovery -----------------------------------------------------
103
+
104
+ async function pathKind(p) {
105
+ try {
106
+ const s = await stat(p);
107
+ if (s.isDirectory()) return 'dir';
108
+ if (s.isFile()) return 'file';
109
+ return 'other';
110
+ } catch { return 'missing'; }
111
+ }
112
+
113
+ async function findRepoRoot(start) {
114
+ let d = start;
115
+ while (d && d !== dirname(d)) {
116
+ const k = await pathKind(join(d, '.git'));
117
+ if (k === 'dir' || k === 'file') return d;
118
+ d = dirname(d);
119
+ }
120
+ return null;
121
+ }
122
+
123
+ async function resolveRepoRoot(explicit) {
124
+ if (explicit) {
125
+ const abs = resolve(explicit);
126
+ if (!existsSync(abs)) throw usageError(`--repo path does not exist: ${abs}`);
127
+ return abs;
128
+ }
129
+ const env = process.env.DAVE_REPO_ROOT || process.env.PA_REPO_ROOT;
130
+ if (env) return resolve(env);
131
+ const found = await findRepoRoot(process.cwd());
132
+ if (!found) throw usageError(`could not find repo root from ${process.cwd()} (no .git ancestor)`);
133
+ return found;
134
+ }
135
+
136
+ // -- normalisation (mirrors packages/cli/src/pa/doc-path.mjs) ---------------
137
+
138
+ // A tiny in-file re-implementation of the same rule so this script has
139
+ // zero cross-package coupling and stays runnable as a plain node script
140
+ // after install. Keep in lockstep with doc-path.mjs — the migration
141
+ // script's tests pin the equivalence.
142
+ function classify({ repoRoot, projectId, docPath }) {
143
+ if (!docPath || typeof docPath !== 'string') {
144
+ return { action: 'report-unresolvable', reason: 'empty or non-string path' };
145
+ }
146
+ if (isAbsolute(docPath)) {
147
+ return { action: 'report-unresolvable', reason: `absolute path: ${docPath}` };
148
+ }
149
+ const asIsAbs = resolve(repoRoot, docPath);
150
+ if (insideRepo(repoRoot, asIsAbs) && existsSync(asIsAbs)) {
151
+ return { action: 'untouched', absPath: asIsAbs, storedPath: toPosix(docPath) };
152
+ }
153
+ const joinedAbs = resolve(repoRoot, 'projects', projectId, docPath);
154
+ if (insideRepo(repoRoot, joinedAbs) && existsSync(joinedAbs)) {
155
+ return {
156
+ action: 'joined',
157
+ absPath: joinedAbs,
158
+ storedPath: toPosix(relative(repoRoot, joinedAbs)),
159
+ };
160
+ }
161
+ return {
162
+ action: 'report-unresolvable',
163
+ reason: 'not found as-is under repoRoot nor when joined under projects/<id>/',
164
+ triedAsIs: asIsAbs,
165
+ triedJoined: joinedAbs,
166
+ };
167
+ }
168
+
169
+ function insideRepo(repoRoot, absPath) {
170
+ const rel = relative(repoRoot, absPath);
171
+ if (rel.startsWith('..')) return false;
172
+ return resolve(repoRoot, rel) === absPath;
173
+ }
174
+
175
+ function toPosix(p) {
176
+ if (sep === posix.sep) return p;
177
+ return p.split(sep).join(posix.sep);
178
+ }
179
+
180
+ // -- walk + rewrite ----------------------------------------------------------
181
+
182
+ async function listProjectDirs(projectsRoot) {
183
+ let entries;
184
+ try { entries = await readdir(projectsRoot, { withFileTypes: true }); }
185
+ catch (err) {
186
+ if (err.code === 'ENOENT') return [];
187
+ throw err;
188
+ }
189
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
190
+ }
191
+
192
+ // Read + parse a project.json. Returns { manifest, raw } or null when the
193
+ // file is missing / unreadable / unparseable. Parse failure is reported
194
+ // upstream so the operator sees it.
195
+ async function readManifest(manifestPath) {
196
+ if (!existsSync(manifestPath)) return null;
197
+ const raw = await readFile(manifestPath, 'utf8');
198
+ try {
199
+ return { manifest: JSON.parse(raw), raw };
200
+ } catch (err) {
201
+ throw usageError(`failed to parse ${manifestPath}: ${err.message}`);
202
+ }
203
+ }
204
+
205
+ // Walk one project's docs and yield per-entry rows. Docs may be a flat
206
+ // array (legacy) or a grouped object. Entries may carry any set of keys —
207
+ // we only reason about `.path`.
208
+ function* iterDocEntries(docs) {
209
+ if (docs == null) return;
210
+ if (Array.isArray(docs)) {
211
+ for (let i = 0; i < docs.length; i++) {
212
+ yield { group: null, index: i, entry: docs[i] };
213
+ }
214
+ return;
215
+ }
216
+ if (typeof docs === 'object') {
217
+ for (const [group, arr] of Object.entries(docs)) {
218
+ if (!Array.isArray(arr)) continue;
219
+ for (let i = 0; i < arr.length; i++) {
220
+ yield { group, index: i, entry: arr[i] };
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ // Reassign only `.path` on the entry object. All other keys stay in place
227
+ // and in their original insertion order because we mutate rather than
228
+ // spread — `{...entry, path: X}` would re-order path to the end.
229
+ function rewritePathInPlace(entry, newPath) {
230
+ entry.path = newPath;
231
+ }
232
+
233
+ // Serialise back to the operator-repo JSON convention (2-space, trailing
234
+ // newline). Matches io.mjs's writeJson.
235
+ function stringifyManifest(manifest) {
236
+ return JSON.stringify(manifest, null, 2) + '\n';
237
+ }
238
+
239
+ async function processProject({ repoRoot, projectId, apply }) {
240
+ const manifestPath = join(repoRoot, 'projects', projectId, 'project.json');
241
+ const loaded = await readManifest(manifestPath);
242
+ if (!loaded) return { projectId, missingManifest: true, rows: [] };
243
+ const { manifest, raw } = loaded;
244
+ const rows = [];
245
+ let dirty = false;
246
+
247
+ for (const { group, index, entry } of iterDocEntries(manifest.docs)) {
248
+ if (!entry || typeof entry !== 'object') {
249
+ rows.push({ projectId, group, index, entry, action: 'skipped-not-an-object' });
250
+ continue;
251
+ }
252
+ const docPath = entry.path;
253
+ const cls = classify({ repoRoot, projectId, docPath });
254
+ const row = { projectId, group, index, path: docPath, entryShape: Object.keys(entry).sort() };
255
+ if (cls.action === 'untouched') {
256
+ row.action = 'untouched';
257
+ } else if (cls.action === 'joined') {
258
+ row.action = 'joined';
259
+ row.newPath = cls.storedPath;
260
+ if (apply) {
261
+ rewritePathInPlace(entry, cls.storedPath);
262
+ dirty = true;
263
+ }
264
+ } else {
265
+ row.action = 'report-unresolvable';
266
+ row.reason = cls.reason;
267
+ if (cls.triedAsIs) row.triedAsIs = cls.triedAsIs;
268
+ if (cls.triedJoined) row.triedJoined = cls.triedJoined;
269
+ }
270
+ rows.push(row);
271
+ }
272
+
273
+ let wrote = false;
274
+ if (apply && dirty) {
275
+ // Preserve the file exactly if nothing changed; only touch on real change.
276
+ const next = stringifyManifest(manifest);
277
+ if (next !== raw) {
278
+ try {
279
+ await writeFile(manifestPath, next, 'utf8');
280
+ wrote = true;
281
+ } catch (err) {
282
+ const e = new Error(`failed to write ${manifestPath}: ${err.message}`);
283
+ e.exitCode = 2;
284
+ throw e;
285
+ }
286
+ }
287
+ }
288
+ return { projectId, manifestPath, rows, wrote, dirty };
289
+ }
290
+
291
+ // -- reporting ---------------------------------------------------------------
292
+
293
+ function summarise(results) {
294
+ let untouched = 0, joined = 0, unresolvable = 0, skipped = 0;
295
+ let projectsWritten = 0;
296
+ const unresolvables = [];
297
+ const joins = [];
298
+ for (const r of results) {
299
+ if (r.wrote) projectsWritten += 1;
300
+ for (const row of r.rows) {
301
+ if (row.action === 'untouched') untouched += 1;
302
+ else if (row.action === 'joined') { joined += 1; joins.push(row); }
303
+ else if (row.action === 'report-unresolvable') { unresolvable += 1; unresolvables.push(row); }
304
+ else skipped += 1;
305
+ }
306
+ }
307
+ return { untouched, joined, unresolvable, skipped, projectsWritten, unresolvables, joins };
308
+ }
309
+
310
+ function renderHumanLog(results, summary, { apply, repoRoot }) {
311
+ const out = [];
312
+ out.push(`migrate-doc-paths ${apply ? '(APPLY)' : '(dry-run)'} repo: ${repoRoot}`);
313
+ out.push('');
314
+ for (const r of results) {
315
+ if (r.missingManifest) continue;
316
+ if (r.rows.length === 0) continue;
317
+ out.push(`# ${r.projectId}`);
318
+ for (const row of r.rows) {
319
+ if (row.action === 'untouched') {
320
+ out.push(` ok ${formatLocation(row)} ${row.path}`);
321
+ } else if (row.action === 'joined') {
322
+ out.push(` join ${formatLocation(row)} ${row.path} -> ${row.newPath}`);
323
+ } else if (row.action === 'report-unresolvable') {
324
+ out.push(` UNRESOLV ${formatLocation(row)} ${row.path}`);
325
+ out.push(` reason: ${row.reason}`);
326
+ if (row.triedAsIs) out.push(` tried as-is: ${row.triedAsIs}`);
327
+ if (row.triedJoined) out.push(` tried joined: ${row.triedJoined}`);
328
+ } else {
329
+ out.push(` skip ${formatLocation(row)} ${row.action}`);
330
+ }
331
+ }
332
+ if (r.wrote) out.push(` -> wrote ${r.manifestPath}`);
333
+ else if (r.dirty && !apply) out.push(` (dry-run: would write ${r.manifestPath})`);
334
+ out.push('');
335
+ }
336
+ out.push('---');
337
+ out.push(`untouched: ${summary.untouched}`);
338
+ out.push(`joined: ${summary.joined}${apply ? '' : ' (dry-run — no files written)'}`);
339
+ out.push(`unresolvable: ${summary.unresolvable} (report-only; no entries deleted)`);
340
+ if (summary.skipped) out.push(`skipped: ${summary.skipped}`);
341
+ out.push(`projects ${apply ? 'written' : 'that would be written'}: ${summary.projectsWritten}`);
342
+ return out.join('\n') + '\n';
343
+ }
344
+
345
+ function formatLocation(row) {
346
+ const group = row.group == null ? 'flat' : `[${row.group}]`;
347
+ return `${group}#${row.index}`.padEnd(14, ' ');
348
+ }
349
+
350
+ // -- main --------------------------------------------------------------------
351
+
352
+ export async function main(argv) {
353
+ let args;
354
+ try { args = parseArgs(argv); }
355
+ catch (err) {
356
+ process.stderr.write(err.message + '\n');
357
+ process.stderr.write(USAGE);
358
+ return err.exitCode || 1;
359
+ }
360
+ if (args.help) {
361
+ process.stdout.write(USAGE);
362
+ return 0;
363
+ }
364
+ let repoRoot;
365
+ try { repoRoot = await resolveRepoRoot(args.repo); }
366
+ catch (err) {
367
+ process.stderr.write(err.message + '\n');
368
+ return err.exitCode || 1;
369
+ }
370
+ const projectsRoot = join(repoRoot, 'projects');
371
+ const projectIds = await listProjectDirs(projectsRoot);
372
+ const results = [];
373
+ for (const projectId of projectIds) {
374
+ try {
375
+ const r = await processProject({ repoRoot, projectId, apply: args.apply });
376
+ results.push(r);
377
+ } catch (err) {
378
+ process.stderr.write(err.message + '\n');
379
+ return err.exitCode || 2;
380
+ }
381
+ }
382
+ const summary = summarise(results);
383
+ if (args.json) {
384
+ process.stdout.write(JSON.stringify({
385
+ repoRoot,
386
+ apply: args.apply,
387
+ summary: {
388
+ untouched: summary.untouched,
389
+ joined: summary.joined,
390
+ unresolvable: summary.unresolvable,
391
+ skipped: summary.skipped,
392
+ projectsWritten: summary.projectsWritten,
393
+ },
394
+ results,
395
+ }, null, 2) + '\n');
396
+ } else {
397
+ process.stdout.write(renderHumanLog(results, summary, { apply: args.apply, repoRoot }));
398
+ }
399
+ return 0;
400
+ }
401
+
402
+ // Export the helpers so the test suite can pin per-piece behaviour without
403
+ // spawning a subprocess.
404
+ export { classify, iterDocEntries, processProject, summarise };
405
+
406
+ // Direct-run entrypoint.
407
+ const isDirectRun =
408
+ import.meta.url === `file://${process.argv[1]}` ||
409
+ process.argv[1]?.endsWith('/migrate-doc-paths.mjs');
410
+
411
+ if (isDirectRun) {
412
+ main(process.argv.slice(2))
413
+ .then((code) => process.exit(code))
414
+ .catch((err) => {
415
+ process.stderr.write((err.stack || err.message || String(err)) + '\n');
416
+ process.exit(err.exitCode || 2);
417
+ });
418
+ }
@@ -0,0 +1,125 @@
1
+ // doc-path.mjs — normalisation for project.json doc `path` entries.
2
+ //
3
+ // The contract (Baz ruling 2026-08-06 under the PA-stack domain):
4
+ // Doc paths stored in project.json:docs[*].path are REPO-relative.
5
+ //
6
+ // This matches the workspace viewer's file API (packages/workspace/src/api.mjs)
7
+ // which resolves the `?file=` parameter against the operator repo root and
8
+ // applies a traversal guard. Storing repo-relative paths keeps the CLI, the
9
+ // stored data, and the viewer link on the same contract. Repo-relative also
10
+ // expresses legitimate out-of-project references (operator/notes/*, cross-
11
+ // project links) that project-relative cannot.
12
+ //
13
+ // The historical bug: `pa project doc add <id> --path <p>` stored `<p>`
14
+ // verbatim. Operators frequently invoked it with a project-relative path
15
+ // (e.g. `docs/foo.md`) because that reads naturally when their cwd is the
16
+ // project dir. The resulting entry then 404'd in the workspace viewer.
17
+ //
18
+ // This module's normalisation resolves the three input cases at write time:
19
+ //
20
+ // 1. `<p>` resolves against repoRoot and exists -> untouched
21
+ // 2. `<p>` resolves against `projects/<id>/` and exists -> joined (with notice)
22
+ // 3. neither resolves -> refused
23
+ //
24
+ // Case 1 covers everything already correct (~96% of the estate) plus any
25
+ // deliberate out-of-project reference. Case 2 is the failing case — the
26
+ // input is auto-corrected to `projects/<id>/<p>`. Case 3 refuses to create
27
+ // a dangling entry, matching the file API's own 404 contract.
28
+ //
29
+ // The normaliser is a pure function of (repoRoot, projectId, docPath) plus
30
+ // filesystem existence — no state, no I/O beyond stat.
31
+
32
+ import { existsSync } from 'node:fs';
33
+ import { isAbsolute, resolve, relative, sep, posix } from 'node:path';
34
+
35
+ /**
36
+ * Normalise a doc path against a project's location under the operator repo.
37
+ *
38
+ * @param {object} opts
39
+ * @param {string} opts.repoRoot Absolute path to the operator repo root.
40
+ * @param {string} opts.projectId Project id (matches the projects/<id>/ dir).
41
+ * @param {string} opts.docPath The `--path` value as supplied by the caller.
42
+ *
43
+ * @returns {{ path: string, action: 'as-is'|'joined',
44
+ * absPath: string, joinedFrom?: string }}
45
+ * `path` is the value that should be stored (repo-relative, forward
46
+ * slashes on any platform). `action` reports how it was resolved.
47
+ * `absPath` is the resolved absolute path that DOES exist on disk.
48
+ * When `action === 'joined'`, `joinedFrom` carries the original
49
+ * input so callers can print a "normalised: X -> Y" notice.
50
+ *
51
+ * @throws {Error & {exitCode:1}} when the input resolves neither way, when
52
+ * the input is absolute, or when the input escapes the repo root.
53
+ * The message names the two candidate absolute paths that were
54
+ * tried so the operator can diagnose immediately.
55
+ */
56
+ export function normaliseDocPath({ repoRoot, projectId, docPath }) {
57
+ if (!repoRoot) throw makeError('normaliseDocPath: repoRoot is required');
58
+ if (!projectId) throw makeError('normaliseDocPath: projectId is required');
59
+ if (!docPath || typeof docPath !== 'string') {
60
+ throw makeError('normaliseDocPath: docPath must be a non-empty string');
61
+ }
62
+
63
+ // Absolute paths are refused outright — the contract is repo-relative,
64
+ // and an absolute path (a) breaks portability across machines and (b)
65
+ // will fail the workspace viewer's traversal guard even if we tried to
66
+ // rewrite it. Operator diagnosis is more useful when we say why.
67
+ if (isAbsolute(docPath)) {
68
+ throw makeError(
69
+ `path must be repo-relative, got absolute path: ${docPath}\n` +
70
+ ` the doc-path contract is repo-relative (see packages/workspace/src/api.mjs)`,
71
+ );
72
+ }
73
+
74
+ const asIsAbs = resolve(repoRoot, docPath);
75
+ const asIsInside = insideRepo(repoRoot, asIsAbs);
76
+ if (asIsInside && existsSync(asIsAbs)) {
77
+ return {
78
+ path: toPosix(docPath),
79
+ action: 'as-is',
80
+ absPath: asIsAbs,
81
+ };
82
+ }
83
+
84
+ const joinedAbs = resolve(repoRoot, 'projects', projectId, docPath);
85
+ const joinedInside = insideRepo(repoRoot, joinedAbs);
86
+ if (joinedInside && existsSync(joinedAbs)) {
87
+ const joinedRel = toPosix(relative(repoRoot, joinedAbs));
88
+ return {
89
+ path: joinedRel,
90
+ action: 'joined',
91
+ absPath: joinedAbs,
92
+ joinedFrom: docPath,
93
+ };
94
+ }
95
+
96
+ // Neither resolves — refuse with both candidates so the operator can see
97
+ // which one they meant to hit.
98
+ const asIsCandidate = asIsInside ? asIsAbs : `${asIsAbs} (outside repo root)`;
99
+ const joinedCandidate = joinedInside ? joinedAbs : `${joinedAbs} (outside repo root)`;
100
+ throw makeError(
101
+ `doc path does not resolve: ${docPath}\n` +
102
+ ` tried as-is: ${asIsCandidate}\n` +
103
+ ` tried joined: ${joinedCandidate}\n` +
104
+ ` the doc-path contract is repo-relative — create the file first, or use --path <existing repo-relative path>`,
105
+ );
106
+ }
107
+
108
+ function insideRepo(repoRoot, absPath) {
109
+ const rel = relative(repoRoot, absPath);
110
+ if (rel.startsWith('..')) return false;
111
+ // Cross-platform: `path.resolve` uses OS-native separators. The api.mjs
112
+ // traversal guard does the same round-trip we do here.
113
+ return resolve(repoRoot, rel) === absPath;
114
+ }
115
+
116
+ function toPosix(p) {
117
+ if (sep === posix.sep) return p;
118
+ return p.split(sep).join(posix.sep);
119
+ }
120
+
121
+ function makeError(message) {
122
+ const err = new Error(message);
123
+ err.exitCode = 1;
124
+ return err;
125
+ }
@@ -174,6 +174,7 @@ async function doDocAdd(args) {
174
174
  path: typeof flags.path === 'string' ? flags.path : null,
175
175
  status: typeof flags.status === 'string' ? flags.status : null,
176
176
  });
177
+ printNormalisationNotice(res.normalisation);
177
178
  if (res.changed) {
178
179
  let msg = `added "${res.label}" -> workstream [${res.workstream}] in ${id}`;
179
180
  if (res.status) msg += ` (status: ${res.status})`;
@@ -191,6 +192,7 @@ async function doDocAdd(args) {
191
192
  group: typeof flags.group === 'string' ? flags.group : null,
192
193
  status: typeof flags.status === 'string' ? flags.status : null,
193
194
  });
195
+ printNormalisationNotice(res.normalisation);
194
196
  if (res.changed) {
195
197
  let msg = `added "${res.label}" -> [${res.group}] in ${id}`;
196
198
  if (res.status) msg += ` (status: ${res.status})`;
@@ -201,6 +203,16 @@ async function doDocAdd(args) {
201
203
  return 0;
202
204
  }
203
205
 
206
+ // Emit a one-line notice when the path was auto-joined under projects/<id>/.
207
+ // Silent on `action === 'as-is'` — the vast majority of calls are already
208
+ // correctly repo-relative and shouldn't be spammed.
209
+ function printNormalisationNotice(normalisation) {
210
+ if (!normalisation || normalisation.action !== 'joined') return;
211
+ process.stdout.write(
212
+ `notice: path normalised to repo-relative — ${normalisation.inputPath} -> ${normalisation.storedPath}\n`,
213
+ );
214
+ }
215
+
204
216
  async function doDocRm(args) {
205
217
  const { positional, flags } = parseFlags(args);
206
218
  if (positional.length === 0) {
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
  import { getRepoRoot } from './repo-root.mjs';
20
20
  import { readJson, writeJson, ensureDir, listSubdirs, today } from './io.mjs';
21
21
  import { addDoc, removeDocByPath, setDocStatus, DEFAULT_GROUP, VALID_DOC_STATUSES } from './doc-groups.mjs';
22
+ import { normaliseDocPath } from './doc-path.mjs';
22
23
  import { filterProjectsByTeam, readTeamScopeConfig } from '../../../engineering-pa-core/src/desk/team-scope.mjs';
23
24
 
24
25
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
@@ -190,6 +191,11 @@ export async function setProjectStatus(id, status) {
190
191
  /**
191
192
  * Append a doc entry to a group. Migrates a legacy flat-array shape to
192
193
  * grouped in the same write. Idempotent on {label,path} match.
194
+ *
195
+ * The `--path` value is normalised against the repo-relative contract via
196
+ * `normaliseDocPath` before storage — an input that only resolves under
197
+ * `projects/<id>/` is auto-joined; an input that resolves neither way is
198
+ * refused. See `./doc-path.mjs` for the full contract.
193
199
  */
194
200
  export async function addProjectDoc(id, { label, path: docPath, group, status }) {
195
201
  validateId(id);
@@ -217,9 +223,11 @@ export async function addProjectDoc(id, { label, path: docPath, group, status })
217
223
  err.exitCode = 1;
218
224
  throw err;
219
225
  }
226
+ const normalised = normaliseDocPath({ repoRoot, projectId: id, docPath });
227
+ const storedPath = normalised.path;
220
228
  const manifest = await readJson(manifestPath);
221
229
  const before = JSON.stringify(manifest.docs);
222
- const entry = { label, path: docPath };
230
+ const entry = { label, path: storedPath };
223
231
  if (status) entry.status = status;
224
232
  manifest.docs = addDoc(manifest.docs, group, entry);
225
233
  const after = JSON.stringify(manifest.docs);
@@ -231,10 +239,15 @@ export async function addProjectDoc(id, { label, path: docPath, group, status })
231
239
  id,
232
240
  group: group || DEFAULT_GROUP,
233
241
  label,
234
- path: docPath,
242
+ path: storedPath,
235
243
  status: status || null,
236
244
  changed: before !== after,
237
245
  manifestPath,
246
+ normalisation: {
247
+ action: normalised.action,
248
+ inputPath: docPath,
249
+ storedPath,
250
+ },
238
251
  };
239
252
  }
240
253
 
@@ -21,6 +21,7 @@ import { join } from 'node:path';
21
21
  import { getRepoRoot } from './repo-root.mjs';
22
22
  import { readJson, writeJson, today } from './io.mjs';
23
23
  import { VALID_DOC_STATUSES } from './doc-groups.mjs';
24
+ import { normaliseDocPath } from './doc-path.mjs';
24
25
 
25
26
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
26
27
 
@@ -171,6 +172,11 @@ export async function removeWorkstream(projectId, { id: wsId }) {
171
172
  /**
172
173
  * Add a doc entry to a specific workstream's docs array.
173
174
  * Idempotent on {label, path} match within the workstream.
175
+ *
176
+ * The `--path` value is normalised against the repo-relative contract via
177
+ * `normaliseDocPath` before storage, mirroring `addProjectDoc` — inputs
178
+ * that only resolve under `projects/<id>/` are auto-joined, unresolvables
179
+ * are refused. See `./doc-path.mjs`.
174
180
  */
175
181
  export async function addDocToWorkstream(projectId, wsId, { label, path: docPath, status }) {
176
182
  validateWsId(wsId);
@@ -180,6 +186,10 @@ export async function addDocToWorkstream(projectId, wsId, { label, path: docPath
180
186
  fail(`invalid doc status: ${status} (expected one of: ${[...VALID_DOC_STATUSES].join(', ')})`);
181
187
  }
182
188
 
189
+ const repoRoot = await getRepoRoot();
190
+ const normalised = normaliseDocPath({ repoRoot, projectId, docPath });
191
+ const storedPath = normalised.path;
192
+
183
193
  const { manifest, path } = await loadProject(projectId);
184
194
  const workstreams = ensureWorkstreams(manifest);
185
195
  const ws = findWorkstream(workstreams, wsId);
@@ -187,15 +197,23 @@ export async function addDocToWorkstream(projectId, wsId, { label, path: docPath
187
197
 
188
198
  if (!Array.isArray(ws.docs)) ws.docs = [];
189
199
 
190
- const dup = ws.docs.find((d) => d.path === docPath && d.label === label);
200
+ const dup = ws.docs.find((d) => d.path === storedPath && d.label === label);
191
201
  if (dup) {
192
- return { projectId, workstream: wsId, label, path: docPath, status: status || null, changed: false, manifestPath: path };
202
+ return {
203
+ projectId, workstream: wsId, label, path: storedPath,
204
+ status: status || null, changed: false, manifestPath: path,
205
+ normalisation: { action: normalised.action, inputPath: docPath, storedPath },
206
+ };
193
207
  }
194
208
 
195
- const entry = { label, path: docPath };
209
+ const entry = { label, path: storedPath };
196
210
  if (status) entry.status = status;
197
211
  ws.docs.push(entry);
198
212
 
199
213
  await saveProject(path, manifest);
200
- return { projectId, workstream: wsId, label, path: docPath, status: status || null, changed: true, manifestPath: path };
214
+ return {
215
+ projectId, workstream: wsId, label, path: storedPath,
216
+ status: status || null, changed: true, manifestPath: path,
217
+ normalisation: { action: normalised.action, inputPath: docPath, storedPath },
218
+ };
201
219
  }
@@ -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.14",
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",