@stravica-ai/digital-operator-pa 0.1.13 → 0.1.14
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/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-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/package.json +2 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Agent Baseline Manifest",
|
|
4
|
+
"description": "Machine-readable baseline of capabilities every operator PA is expected to carry. Consumed by `pa agent audit` and by `pa-init` at scaffold time.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "manifestVersion", "capabilities"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": { "type": "string" },
|
|
9
|
+
"schemaVersion": { "type": "string", "const": "1.0" },
|
|
10
|
+
"manifestVersion": {
|
|
11
|
+
"type": "integer",
|
|
12
|
+
"minimum": 1,
|
|
13
|
+
"description": "Monotonic manifest version. Bump on every capability addition/removal so an audit can detect its own manifest is behind."
|
|
14
|
+
},
|
|
15
|
+
"description": { "type": "string" },
|
|
16
|
+
"capabilities": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": { "$ref": "#/definitions/capability" }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"definitions": {
|
|
22
|
+
"capability": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": ["id", "label", "sinceVersion", "rationale", "probe"],
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"properties": {
|
|
27
|
+
"id": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
|
30
|
+
"description": "Stable capability id — kebab-case, referenced in audit output. Never renamed once shipped."
|
|
31
|
+
},
|
|
32
|
+
"label": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Human-facing one-liner rendered in audit summaries."
|
|
35
|
+
},
|
|
36
|
+
"sinceVersion": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
|
39
|
+
"description": "@stravica-ai/engineering-pa-core version that introduced this capability."
|
|
40
|
+
},
|
|
41
|
+
"rationale": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Why this capability exists in the baseline. Renders in --verbose audit output."
|
|
44
|
+
},
|
|
45
|
+
"severity": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["fail", "warn"],
|
|
48
|
+
"default": "fail",
|
|
49
|
+
"description": "How a missing/failing probe is reported. `warn` never contributes to a non-zero --strict exit."
|
|
50
|
+
},
|
|
51
|
+
"hostDependent": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"default": false,
|
|
54
|
+
"description": "Declares the capability lives on a specific host or subset of hosts. The audit's fail-vs-unverified-here treatment is probe-specific: only `schedule-defined` currently reads host-shape (via each schedule's `host` field) and upgrades a matching-schedule-on-wrong-host outcome to `unverified-here`. For all other probe types, `hostDependent:true` is documentary metadata only — a mechanical fail (missing file, missing directory, missing field) is reported as `fail`, because a missing artefact is missing on every host, not just this one. The upgrade is deliberately kept probe-local: a generic 'hostDependent+fail → unverified-here' fold would silence genuine estate-wide gaps that no host has."
|
|
55
|
+
},
|
|
56
|
+
"probe": {
|
|
57
|
+
"oneOf": [
|
|
58
|
+
{ "$ref": "#/definitions/probeFileExists" },
|
|
59
|
+
{ "$ref": "#/definitions/probeDirectoryExists" },
|
|
60
|
+
{ "$ref": "#/definitions/probeStagingRecentWrite" },
|
|
61
|
+
{ "$ref": "#/definitions/probeKnowledgeDocExists" },
|
|
62
|
+
{ "$ref": "#/definitions/probeScheduleDefined" },
|
|
63
|
+
{ "$ref": "#/definitions/probeJsonFieldIncludes" }
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"probeFileExists": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"required": ["type", "path"],
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"properties": {
|
|
73
|
+
"type": { "const": "file-exists" },
|
|
74
|
+
"path": { "type": "string", "description": "Path relative to repo root. Tokens: {paName}." }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"probeDirectoryExists": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"required": ["type", "path"],
|
|
80
|
+
"additionalProperties": false,
|
|
81
|
+
"properties": {
|
|
82
|
+
"type": { "const": "directory-exists" },
|
|
83
|
+
"path": { "type": "string" }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"probeKnowledgeDocExists": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"required": ["type", "path"],
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"properties": {
|
|
91
|
+
"type": { "const": "knowledge-doc-exists" },
|
|
92
|
+
"path": { "type": "string" }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"probeStagingRecentWrite": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"required": ["type", "path", "maxAgeDays"],
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"properties": {
|
|
100
|
+
"type": { "const": "staging-recent-write" },
|
|
101
|
+
"path": { "type": "string" },
|
|
102
|
+
"maxAgeDays": { "type": "number", "minimum": 1 }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"probeScheduleDefined": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"required": ["type", "path", "commandIncludes"],
|
|
108
|
+
"additionalProperties": false,
|
|
109
|
+
"properties": {
|
|
110
|
+
"type": { "const": "schedule-defined" },
|
|
111
|
+
"path": { "type": "string", "description": "Directory containing schedule JSON files (typically `schedules`)." },
|
|
112
|
+
"commandIncludes": {
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": { "type": "string" },
|
|
115
|
+
"minItems": 1,
|
|
116
|
+
"description": "Substrings that MUST all appear (AND) in a schedule's `command` field."
|
|
117
|
+
},
|
|
118
|
+
"commandIncludesAny": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"items": { "type": "string" },
|
|
121
|
+
"description": "Substrings where AT LEAST ONE (OR) must appear in the same schedule's command."
|
|
122
|
+
},
|
|
123
|
+
"outputPathHints": {
|
|
124
|
+
"type": "array",
|
|
125
|
+
"items": { "type": "string" },
|
|
126
|
+
"description": "Optional path substrings that, if present in the estate, corroborate the schedule's declared output."
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"probeJsonFieldIncludes": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"required": ["type", "path", "field", "expectedValue"],
|
|
133
|
+
"additionalProperties": false,
|
|
134
|
+
"properties": {
|
|
135
|
+
"type": { "const": "json-field-includes" },
|
|
136
|
+
"path": { "type": "string" },
|
|
137
|
+
"field": { "type": "string", "description": "Top-level JSON field name (dot-path not supported in v1)." },
|
|
138
|
+
"expectedValue": { "type": "string", "description": "Value expected in the field's array. Tokens: {paName}." },
|
|
139
|
+
"requiredTool": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "If set, the probe is skipped (unverified-here) when the named tool directory is absent under operator/extensions/tools/."
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// manifest.mjs — loader for the agent baseline manifest.
|
|
2
|
+
//
|
|
3
|
+
// The manifest itself lives at
|
|
4
|
+
// packages/core/knowledge/scaffold/agent-baseline.json
|
|
5
|
+
// and is shipped inside the core package (single-bundled by
|
|
6
|
+
// packages/engineering-pa/build.mjs at publish time). Downstream code
|
|
7
|
+
// resolves the on-disk path via `bundledManifestPath()` — no hard-coded
|
|
8
|
+
// consumer paths.
|
|
9
|
+
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path to the manifest shipped inside this package. Resolves both
|
|
19
|
+
* in the dev workspace (packages/core/knowledge/scaffold/agent-baseline.json)
|
|
20
|
+
* and in the published tarball (dist/engineering-pa-core/knowledge/scaffold/agent-baseline.json).
|
|
21
|
+
*/
|
|
22
|
+
export function bundledManifestPath() {
|
|
23
|
+
return resolve(__dirname, '..', '..', 'knowledge', 'scaffold', 'agent-baseline.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load the bundled manifest synchronously. Throws with a clear diagnostic
|
|
28
|
+
* if the file is missing or malformed — callers surface that via exit
|
|
29
|
+
* code 2 (unresolvable state).
|
|
30
|
+
*/
|
|
31
|
+
export function loadBundledManifestSync() {
|
|
32
|
+
const path = bundledManifestPath();
|
|
33
|
+
if (!existsSync(path)) {
|
|
34
|
+
const err = new Error(`agent baseline manifest missing at ${path}`);
|
|
35
|
+
err.exitCode = 2;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
const raw = readFileSync(path, 'utf8');
|
|
39
|
+
return parseManifest(raw, path);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Async loader for the bundled manifest. Same contract as the sync form.
|
|
44
|
+
*/
|
|
45
|
+
export async function loadBundledManifest() {
|
|
46
|
+
const path = bundledManifestPath();
|
|
47
|
+
const raw = await readFile(path, 'utf8');
|
|
48
|
+
return parseManifest(raw, path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse + shape-validate a raw manifest string. Rejects obvious structural
|
|
53
|
+
* corruption (missing capabilities array, empty ids, unknown probe types)
|
|
54
|
+
* — a false PASS at boot time would be worse than a loud crash.
|
|
55
|
+
*/
|
|
56
|
+
export function parseManifest(raw, sourcePath = '<manifest>') {
|
|
57
|
+
let doc;
|
|
58
|
+
try {
|
|
59
|
+
doc = JSON.parse(raw);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const wrapped = new Error(`agent baseline manifest at ${sourcePath} is not valid JSON: ${err.message}`);
|
|
62
|
+
wrapped.exitCode = 2;
|
|
63
|
+
throw wrapped;
|
|
64
|
+
}
|
|
65
|
+
if (!doc || typeof doc !== 'object') {
|
|
66
|
+
throw shapeError(`manifest ${sourcePath}: not an object`);
|
|
67
|
+
}
|
|
68
|
+
if (doc.schemaVersion !== '1.0') {
|
|
69
|
+
throw shapeError(`manifest ${sourcePath}: schemaVersion must be "1.0" (got ${JSON.stringify(doc.schemaVersion)})`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof doc.manifestVersion !== 'number' || !Number.isInteger(doc.manifestVersion) || doc.manifestVersion < 1) {
|
|
72
|
+
throw shapeError(`manifest ${sourcePath}: manifestVersion must be a positive integer`);
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(doc.capabilities)) {
|
|
75
|
+
throw shapeError(`manifest ${sourcePath}: capabilities must be an array`);
|
|
76
|
+
}
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const cap of doc.capabilities) {
|
|
79
|
+
validateCapability(cap, sourcePath, seen);
|
|
80
|
+
}
|
|
81
|
+
return doc;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateCapability(cap, sourcePath, seen) {
|
|
85
|
+
if (!cap || typeof cap !== 'object') {
|
|
86
|
+
throw shapeError(`manifest ${sourcePath}: capability entry is not an object`);
|
|
87
|
+
}
|
|
88
|
+
for (const field of ['id', 'label', 'sinceVersion', 'rationale']) {
|
|
89
|
+
if (typeof cap[field] !== 'string' || cap[field].length === 0) {
|
|
90
|
+
throw shapeError(`manifest ${sourcePath}: capability missing required string field '${field}'`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(cap.id)) {
|
|
94
|
+
throw shapeError(`manifest ${sourcePath}: capability id ${JSON.stringify(cap.id)} is not kebab-case`);
|
|
95
|
+
}
|
|
96
|
+
if (seen.has(cap.id)) {
|
|
97
|
+
throw shapeError(`manifest ${sourcePath}: duplicate capability id ${JSON.stringify(cap.id)}`);
|
|
98
|
+
}
|
|
99
|
+
seen.add(cap.id);
|
|
100
|
+
if (cap.severity && cap.severity !== 'fail' && cap.severity !== 'warn') {
|
|
101
|
+
throw shapeError(`manifest ${sourcePath}: capability ${cap.id} has invalid severity ${JSON.stringify(cap.severity)}`);
|
|
102
|
+
}
|
|
103
|
+
if (!cap.probe || typeof cap.probe !== 'object' || typeof cap.probe.type !== 'string') {
|
|
104
|
+
throw shapeError(`manifest ${sourcePath}: capability ${cap.id} has no probe.type`);
|
|
105
|
+
}
|
|
106
|
+
const supported = new Set([
|
|
107
|
+
'file-exists',
|
|
108
|
+
'directory-exists',
|
|
109
|
+
'staging-recent-write',
|
|
110
|
+
'knowledge-doc-exists',
|
|
111
|
+
'schedule-defined',
|
|
112
|
+
'json-field-includes',
|
|
113
|
+
]);
|
|
114
|
+
if (!supported.has(cap.probe.type)) {
|
|
115
|
+
throw shapeError(`manifest ${sourcePath}: capability ${cap.id} declares unknown probe.type ${JSON.stringify(cap.probe.type)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shapeError(msg) {
|
|
120
|
+
const err = new Error(msg);
|
|
121
|
+
err.exitCode = 2;
|
|
122
|
+
return err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Substitute {paName} tokens in a path template. Extend here if new tokens
|
|
127
|
+
* land — kept small on purpose (path templates are configuration, not code).
|
|
128
|
+
*/
|
|
129
|
+
export function expandTemplate(str, tokens) {
|
|
130
|
+
if (typeof str !== 'string') return str;
|
|
131
|
+
return str.replaceAll('{paName}', tokens.paName);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Derive the effective severity of a capability (default: fail).
|
|
136
|
+
*/
|
|
137
|
+
export function capabilitySeverity(cap) {
|
|
138
|
+
return cap.severity ?? 'fail';
|
|
139
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// envelope.mjs — pure functions for autonomy-envelope frontmatter parsing +
|
|
2
|
+
// rendering. Zero IO. All filesystem side effects live in cli.mjs.
|
|
3
|
+
//
|
|
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
|
+
}
|
|
@@ -11,6 +11,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
11
11
|
import { dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
|
+
import { emptyEnvelope, renderEnvelope } from '../../engineering-pa-core/src/autonomy/envelope.mjs';
|
|
15
|
+
|
|
14
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
|
|
16
18
|
/** Path to the CLAUDE.md template (relative to this module). */
|
|
@@ -254,6 +256,25 @@ export function buildJournalMd(config) {
|
|
|
254
256
|
].join('\n');
|
|
255
257
|
}
|
|
256
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Build the agents/{paName}/autonomy-envelope.md placeholder. The
|
|
261
|
+
* envelope tracks per-task-type approval state; ships as a stub so the
|
|
262
|
+
* CLAUDE.md template's reference resolves on day one and the agent
|
|
263
|
+
* baseline audit (`pa agent audit`) passes on a fresh scaffold.
|
|
264
|
+
*
|
|
265
|
+
* Uses the autonomy package's own `emptyEnvelope` + `renderEnvelope`
|
|
266
|
+
* seeders so the stub always round-trips through `parseEnvelope` — no
|
|
267
|
+
* risk of `pa autonomy get/set/list` throwing on a fresh scaffold
|
|
268
|
+
* because the seeder shape drifted from the parser's contract.
|
|
269
|
+
* `generatedAt` (a fixed ISO from the runner) pins `updated:` so the
|
|
270
|
+
* scaffold plan is deterministic.
|
|
271
|
+
*/
|
|
272
|
+
export function buildAutonomyEnvelopeMd(config) {
|
|
273
|
+
const { frontmatter } = emptyEnvelope(config.paName, config.operatorName);
|
|
274
|
+
frontmatter.updated = config.generatedAt;
|
|
275
|
+
return renderEnvelope(frontmatter, []);
|
|
276
|
+
}
|
|
277
|
+
|
|
257
278
|
/** Build the agents/{paName}/next-session.md placeholder. */
|
|
258
279
|
export function buildNextSessionMd(config) {
|
|
259
280
|
return [
|
|
@@ -303,6 +324,7 @@ export function buildPlanFromTemplates(config, templates) {
|
|
|
303
324
|
const profile = buildProfileMd(config);
|
|
304
325
|
const journal = buildJournalMd(config);
|
|
305
326
|
const nextSession = buildNextSessionMd(config);
|
|
327
|
+
const autonomyEnvelope = buildAutonomyEnvelopeMd(config);
|
|
306
328
|
|
|
307
329
|
const dirs = [
|
|
308
330
|
'operator',
|
|
@@ -339,6 +361,7 @@ export function buildPlanFromTemplates(config, templates) {
|
|
|
339
361
|
plan.push({ kind: 'file', path: `agents/${paName}/CLAUDE.md`, contents: claudeMd });
|
|
340
362
|
plan.push({ kind: 'file', path: `agents/${paName}/journal.md`, contents: journal });
|
|
341
363
|
plan.push({ kind: 'file', path: `agents/${paName}/next-session.md`, contents: nextSession });
|
|
364
|
+
plan.push({ kind: 'file', path: `agents/${paName}/autonomy-envelope.md`, contents: autonomyEnvelope });
|
|
342
365
|
plan.push({ kind: 'json', path: `agents/${paName}/spawn-log.json`, contents: spawnLog });
|
|
343
366
|
|
|
344
367
|
// Empty-directory markers — git won't track empty dirs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/digital-operator-pa",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "node build.mjs",
|
|
55
55
|
"test": "node --test --test-isolation=none test/*.test.mjs",
|
|
56
|
-
"dry-publish": "node build.mjs &&
|
|
56
|
+
"dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
|
|
57
57
|
}
|
|
58
58
|
}
|