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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,8 +11,9 @@ code into a self-contained tarball.
11
11
  pnpm add @stravica-ai/digital-operator-pa
12
12
  ```
13
13
 
14
- Requires Node 24+. See [`../../docs/installing.md`](../../docs/installing.md)
15
- for the GitHub Packages `~/.npmrc` setup (PAT with `read:packages` scope).
14
+ Requires Node 24+. The package is public on `registry.npmjs.org` and needs no
15
+ registry auth. See [`../../docs/installing.md`](../../docs/installing.md) for the
16
+ full install guide.
16
17
 
17
18
  ## Bins
18
19
 
@@ -157,9 +158,21 @@ pnpm --filter @stravica-ai/digital-operator-pa pack # produce a rea
157
158
 
158
159
  ## Publish target
159
160
 
160
- `publishConfig.registry` points at `https://npm.pkg.github.com`. Publishes
161
- happen via `.github/workflows/publish.yml` on `v*.*.*` tag push or manual
162
- dispatch. See the root README for the release flow.
161
+ `publishConfig.registry` points at `https://registry.npmjs.org/` with
162
+ `access: public`. Publishes are manual (run by a maintainer with npmjs publish
163
+ rights) from this directory: `pnpm publish` triggers `build.mjs` via `prepack`,
164
+ then ships the bundled tarball to npmjs.org. `ci.yml`'s `dry-publish` job proves
165
+ the pack/publish mechanics on every push. See [`../../docs/installing.md`](../../docs/installing.md)
166
+ for the release-pipeline section.
167
+
168
+ ### Retired predecessor
169
+
170
+ The prior identity of this codebase was `@stravica/engineering-pa` on GitHub
171
+ Packages (`https://npm.pkg.github.com`, scope `@stravica`). That package is
172
+ retired as of 2026-07-30; its final version (`0.6.2`) is a republish of `0.6.1`
173
+ carrying a deprecation notice. Note the scope difference from `@stravica-ai`
174
+ (the current package) and from `@stravica-ai/engineering-pa-core`, which is a
175
+ separate live package and is unaffected.
163
176
 
164
177
  ## Standards
165
178
 
@@ -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,77 @@
1
+ // audit.mjs — orchestrator for the agent baseline audit.
2
+ //
3
+ // Given a manifest and an audit context, run every probe and fold in
4
+ // severity + hostDependent semantics to produce the final per-capability
5
+ // status. Pure — no filesystem writes here; the CLI (cli.mjs) handles
6
+ // cache IO and stdout.
7
+
8
+ import { capabilitySeverity } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
9
+
10
+ import { runProbe } from './probes.mjs';
11
+
12
+ /**
13
+ * Fold a probe outcome + capability metadata into a final rendered status.
14
+ *
15
+ * Status ladder:
16
+ * 'pass' — probe matched
17
+ * 'warn' — probe failed but severity=warn (never gates --strict)
18
+ * 'fail' — probe failed and severity=fail
19
+ * 'unverified-here' — mechanically indeterminate on this host (host-pinned
20
+ * capability declared elsewhere, tool absent, or
21
+ * probe threw)
22
+ *
23
+ * `hostDependent` upgrades a hard mechanical fail into unverified-here only
24
+ * when the probe itself already tells us the reason is host-shape (schedule
25
+ * declared on another host). A simple missing-file on a host-dependent
26
+ * probe is still a fail — there is no host on the estate that has it.
27
+ */
28
+ export function foldOutcome(cap, mech) {
29
+ if (mech.outcome === 'pass') return { status: 'pass', detail: mech.detail };
30
+ if (mech.outcome === 'unverified-here') return { status: 'unverified-here', detail: mech.detail };
31
+ // mech.outcome === 'fail'
32
+ const sev = capabilitySeverity(cap);
33
+ if (sev === 'warn') return { status: 'warn', detail: mech.detail };
34
+ return { status: 'fail', detail: mech.detail };
35
+ }
36
+
37
+ /**
38
+ * Run every capability probe in the manifest.
39
+ */
40
+ export function auditManifest(manifest, ctx) {
41
+ const capabilities = manifest.capabilities.map((cap) => {
42
+ const mech = runProbe(cap.probe, ctx);
43
+ const folded = foldOutcome(cap, mech);
44
+ return {
45
+ id: cap.id,
46
+ label: cap.label,
47
+ sinceVersion: cap.sinceVersion,
48
+ severity: capabilitySeverity(cap),
49
+ hostDependent: cap.hostDependent === true,
50
+ status: folded.status,
51
+ detail: folded.detail,
52
+ };
53
+ });
54
+ return {
55
+ capturedAt: new Date().toISOString(),
56
+ manifestVersion: manifest.manifestVersion,
57
+ schemaVersion: manifest.schemaVersion,
58
+ context: {
59
+ repoRoot: ctx.repoRoot,
60
+ paName: ctx.paName,
61
+ hostname: ctx.hostname ?? null,
62
+ },
63
+ summary: summarise(capabilities),
64
+ capabilities,
65
+ };
66
+ }
67
+
68
+ function summarise(caps) {
69
+ const summary = { total: caps.length, pass: 0, warn: 0, fail: 0, unverifiedHere: 0 };
70
+ for (const c of caps) {
71
+ if (c.status === 'pass') summary.pass++;
72
+ else if (c.status === 'warn') summary.warn++;
73
+ else if (c.status === 'fail') summary.fail++;
74
+ else if (c.status === 'unverified-here') summary.unverifiedHere++;
75
+ }
76
+ return summary;
77
+ }