@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
|
@@ -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
|
+
}
|