@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 +18 -5
- package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +418 -0
- package/dist/engineering-pa-cli/src/agent/audit.mjs +77 -0
- package/dist/engineering-pa-cli/src/agent/cli.mjs +266 -0
- package/dist/engineering-pa-cli/src/agent/probes.mjs +236 -0
- package/dist/engineering-pa-cli/src/autonomy/cli.mjs +1 -1
- package/dist/engineering-pa-cli/src/autonomy/envelope.mjs +19 -300
- package/dist/engineering-pa-cli/src/pa/cli.mjs +8 -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-core/index.mjs +13 -0
- package/dist/engineering-pa-core/knowledge/scaffold/README.md +66 -0
- package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.json +132 -0
- package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.schema.json +146 -0
- package/dist/engineering-pa-core/src/agent-baseline/manifest.mjs +139 -0
- package/dist/engineering-pa-core/src/autonomy/envelope.mjs +301 -0
- package/dist/engineering-pa-scaffold/package.json +3 -0
- package/dist/engineering-pa-scaffold/src/generator.mjs +23 -0
- package/dist/engineering-pa-workspace/app/index.html +8 -3
- package/dist/engineering-pa-workspace/src/api.mjs +11 -1
- package/package.json +2 -2
|
@@ -1,301 +1,20 @@
|
|
|
1
|
-
// envelope.mjs —
|
|
2
|
-
// rendering. Zero IO. All filesystem side effects live in cli.mjs.
|
|
1
|
+
// envelope.mjs — re-export shim.
|
|
3
2
|
//
|
|
4
|
-
// The envelope
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -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';
|