@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.
- package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +418 -0
- package/dist/engineering-pa-cli/src/agent/cli.mjs +6 -11
- package/dist/engineering-pa-cli/src/coordinator-hook/self.mjs +4 -4
- package/dist/engineering-pa-cli/src/environment/cli.mjs +4 -6
- package/dist/engineering-pa-cli/src/environment/probes.mjs +3 -5
- package/dist/engineering-pa-cli/src/pa/cli.mjs +3 -5
- 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/session.mjs +24 -1
- package/dist/engineering-pa-cli/src/pa/work-cli.mjs +5 -7
- package/dist/engineering-pa-cli/src/pa/workstreams.mjs +22 -4
- package/dist/engineering-pa-core/src/desk/team-scope.mjs +9 -64
- package/dist/engineering-pa-core/src/preferences.mjs +133 -0
- package/dist/engineering-pa-workspace/app/index.html +8 -3
- package/dist/engineering-pa-workspace/src/api.mjs +22 -19
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +6 -5
- package/dist/engineering-pa-workspace/src/server.mjs +6 -5
- package/dist/engineering-pa-workspace/src/status-mon/poller.mjs +4 -11
- package/dist/engineering-pa-workspace/src/status-mon/run.mjs +30 -16
- package/dist/engineering-pa-workspace/src/ws.mjs +5 -4
- 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
|
+
}
|
|
@@ -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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
219
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
238
|
-
if (
|
|
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
|
|
202
|
-
if (
|
|
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
|
+
}
|