@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.
- package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +418 -0
- package/dist/engineering-pa-cli/src/pa/doc-path.mjs +125 -0
- package/dist/engineering-pa-cli/src/pa/project-cli.mjs +12 -0
- package/dist/engineering-pa-cli/src/pa/projects.mjs +15 -2
- package/dist/engineering-pa-cli/src/pa/workstreams.mjs +22 -4
- package/dist/engineering-pa-workspace/app/index.html +8 -3
- package/dist/engineering-pa-workspace/src/api.mjs +11 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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:
|
|
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 ===
|
|
200
|
+
const dup = ws.docs.find((d) => d.path === storedPath && d.label === label);
|
|
191
201
|
if (dup) {
|
|
192
|
-
return {
|
|
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:
|
|
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 {
|
|
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 + ' —
|
|
355
|
+
document.title = capName + ' — ' + subtitle;
|
|
351
356
|
} else {
|
|
352
357
|
brandName.textContent = 'Workspace';
|
|
353
358
|
}
|
|
354
|
-
brandSub.textContent =
|
|
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