@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 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,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
+ }
@@ -0,0 +1,266 @@
1
+ // cli.mjs — argv router for `pa agent <verb>`.
2
+ //
3
+ // Verbs:
4
+ // pa agent audit Read cache, auto-refresh if stale, print
5
+ // summary + gap list. Fast path — cached.
6
+ // pa agent audit refresh Force rebuild of the audit cache.
7
+ //
8
+ // Global flags:
9
+ // --format <human|json> Output format (default: human)
10
+ // --quiet Suppress stdout; the cache write is the payload
11
+ // --strict Return non-zero exit if any capability is 'fail'
12
+ // --pa-name <name> Override the PA name (defaults to preferences.paName)
13
+ //
14
+ // Exit codes (tooling standard §6):
15
+ // 0 ok | 1 usage error | 2 unresolvable state (missing manifest,
16
+ // missing repo root, unresolvable paName, cache write failure). Under
17
+ // --strict, any 'fail' capability also produces exit 2.
18
+ //
19
+ // Cache: <repo-root>/.session/agent-audit.json.
20
+ // Staleness: 60 minutes, override via operator/config/preferences.json:
21
+ // agentAudit.cacheStalenessMinutes.
22
+ //
23
+ // Performance target: cache-read <2s (session-boot budget), refresh <3s
24
+ // on a real estate. All probes are Node-only synchronous, so the whole
25
+ // audit is a walk of a handful of files — well under budget.
26
+
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
+ import { hostname as osHostname } from 'node:os';
29
+ import { dirname, join } from 'node:path';
30
+
31
+ import { loadBundledManifestSync } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
32
+
33
+ import { auditManifest } from './audit.mjs';
34
+ import { getRepoRoot } from '../pa/repo-root.mjs';
35
+
36
+ const CLI_VERSION = '0.1.0';
37
+ const DEFAULT_STALENESS_MINUTES = 60;
38
+
39
+ const USAGE = `pa agent — agent baseline audit
40
+
41
+ Usage:
42
+ pa agent audit Read cache; auto-refresh if missing/stale
43
+ pa agent audit refresh Force rebuild of the audit cache
44
+ pa agent -h, --help Show this help
45
+
46
+ Global flags:
47
+ --format <human|json> Output format (default: human)
48
+ --quiet Suppress stdout summary
49
+ --strict Exit non-zero if any capability is 'fail'
50
+ --pa-name <name> Override the PA name (defaults to preferences.paName)
51
+
52
+ Cache file: <repo-root>/.session/agent-audit.json.
53
+ Staleness threshold: 60 minutes (override via operator/config/preferences.json:
54
+ agentAudit.cacheStalenessMinutes).
55
+
56
+ Exit codes (tooling standard §6):
57
+ 0 ok | 1 usage | 2 unresolvable state | 2 --strict fail
58
+ `;
59
+
60
+ /**
61
+ * Entry point. Returns an exit code.
62
+ */
63
+ export async function run(argv) {
64
+ const args = argv ?? [];
65
+ if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
66
+ process.stdout.write(USAGE);
67
+ return 0;
68
+ }
69
+ try {
70
+ // parseArgs is inside the try so usage errors (unknown flag, bad
71
+ // --format value) surface as the clean `[pa agent] ...` stderr line
72
+ // + intentional exit code, not a raw Node stack trace via the
73
+ // unhandled-rejection path.
74
+ const { positionals, flags } = parseArgs(args);
75
+ const verb = positionals[0];
76
+ const sub = positionals[1] ?? 'default';
77
+
78
+ if (verb !== 'audit') {
79
+ process.stderr.write(`[pa agent] unknown verb: ${verb}\n`);
80
+ process.stderr.write(USAGE);
81
+ return 1;
82
+ }
83
+
84
+ if (sub === 'default') return await runAudit({ flags, forceRefresh: false });
85
+ if (sub === 'refresh') return await runAudit({ flags, forceRefresh: true });
86
+ process.stderr.write(`[pa agent audit] unknown subverb: ${sub}\n`);
87
+ process.stderr.write(USAGE);
88
+ return 1;
89
+ } catch (err) {
90
+ process.stderr.write(`[pa agent] ${err.message || err}\n`);
91
+ return err.exitCode ?? 2;
92
+ }
93
+ }
94
+
95
+ function parseArgs(argv) {
96
+ const flags = { format: 'human', quiet: false, strict: false, paName: null };
97
+ const positionals = [];
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ if (arg === '--format') { flags.format = argv[++i]; continue; }
101
+ if (arg.startsWith('--format=')) { flags.format = arg.slice('--format='.length); continue; }
102
+ if (arg === '--quiet') { flags.quiet = true; continue; }
103
+ if (arg === '--strict') { flags.strict = true; continue; }
104
+ if (arg === '--pa-name') { flags.paName = argv[++i]; continue; }
105
+ if (arg.startsWith('--pa-name=')) { flags.paName = arg.slice('--pa-name='.length); continue; }
106
+ if (arg.startsWith('--')) {
107
+ const err = new Error(`unknown flag ${arg}`);
108
+ err.exitCode = 1;
109
+ throw err;
110
+ }
111
+ positionals.push(arg);
112
+ }
113
+ if (flags.format !== 'human' && flags.format !== 'json') {
114
+ const err = new Error(`--format must be human|json (got ${JSON.stringify(flags.format)})`);
115
+ err.exitCode = 1;
116
+ throw err;
117
+ }
118
+ return { positionals, flags };
119
+ }
120
+
121
+ async function runAudit({ flags, forceRefresh }) {
122
+ const repoRoot = await getRepoRoot();
123
+ const cachePath = cacheFilePath(repoRoot);
124
+ const staleness = readStalenessMinutes(repoRoot);
125
+ const paName = flags.paName ?? readPaName(repoRoot);
126
+ if (!paName) {
127
+ const err = new Error('paName not resolvable — set operator/config/preferences.json:paName or pass --pa-name');
128
+ err.exitCode = 2;
129
+ throw err;
130
+ }
131
+ const cached = forceRefresh ? null : readCache(cachePath);
132
+ const stale = cached ? isStale(cached, staleness) : true;
133
+ let audit;
134
+ if (!cached || stale) {
135
+ audit = performAudit({ repoRoot, paName });
136
+ writeCache(cachePath, audit);
137
+ } else {
138
+ audit = cached;
139
+ }
140
+ if (!flags.quiet) {
141
+ if (flags.format === 'json') {
142
+ process.stdout.write(JSON.stringify(audit, null, 2) + '\n');
143
+ } else {
144
+ process.stdout.write(formatHuman(audit) + '\n');
145
+ }
146
+ }
147
+ if (flags.strict && audit.summary.fail > 0) return 2;
148
+ return 0;
149
+ }
150
+
151
+ /**
152
+ * Perform a fresh audit — pure orchestration around the manifest loader
153
+ * and the audit runner. Exported for the boot-context hook and tests.
154
+ */
155
+ export function performAudit({ repoRoot, paName, now, hostname }) {
156
+ const manifest = loadBundledManifestSync();
157
+ const ctx = {
158
+ repoRoot,
159
+ paName,
160
+ hostname: hostname ?? safeHostname(),
161
+ now: now ?? Date.now(),
162
+ };
163
+ return auditManifest(manifest, ctx);
164
+ }
165
+
166
+ function safeHostname() {
167
+ try { return osHostname(); } catch { return null; }
168
+ }
169
+
170
+ function cacheFilePath(repoRoot) {
171
+ return join(repoRoot, '.session', 'agent-audit.json');
172
+ }
173
+
174
+ function readCache(path) {
175
+ if (!existsSync(path)) return null;
176
+ try {
177
+ return JSON.parse(readFileSync(path, 'utf8'));
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function writeCache(path, cache) {
184
+ try {
185
+ mkdirSync(dirname(path), { recursive: true });
186
+ writeFileSync(path, JSON.stringify(cache, null, 2) + '\n');
187
+ } catch (err) {
188
+ const wrapped = new Error(`could not write cache to ${path}: ${err.message}`);
189
+ wrapped.exitCode = 2;
190
+ throw wrapped;
191
+ }
192
+ }
193
+
194
+ function isStale(cache, stalenessMinutes) {
195
+ if (!cache?.capturedAt) return true;
196
+ const capturedMs = Date.parse(cache.capturedAt);
197
+ if (!Number.isFinite(capturedMs)) return true;
198
+ const ageMs = Date.now() - capturedMs;
199
+ return ageMs > stalenessMinutes * 60 * 1000;
200
+ }
201
+
202
+ function readStalenessMinutes(repoRoot) {
203
+ try {
204
+ const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
205
+ if (existsSync(prefsPath)) {
206
+ const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
207
+ const value = prefs?.agentAudit?.cacheStalenessMinutes;
208
+ if (typeof value === 'number' && value > 0) return value;
209
+ }
210
+ } catch {
211
+ // fall through
212
+ }
213
+ return DEFAULT_STALENESS_MINUTES;
214
+ }
215
+
216
+ function readPaName(repoRoot) {
217
+ try {
218
+ const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
219
+ if (existsSync(prefsPath)) {
220
+ const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
221
+ if (typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
222
+ }
223
+ } catch {
224
+ // fall through
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function formatHuman(audit) {
230
+ const parts = [];
231
+ parts.push(`Agent baseline audit @ ${audit.capturedAt}`);
232
+ parts.push(` manifest v${audit.manifestVersion} (schema ${audit.schemaVersion})`);
233
+ parts.push(` paName: ${audit.context.paName} hostname: ${audit.context.hostname ?? '-'}`);
234
+ parts.push('');
235
+ const s = audit.summary;
236
+ parts.push(
237
+ `Summary: ${s.pass}/${s.total} pass, ${s.fail} fail, ${s.warn} warn, ${s.unverifiedHere} unverified-here`,
238
+ );
239
+ parts.push('');
240
+ for (const cap of audit.capabilities) {
241
+ const marker = statusMarker(cap.status);
242
+ parts.push(` ${marker} ${cap.id.padEnd(28)} ${cap.label}`);
243
+ parts.push(` ${cap.detail}`);
244
+ }
245
+ return parts.join('\n');
246
+ }
247
+
248
+ function statusMarker(status) {
249
+ switch (status) {
250
+ case 'pass': return '[PASS]';
251
+ case 'warn': return '[WARN]';
252
+ case 'fail': return '[FAIL]';
253
+ case 'unverified-here': return '[UNVERIFIED-HERE]';
254
+ default: return '[?]';
255
+ }
256
+ }
257
+
258
+ // Test seam: expose helpers.
259
+ export const _internals = {
260
+ cacheFilePath,
261
+ readCache,
262
+ readStalenessMinutes,
263
+ readPaName,
264
+ formatHuman,
265
+ parseArgs,
266
+ };
@@ -0,0 +1,236 @@
1
+ // probes.mjs — mechanical presence-probes for the agent baseline manifest.
2
+ //
3
+ // Every probe is pure Node stdlib, synchronous, and returns a shape:
4
+ // { outcome: 'pass' | 'fail' | 'unverified-here', detail: '<string>' }
5
+ //
6
+ // The caller (packages/cli/src/agent/cli.mjs) folds severity and
7
+ // hostDependent flags on top of these mechanical outcomes to arrive at
8
+ // the final rendered status.
9
+ //
10
+ // Cross-platform: no `which`, no shell scripts, no external tools.
11
+ // Filesystem access via node:fs sync; JSON parsed inline. All errors are
12
+ // converted to `unverified-here` with a diagnostic string — a probe must
13
+ // never throw and never crash the audit.
14
+
15
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ import { expandTemplate } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
19
+
20
+ const PASS = 'pass';
21
+ const FAIL = 'fail';
22
+ const UNVERIFIED = 'unverified-here';
23
+
24
+ /**
25
+ * Dispatch a single probe against a repo root. Never throws.
26
+ */
27
+ export function runProbe(probe, ctx) {
28
+ try {
29
+ switch (probe.type) {
30
+ case 'file-exists': return probeFileExists(probe, ctx);
31
+ case 'directory-exists': return probeDirectoryExists(probe, ctx);
32
+ case 'staging-recent-write': return probeStagingRecentWrite(probe, ctx);
33
+ case 'knowledge-doc-exists': return probeKnowledgeDocExists(probe, ctx);
34
+ case 'schedule-defined': return probeScheduleDefined(probe, ctx);
35
+ case 'json-field-includes': return probeJsonFieldIncludes(probe, ctx);
36
+ default:
37
+ return {
38
+ outcome: UNVERIFIED,
39
+ detail: `unknown probe type: ${probe.type}`,
40
+ };
41
+ }
42
+ } catch (err) {
43
+ // Probes must never abort the audit — degrade to unverified-here with
44
+ // the diagnostic. A missing repoRoot / permission denied should look
45
+ // like a diagnostic on the line, not a stack trace.
46
+ return { outcome: UNVERIFIED, detail: `probe threw: ${err?.message || String(err)}` };
47
+ }
48
+ }
49
+
50
+ function probeFileExists(probe, ctx) {
51
+ const rel = expandTemplate(probe.path, ctx);
52
+ const abs = join(ctx.repoRoot, rel);
53
+ if (!existsSync(abs)) {
54
+ return { outcome: FAIL, detail: `missing: ${rel}` };
55
+ }
56
+ // Symmetrical with probeDirectoryExists: exists-but-wrong-shape is a
57
+ // hard fail, not a pass. A dir sitting where a file should be defeats
58
+ // the whole intent — `readFileSync` would throw on it.
59
+ try {
60
+ if (!statSync(abs).isFile()) {
61
+ return { outcome: FAIL, detail: `path exists but is not a file: ${rel}` };
62
+ }
63
+ } catch (err) {
64
+ return { outcome: UNVERIFIED, detail: `stat failed for ${rel}: ${err.message}` };
65
+ }
66
+ return { outcome: PASS, detail: `present at ${rel}` };
67
+ }
68
+
69
+ function probeDirectoryExists(probe, ctx) {
70
+ const rel = expandTemplate(probe.path, ctx);
71
+ const abs = join(ctx.repoRoot, rel);
72
+ if (!existsSync(abs)) {
73
+ return { outcome: FAIL, detail: `directory missing: ${rel}` };
74
+ }
75
+ try {
76
+ if (!statSync(abs).isDirectory()) {
77
+ return { outcome: FAIL, detail: `path exists but is not a directory: ${rel}` };
78
+ }
79
+ } catch (err) {
80
+ return { outcome: UNVERIFIED, detail: `stat failed for ${rel}: ${err.message}` };
81
+ }
82
+ return { outcome: PASS, detail: `directory present at ${rel}` };
83
+ }
84
+
85
+ function probeKnowledgeDocExists(probe, ctx) {
86
+ // Currently a thin alias for file-exists — kept as its own probe type so
87
+ // future extensions (link-in-graph, non-orphan) can enrich the check
88
+ // without a manifest schema break.
89
+ return probeFileExists(probe, ctx);
90
+ }
91
+
92
+ function probeStagingRecentWrite(probe, ctx) {
93
+ const rel = expandTemplate(probe.path, ctx);
94
+ const abs = join(ctx.repoRoot, rel);
95
+ if (!existsSync(abs)) {
96
+ return { outcome: FAIL, detail: `staging dir missing: ${rel}` };
97
+ }
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(abs);
101
+ } catch (err) {
102
+ return { outcome: UNVERIFIED, detail: `readdir failed for ${rel}: ${err.message}` };
103
+ }
104
+ const maxAgeMs = probe.maxAgeDays * 24 * 60 * 60 * 1000;
105
+ const now = ctx.now ?? Date.now();
106
+ let latestMs = 0;
107
+ let latestName = null;
108
+ for (const name of entries) {
109
+ if (name.startsWith('.')) continue;
110
+ try {
111
+ const st = statSync(join(abs, name));
112
+ const mtime = st.mtimeMs;
113
+ if (mtime > latestMs) {
114
+ latestMs = mtime;
115
+ latestName = name;
116
+ }
117
+ } catch {
118
+ // ignore per-entry stat failures; keep scanning
119
+ }
120
+ }
121
+ if (latestMs === 0) {
122
+ return { outcome: FAIL, detail: `staging dir has no non-hidden entries: ${rel}` };
123
+ }
124
+ const ageDays = (now - latestMs) / (24 * 60 * 60 * 1000);
125
+ if (now - latestMs > maxAgeMs) {
126
+ return {
127
+ outcome: FAIL,
128
+ detail: `newest entry ${latestName} is ${ageDays.toFixed(1)}d old (max ${probe.maxAgeDays}d)`,
129
+ };
130
+ }
131
+ return {
132
+ outcome: PASS,
133
+ detail: `newest entry ${latestName} is ${ageDays.toFixed(1)}d old`,
134
+ };
135
+ }
136
+
137
+ function probeScheduleDefined(probe, ctx) {
138
+ const rel = expandTemplate(probe.path, ctx);
139
+ const dir = join(ctx.repoRoot, rel);
140
+ if (!existsSync(dir)) {
141
+ return { outcome: FAIL, detail: `schedules dir missing: ${rel}` };
142
+ }
143
+ let files;
144
+ try {
145
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'));
146
+ } catch (err) {
147
+ return { outcome: UNVERIFIED, detail: `readdir failed for ${rel}: ${err.message}` };
148
+ }
149
+ const includesAll = (probe.commandIncludes ?? []).map(String);
150
+ const includesAny = (probe.commandIncludesAny ?? []).map(String);
151
+ const matches = [];
152
+ for (const name of files) {
153
+ let sched;
154
+ try {
155
+ sched = JSON.parse(readFileSync(join(dir, name), 'utf8'));
156
+ } catch {
157
+ continue;
158
+ }
159
+ if (sched?.enabled === false) continue;
160
+ const cmd = typeof sched?.command === 'string' ? sched.command : '';
161
+ if (!cmd) continue;
162
+ const allOk = includesAll.every((s) => cmd.includes(s));
163
+ const anyOk = includesAny.length === 0 || includesAny.some((s) => cmd.includes(s));
164
+ if (allOk && anyOk) matches.push({ file: name, host: sched.host ?? null });
165
+ }
166
+ if (matches.length === 0) {
167
+ return {
168
+ outcome: FAIL,
169
+ detail: `no enabled schedule matches include=${JSON.stringify(includesAll)} any=${JSON.stringify(includesAny)}`,
170
+ };
171
+ }
172
+ // If the matching schedule is host-pinned to a different host from the
173
+ // audited host, the estate declares it — just not verifiable HERE. The
174
+ // caller sees this via the outcome + host context on the audit line.
175
+ const currentHost = ctx.hostname ?? null;
176
+ const hostMatched = matches.some((m) => !m.host || matchesHost(m.host, currentHost));
177
+ if (!hostMatched) {
178
+ const declared = matches.map((m) => m.host).filter(Boolean).join(', ');
179
+ return {
180
+ outcome: UNVERIFIED,
181
+ detail: `declared on host(s) ${declared} — not this host (${currentHost ?? 'unknown'})`,
182
+ };
183
+ }
184
+ return {
185
+ outcome: PASS,
186
+ detail: `${matches.length} matching schedule(s): ${matches.map((m) => m.file).join(', ')}`,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Match a schedule.host string against the current hostname. Mirrors the
192
+ * scheduler's convention: exact match, or short-name-before-first-dot.
193
+ */
194
+ function matchesHost(scheduleHost, currentHost) {
195
+ if (!currentHost) return false;
196
+ if (scheduleHost === currentHost) return true;
197
+ const shortSched = scheduleHost.split('.')[0];
198
+ const shortCurrent = currentHost.split('.')[0];
199
+ return shortSched === shortCurrent;
200
+ }
201
+
202
+ function probeJsonFieldIncludes(probe, ctx) {
203
+ // Optional gating on a required tool — if the tool's directory is
204
+ // absent, the whole capability doesn't apply on this estate, and we
205
+ // report unverified-here rather than fail.
206
+ if (probe.requiredTool) {
207
+ const toolDir = join(ctx.repoRoot, 'operator', 'extensions', 'tools', probe.requiredTool);
208
+ if (!existsSync(toolDir)) {
209
+ return { outcome: UNVERIFIED, detail: `required tool not installed on this estate: ${probe.requiredTool}` };
210
+ }
211
+ }
212
+ const rel = expandTemplate(probe.path, ctx);
213
+ const abs = join(ctx.repoRoot, rel);
214
+ if (!existsSync(abs)) {
215
+ return { outcome: FAIL, detail: `config file missing: ${rel}` };
216
+ }
217
+ let doc;
218
+ try {
219
+ doc = JSON.parse(readFileSync(abs, 'utf8'));
220
+ } catch (err) {
221
+ return { outcome: UNVERIFIED, detail: `${rel} not valid JSON: ${err.message}` };
222
+ }
223
+ const field = probe.field;
224
+ const value = expandTemplate(probe.expectedValue, ctx);
225
+ const target = doc?.[field];
226
+ if (!Array.isArray(target)) {
227
+ return { outcome: FAIL, detail: `${rel}: field '${field}' is not an array` };
228
+ }
229
+ if (!target.includes(value)) {
230
+ return {
231
+ outcome: FAIL,
232
+ detail: `${rel}: field '${field}' does not include ${JSON.stringify(value)}; got ${JSON.stringify(target)}`,
233
+ };
234
+ }
235
+ return { outcome: PASS, detail: `${rel}: '${field}' includes ${JSON.stringify(value)}` };
236
+ }
@@ -21,7 +21,7 @@ import {
21
21
  validateFrontmatter,
22
22
  nowIso,
23
23
  emptyEnvelope,
24
- } from './envelope.mjs';
24
+ } from '../../../engineering-pa-core/src/autonomy/envelope.mjs';
25
25
 
26
26
  const USAGE = `pa autonomy — per-agent per-task-type approval state
27
27