@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.
@@ -1,301 +1,20 @@
1
- // envelope.mjs — pure functions for autonomy-envelope frontmatter parsing +
2
- // rendering. Zero IO. All filesystem side effects live in cli.mjs.
1
+ // envelope.mjs — re-export shim.
3
2
  //
4
- // The envelope is a markdown file with a YAML frontmatter block carrying the
5
- // machine-readable state. We ship our own minimal YAML reader/writer here
6
- // (limited to the shape the schema defines) to avoid pulling in a YAML dep
7
- // for such a narrow surface. Any file whose frontmatter falls outside the
8
- // documented shape is rejected as invalid.
9
-
10
- export const VALID_STATES = ['fullyApproved', 'checkBeforeCommitting', 'needsFullSupervision'];
11
-
12
- const STATE_LABELS = {
13
- fullyApproved: 'fully approved',
14
- checkBeforeCommitting: 'check before committing',
15
- needsFullSupervision: 'needs full supervision',
16
- };
17
-
18
- /**
19
- * Parse an autonomy-envelope markdown file. Returns
20
- * { frontmatter, body }
21
- * where `frontmatter` matches the schema shape and `body` is the markdown
22
- * after the closing `---`. Throws on malformed input; the error carries an
23
- * `exitCode` for the CLI to propagate.
24
- */
25
- export function parseEnvelope(source) {
26
- if (typeof source !== 'string' || source.length === 0) {
27
- throwUsage('envelope file is empty');
28
- }
29
- const lines = source.split(/\r?\n/);
30
- if (lines[0].trim() !== '---') {
31
- throwParse('envelope must start with YAML frontmatter delimited by ---');
32
- }
33
- let closeIdx = -1;
34
- for (let i = 1; i < lines.length; i++) {
35
- if (lines[i].trim() === '---') {
36
- closeIdx = i;
37
- break;
38
- }
39
- }
40
- if (closeIdx < 0) {
41
- throwParse('unterminated YAML frontmatter (missing closing ---)');
42
- }
43
- const fmLines = lines.slice(1, closeIdx);
44
- const bodyLines = lines.slice(closeIdx + 1);
45
- const frontmatter = parseFrontmatter(fmLines);
46
- return { frontmatter, body: bodyLines.join('\n') };
47
- }
48
-
49
- /**
50
- * Parse a limited-shape YAML block. Supports:
51
- * key: value (string)
52
- * key: (empty list start)
53
- * - key: value (list item — object)
54
- * key: value
55
- * Numbers, booleans, and nested maps beyond one level are NOT supported —
56
- * the schema doesn't need them. Values are always treated as strings.
57
- */
58
- function parseFrontmatter(lines) {
59
- const out = {};
60
- let i = 0;
61
- while (i < lines.length) {
62
- const raw = lines[i];
63
- if (raw.trim() === '' || raw.trim().startsWith('#')) { i++; continue; }
64
- const match = raw.match(/^([a-zA-Z][a-zA-Z0-9]*):(?:\s*(.*))?$/);
65
- if (!match) {
66
- throwParse(`could not parse frontmatter line ${i + 1}: ${raw}`);
67
- }
68
- const key = match[1];
69
- const value = (match[2] ?? '').trim();
70
- if (value === '') {
71
- // Expect either a list (starts with "- " at next non-empty indented
72
- // line) or an empty value.
73
- const listItems = [];
74
- let j = i + 1;
75
- while (j < lines.length) {
76
- const peek = lines[j];
77
- if (peek.trim() === '' || peek.trim().startsWith('#')) { j++; continue; }
78
- if (!/^\s+-\s/.test(peek)) break;
79
- const item = readListItem(lines, j);
80
- listItems.push(item.value);
81
- j = item.next;
82
- }
83
- out[key] = listItems;
84
- i = j;
85
- } else {
86
- out[key] = stripQuotes(value);
87
- i++;
88
- }
89
- }
90
- return out;
91
- }
92
-
93
- function readListItem(lines, start) {
94
- const first = lines[start];
95
- const indentMatch = first.match(/^(\s+)-\s/);
96
- if (!indentMatch) {
97
- throwParse(`expected list item at line ${start + 1}: ${first}`);
98
- }
99
- const itemIndent = indentMatch[1] + ' '; // continuation is deeper than "- "
100
- const item = {};
101
- // The "- key: value" first line is the first key on the item.
102
- const firstBody = first.slice(indentMatch[0].length);
103
- const firstMatch = firstBody.match(/^([a-zA-Z][a-zA-Z0-9]*):\s*(.*)$/);
104
- if (!firstMatch) {
105
- throwParse(`could not parse list item at line ${start + 1}: ${first}`);
106
- }
107
- item[firstMatch[1]] = stripQuotes(firstMatch[2].trim());
108
- let j = start + 1;
109
- while (j < lines.length) {
110
- const peek = lines[j];
111
- if (peek.trim() === '' || peek.trim().startsWith('#')) { j++; continue; }
112
- // Terminate when indent doesn't extend the item (either new list item
113
- // at the same "- " indent, or dedent back to a top-level key).
114
- if (peek.startsWith(itemIndent)) {
115
- const bodyLine = peek.slice(itemIndent.length);
116
- const m = bodyLine.match(/^([a-zA-Z][a-zA-Z0-9]*):\s*(.*)$/);
117
- if (!m) {
118
- throwParse(`could not parse list item continuation at line ${j + 1}: ${peek}`);
119
- }
120
- item[m[1]] = stripQuotes(m[2].trim());
121
- j++;
122
- } else {
123
- break;
124
- }
125
- }
126
- return { value: item, next: j };
127
- }
128
-
129
- function stripQuotes(value) {
130
- if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
131
- return value.slice(1, -1);
132
- }
133
- if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
134
- return value.slice(1, -1);
135
- }
136
- return value;
137
- }
138
-
139
- /**
140
- * Render an envelope object (frontmatter shape) back to markdown. Body is
141
- * regenerated: a current-state table + a history log preserved from prior
142
- * body sections. This keeps the file readable + deterministic.
143
- */
144
- export function renderEnvelope(frontmatter, history = []) {
145
- const fm = renderFrontmatter(frontmatter);
146
- const body = renderBody(frontmatter, history);
147
- return `---\n${fm}---\n\n${body}\n`;
148
- }
149
-
150
- function renderFrontmatter(fm) {
151
- const lines = [];
152
- lines.push(`agent: ${quoteIfNeeded(fm.agent)}`);
153
- lines.push(`owner: ${quoteIfNeeded(fm.owner)}`);
154
- lines.push(`updated: ${quoteIfNeeded(fm.updated)}`);
155
- lines.push('taskTypes:');
156
- const items = fm.taskTypes ?? [];
157
- for (const item of items) {
158
- lines.push(` - id: ${quoteIfNeeded(item.id)}`);
159
- lines.push(` label: ${quoteIfNeeded(item.label)}`);
160
- lines.push(` state: ${quoteIfNeeded(item.state)}`);
161
- if (item.stateSince) lines.push(` stateSince: ${quoteIfNeeded(item.stateSince)}`);
162
- if (item.operatorNote) lines.push(` operatorNote: ${quoteIfNeeded(item.operatorNote)}`);
163
- }
164
- return lines.join('\n') + '\n';
165
- }
166
-
167
- function quoteIfNeeded(value) {
168
- if (value == null) return '""';
169
- const str = String(value);
170
- // Quote values that could be misparsed as YAML control sequences.
171
- if (/^[\s"'#\-\[\{,&*!|>%@`]/.test(str)) return JSON.stringify(str);
172
- if (/[:#]\s/.test(str)) return JSON.stringify(str);
173
- if (str.trim() !== str) return JSON.stringify(str);
174
- return str;
175
- }
176
-
177
- function renderBody(fm, history) {
178
- const lines = [];
179
- lines.push(`# Autonomy envelope — ${fm.agent}`);
180
- lines.push('');
181
- lines.push('## Current state');
182
- lines.push('');
183
- if (!fm.taskTypes || fm.taskTypes.length === 0) {
184
- lines.push('_No task types configured. Use `pa autonomy set <agent> <task-type> <state>` to add one._');
185
- } else {
186
- lines.push('| Task type | State | Since | Note |');
187
- lines.push('|---|---|---|---|');
188
- for (const item of fm.taskTypes) {
189
- const since = (item.stateSince ?? '').slice(0, 10);
190
- const note = (item.operatorNote ?? '—').replace(/\|/g, '\\|');
191
- lines.push(`| ${escapeCell(item.label)} | ${STATE_LABELS[item.state] ?? item.state} | ${since || '—'} | ${note} |`);
192
- }
193
- }
194
- lines.push('');
195
- lines.push('## History');
196
- lines.push('');
197
- const entries = [...history].sort((a, b) => (b.at ?? '').localeCompare(a.at ?? ''));
198
- if (entries.length === 0) {
199
- lines.push('_No history yet._');
200
- } else {
201
- for (const entry of entries) {
202
- lines.push(`### ${entry.at} — ${entry.taskTypeId} → ${entry.state}`);
203
- if (entry.note) {
204
- lines.push('');
205
- lines.push(`Operator note: ${entry.note}`);
206
- }
207
- lines.push('');
208
- }
209
- }
210
- return lines.join('\n');
211
- }
212
-
213
- function escapeCell(str) {
214
- return String(str).replace(/\|/g, '\\|');
215
- }
216
-
217
- /**
218
- * Extract history entries from a rendered body. Returns an array of
219
- * { at, taskTypeId, state, note }
220
- * entries. Best-effort — malformed sections are skipped, not raised.
221
- */
222
- export function extractHistory(body) {
223
- const entries = [];
224
- if (typeof body !== 'string') return entries;
225
- const lines = body.split(/\r?\n/);
226
- let i = 0;
227
- while (i < lines.length) {
228
- const line = lines[i];
229
- const match = line.match(/^###\s+(\S+)\s+—\s+([a-z0-9][a-z0-9-]*)\s+→\s+([A-Za-z]+)\s*$/);
230
- if (!match) { i++; continue; }
231
- const at = match[1];
232
- const taskTypeId = match[2];
233
- const state = match[3];
234
- let note = null;
235
- let j = i + 1;
236
- while (j < lines.length && lines[j].trim() === '') j++;
237
- if (j < lines.length && lines[j].startsWith('Operator note:')) {
238
- note = lines[j].slice('Operator note:'.length).trim();
239
- }
240
- entries.push({ at, taskTypeId, state, note });
241
- i = j + 1;
242
- }
243
- return entries;
244
- }
245
-
246
- /**
247
- * Validate a frontmatter object against the schema. Throws on invalid;
248
- * returns nothing on valid.
249
- */
250
- export function validateFrontmatter(fm) {
251
- if (!fm || typeof fm !== 'object') throwParse('frontmatter must be an object');
252
- requireString(fm, 'agent');
253
- requireString(fm, 'owner');
254
- requireString(fm, 'updated');
255
- if (!Array.isArray(fm.taskTypes)) throwParse('taskTypes must be an array');
256
- for (const [i, item] of fm.taskTypes.entries()) {
257
- if (!item || typeof item !== 'object') throwParse(`taskTypes[${i}] must be an object`);
258
- requireString(item, `taskTypes[${i}].id`, item.id);
259
- if (!/^[a-z0-9][a-z0-9-]*$/.test(item.id)) throwParse(`taskTypes[${i}].id '${item.id}' must match ^[a-z0-9][a-z0-9-]*$`);
260
- requireString(item, `taskTypes[${i}].label`, item.label);
261
- requireString(item, `taskTypes[${i}].state`, item.state);
262
- if (!VALID_STATES.includes(item.state)) {
263
- throwParse(`taskTypes[${i}].state '${item.state}' must be one of ${VALID_STATES.join(', ')}`);
264
- }
265
- }
266
- }
267
-
268
- function requireString(obj, field, value) {
269
- const v = value ?? obj?.[field];
270
- if (typeof v !== 'string' || v.length === 0) {
271
- throwParse(`${field} must be a non-empty string`);
272
- }
273
- }
274
-
275
- function throwParse(msg) {
276
- const err = new Error(msg);
277
- err.exitCode = 2;
278
- throw err;
279
- }
280
-
281
- function throwUsage(msg) {
282
- const err = new Error(msg);
283
- err.exitCode = 1;
284
- throw err;
285
- }
286
-
287
- export function nowIso() {
288
- return new Date().toISOString();
289
- }
290
-
291
- export function emptyEnvelope(agent, owner) {
292
- return {
293
- frontmatter: {
294
- agent,
295
- owner,
296
- updated: nowIso(),
297
- taskTypes: [],
298
- },
299
- history: [],
300
- };
301
- }
3
+ // The envelope parse/render/seed logic lives in
4
+ // `@stravica-ai/engineering-pa-core/src/autonomy/envelope.mjs` — pure, no
5
+ // CLI coupling, so scaffold can seed a round-trippable stub without an
6
+ // inverted package dependency (scaffold → core, not scaffold → cli).
7
+ //
8
+ // This shim exists so anything that historically imported from
9
+ // `packages/cli/src/autonomy/envelope.mjs` (tests, internal callers)
10
+ // keeps working. New callers should import from core directly.
11
+
12
+ export {
13
+ VALID_STATES,
14
+ parseEnvelope,
15
+ renderEnvelope,
16
+ extractHistory,
17
+ validateFrontmatter,
18
+ nowIso,
19
+ emptyEnvelope,
20
+ } from '../../../engineering-pa-core/src/autonomy/envelope.mjs';
@@ -27,6 +27,7 @@ Usage:
27
27
  pa research <verb> Deep-research session store (run | list | show | gates | prune | mark | paths)
28
28
  pa autonomy <verb> Per-agent per-task-type approval state (init | get | set | list)
29
29
  pa environment [verb] Session-boot environment cache (default | refresh | extended)
30
+ pa agent <verb> Agent baseline audit (audit | audit refresh)
30
31
  pa staging-alias <verb> Canonical staging.<site> alias for CF Workers sites (set | unset | status | list)
31
32
  pa lint <verb> Lint operator text against voice rules (linkedin)
32
33
  pa migrate-inspect [options] Host inspection for migration planning
@@ -76,6 +77,8 @@ export async function main(argv) {
76
77
  return await doAutonomy(rest);
77
78
  case 'environment':
78
79
  return await doEnvironment(rest);
80
+ case 'agent':
81
+ return await doAgent(rest);
79
82
  case 'lint':
80
83
  return await doLint(rest);
81
84
  case 'staging-alias':
@@ -276,6 +279,11 @@ async function doEnvironment(rest) {
276
279
  return await run(rest);
277
280
  }
278
281
 
282
+ async function doAgent(rest) {
283
+ const { run } = await import('../agent/cli.mjs');
284
+ return await run(rest);
285
+ }
286
+
279
287
  async function doStagingAlias(rest) {
280
288
  const { main } = await import('../staging-alias/cli.mjs');
281
289
  return await main(rest);
@@ -0,0 +1,125 @@
1
+ // doc-path.mjs — normalisation for project.json doc `path` entries.
2
+ //
3
+ // The contract (Baz ruling 2026-08-06 under the PA-stack domain):
4
+ // Doc paths stored in project.json:docs[*].path are REPO-relative.
5
+ //
6
+ // This matches the workspace viewer's file API (packages/workspace/src/api.mjs)
7
+ // which resolves the `?file=` parameter against the operator repo root and
8
+ // applies a traversal guard. Storing repo-relative paths keeps the CLI, the
9
+ // stored data, and the viewer link on the same contract. Repo-relative also
10
+ // expresses legitimate out-of-project references (operator/notes/*, cross-
11
+ // project links) that project-relative cannot.
12
+ //
13
+ // The historical bug: `pa project doc add <id> --path <p>` stored `<p>`
14
+ // verbatim. Operators frequently invoked it with a project-relative path
15
+ // (e.g. `docs/foo.md`) because that reads naturally when their cwd is the
16
+ // project dir. The resulting entry then 404'd in the workspace viewer.
17
+ //
18
+ // This module's normalisation resolves the three input cases at write time:
19
+ //
20
+ // 1. `<p>` resolves against repoRoot and exists -> untouched
21
+ // 2. `<p>` resolves against `projects/<id>/` and exists -> joined (with notice)
22
+ // 3. neither resolves -> refused
23
+ //
24
+ // Case 1 covers everything already correct (~96% of the estate) plus any
25
+ // deliberate out-of-project reference. Case 2 is the failing case — the
26
+ // input is auto-corrected to `projects/<id>/<p>`. Case 3 refuses to create
27
+ // a dangling entry, matching the file API's own 404 contract.
28
+ //
29
+ // The normaliser is a pure function of (repoRoot, projectId, docPath) plus
30
+ // filesystem existence — no state, no I/O beyond stat.
31
+
32
+ import { existsSync } from 'node:fs';
33
+ import { isAbsolute, resolve, relative, sep, posix } from 'node:path';
34
+
35
+ /**
36
+ * Normalise a doc path against a project's location under the operator repo.
37
+ *
38
+ * @param {object} opts
39
+ * @param {string} opts.repoRoot Absolute path to the operator repo root.
40
+ * @param {string} opts.projectId Project id (matches the projects/<id>/ dir).
41
+ * @param {string} opts.docPath The `--path` value as supplied by the caller.
42
+ *
43
+ * @returns {{ path: string, action: 'as-is'|'joined',
44
+ * absPath: string, joinedFrom?: string }}
45
+ * `path` is the value that should be stored (repo-relative, forward
46
+ * slashes on any platform). `action` reports how it was resolved.
47
+ * `absPath` is the resolved absolute path that DOES exist on disk.
48
+ * When `action === 'joined'`, `joinedFrom` carries the original
49
+ * input so callers can print a "normalised: X -> Y" notice.
50
+ *
51
+ * @throws {Error & {exitCode:1}} when the input resolves neither way, when
52
+ * the input is absolute, or when the input escapes the repo root.
53
+ * The message names the two candidate absolute paths that were
54
+ * tried so the operator can diagnose immediately.
55
+ */
56
+ export function normaliseDocPath({ repoRoot, projectId, docPath }) {
57
+ if (!repoRoot) throw makeError('normaliseDocPath: repoRoot is required');
58
+ if (!projectId) throw makeError('normaliseDocPath: projectId is required');
59
+ if (!docPath || typeof docPath !== 'string') {
60
+ throw makeError('normaliseDocPath: docPath must be a non-empty string');
61
+ }
62
+
63
+ // Absolute paths are refused outright — the contract is repo-relative,
64
+ // and an absolute path (a) breaks portability across machines and (b)
65
+ // will fail the workspace viewer's traversal guard even if we tried to
66
+ // rewrite it. Operator diagnosis is more useful when we say why.
67
+ if (isAbsolute(docPath)) {
68
+ throw makeError(
69
+ `path must be repo-relative, got absolute path: ${docPath}\n` +
70
+ ` the doc-path contract is repo-relative (see packages/workspace/src/api.mjs)`,
71
+ );
72
+ }
73
+
74
+ const asIsAbs = resolve(repoRoot, docPath);
75
+ const asIsInside = insideRepo(repoRoot, asIsAbs);
76
+ if (asIsInside && existsSync(asIsAbs)) {
77
+ return {
78
+ path: toPosix(docPath),
79
+ action: 'as-is',
80
+ absPath: asIsAbs,
81
+ };
82
+ }
83
+
84
+ const joinedAbs = resolve(repoRoot, 'projects', projectId, docPath);
85
+ const joinedInside = insideRepo(repoRoot, joinedAbs);
86
+ if (joinedInside && existsSync(joinedAbs)) {
87
+ const joinedRel = toPosix(relative(repoRoot, joinedAbs));
88
+ return {
89
+ path: joinedRel,
90
+ action: 'joined',
91
+ absPath: joinedAbs,
92
+ joinedFrom: docPath,
93
+ };
94
+ }
95
+
96
+ // Neither resolves — refuse with both candidates so the operator can see
97
+ // which one they meant to hit.
98
+ const asIsCandidate = asIsInside ? asIsAbs : `${asIsAbs} (outside repo root)`;
99
+ const joinedCandidate = joinedInside ? joinedAbs : `${joinedAbs} (outside repo root)`;
100
+ throw makeError(
101
+ `doc path does not resolve: ${docPath}\n` +
102
+ ` tried as-is: ${asIsCandidate}\n` +
103
+ ` tried joined: ${joinedCandidate}\n` +
104
+ ` the doc-path contract is repo-relative — create the file first, or use --path <existing repo-relative path>`,
105
+ );
106
+ }
107
+
108
+ function insideRepo(repoRoot, absPath) {
109
+ const rel = relative(repoRoot, absPath);
110
+ if (rel.startsWith('..')) return false;
111
+ // Cross-platform: `path.resolve` uses OS-native separators. The api.mjs
112
+ // traversal guard does the same round-trip we do here.
113
+ return resolve(repoRoot, rel) === absPath;
114
+ }
115
+
116
+ function toPosix(p) {
117
+ if (sep === posix.sep) return p;
118
+ return p.split(sep).join(posix.sep);
119
+ }
120
+
121
+ function makeError(message) {
122
+ const err = new Error(message);
123
+ err.exitCode = 1;
124
+ return err;
125
+ }
@@ -174,6 +174,7 @@ async function doDocAdd(args) {
174
174
  path: typeof flags.path === 'string' ? flags.path : null,
175
175
  status: typeof flags.status === 'string' ? flags.status : null,
176
176
  });
177
+ printNormalisationNotice(res.normalisation);
177
178
  if (res.changed) {
178
179
  let msg = `added "${res.label}" -> workstream [${res.workstream}] in ${id}`;
179
180
  if (res.status) msg += ` (status: ${res.status})`;
@@ -191,6 +192,7 @@ async function doDocAdd(args) {
191
192
  group: typeof flags.group === 'string' ? flags.group : null,
192
193
  status: typeof flags.status === 'string' ? flags.status : null,
193
194
  });
195
+ printNormalisationNotice(res.normalisation);
194
196
  if (res.changed) {
195
197
  let msg = `added "${res.label}" -> [${res.group}] in ${id}`;
196
198
  if (res.status) msg += ` (status: ${res.status})`;
@@ -201,6 +203,16 @@ async function doDocAdd(args) {
201
203
  return 0;
202
204
  }
203
205
 
206
+ // Emit a one-line notice when the path was auto-joined under projects/<id>/.
207
+ // Silent on `action === 'as-is'` — the vast majority of calls are already
208
+ // correctly repo-relative and shouldn't be spammed.
209
+ function printNormalisationNotice(normalisation) {
210
+ if (!normalisation || normalisation.action !== 'joined') return;
211
+ process.stdout.write(
212
+ `notice: path normalised to repo-relative — ${normalisation.inputPath} -> ${normalisation.storedPath}\n`,
213
+ );
214
+ }
215
+
204
216
  async function doDocRm(args) {
205
217
  const { positional, flags } = parseFlags(args);
206
218
  if (positional.length === 0) {
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
  import { getRepoRoot } from './repo-root.mjs';
20
20
  import { readJson, writeJson, ensureDir, listSubdirs, today } from './io.mjs';
21
21
  import { addDoc, removeDocByPath, setDocStatus, DEFAULT_GROUP, VALID_DOC_STATUSES } from './doc-groups.mjs';
22
+ import { normaliseDocPath } from './doc-path.mjs';
22
23
  import { filterProjectsByTeam, readTeamScopeConfig } from '../../../engineering-pa-core/src/desk/team-scope.mjs';
23
24
 
24
25
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
@@ -190,6 +191,11 @@ export async function setProjectStatus(id, status) {
190
191
  /**
191
192
  * Append a doc entry to a group. Migrates a legacy flat-array shape to
192
193
  * grouped in the same write. Idempotent on {label,path} match.
194
+ *
195
+ * The `--path` value is normalised against the repo-relative contract via
196
+ * `normaliseDocPath` before storage — an input that only resolves under
197
+ * `projects/<id>/` is auto-joined; an input that resolves neither way is
198
+ * refused. See `./doc-path.mjs` for the full contract.
193
199
  */
194
200
  export async function addProjectDoc(id, { label, path: docPath, group, status }) {
195
201
  validateId(id);
@@ -217,9 +223,11 @@ export async function addProjectDoc(id, { label, path: docPath, group, status })
217
223
  err.exitCode = 1;
218
224
  throw err;
219
225
  }
226
+ const normalised = normaliseDocPath({ repoRoot, projectId: id, docPath });
227
+ const storedPath = normalised.path;
220
228
  const manifest = await readJson(manifestPath);
221
229
  const before = JSON.stringify(manifest.docs);
222
- const entry = { label, path: docPath };
230
+ const entry = { label, path: storedPath };
223
231
  if (status) entry.status = status;
224
232
  manifest.docs = addDoc(manifest.docs, group, entry);
225
233
  const after = JSON.stringify(manifest.docs);
@@ -231,10 +239,15 @@ export async function addProjectDoc(id, { label, path: docPath, group, status })
231
239
  id,
232
240
  group: group || DEFAULT_GROUP,
233
241
  label,
234
- path: docPath,
242
+ path: storedPath,
235
243
  status: status || null,
236
244
  changed: before !== after,
237
245
  manifestPath,
246
+ normalisation: {
247
+ action: normalised.action,
248
+ inputPath: docPath,
249
+ storedPath,
250
+ },
238
251
  };
239
252
  }
240
253
 
@@ -21,6 +21,7 @@ import { join } from 'node:path';
21
21
  import { getRepoRoot } from './repo-root.mjs';
22
22
  import { readJson, writeJson, today } from './io.mjs';
23
23
  import { VALID_DOC_STATUSES } from './doc-groups.mjs';
24
+ import { normaliseDocPath } from './doc-path.mjs';
24
25
 
25
26
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
26
27
 
@@ -171,6 +172,11 @@ export async function removeWorkstream(projectId, { id: wsId }) {
171
172
  /**
172
173
  * Add a doc entry to a specific workstream's docs array.
173
174
  * Idempotent on {label, path} match within the workstream.
175
+ *
176
+ * The `--path` value is normalised against the repo-relative contract via
177
+ * `normaliseDocPath` before storage, mirroring `addProjectDoc` — inputs
178
+ * that only resolve under `projects/<id>/` are auto-joined, unresolvables
179
+ * are refused. See `./doc-path.mjs`.
174
180
  */
175
181
  export async function addDocToWorkstream(projectId, wsId, { label, path: docPath, status }) {
176
182
  validateWsId(wsId);
@@ -180,6 +186,10 @@ export async function addDocToWorkstream(projectId, wsId, { label, path: docPath
180
186
  fail(`invalid doc status: ${status} (expected one of: ${[...VALID_DOC_STATUSES].join(', ')})`);
181
187
  }
182
188
 
189
+ const repoRoot = await getRepoRoot();
190
+ const normalised = normaliseDocPath({ repoRoot, projectId, docPath });
191
+ const storedPath = normalised.path;
192
+
183
193
  const { manifest, path } = await loadProject(projectId);
184
194
  const workstreams = ensureWorkstreams(manifest);
185
195
  const ws = findWorkstream(workstreams, wsId);
@@ -187,15 +197,23 @@ export async function addDocToWorkstream(projectId, wsId, { label, path: docPath
187
197
 
188
198
  if (!Array.isArray(ws.docs)) ws.docs = [];
189
199
 
190
- const dup = ws.docs.find((d) => d.path === docPath && d.label === label);
200
+ const dup = ws.docs.find((d) => d.path === storedPath && d.label === label);
191
201
  if (dup) {
192
- return { projectId, workstream: wsId, label, path: docPath, status: status || null, changed: false, manifestPath: path };
202
+ return {
203
+ projectId, workstream: wsId, label, path: storedPath,
204
+ status: status || null, changed: false, manifestPath: path,
205
+ normalisation: { action: normalised.action, inputPath: docPath, storedPath },
206
+ };
193
207
  }
194
208
 
195
- const entry = { label, path: docPath };
209
+ const entry = { label, path: storedPath };
196
210
  if (status) entry.status = status;
197
211
  ws.docs.push(entry);
198
212
 
199
213
  await saveProject(path, manifest);
200
- return { projectId, workstream: wsId, label, path: docPath, status: status || null, changed: true, manifestPath: path };
214
+ return {
215
+ projectId, workstream: wsId, label, path: storedPath,
216
+ status: status || null, changed: true, manifestPath: path,
217
+ normalisation: { action: normalised.action, inputPath: docPath, storedPath },
218
+ };
201
219
  }
@@ -19,3 +19,16 @@ export { acquireLock, DEFAULT_STALE_AFTER_MS } from './src/lock/lock.mjs';
19
19
  // Distill prereqs / prompt — also useful as a library entry point.
20
20
  export { resolvePrereqs, defaultRepoRoot as distillDefaultRepoRoot, BUNDLED_LIBRARIAN_DEF } from './src/distill/prereqs.mjs';
21
21
  export { buildDistillPrompt, DISTILL_PROMPT, claudeArgs } from './src/distill/prompt.mjs';
22
+
23
+ // Agent baseline manifest — machine-readable list of capabilities every
24
+ // operator PA must carry. Consumed by `pa agent audit` (drift detection)
25
+ // and by `pa-init` (creation-time coverage check). Docs:
26
+ // packages/core/knowledge/scaffold/README.md.
27
+ export {
28
+ bundledManifestPath,
29
+ loadBundledManifest,
30
+ loadBundledManifestSync,
31
+ parseManifest,
32
+ expandTemplate,
33
+ capabilitySeverity,
34
+ } from './src/agent-baseline/manifest.mjs';