agentic-workflow-manager 9.17.0 → 9.19.0

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.
Files changed (36) hide show
  1. package/dist/src/commands/env/index.js +192 -0
  2. package/dist/src/commands/plan/index.js +1 -1
  3. package/dist/src/commands/registry/doctor.js +428 -0
  4. package/dist/src/commands/registry/index.js +52 -0
  5. package/dist/src/commands/registry/scaffold.js +140 -0
  6. package/dist/src/commands/sensors/read-only-observation.js +305 -0
  7. package/dist/src/commands/sensors/run.js +23 -60
  8. package/dist/src/commands/watch/index.js +3 -0
  9. package/dist/src/core/admission/index.js +11 -1
  10. package/dist/src/core/currentness/check.js +5 -2
  11. package/dist/src/core/environments/resolve.js +122 -0
  12. package/dist/src/core/environments/store.js +323 -0
  13. package/dist/src/core/facts/catalog.js +138 -0
  14. package/dist/src/core/facts/needs.js +57 -0
  15. package/dist/src/core/facts/types.js +21 -0
  16. package/dist/src/index.js +2 -0
  17. package/dist/tests/commands/env/index.test.js +261 -0
  18. package/dist/tests/commands/registry/doctor.test.js +283 -0
  19. package/dist/tests/commands/registry/scaffold.test.js +186 -0
  20. package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
  21. package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
  22. package/dist/tests/commands/watch/reopened-196.test.js +9 -2
  23. package/dist/tests/core/admission/index.test.js +60 -0
  24. package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
  25. package/dist/tests/core/currentness/check.test.js +75 -14
  26. package/dist/tests/core/environments/resolve.test.js +347 -0
  27. package/dist/tests/core/environments/store.test.js +248 -0
  28. package/dist/tests/core/facts/catalog.test.js +179 -0
  29. package/dist/tests/core/facts/needs.test.js +88 -0
  30. package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
  31. package/dist/tests/structural/operator-environments-docs-contract.test.js +70 -0
  32. package/package.json +1 -1
  33. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  34. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  35. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  36. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -101,6 +101,16 @@ function currentness(report, consumedRegistryComponents) {
101
101
  return { status, cli, diagnostics: [diagnostic('ADMISSION_CURRENTNESS_BLOCKED', `Consumed contract currentness is ${status}: ${names}.`)] };
102
102
  }
103
103
  function sensorVerdict(report) { return report.overall === 'not_certified' ? 'not-certified' : report.overall === 'pass' ? 'pass' : 'fail'; }
104
+ /** A not-certified verdict alone does not say what to fix; carry the run's own cause. */
105
+ function sensorBlockedMessage(report, verdict) {
106
+ // The diagnostic code must stay the message's only ADMISSION_ token (recovery classification counts them); echoed text may carry user-chosen ones.
107
+ const plain = (text) => text.trim().replace(/ADMISSION_/g, 'ADMISSION-');
108
+ const reason = verdict === 'not-certified' && typeof report.reason === 'string' ? plain(report.reason) : '';
109
+ if (!reason)
110
+ return `Sensor verdict is ${verdict}.`;
111
+ const remedy = typeof report.remedy === 'string' ? plain(report.remedy).replace(/\.+$/, '') : '';
112
+ return remedy ? `Sensor verdict is ${verdict} (${reason}): ${remedy}.` : `Sensor verdict is ${verdict} (${reason}).`;
113
+ }
104
114
  function journalStatus(input, plan) {
105
115
  if (input.journalCorrupt)
106
116
  return 'corrupt';
@@ -237,7 +247,7 @@ async function admitPlan(input) {
237
247
  const sensors = sensorVerdict(input.sensors);
238
248
  const registryOptOut = sensors === 'not-certified' && input.sensorClosurePolicy === 'r8-registry-opt-out' && input.sensors.mode === 'opt-out';
239
249
  if (sensors !== 'pass' && !registryOptOut)
240
- return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED', `Sensor verdict is ${sensors}.`)], { planDigest: plan.planDigest, provider, executionMode: mode, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors });
250
+ return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED', sensorBlockedMessage(input.sensors, sensors))], { planDigest: plan.planDigest, provider, executionMode: mode, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors });
241
251
  sensorsReported = sensors;
242
252
  if (registryOptOut)
243
253
  sensorEvidence = { sensorEvidence: 'r8-registry-opt-out' };
@@ -162,12 +162,15 @@ async function registryComponent(registry, git, prefs, checkedAt) {
162
162
  if (!authorizedSource)
163
163
  return fail('Configured registry remote is not an authoritative HTTPS or SSH source.', 'Configure an authoritative registry remote and rerun strict preflight.');
164
164
  const [originResult, headResult, tagResult, remoteResult] = await Promise.allSettled([
165
- deadline(git(registry.contentRoot, ['remote', 'get-url', 'origin'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
165
+ // The literal stored value of THIS checkout: `git remote get-url` applies url.*.insteadOf, and
166
+ // cloud environments (AWM's own D-7 recipe) rewrite to a token-bearing URL; without
167
+ // `--local`, a global/system `remote.origin.url` could stand in for a missing origin.
168
+ deadline(git(registry.contentRoot, ['config', '--local', '--get', 'remote.origin.url'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
166
169
  deadline(git(registry.contentRoot, ['rev-parse', 'HEAD'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
167
170
  deadline(git(registry.contentRoot, ['describe', '--tags', '--exact-match', 'HEAD'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
168
171
  deadline(git(registry.contentRoot, ['ls-remote', '--tags', registry.remote], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
169
172
  ]);
170
- if (originResult.status === 'rejected')
173
+ if (originResult.status === 'rejected' || originResult.value.trim() === '')
171
174
  return fail('Could not read local registry origin.', 'Inspect the local registry checkout and its origin remote.');
172
175
  if (headResult.status === 'rejected')
173
176
  return fail('Could not read local registry HEAD.', 'Inspect or repair the local registry checkout.');
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONTEXT_INJECT_MAX_CHARS = void 0;
4
+ exports.statusFor = statusFor;
5
+ exports.buildInjection = buildInjection;
6
+ /** Fixed budget for `inject: context` fresh values (RF-3.2). */
7
+ exports.CONTEXT_INJECT_MAX_CHARS = 2048;
8
+ function assertValidNow(now) {
9
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
10
+ throw new Error('now must be a Date with a finite getTime()');
11
+ }
12
+ }
13
+ function effectiveStatus(definitionDurability, stored, now) {
14
+ if (!stored)
15
+ return 'missing';
16
+ if (stored.status === 'missing')
17
+ return 'missing';
18
+ if (stored.status === 'stale')
19
+ return 'stale';
20
+ // Durable never time-stales (RF-2.3).
21
+ if (definitionDurability === 'durable') {
22
+ return stored.status === 'fresh' ? 'fresh' : stored.status;
23
+ }
24
+ // Volatile: fail closed — missing/invalid expiresAt or now >= expiresAt → stale (RF-2.4).
25
+ if (typeof stored.expiresAt !== 'string' || stored.expiresAt.trim() === '') {
26
+ return 'stale';
27
+ }
28
+ const expiresMs = Date.parse(stored.expiresAt);
29
+ if (!Number.isFinite(expiresMs) || now.getTime() >= expiresMs) {
30
+ return 'stale';
31
+ }
32
+ return stored.status === 'fresh' ? 'fresh' : stored.status;
33
+ }
34
+ /**
35
+ * Per-catalog-id status against the store. Missing → `missing` with no default (RF-2.5).
36
+ */
37
+ function statusFor(catalog, store, now = new Date()) {
38
+ if (!catalog || typeof catalog !== 'object' || !catalog.facts) {
39
+ throw new Error('statusFor: catalog with facts is required');
40
+ }
41
+ if (!store || typeof store !== 'object') {
42
+ throw new Error('statusFor: store must be an object');
43
+ }
44
+ assertValidNow(now);
45
+ const out = {};
46
+ for (const [id, def] of Object.entries(catalog.facts)) {
47
+ out[id] = effectiveStatus(def.durability, store[id], now);
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Build injection payload. Default / explicit `reference` exposes path + ids only (RF-3.1).
53
+ * `context` includes fresh `inject: context` values under budget; overflow degrades (RF-3.2).
54
+ */
55
+ function buildInjection(opts) {
56
+ if (!opts || typeof opts !== 'object') {
57
+ throw new Error('buildInjection: options object is required');
58
+ }
59
+ if (!opts.catalog?.facts) {
60
+ throw new Error('buildInjection: catalog with facts is required');
61
+ }
62
+ if (typeof opts.storePath !== 'string' || opts.storePath.trim() === '') {
63
+ throw new Error('buildInjection: storePath must be a non-empty string');
64
+ }
65
+ if (!opts.store || typeof opts.store !== 'object') {
66
+ throw new Error('buildInjection: store must be an object');
67
+ }
68
+ const rawInject = opts.inject ?? 'reference';
69
+ // Unknown/typo must not fall through into context (fail-open). Prefer reference (RNF-2).
70
+ const inject = rawInject === 'reference' || rawInject === 'context' ? rawInject : 'reference';
71
+ const maxChars = opts.maxChars ?? exports.CONTEXT_INJECT_MAX_CHARS;
72
+ if (typeof maxChars !== 'number' || !Number.isFinite(maxChars) || maxChars < 0) {
73
+ throw new Error('buildInjection: maxChars must be a non-negative finite number');
74
+ }
75
+ const now = opts.now ?? new Date();
76
+ assertValidNow(now);
77
+ if (opts.factIds !== undefined) {
78
+ if (!Array.isArray(opts.factIds) || opts.factIds.some((id) => typeof id !== 'string' || id === '')) {
79
+ throw new Error('buildInjection: factIds must be an array of non-empty strings');
80
+ }
81
+ }
82
+ const factIds = opts.factIds ?? Object.keys(opts.catalog.facts);
83
+ const statuses = statusFor(opts.catalog, opts.store, now);
84
+ const referenceResult = () => ({
85
+ mode: 'reference',
86
+ storePath: opts.storePath,
87
+ factIds: [...factIds],
88
+ });
89
+ if (inject !== 'context') {
90
+ return referenceResult();
91
+ }
92
+ // Context mode: only fresh values whose catalog inject is `context` (RF-3.2).
93
+ const contextValues = {};
94
+ let totalChars = 0;
95
+ for (const id of factIds) {
96
+ const def = opts.catalog.facts[id];
97
+ if (!def || def.inject !== 'context')
98
+ continue;
99
+ if (statuses[id] !== 'fresh')
100
+ continue;
101
+ const stored = opts.store[id];
102
+ if (!stored || typeof stored.value !== 'string')
103
+ continue;
104
+ totalChars += stored.value.length;
105
+ contextValues[id] = stored.value;
106
+ }
107
+ if (Object.keys(contextValues).length === 0) {
108
+ return referenceResult();
109
+ }
110
+ if (totalChars > maxChars) {
111
+ return {
112
+ ...referenceResult(),
113
+ warning: `Context inject budget exceeded (${totalChars} > ${maxChars} chars); degraded to reference`,
114
+ };
115
+ }
116
+ return {
117
+ mode: 'context',
118
+ storePath: opts.storePath,
119
+ factIds: [...factIds],
120
+ values: contextValues,
121
+ };
122
+ }
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EnvironmentStoreError = void 0;
7
+ exports.environmentsRoot = environmentsRoot;
8
+ exports.envDir = envDir;
9
+ exports.valuesPath = valuesPath;
10
+ exports.getActiveEnvironmentName = getActiveEnvironmentName;
11
+ exports.setActiveEnvironmentName = setActiveEnvironmentName;
12
+ exports.readValues = readValues;
13
+ exports.isInsideRegistryTree = isInsideRegistryTree;
14
+ exports.assertNotInsideRegistryTree = assertNotInsideRegistryTree;
15
+ exports.writeEnvValue = writeEnvValue;
16
+ exports.writeFact = writeFact;
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const atomic_file_1 = require("../atomic-file");
20
+ const paths_1 = require("../paths");
21
+ const registries_1 = require("../registries");
22
+ class EnvironmentStoreError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'EnvironmentStoreError';
26
+ }
27
+ }
28
+ exports.EnvironmentStoreError = EnvironmentStoreError;
29
+ const DEFAULT_ENV_NAME = 'default';
30
+ const META_FILE = 'meta.json';
31
+ const VALUES_FILE = 'values.json';
32
+ function isRecord(value) {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+ /** Root of machine-local environment stores: `AWM_HOME/environments`. */
36
+ function environmentsRoot() {
37
+ return path_1.default.join((0, paths_1.awmHome)(), 'environments');
38
+ }
39
+ function envDir(name) {
40
+ assertEnvName(name);
41
+ return path_1.default.join(environmentsRoot(), name);
42
+ }
43
+ function valuesPath(name) {
44
+ return path_1.default.join(envDir(name), VALUES_FILE);
45
+ }
46
+ function metaPath() {
47
+ return path_1.default.join(environmentsRoot(), META_FILE);
48
+ }
49
+ function assertEnvName(name) {
50
+ if (typeof name !== 'string' || name.trim() === '') {
51
+ throw new EnvironmentStoreError('environment name must be a non-empty string');
52
+ }
53
+ if (name.includes('\0')) {
54
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}: NUL byte`);
55
+ }
56
+ const lower = name.toLowerCase();
57
+ if (lower === META_FILE.toLowerCase() || lower === VALUES_FILE.toLowerCase()) {
58
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}: reserved store filename`);
59
+ }
60
+ if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.' || name === '..') {
61
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}`);
62
+ }
63
+ }
64
+ function readMeta() {
65
+ const file = metaPath();
66
+ if (!fs_1.default.existsSync(file))
67
+ return null;
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
71
+ }
72
+ catch (e) {
73
+ throw new EnvironmentStoreError(`Invalid environments meta at ${file}: ${e instanceof Error ? e.message : String(e)}`);
74
+ }
75
+ if (!isRecord(raw) || typeof raw.active !== 'string' || raw.active.trim() === '') {
76
+ throw new EnvironmentStoreError(`Invalid environments meta at ${file}: expected { active: string }`);
77
+ }
78
+ return { active: raw.active };
79
+ }
80
+ function writeMeta(meta) {
81
+ const file = metaPath();
82
+ assertNotInsideRegistryTree(file);
83
+ (0, atomic_file_1.writeFileAtomic)(file, JSON.stringify(meta, null, 2) + '\n');
84
+ }
85
+ /** Exactly one active environment per machine; default name `default` (RF-2.2). */
86
+ function getActiveEnvironmentName() {
87
+ const meta = readMeta();
88
+ if (!meta)
89
+ return DEFAULT_ENV_NAME;
90
+ assertEnvName(meta.active);
91
+ return meta.active;
92
+ }
93
+ function setActiveEnvironmentName(name) {
94
+ assertEnvName(name);
95
+ writeMeta({ active: name });
96
+ }
97
+ function parseStoredEntry(id, raw) {
98
+ if (!isRecord(raw)) {
99
+ throw new EnvironmentStoreError(`values.${id} must be an object`);
100
+ }
101
+ if (typeof raw.value !== 'string') {
102
+ throw new EnvironmentStoreError(`values.${id}.value must be a string`);
103
+ }
104
+ if (raw.status !== 'fresh' && raw.status !== 'stale' && raw.status !== 'missing') {
105
+ throw new EnvironmentStoreError(`values.${id}.status must be fresh|stale|missing`);
106
+ }
107
+ if (typeof raw.updatedAt !== 'string') {
108
+ throw new EnvironmentStoreError(`values.${id}.updatedAt must be a string`);
109
+ }
110
+ const entry = {
111
+ value: raw.value,
112
+ status: raw.status,
113
+ updatedAt: raw.updatedAt,
114
+ };
115
+ if (raw.expiresAt !== undefined) {
116
+ if (typeof raw.expiresAt !== 'string') {
117
+ throw new EnvironmentStoreError(`values.${id}.expiresAt must be a string`);
118
+ }
119
+ entry.expiresAt = raw.expiresAt;
120
+ }
121
+ return entry;
122
+ }
123
+ function readValues(name) {
124
+ const file = valuesPath(name);
125
+ if (!fs_1.default.existsSync(file))
126
+ return {};
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
130
+ }
131
+ catch (e) {
132
+ throw new EnvironmentStoreError(`Invalid values.json at ${file}: ${e instanceof Error ? e.message : String(e)}`);
133
+ }
134
+ if (!isRecord(raw)) {
135
+ throw new EnvironmentStoreError(`values.json at ${file} must be a JSON object`);
136
+ }
137
+ const out = Object.create(null);
138
+ for (const [id, entry] of Object.entries(raw)) {
139
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') {
140
+ throw new EnvironmentStoreError(`values.json at ${file}: forbidden fact id ${JSON.stringify(id)}`);
141
+ }
142
+ out[id] = parseStoredEntry(id, entry);
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Resolve a path through any intermediate symlinks (including when the leaf does
148
+ * not exist yet). Walks up to the nearest existing ancestor, realpaths it, then
149
+ * re-appends missing segments — same spirit as sensors/ledger realpath containment.
150
+ */
151
+ function resolvePathForContainment(targetPath) {
152
+ const absolute = path_1.default.resolve(targetPath);
153
+ const missing = [];
154
+ let cursor = absolute;
155
+ while (!fs_1.default.existsSync(cursor)) {
156
+ const parent = path_1.default.dirname(cursor);
157
+ if (parent === cursor)
158
+ break;
159
+ missing.unshift(path_1.default.basename(cursor));
160
+ cursor = parent;
161
+ }
162
+ let realBase;
163
+ try {
164
+ realBase = fs_1.default.realpathSync.native(cursor);
165
+ }
166
+ catch {
167
+ realBase = cursor;
168
+ }
169
+ return missing.length === 0 ? realBase : path_1.default.join(realBase, ...missing);
170
+ }
171
+ function isPathInsideRoot(candidate, root) {
172
+ if ((0, paths_1.sameExistingPath)(candidate, root))
173
+ return true;
174
+ const rel = path_1.default.relative(root, candidate);
175
+ if (rel === '')
176
+ return true;
177
+ if (rel === '..')
178
+ return false;
179
+ if (!rel.startsWith(`..${path_1.default.sep}`) && !rel.startsWith('..') && !path_1.default.isAbsolute(rel))
180
+ return true;
181
+ const prefix = root.endsWith(path_1.default.sep) ? root : root + path_1.default.sep;
182
+ return candidate === root || candidate.startsWith(prefix);
183
+ }
184
+ /**
185
+ * True when `targetPath` (after resolving symlinks) is the same as or nested under
186
+ * any registry content root. Catches AWM_HOME/environments → registry symlink (RNF-1).
187
+ *
188
+ * Always unions `contentRoots()` with any explicit `registryRoots`. An empty
189
+ * explicit array no longer disables checks — callers that need extra roots pass
190
+ * them; they cannot opt out of installed content roots.
191
+ */
192
+ function isInsideRegistryTree(targetPath, registryRoots) {
193
+ if (typeof targetPath !== 'string' || targetPath.trim() === '') {
194
+ throw new EnvironmentStoreError('target path must be a non-empty string');
195
+ }
196
+ const lexicalTarget = path_1.default.resolve(targetPath);
197
+ const realTarget = resolvePathForContainment(targetPath);
198
+ const roots = [...(0, registries_1.contentRoots)(), ...(registryRoots ?? [])];
199
+ for (const root of roots) {
200
+ if (typeof root !== 'string' || root.trim() === '')
201
+ continue;
202
+ const lexicalRoot = path_1.default.resolve(root);
203
+ const realRoot = resolvePathForContainment(root);
204
+ if (isPathInsideRoot(realTarget, realRoot))
205
+ return true;
206
+ // Also check lexical forms so non-existent trees still refuse obvious nesting.
207
+ if (isPathInsideRoot(lexicalTarget, lexicalRoot))
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+ function assertNotInsideRegistryTree(targetPath, registryRoots) {
213
+ if (isInsideRegistryTree(targetPath, registryRoots)) {
214
+ throw new EnvironmentStoreError(`Refusing to write environment store inside a registry tree: ${targetPath} (RNF-1)`);
215
+ }
216
+ }
217
+ /**
218
+ * Atomic write of a values.json document. Refuses targets under registry roots (RNF-1).
219
+ */
220
+ function writeEnvValue(targetPath, values, opts = {}) {
221
+ if (typeof targetPath !== 'string' || targetPath.trim() === '') {
222
+ throw new EnvironmentStoreError('target path must be a non-empty string');
223
+ }
224
+ if (!isRecord(values)) {
225
+ throw new EnvironmentStoreError('values must be an object');
226
+ }
227
+ for (const [id, entry] of Object.entries(values)) {
228
+ parseStoredEntry(id, entry);
229
+ }
230
+ // RNF-1: refuse registry-tree targets. Callers that need environmentsRoot placement
231
+ // (writeFact) pass valuesPath(name); this helper does not re-check environmentsRoot.
232
+ assertNotInsideRegistryTree(targetPath, opts.registryRoots);
233
+ (0, atomic_file_1.writeFileAtomic)(targetPath, JSON.stringify(values, null, 2) + '\n');
234
+ }
235
+ function validateValue(definition, value) {
236
+ if (definition.valueType === 'path') {
237
+ if (value.trim() === '') {
238
+ throw new EnvironmentStoreError('path value must be a non-empty string');
239
+ }
240
+ }
241
+ if (definition.valueType === 'enum') {
242
+ const allowed = definition.values ?? [];
243
+ if (!allowed.includes(value)) {
244
+ throw new EnvironmentStoreError(`enum value ${JSON.stringify(value)} is not one of: ${allowed.join(', ')}`);
245
+ }
246
+ }
247
+ }
248
+ function withExpiresAt(entry, definition) {
249
+ if (!definition || definition.durability !== 'volatile') {
250
+ return {
251
+ value: entry.value,
252
+ status: entry.status,
253
+ updatedAt: entry.updatedAt,
254
+ };
255
+ }
256
+ if (typeof definition.ttl !== 'number' || !Number.isFinite(definition.ttl) || definition.ttl <= 0) {
257
+ throw new EnvironmentStoreError('volatile fact definition requires a finite ttl > 0');
258
+ }
259
+ const ttlSeconds = definition.ttl;
260
+ const updatedMs = Date.parse(entry.updatedAt);
261
+ if (!Number.isFinite(updatedMs)) {
262
+ throw new EnvironmentStoreError(`updatedAt must be a valid ISO timestamp, got ${JSON.stringify(entry.updatedAt)}`);
263
+ }
264
+ let expiresAt;
265
+ try {
266
+ expiresAt = new Date(updatedMs + ttlSeconds * 1000).toISOString();
267
+ }
268
+ catch (e) {
269
+ if (e instanceof RangeError) {
270
+ throw new EnvironmentStoreError(`volatile expiresAt out of range for updatedAt=${JSON.stringify(entry.updatedAt)} ttl=${ttlSeconds}`);
271
+ }
272
+ throw e;
273
+ }
274
+ return { ...entry, expiresAt };
275
+ }
276
+ /**
277
+ * Write one fact into `environments/<name>/values.json` (RF-2.1). Atomic rename.
278
+ * When `catalog` is provided, unknown ids error and list `consultedRoots`.
279
+ */
280
+ function writeFact(name, id, entry, opts = {}) {
281
+ assertEnvName(name);
282
+ if (typeof id !== 'string' || id.trim() === '') {
283
+ throw new EnvironmentStoreError('fact id must be a non-empty string');
284
+ }
285
+ if (!isRecord(entry) || typeof entry.value !== 'string') {
286
+ throw new EnvironmentStoreError('fact entry.value must be a string');
287
+ }
288
+ if (entry.status !== 'fresh' && entry.status !== 'stale' && entry.status !== 'missing') {
289
+ throw new EnvironmentStoreError('fact entry.status must be fresh|stale|missing');
290
+ }
291
+ if (typeof entry.updatedAt !== 'string') {
292
+ throw new EnvironmentStoreError('fact entry.updatedAt must be a string');
293
+ }
294
+ let definition = opts.definition;
295
+ if (opts.catalog) {
296
+ // Own keys only — prototype members (toString, …) are not catalog facts.
297
+ if (!Object.hasOwn(opts.catalog.facts, id)) {
298
+ const roots = opts.consultedRoots?.length
299
+ ? opts.consultedRoots.join(', ')
300
+ : '(no catalog roots)';
301
+ throw new EnvironmentStoreError(`Unknown fact id ${JSON.stringify(id)}; consulted: ${roots}`);
302
+ }
303
+ definition = definition ?? opts.catalog.facts[id];
304
+ }
305
+ if (definition) {
306
+ validateValue(definition, entry.value);
307
+ }
308
+ const nextEntry = withExpiresAt({
309
+ value: entry.value,
310
+ status: entry.status,
311
+ updatedAt: entry.updatedAt,
312
+ ...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
313
+ }, definition);
314
+ const file = valuesPath(name);
315
+ assertNotInsideRegistryTree(file, opts.registryRoots);
316
+ const current = readValues(name);
317
+ const next = { ...current, [id]: nextEntry };
318
+ writeEnvValue(file, next, { registryRoots: opts.registryRoots });
319
+ // First write materializes the single active env as the env being written (RF-2.2).
320
+ if (!readMeta()) {
321
+ writeMeta({ active: name });
322
+ }
323
+ }
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_VOLATILE_TTL_SECONDS = void 0;
7
+ exports.parseFactsCatalog = parseFactsCatalog;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const registries_1 = require("../registries");
10
+ const types_1 = require("./types");
11
+ const VALUE_TYPES = ['string', 'path', 'enum'];
12
+ const RESOLUTIONS = ['prompt'];
13
+ const DURABILITIES = ['durable', 'volatile'];
14
+ const INJECTS = ['reference', 'context'];
15
+ /** Max volatile ttl (seconds) so updatedMs + ttl*1000 stays in a safe Date range. */
16
+ exports.MAX_VOLATILE_TTL_SECONDS = 365 * 24 * 3600;
17
+ function isRecord(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+ function parseFactEntry(id, raw) {
21
+ if (!types_1.FACT_ID_RE.test(id)) {
22
+ throw new types_1.FactsCatalogError(`Invalid fact id ${JSON.stringify(id)}: must match ${types_1.FACT_ID_RE}`);
23
+ }
24
+ if (!isRecord(raw)) {
25
+ throw new types_1.FactsCatalogError(`facts.${id} must be an object`);
26
+ }
27
+ if (typeof raw.valueType !== 'string' || !VALUE_TYPES.includes(raw.valueType)) {
28
+ throw new types_1.FactsCatalogError(`facts.${id}.valueType must be one of ${VALUE_TYPES.join(', ')}`);
29
+ }
30
+ const valueType = raw.valueType;
31
+ if (raw.resolution === 'command') {
32
+ throw new types_1.FactsCatalogError(`facts.${id}: resolution: command is not supported in v1 (use prompt / --value / editable store)`);
33
+ }
34
+ if (raw.resolution !== undefined) {
35
+ if (typeof raw.resolution !== 'string' || !RESOLUTIONS.includes(raw.resolution)) {
36
+ throw new types_1.FactsCatalogError(`facts.${id}.resolution must be "prompt" in v1`);
37
+ }
38
+ }
39
+ if (raw.durability !== undefined) {
40
+ if (typeof raw.durability !== 'string' || !DURABILITIES.includes(raw.durability)) {
41
+ throw new types_1.FactsCatalogError(`facts.${id}.durability must be durable|volatile`);
42
+ }
43
+ }
44
+ if (raw.inject !== undefined) {
45
+ if (typeof raw.inject !== 'string' || !INJECTS.includes(raw.inject)) {
46
+ throw new types_1.FactsCatalogError(`facts.${id}.inject must be reference|context`);
47
+ }
48
+ }
49
+ const def = {
50
+ valueType,
51
+ resolution: raw.resolution ?? 'prompt',
52
+ durability: raw.durability ?? 'durable',
53
+ inject: raw.inject ?? 'reference',
54
+ };
55
+ if (raw.description !== undefined) {
56
+ if (typeof raw.description !== 'string') {
57
+ throw new types_1.FactsCatalogError(`facts.${id}.description must be a string`);
58
+ }
59
+ def.description = raw.description;
60
+ }
61
+ if (valueType === 'enum') {
62
+ if (!Array.isArray(raw.values) || raw.values.length === 0 || raw.values.some((v) => typeof v !== 'string')) {
63
+ throw new types_1.FactsCatalogError(`facts.${id}.values must be a non-empty string array when valueType is enum`);
64
+ }
65
+ def.values = raw.values;
66
+ }
67
+ else if (raw.values !== undefined) {
68
+ throw new types_1.FactsCatalogError(`facts.${id}.values is only allowed when valueType is enum`);
69
+ }
70
+ if (raw.ttl !== undefined) {
71
+ if (typeof raw.ttl !== 'number' || !Number.isFinite(raw.ttl) || raw.ttl < 0) {
72
+ throw new types_1.FactsCatalogError(`facts.${id}.ttl must be a non-negative number`);
73
+ }
74
+ def.ttl = raw.ttl;
75
+ }
76
+ if (def.durability === 'volatile') {
77
+ if (typeof def.ttl !== 'number' ||
78
+ !Number.isFinite(def.ttl) ||
79
+ def.ttl <= 0 ||
80
+ def.ttl > exports.MAX_VOLATILE_TTL_SECONDS) {
81
+ throw new types_1.FactsCatalogError(`facts.${id}: durability volatile requires finite ttl in (0, ${exports.MAX_VOLATILE_TTL_SECONDS}] seconds`);
82
+ }
83
+ }
84
+ return def;
85
+ }
86
+ function parseObject(raw) {
87
+ if (!isRecord(raw)) {
88
+ throw new types_1.FactsCatalogError('facts catalog must be a JSON object');
89
+ }
90
+ if (raw.schemaVersion !== 1) {
91
+ throw new types_1.FactsCatalogError(`facts catalog schemaVersion must be 1, got ${JSON.stringify(raw.schemaVersion)}`);
92
+ }
93
+ if (!isRecord(raw.facts)) {
94
+ throw new types_1.FactsCatalogError('facts catalog must include a facts object');
95
+ }
96
+ const facts = {};
97
+ for (const [id, entry] of Object.entries(raw.facts)) {
98
+ if (id === '') {
99
+ throw new types_1.FactsCatalogError('fact id must be non-empty');
100
+ }
101
+ facts[id] = parseFactEntry(id, entry);
102
+ }
103
+ return { schemaVersion: 1, facts };
104
+ }
105
+ /**
106
+ * Parse a facts catalog from an in-memory object or from a file path string.
107
+ * Named errors: {@link FactsCatalogError}.
108
+ */
109
+ function parseFactsCatalog(rawOrPath) {
110
+ if (typeof rawOrPath === 'string') {
111
+ try {
112
+ if (!(0, registries_1.assertRegularRegistryFile)(rawOrPath)) {
113
+ throw new types_1.FactsCatalogError(`facts catalog not found at ${rawOrPath}`);
114
+ }
115
+ }
116
+ catch (e) {
117
+ if (e instanceof types_1.FactsCatalogError)
118
+ throw e;
119
+ throw new types_1.FactsCatalogError(e instanceof Error ? e.message : `Cannot inspect facts catalog at ${rawOrPath}: ${String(e)}`);
120
+ }
121
+ let text;
122
+ try {
123
+ text = fs_1.default.readFileSync(rawOrPath, 'utf-8');
124
+ }
125
+ catch (e) {
126
+ throw new types_1.FactsCatalogError(`Cannot read facts catalog at ${rawOrPath}: ${e instanceof Error ? e.message : String(e)}`);
127
+ }
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(text);
131
+ }
132
+ catch (e) {
133
+ throw new types_1.FactsCatalogError(`Invalid JSON in facts catalog at ${rawOrPath}: ${e instanceof Error ? e.message : String(e)}`);
134
+ }
135
+ return parseObject(parsed);
136
+ }
137
+ return parseObject(rawOrPath);
138
+ }