@stravica-ai/digital-operator-pa 0.1.14 → 0.1.17

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
+ }
@@ -29,6 +29,7 @@ import { hostname as osHostname } from 'node:os';
29
29
  import { dirname, join } from 'node:path';
30
30
 
31
31
  import { loadBundledManifestSync } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
32
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
32
33
 
33
34
  import { auditManifest } from './audit.mjs';
34
35
  import { getRepoRoot } from '../pa/repo-root.mjs';
@@ -201,12 +202,9 @@ function isStale(cache, stalenessMinutes) {
201
202
 
202
203
  function readStalenessMinutes(repoRoot) {
203
204
  try {
204
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
205
- if (existsSync(prefsPath)) {
206
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
207
- const value = prefs?.agentAudit?.cacheStalenessMinutes;
208
- if (typeof value === 'number' && value > 0) return value;
209
- }
205
+ const prefs = readPreferencesSync(repoRoot);
206
+ const value = prefs?.agentAudit?.cacheStalenessMinutes;
207
+ if (typeof value === 'number' && value > 0) return value;
210
208
  } catch {
211
209
  // fall through
212
210
  }
@@ -215,11 +213,8 @@ function readStalenessMinutes(repoRoot) {
215
213
 
216
214
  function readPaName(repoRoot) {
217
215
  try {
218
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
219
- if (existsSync(prefsPath)) {
220
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
221
- if (typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
222
- }
216
+ const prefs = readPreferencesSync(repoRoot);
217
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
223
218
  } catch {
224
219
  // fall through
225
220
  }
@@ -11,15 +11,15 @@
11
11
  //
12
12
  // Returns a string paName, or null when unresolved.
13
13
 
14
- import { readFile } from 'node:fs/promises';
15
- import { join } from 'node:path';
14
+ import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
16
15
 
17
16
  export async function resolveSelfAgent(repoRoot, env = process.env) {
18
17
  if (env.PA_SELF_AGENT) return env.PA_SELF_AGENT;
19
18
  if (!repoRoot) return null;
20
19
  try {
21
- const raw = await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8');
22
- const config = JSON.parse(raw);
20
+ // Overlay-aware: preferences.local.json shallow-merges OVER the shared
21
+ // preferences.json so a device-local paName wins for the coordinator hook.
22
+ const config = await readPreferences(repoRoot);
23
23
  if (config && typeof config.paName === 'string' && config.paName.trim()) {
24
24
  return config.paName.trim();
25
25
  }
@@ -22,6 +22,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { getRepoRoot } from '../pa/repo-root.mjs';
25
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
25
26
  import {
26
27
  probePlatform,
27
28
  probeRuntimes,
@@ -252,12 +253,9 @@ function isStale(cache, stalenessMinutes) {
252
253
 
253
254
  function readStalenessMinutes(repoRoot) {
254
255
  try {
255
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
256
- if (existsSync(prefsPath)) {
257
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
258
- const value = prefs?.environment?.cacheStalenessMinutes;
259
- if (typeof value === 'number' && value > 0) return value;
260
- }
256
+ const prefs = readPreferencesSync(repoRoot);
257
+ const value = prefs?.environment?.cacheStalenessMinutes;
258
+ if (typeof value === 'number' && value > 0) return value;
261
259
  } catch {
262
260
  // fall through
263
261
  }
@@ -10,6 +10,7 @@ import { arch, platform, release, homedir } from 'node:os';
10
10
  import { basename, join, resolve, dirname } from 'node:path';
11
11
  import { resolve4 } from 'node:dns/promises';
12
12
  import { fileURLToPath } from 'node:url';
13
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
13
14
 
14
15
  const execFileP = promisify(execFile);
15
16
 
@@ -234,11 +235,8 @@ export async function probeWorkspace(repoRoot) {
234
235
 
235
236
  function readWorkspacePort(repoRoot) {
236
237
  try {
237
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
238
- if (existsSync(prefsPath)) {
239
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
240
- if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
241
- }
238
+ const prefs = readPreferencesSync(repoRoot);
239
+ if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
242
240
  } catch {
243
241
  // fall through
244
242
  }
@@ -10,6 +10,7 @@ import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { run as runDesk } from '../../../engineering-pa-core/src/desk/cli.mjs';
12
12
  import { PA_PACKAGE_NAME } from '../../../engineering-pa-core/src/config/pa-package-name.mjs';
13
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
13
14
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -198,11 +199,8 @@ async function doStatus(opts = {}) {
198
199
  function resolveWorkspacePort(repoRoot, opts = {}) {
199
200
  if (opts.workspacePort) return opts.workspacePort;
200
201
  try {
201
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
202
- if (existsSync(prefsPath)) {
203
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
204
- if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
205
- }
202
+ const prefs = readPreferencesSync(repoRoot);
203
+ if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
206
204
  } catch { /* fall through to default */ }
207
205
  return 4200;
208
206
  }
@@ -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
+ }