@volter/supercode-orchestrator 0.4.4

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.
@@ -0,0 +1,129 @@
1
+ """The applier's door into one Hermes home: Hermes's own functions, in the order
2
+ Hermes's own callers run them (docs/plans/content-spec-status.md §7).
3
+
4
+ Run under Hermes's interpreter with HERMES_HOME set to the home being applied.
5
+ One request on stdin, one JSON answer on stdout (the line marked MARK): {"ok": true, "result": ...} or
6
+ {"ok": false, "error": "..."}. Nothing here writes a Hermes file itself.
7
+
8
+ list cron.jobs.load_jobs()
9
+ create {kwargs} the prompt scan the agent's tool and the API
10
+ server run, then create_job_with_scheduler_registration,
11
+ then _notify_provider_jobs_changed
12
+ update {id, updates} update_job, then the notify
13
+ pause {id, reason} pause_job, then the notify
14
+ resume {id} resume_job, then the notify
15
+ remove {id} remove_job, then the notify
16
+ capture {id} a job's durable data before it is deleted: its notepad
17
+ (cron.notepad.list_notes) and its run outputs
18
+ config_read {keys} load_config(): the effective value of each dotted key, with the
19
+ file's own ${VAR} template where the value is its expansion
20
+ (_preserve_env_ref_templates against read_raw_config)
21
+ config_write {values} save_config(<those keys only>, merge_existing=True)
22
+ """
23
+
24
+ import json
25
+ import sys
26
+
27
+
28
+ def _notify():
29
+ try:
30
+ from cron.scheduler import _notify_provider_jobs_changed
31
+ _notify_provider_jobs_changed()
32
+ except Exception:
33
+ pass # best-effort, as every Hermes caller treats it
34
+
35
+
36
+ def _dig(tree, dotted):
37
+ node = tree
38
+ for part in dotted.split("."):
39
+ if not isinstance(node, dict) or part not in node:
40
+ return None
41
+ node = node[part]
42
+ return node
43
+
44
+
45
+ def _nest(values):
46
+ tree = {}
47
+ for dotted, value in values.items():
48
+ node = tree
49
+ parts = dotted.split(".")
50
+ for part in parts[:-1]:
51
+ node = node.setdefault(part, {})
52
+ node[parts[-1]] = value
53
+ return tree
54
+
55
+
56
+ def main():
57
+ request = json.loads(sys.stdin.read() or "{}")
58
+ op = request.get("op")
59
+ args = request.get("args") or {}
60
+ if op == "list":
61
+ from cron.jobs import load_jobs
62
+ return load_jobs()
63
+ if op == "create":
64
+ kwargs = dict(args.get("kwargs") or {})
65
+ prompt = kwargs.get("prompt")
66
+ if prompt:
67
+ from tools.cronjob_tools import _scan_cron_prompt
68
+ scan_error = _scan_cron_prompt(prompt)
69
+ if scan_error:
70
+ raise ValueError(scan_error)
71
+ from cron.scheduler import create_job_with_scheduler_registration
72
+ job = create_job_with_scheduler_registration(**kwargs)
73
+ _notify()
74
+ return job
75
+ if op in ("update", "pause", "resume", "remove"):
76
+ from cron import jobs as store
77
+ job_id = args["id"]
78
+ if op == "update":
79
+ prompt = (args.get("updates") or {}).get("prompt")
80
+ if prompt:
81
+ from tools.cronjob_tools import _scan_cron_prompt
82
+ scan_error = _scan_cron_prompt(prompt)
83
+ if scan_error:
84
+ raise ValueError(scan_error)
85
+ result = store.update_job(job_id, args.get("updates") or {})
86
+ elif op == "pause":
87
+ result = store.pause_job(job_id, args.get("reason"))
88
+ elif op == "resume":
89
+ result = store.resume_job(job_id)
90
+ else:
91
+ result = store.remove_job(job_id)
92
+ if result is None or result is False:
93
+ raise LookupError(f"Hermes has no job {job_id}")
94
+ _notify()
95
+ return result
96
+ if op == "capture":
97
+ from cron.notepad import list_notes
98
+ from cron.jobs import _job_output_dir
99
+ job_id = args["id"]
100
+ outputs = {}
101
+ try:
102
+ folder = _job_output_dir(job_id)
103
+ for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
104
+ outputs[path.name] = path.read_text(encoding="utf-8", errors="replace")
105
+ except ValueError:
106
+ pass
107
+ return {"notes": list_notes(job_id), "outputs": outputs}
108
+ if op == "config_read":
109
+ from hermes_cli.config import _preserve_env_ref_templates, load_config, read_raw_config
110
+ effective = load_config() or {}
111
+ raw = read_raw_config() or {}
112
+ # load_config expands ${VAR}; a value declared as a template reads back as the template it is on disk
113
+ return {key: _preserve_env_ref_templates(_dig(effective, key), _dig(raw, key)) for key in args.get("keys") or []}
114
+ if op == "config_write":
115
+ from hermes_cli.config import save_config
116
+ save_config(_nest(args.get("values") or {}), merge_existing=True)
117
+ return True
118
+ raise ValueError(f"unknown op {op!r}")
119
+
120
+
121
+ MARK = "@@supercode-apply@@" # Hermes may print while importing; the answer is the marked line
122
+
123
+
124
+ if __name__ == "__main__":
125
+ try:
126
+ answer = {"ok": True, "result": main()}
127
+ except Exception as exc: # the applier reports it per record
128
+ answer = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
129
+ print(MARK + json.dumps(answer, default=str), flush=True)
@@ -0,0 +1,4 @@
1
+ // The applier's entry point: the same declarations as the root's "agent-setup applier" (index.d.ts), under the names
2
+ // this module exports them by (index.mjs re-exports them renamed). A consumer that loads only the applier imports this
3
+ // path and keeps its types.
4
+ export { planSetup as plan, applySetup as apply, adoptSetup as adopt, resolveSetup as resolve, provisionHome as provision, validateSetup as validate, resolveParameters, hermesConfig, SETUP_SCHEMA_VERSION as SCHEMA_VERSION } from '@volter/supercode-orchestrator';
@@ -0,0 +1,441 @@
1
+ // The agent-setup applier (docs/plans/content-spec-status.md §7): a package's declared jobs, its
2
+ // Inference and its harness settings reach one home through the gateway's own door (doors.mjs), against
3
+ // a base the applier keeps in its own state (state.mjs). Ownership is by harness id, never by name.
4
+ // Every record gets one row of §7's table:
5
+ //
6
+ // owned, spec equals base and live equals read-back stamp
7
+ // spec changed, live equals read-back act (only the fields that differ from live)
8
+ // live changed, spec unchanged conflict, reported to the manager
9
+ // both changed conflict, reported
10
+ // declared, no base, no unowned live record with the key create (home provisioned; else plan-only)
11
+ // declared, no base, an unowned live record has the key unowned, reported (adopt is an act)
12
+ // owned, live in error the harness's error, reported
13
+ // owned, live missing, last lifecycle completed forgotten (lifecycle), no report
14
+ // owned, live missing otherwise deleted, reported, never re-created
15
+ // owned, `removed` in the declaration forget | pause | delete (delete only through a
16
+ // reviewed plan, after its durable data is captured)
17
+ // owned, bare removal from the declaration still live, reported
18
+ // a record the target cannot express refused: that record only
19
+ //
20
+ // `act` and `create` need no judgment and run whenever apply runs; every report goes to the manager.
21
+ // Inference and harness settings are planned and applied before triggers. A plan is bound by its hash
22
+ // to the base, the spec and the live records it read; applying a stale plan is refused.
23
+
24
+ import { canonical, hash, lock, provision as provisionState, readIntents, readState, stateRoot, writeCapture, writeIntents, writeState } from './state.mjs';
25
+ import { MANAGED_JOB_FIELDS, namedModel } from './doors.mjs';
26
+
27
+ export const SCHEMA_VERSION = 1;
28
+ /** How long after an intent a live record may be taken as the applier's own create. */
29
+ const INTENT_WINDOW_MS = 10 * 60_000;
30
+
31
+ const managedOf = (row) => canonical(Object.fromEntries(MANAGED_JOB_FIELDS.map((f) => [f, row.managed[f] ?? null])));
32
+
33
+ /**
34
+ * Parameters (plan §6): scalars the manifest names, resolved at render time
35
+ * from the values the runtime passes (the workspace is the checkout's path).
36
+ * `{{name}}` in any string is replaced; a name the manifest does not list, or a
37
+ * listed one with no value and no default, is refused.
38
+ */
39
+ export function resolveParameters(spec, values = {}) {
40
+ const declared = spec?.parameters ?? {};
41
+ const value = (name) => {
42
+ if (!(name in declared)) throw new Error(`{{${name}}} is not a parameter the setup declares`);
43
+ const v = values[name] ?? declared[name]?.default;
44
+ if (v === undefined || v === null) throw new Error(`parameter ${name} has no value`);
45
+ if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') throw new Error(`parameter ${name} must be a scalar`);
46
+ return String(v);
47
+ };
48
+ const walk = (node) => {
49
+ if (typeof node === 'string') return node.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_, name) => value(name));
50
+ if (Array.isArray(node)) return node.map(walk);
51
+ if (node && typeof node === 'object') return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, k === 'parameters' ? v : walk(v)]));
52
+ return node;
53
+ };
54
+ return walk(spec);
55
+ }
56
+
57
+ /** Refuse what the package as a whole cannot mean; a single record's problem is that record's refusal. */
58
+ export function validate(spec) {
59
+ if (spec?.schema_version !== SCHEMA_VERSION) throw new Error(`schema_version ${JSON.stringify(spec?.schema_version)} is not ${SCHEMA_VERSION}`);
60
+ for (const key of Object.keys(spec.jobs ?? {})) if (!/^[A-Za-z0-9._ -]{1,128}$/.test(key)) throw new Error(`job key ${JSON.stringify(key)} is not a name`);
61
+ for (const m of spec.moved ?? []) if (!m?.from || !m?.to) throw new Error('every moved entry needs from and to');
62
+ for (const r of spec.removed ?? []) if (!r?.key || !['forget', 'pause', 'delete'].includes(r.then)) throw new Error('every removed entry needs a key and then: forget | pause | delete');
63
+ return spec;
64
+ }
65
+
66
+ /** A custody name (plan §3.4): an environment variable's name, written as Hermes's `${NAME}` reference, never its value. */
67
+ function custodyRef(name, what) {
68
+ if (!/^[A-Z][A-Z0-9_]*$/.test(String(name ?? ''))) throw new Error(`${what} must be a custody name (an environment variable's name), got ${JSON.stringify(name)}`);
69
+ return `\${${name}}`;
70
+ }
71
+
72
+ /**
73
+ * The Hermes config keys a package manages (plan §5, §6), grouped into the
74
+ * units apply writes whole:
75
+ * - Inference: the default pointer's `model.*` (its endpoint and key as custody
76
+ * references, or a literal URL and a placeholder key); the unattended
77
+ * pointer's `cron.model` with `cron.model_provider`, one unit that is never
78
+ * written half; delegation's `delegation.*`; the fallback chain;
79
+ * - `extensions.hermes.config`: Hermes-only settings in its own dotted names.
80
+ */
81
+ export function hermesConfig(spec) {
82
+ const inf = spec.inference ?? {};
83
+ const pick = (name) => {
84
+ const m = name ? inf.models?.[name] : null;
85
+ if (name && !m) throw new Error(`inference pointer names ${JSON.stringify(name)}, which is not in inference.models`);
86
+ return m;
87
+ };
88
+ const units = [];
89
+ // each unit is built on its own: one bad entry refuses that unit, never the others
90
+ const unit = (name, build) => { try { units.push({ unit: name, values: build() }); } catch (error) { units.push({ unit: name, error: error.message }); } };
91
+ const d = (() => { try { return pick(inf.default); } catch (error) { units.push({ unit: 'inference.default', error: error.message }); return null; } })();
92
+ if (d) unit('inference.default', () => {
93
+ const v = { 'model.default': d.model, 'model.provider': d.provider ?? '' };
94
+ if (d.endpoint && d.base_url) throw new Error('a named model takes endpoint (a custody name) or base_url (a literal URL), not both');
95
+ if (d.endpoint) v['model.base_url'] = custodyRef(d.endpoint, 'an endpoint');
96
+ if (d.base_url) {
97
+ if (!/^https?:\/\/[^\s@?#]+$/.test(d.base_url)) throw new Error(`base_url ${JSON.stringify(d.base_url)} must be a plain http(s) URL with no credentials or query`);
98
+ v['model.base_url'] = d.base_url;
99
+ }
100
+ if (d.credential && d.placeholder_key) throw new Error('a named model takes credential or placeholder_key, not both');
101
+ if (d.credential && d.credential !== 'harness-login') v['model.api_key'] = custodyRef(d.credential, 'a credential');
102
+ if (d.placeholder_key) {
103
+ if (!/^[a-z][a-z-]{0,15}$/.test(d.placeholder_key)) throw new Error('placeholder_key is a short lowercase word, never a secret');
104
+ v['model.api_key'] = d.placeholder_key;
105
+ }
106
+ if (d.api_mode) v['model.api_mode'] = d.api_mode;
107
+ return v;
108
+ });
109
+ if (inf.unattended) unit('inference.unattended', () => {
110
+ const u = pick(inf.unattended);
111
+ // with cron.model alone, Hermes's drift guard trips (plan §5, measured): the provider is required
112
+ if (!u.provider) throw new Error('the unattended model needs its provider: cron.model is written with cron.model_provider, never alone');
113
+ return { 'cron.model': u.model, 'cron.model_provider': u.provider };
114
+ });
115
+ if (inf.delegation) unit('inference.delegation', () => { const g = pick(inf.delegation); return { 'delegation.model': g.model, 'delegation.provider': g.provider ?? '' }; });
116
+ if (Array.isArray(inf.fallbacks)) unit('inference.fallbacks', () => ({ fallback_providers: inf.fallbacks.map((name) => { const m = pick(name); return { provider: m.provider, model: m.model }; }) }));
117
+ for (const [key, value] of Object.entries(spec.extensions?.hermes?.config ?? {})) {
118
+ unit(`setting.${key}`, () => {
119
+ if (/^model(\.|$)|^cron\.model|^delegation\.(model|provider)$|^fallback_providers$/.test(key)) throw new Error(`extensions.hermes.config.${key} is Inference's to manage`);
120
+ return { [key]: value };
121
+ });
122
+ }
123
+ return units;
124
+ }
125
+
126
+ /** Each config unit's row: its values against the base and the live config. */
127
+ function diffConfig({ state, spec, live }) {
128
+ const rows = [];
129
+ const units = hermesConfig(spec);
130
+ const base = state?.config ?? {};
131
+ for (const { unit, values, error } of units) {
132
+ if (error) { rows.push({ key: unit, action: 'refused', detail: error }); continue; }
133
+ const want = canonical(values);
134
+ const has = canonical(Object.fromEntries(Object.keys(values).map((k) => [k, live[k] ?? null])));
135
+ const b = base[unit];
136
+ if (!b) rows.push(has === want ? { key: unit, action: 'stamp', values } : { key: unit, action: state === null ? 'plan-only' : 'act', values });
137
+ else {
138
+ const specChanged = b.declared !== want;
139
+ const liveChanged = b.readback !== has;
140
+ // Both moved to the same value (the owner set by hand what the declaration now says): agreed, not a conflict.
141
+ if ((!specChanged && !liveChanged) || has === want) rows.push({ key: unit, action: 'stamp', values });
142
+ else if (specChanged && !liveChanged) rows.push({ key: unit, action: 'act', values });
143
+ else rows.push({ key: unit, action: 'conflict', values, detail: specChanged ? 'both the declaration and the live value changed' : 'the live value changed (hermes config set, /model); the manager decides whether to capture it back' });
144
+ }
145
+ }
146
+ // a unit applied once and no longer declared: Hermes has no function that unsets a key, so the home keeps
147
+ // the value; said, like a job still live, until the manager sets it or `resolve`s the unit
148
+ const declared = new Set(units.map((u) => u.unit));
149
+ for (const unit of Object.keys(base)) {
150
+ if (!declared.has(unit)) rows.push({ key: unit, action: 'still-live', detail: 'applied before, no longer declared; the home keeps its value until it is set there or resolved' });
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ function diffJobs({ state, live, spec, door }) {
156
+ const records = { ...(state?.records ?? {}) };
157
+ const rows = [];
158
+ // `moved` renames the base's key before anything is compared; a move that cannot happen is said
159
+ for (const { from, to } of spec.moved ?? []) {
160
+ if (records[from] && !records[to]) { records[to] = records[from]; delete records[from]; rows.push({ key: to, action: 'moved', from, detail: `was ${from}` }); }
161
+ else if (records[from] && records[to]) rows.push({ key: to, action: 'refused', detail: `moved from ${from}: ${to} is already owned` });
162
+ }
163
+ const byId = new Map(live.map((r) => [r.id, r]));
164
+ const owned = new Set(Object.values(records).map((r) => r.id));
165
+ const removed = new Map((spec.removed ?? []).map((r) => [r.key, r.then]));
166
+ const declared = spec.jobs ?? {};
167
+ for (const [key, job] of Object.entries(declared)) {
168
+ let want;
169
+ try {
170
+ // context_from names jobs by their declared keys; the harness wants its own ids
171
+ const refs = job.context_from === undefined ? undefined : (Array.isArray(job.context_from) ? job.context_from : [job.context_from]).map((ref) => {
172
+ if (ref === 'self') return ref;
173
+ const owner = records[ref];
174
+ if (!owner) throw new Error(`context_from names ${JSON.stringify(ref)}, which this home does not own yet; it applies once ${ref} exists`);
175
+ return owner.id;
176
+ });
177
+ want = door.fields(refs === undefined ? job : { ...job, context_from: refs }, spec.inference);
178
+ } catch (error) { rows.push({ key, action: 'refused', detail: error.message }); continue; }
179
+ const base = records[key];
180
+ if (!base) {
181
+ const carrier = live.find((r) => r.name === key && !owned.has(r.id));
182
+ if (carrier) rows.push({ key, action: 'unowned', id: carrier.id, detail: 'a live record carries this key; adopt it to own it' });
183
+ else if (state === null) rows.push({ key, action: 'plan-only', detail: 'this home has no apply state: provision or adopt it first' });
184
+ else rows.push({ key, action: 'create', want, enabled: job.enabled !== false });
185
+ continue;
186
+ }
187
+ const row = byId.get(base.id);
188
+ if (!row) {
189
+ rows.push(base.lifecycle === 'completed'
190
+ ? { key, action: 'forget', id: base.id, detail: 'completed and gone (its lifecycle)' }
191
+ : { key, action: 'deleted', id: base.id, detail: 'owned, but the harness no longer has it; never re-created' });
192
+ continue;
193
+ }
194
+ if (row.lifecycle === 'error') { rows.push({ key, action: 'harness-error', id: row.id, detail: 'the harness reports this job in error' }); continue; }
195
+ const specChanged = base.declared !== canonical(want);
196
+ const liveChanged = base.readback !== managedOf(row);
197
+ if (liveChanged) { rows.push({ key, action: 'conflict', id: row.id, detail: specChanged ? 'both the declaration and the live record changed' : 'the live record changed; the manager decides whether to capture it back' }); continue; }
198
+ if (!specChanged) { rows.push({ key, action: 'stamp', id: row.id, lifecycle: row.lifecycle, detail: row.lifecycle === 'paused' ? 'converged; the pause stands (an overlay)' : 'converged' }); continue; }
199
+ // act: only what differs from live, so an unchanged schedule is never re-armed
200
+ let patch = door.differs(want, row.managed);
201
+ // a field the declaration held and no longer does is cleared, never left as it was
202
+ const before = base.declared ? JSON.parse(base.declared) : {};
203
+ const dropped = Object.keys(before).filter((f) => f !== 'schedule' && !(f in want) && !(before[f] === null || before[f] === undefined));
204
+ const clear = Object.fromEntries(dropped.map((f) => [f, f === 'no_agent' ? false : null]));
205
+ let pinKept = base.pinKept ?? null;
206
+ // An adopted record's live model pin is kept, reported, until the declared model itself changes (plan §5)
207
+ const declaredModel = canonical({ model: want.model ?? null, provider: want.provider ?? null });
208
+ if (base.declared === null && !base.resolved && (row.managed.model || row.managed.provider) && patch.some((f) => f === 'model' || f === 'provider')) pinKept = declaredModel;
209
+ if (pinKept && pinKept === declaredModel) patch = patch.filter((f) => f !== 'model' && f !== 'provider');
210
+ else pinKept = null;
211
+ const changes = { ...clear, ...Object.fromEntries(patch.map((f) => [f, want[f]])) };
212
+ const notes = [pinKept ? `the live model pin (${row.managed.provider ?? ''}/${row.managed.model ?? ''}) is kept until the declared model changes` : null, dropped.length ? `no longer declared, cleared: ${dropped.join(', ')}` : null].filter(Boolean);
213
+ rows.push({ key, action: Object.keys(changes).length ? 'act' : 'stamp', id: row.id, want, patch: changes, pinKept, lifecycle: row.lifecycle, detail: notes.length ? notes.join('; ') : undefined });
214
+ }
215
+ for (const [key, base] of Object.entries(records)) {
216
+ if (declared[key]) continue;
217
+ const then = removed.get(key);
218
+ const row = byId.get(base.id);
219
+ // gone or finished already: whatever the removal asked, there is only a record to forget
220
+ if (then && (!row || row.lifecycle === 'completed')) rows.push({ key, action: 'forget', id: base.id, detail: `removed (${then}); the harness no longer runs it` });
221
+ else if (then) rows.push({ key, action: `remove:${then}`, id: base.id });
222
+ else if (row) rows.push({ key, action: 'still-live', id: base.id, detail: 'owned but no longer declared; declare `removed` to forget, pause or delete it' });
223
+ else rows.push({ key, action: 'forget', id: base.id, detail: 'owned, undeclared and gone' });
224
+ }
225
+ return rows;
226
+ }
227
+
228
+ /**
229
+ * The plan for one home: a row per record, and the hash that binds it to the
230
+ * base, the spec and the live records it read (the owned ones and any carrying
231
+ * a declared key; a record the agent makes meanwhile does not stale it).
232
+ */
233
+ export async function plan({ door, homeId, spec: raw, root = stateRoot(), params = {} }) {
234
+ const spec = validate(resolveParameters(raw, params));
235
+ const state = readState(root, homeId);
236
+ const live = await door.list();
237
+ let configLive = {};
238
+ let configRows = [];
239
+ if (door.readInference && (spec.inference || spec.extensions?.hermes || Object.keys(state?.config ?? {}).length)) {
240
+ const units = hermesConfig(spec).filter((u) => u.values);
241
+ const keys = units.flatMap((u) => Object.keys(u.values));
242
+ configLive = keys.length ? await door.readInference(keys) : {};
243
+ configRows = diffConfig({ state, spec, live: configLive });
244
+ } else if (spec.inference && !door.readInference) {
245
+ configRows = [{ key: 'inference', action: 'refused', detail: `${door.target} has no door for Inference here; its worker model is the profile's` }];
246
+ }
247
+ const jobRows = diffJobs({ state, live, spec, door });
248
+ const owned = new Set(Object.values(state?.records ?? {}).map((r) => r.id));
249
+ const keys = new Set(Object.keys(spec.jobs ?? {}));
250
+ const seen = live.filter((r) => owned.has(r.id) || keys.has(r.name)).map((r) => ({ id: r.id, name: r.name, m: managedOf(r), l: r.lifecycle }));
251
+ return {
252
+ homeId, planOnly: state === null, rows: [...configRows, ...jobRows],
253
+ hash: hash({ base: state ?? null, spec, live: seen, config: configLive }),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Apply: under the home's lock, recompute the plan; refuse when a stored plan's
259
+ * hash no longer matches; apply config units, then creates (each behind an
260
+ * intent) and acts; write the new base with every verb's read-back as one
261
+ * generation. Reports come back as rows.
262
+ */
263
+ export async function apply({ door, homeId, spec: raw, root = stateRoot(), planHash = null, reviewedPlan = false, params = {} }) {
264
+ const spec = validate(resolveParameters(raw, params));
265
+ const release = lock(root, homeId);
266
+ try {
267
+ const fresh = await plan({ door, homeId, spec, root });
268
+ if (planHash && planHash !== fresh.hash) throw new Error('the stored plan is stale: the base, the spec or the live records changed since it was made');
269
+ if (fresh.planOnly) return { ...fresh, applied: [] };
270
+ // this apply's one generation (none when it changes nothing); every verb after it is written in place,
271
+ // so a crash loses no record
272
+ const changes = readIntents(root, homeId).length || fresh.rows.some((r) => ['create', 'act', 'moved', 'forget', 'remove:forget', 'remove:pause', 'remove:delete'].includes(r.action)); // config units' acts included
273
+ const state = changes ? writeState(root, homeId, readState(root, homeId)) : readState(root, homeId);
274
+ state.records ??= {};
275
+ state.config ??= {};
276
+ const applied = await recoverIntents({ state, door, spec, root, homeId });
277
+ const save = () => writeState(root, homeId, state, { generation: false });
278
+ for (const row of fresh.rows) {
279
+ try {
280
+ if (row.key.startsWith('inference.') || row.key.startsWith('setting.')) {
281
+ if (row.action === 'act') {
282
+ await door.writeInference(row.values);
283
+ const back = await door.readInference(Object.keys(row.values));
284
+ const has = canonical(Object.fromEntries(Object.keys(row.values).map((k) => [k, back[k] ?? null])));
285
+ state.config[row.key] = { declared: canonical(row.values), readback: has };
286
+ // a managed scope, or Hermes stripping a default, can leave the value other than declared: said, not assumed
287
+ applied.push(has === canonical(row.values) ? { key: row.key, action: 'updated' } : { key: row.key, action: 'not-applied', detail: 'the harness reads back a different value (a managed scope, or a default it strips)' });
288
+ } else if (row.action === 'stamp') {
289
+ state.config[row.key] = { declared: canonical(row.values), readback: canonical(row.values) };
290
+ }
291
+ continue;
292
+ }
293
+ if (row.action === 'moved') {
294
+ if (state.records[row.from] && !state.records[row.key]) { state.records[row.key] = state.records[row.from]; delete state.records[row.from]; }
295
+ } else if (row.action === 'create') {
296
+ const intents = readIntents(root, homeId);
297
+ writeIntents(root, homeId, [...intents, { key: row.key, at: new Date().toISOString(), declared: canonical(row.want), want: row.want, enabled: row.enabled }]);
298
+ let made = await door.create(row.key, row.want);
299
+ // create then pause is one intent: the intent stays until the pause is done
300
+ if (!row.enabled) made = await door.pause(made.id);
301
+ state.records[row.key] = { id: made.id, declared: canonical(row.want), readback: managedOf(made), lifecycle: made.lifecycle };
302
+ save();
303
+ writeIntents(root, homeId, readIntents(root, homeId).filter((i) => i.key !== row.key));
304
+ applied.push({ key: row.key, action: 'created', id: made.id });
305
+ } else if (row.action === 'act') {
306
+ const after = await door.update(row.id, row.patch);
307
+ state.records[row.key] = { id: after.id, declared: canonical(row.want), readback: managedOf(after), lifecycle: after.lifecycle, ...(row.pinKept ? { pinKept: row.pinKept } : {}) };
308
+ // what was sent is checked against what the harness now holds: a field it rewrote is said, not assumed
309
+ const left = door.differs(row.patch, after.managed);
310
+ applied.push(left.length
311
+ ? { key: row.key, action: 'not-applied', id: after.id, detail: `the harness holds other values for ${left.join(', ')} than were sent` }
312
+ : { key: row.key, action: 'updated', id: after.id, detail: Object.keys(row.patch).join(', ') });
313
+ } else if (row.action === 'stamp' && row.id && state.records[row.key]) {
314
+ state.records[row.key].lifecycle = row.lifecycle ?? state.records[row.key].lifecycle;
315
+ if (row.want) { state.records[row.key].declared = canonical(row.want); if (row.pinKept) state.records[row.key].pinKept = row.pinKept; }
316
+ } else if (row.action === 'forget' || row.action === 'remove:forget') {
317
+ delete state.records[row.key];
318
+ applied.push({ key: row.key, action: 'forgotten', id: row.id });
319
+ } else if (row.action === 'remove:pause') {
320
+ await door.pause(row.id);
321
+ delete state.records[row.key];
322
+ applied.push({ key: row.key, action: 'paused and forgotten', id: row.id });
323
+ } else if (row.action === 'remove:delete') {
324
+ // only through the plan the manager reviewed (never at start), and only after the record's
325
+ // durable data is captured beside the applier's state
326
+ if (!reviewedPlan || !planHash) { applied.push({ key: row.key, action: 'refused', detail: 'delete runs only through a reviewed, stored plan (--plan-hash with --reviewed)' }); continue; }
327
+ const captured = writeCapture(root, homeId, row.key, row.id, await door.capture(row.id));
328
+ await door.remove(row.id);
329
+ delete state.records[row.key];
330
+ applied.push({ key: row.key, action: 'deleted', id: row.id, detail: `durable data captured in ${captured}` });
331
+ }
332
+ } catch (error) {
333
+ // refusal is per record: one record's failure never stops the others
334
+ applied.push({ key: row.key, action: 'refused', detail: error.message });
335
+ }
336
+ save();
337
+ }
338
+ return { ...fresh, applied };
339
+ } finally {
340
+ release();
341
+ }
342
+ }
343
+
344
+ /**
345
+ * A crash between a create's verb and the base's write leaves an intent. It is
346
+ * the applier's own act only when exactly one unowned live record carries the
347
+ * key, the declared values and a creation time inside the intent's window;
348
+ * then an intended pause is finished. Anything else stays an intent, reported.
349
+ */
350
+ async function recoverIntents({ state, door, spec, root, homeId }) {
351
+ const intents = readIntents(root, homeId);
352
+ if (!intents.length) return [];
353
+ const live = await door.list();
354
+ const owned = new Set(Object.values(state.records).map((r) => r.id));
355
+ const left = [];
356
+ const report = [];
357
+ for (const intent of intents) {
358
+ const at = Date.parse(intent.at);
359
+ const job = spec.jobs?.[intent.key];
360
+ // the values the create was sent (references already resolved to ids), as the intent recorded them
361
+ const want = job ? intent.want ?? null : null;
362
+ // compared on what Hermes stores as given (paths and references it normalizes are left out)
363
+ const core = want ? Object.fromEntries(Object.entries(want).filter(([f]) => ['schedule', 'prompt', 'skills', 'script', 'no_agent'].includes(f))) : null;
364
+ const matches = live.filter((r) => !owned.has(r.id) && r.name === intent.key && r.created_at
365
+ && Math.abs(Date.parse(r.created_at) - at) <= INTENT_WINDOW_MS && core && door.differs(core, r.managed).length === 0);
366
+ if (matches.length === 1 && want && canonical(want) === intent.declared) {
367
+ let row = matches[0];
368
+ if (intent.enabled === false && row.lifecycle !== 'paused') row = await door.pause(row.id);
369
+ state.records[intent.key] = { id: row.id, declared: intent.declared, readback: managedOf(row), lifecycle: row.lifecycle };
370
+ owned.add(row.id);
371
+ report.push({ key: intent.key, action: 'recovered', id: row.id, detail: 'a create interrupted before its record was written' });
372
+ } else if (!matches.length && (!job || Date.now() - at > INTENT_WINDOW_MS)) {
373
+ // nothing was made (the harness refused it) or the key is no longer declared: nothing to recover
374
+ report.push({ key: intent.key, action: 'intent-dropped', detail: 'an interrupted create left no record to recover' });
375
+ } else {
376
+ left.push(intent);
377
+ report.push({ key: intent.key, action: 'intent-unresolved', detail: `${matches.length} live record(s) match an interrupted create; reported, not assumed` });
378
+ }
379
+ }
380
+ writeIntents(root, homeId, left);
381
+ return report;
382
+ }
383
+
384
+ /**
385
+ * Resolve a conflict (a per-home act, the manager's): the live record becomes
386
+ * the base. The next apply then either restores the declaration over it (the
387
+ * manager chose the package) or finds nothing to change (the manager captured
388
+ * the live value back into the package first). Config units likewise.
389
+ */
390
+ export async function resolve({ door, homeId, keys, root = stateRoot() }) {
391
+ const release = lock(root, homeId);
392
+ try {
393
+ const state = readState(root, homeId);
394
+ if (!state) throw new Error(`home ${homeId} has no apply state; there is nothing to resolve`);
395
+ const live = await door.list();
396
+ const out = [];
397
+ for (const key of keys) {
398
+ if (state.config?.[key]) { delete state.config[key]; out.push({ key, action: 'resolved' }); continue; }
399
+ const base = state.records?.[key];
400
+ const row = base && live.find((r) => r.id === base.id);
401
+ if (!row) { out.push({ key, action: 'refused', detail: 'not an owned record with a live counterpart' }); continue; }
402
+ state.records[key] = { id: base.id, declared: null, readback: managedOf(row), lifecycle: row.lifecycle, resolved: true };
403
+ out.push({ key, action: 'resolved', id: row.id });
404
+ }
405
+ writeState(root, homeId, state);
406
+ return out;
407
+ } finally {
408
+ release();
409
+ }
410
+ }
411
+
412
+ /** Adopt (a per-home act): the named live records become owned, their base the live read-back. */
413
+ export async function adopt({ door, homeId, keys, root = stateRoot() }) {
414
+ const release = lock(root, homeId);
415
+ try {
416
+ const state = readState(root, homeId) ?? { records: {}, generation: 0 };
417
+ state.records ??= {};
418
+ const live = await door.list();
419
+ const owned = new Set(Object.values(state.records).map((r) => r.id));
420
+ const adopted = [];
421
+ for (const key of keys) {
422
+ const candidates = live.filter((r) => r.name === key && !owned.has(r.id));
423
+ if (candidates.length !== 1) { adopted.push({ key, action: 'refused', detail: `${candidates.length} unowned live record(s) carry this key` }); continue; }
424
+ const row = candidates[0];
425
+ // declared is left unset, so the next apply acts: only the fields that differ, a live pin kept
426
+ state.records[key] = { id: row.id, declared: null, readback: managedOf(row), lifecycle: row.lifecycle };
427
+ owned.add(row.id);
428
+ adopted.push({ key, action: 'adopted', id: row.id });
429
+ }
430
+ writeState(root, homeId, state);
431
+ return adopted;
432
+ } finally {
433
+ release();
434
+ }
435
+ }
436
+
437
+ /** Provisioning: the runtime's own new home gets an empty state, so its first apply creates. */
438
+ export function provision({ homeId, root = stateRoot() }) {
439
+ const release = lock(root, homeId);
440
+ try { return provisionState(root, homeId); } finally { release(); }
441
+ }