@uchibeke/gallivant 0.0.0-stage → 0.2.1

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 (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +239 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +261 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +72 -0
  12. package/docs/agent-setup.md +228 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +649 -0
  18. package/docs/config.md +437 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +398 -0
  22. package/docs/launch.md +45 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +149 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +466 -0
  43. package/lib/commands/init.mjs +245 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +249 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +102 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +707 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +140 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1654 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
@@ -0,0 +1,654 @@
1
+ // One coordinator-owned service. CLI/daemon callers cannot supply approval flags,
2
+ // trust keys, arbitrary URLs, commands or an alternative active-state writer.
3
+ import { randomUUID } from "node:crypto";
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { actor } from "./gh.mjs";
7
+ import { deriveEvents } from "./events.mjs";
8
+ import { learningStore, safePath, inside } from "./learning_store.mjs";
9
+ import { storedOpenSshSigner, NAMESPACE, SIGNER_ID, encryptedKey, validateAuthority } from "./learning_signer.mjs";
10
+ import { canonical, hash, candidate, digestField, candidateId, closed, repository, repositoryName, dateRange, minimized, safeDisplay, textField, parseJson, learningError, iso, literalPath } from "./learning_data.mjs";
11
+ import { boundedCall } from "./execution_io.mjs";
12
+ import { probeLearningIsolation } from "./learning_qualification.mjs";
13
+
14
+ export const LEARNING_LIMITS = Object.freeze({ deliveries: 1000, deliveryBytes: 16 * 1024, pathAuditBytes: 2048, omissionAuditBytes: 1024, prs: 100, findings: 1000, pages: 60, candidates: 20, observations: 10_000, lessons: 20, contextBytes: 8192, retentionDays: 30, deadlineMs: 60_000, extractionMs: 30_000, extractionUsd: 0 });
15
+ export const unqualified = () => ({ qualified: false, status: "UNQUALIFIED", reason: "installed execution profiles do not enforce an outer learning authority boundary; collection only", remedy: "qualify key/trust/checkpoint/verifier and terminal/control denial with an enforced execution profile; no config flag or TTY can qualify it" });
16
+ const ordered = (a, b) => a < b ? -1 : a > b ? 1 : 0;
17
+ const refs = rules => rules.map(({ id, digest }) => ({ id, digest })).sort((a, b) => ordered(a.id, b.id));
18
+ const refKey = r => `${r.id}:${r.digest}`;
19
+ const ruleKey = (id, digest) => { candidateId(id); digestField(digest); return `${id}:${digest}`; };
20
+ const sourceId = (pr, kind, id) => `${pr}:${kind}:${id}`;
21
+ const clone = value => parseJson(canonical(value), 32 * 1024 * 1024);
22
+ const dispatchSnapshot = value => ({ ...clone(value), delivered: false, runId: null });
23
+ const auditSample = (values, maxBytes) => {
24
+ const sample = [];
25
+ for (const value of values) {
26
+ if (Buffer.byteLength(canonical([...sample, value])) > maxBytes) break;
27
+ sample.push(value);
28
+ }
29
+ return sample;
30
+ };
31
+ // Arrival order differs between concurrent scans and daemon reads. Source time
32
+ // wins; observation time and digest break ties deterministically without ever
33
+ // treating two versions of one source as current evidence.
34
+ const reconcileObservations = observations => {
35
+ const latest = new Map();
36
+ const rank = ([key, o]) => [Date.parse(o.content.updatedAt), Date.parse(o.observedAt), key];
37
+ const compare = (a, b) => { const x = rank(a), y = rank(b); return ordered(x[0], y[0]) || ordered(x[1], y[1]) || ordered(x[2], y[2]); };
38
+ for (const item of Object.entries(observations)) {
39
+ const source = item[1].content.source, previous = latest.get(source);
40
+ if (!previous || compare(item, previous) > 0) latest.set(source, item);
41
+ }
42
+ for (const [key, o] of Object.entries(observations)) o.superseded = latest.get(o.content.source)[0] !== key;
43
+ };
44
+
45
+ export function createLearningService({ root, store = learningStore(root), signer = null, signerExecutable, clock = { now: Date.now }, gh = null, trust = null, qualify = unqualified, probeIsolation = probeLearningIsolation, extractor = null, onRevocation = async () => [] } = {}) {
46
+ signer ??= storedOpenSshSigner(store, { executable: signerExecutable });
47
+ const at = () => new Date(clock.now()).toISOString();
48
+ const revocationIntent = (s, removed, reason) => {
49
+ const affected = Object.values(s.deliveries).filter(d => d.rules.some(r => !removed || removed.some(x => refKey(x) === refKey(r))));
50
+ for (const d of affected) d.revoked ??= { reason, at: at() };
51
+ return { repo: s.repo, removed, reason, prepared: affected.filter(d => !d.delivered && d.taskId).map(d => ({ taskId: d.taskId, profile: d.profile, key: d.key, operationId: d.operationId })) };
52
+ };
53
+ // Transport metadata is not part of immutable repository identity or approval scope.
54
+ // Resolution is read-only, including during dry-run; observations retain the classification.
55
+ const resolvedRepositories = new Map();
56
+ const visibilityFor = (repo, name, supplied) => {
57
+ const resolved = resolvedRepositories.get(name?.toLowerCase());
58
+ const value = supplied ?? (resolved && canonical(resolved.repo) === canonical(repo) ? resolved.visibility : null);
59
+ return ["public", "private", "internal"].includes(value) ? value : "unknown";
60
+ };
61
+ const expired = o => o.expired || Date.parse(o.observedAt) < clock.now() - LEARNING_LIMITS.retentionDays * 86400_000;
62
+ const expire = s => {
63
+ for (const scan of s.scans) for (const review of scan.continuation?.source?.reviews || []) {
64
+ const observed = Date.parse(review.observedAt);
65
+ if (!Number.isFinite(observed) || observed < clock.now() - LEARNING_LIMITS.retentionDays * 86400_000) review.excerpt = { ...review.excerpt, excerpt: "[raw excerpt expired]", incomplete: true };
66
+ }
67
+ let count = 0; for (const o of Object.values(s.observations)) if (!o.expired && expired(o)) { o.content.excerpt = "[raw excerpt expired]"; o.expired = true; count++; } return count; };
68
+ // The root comes from protected operator configuration, never a task or candidate.
69
+ let pinnedAuthority = null;
70
+ const authority = () => {
71
+ const value = store.readAuthority();
72
+ if (!value) throw learningError("TRUST", "learning authority not enrolled; run learn setup in the operator terminal");
73
+ validateAuthority(value);
74
+ if (pinnedAuthority && canonical(value) !== pinnedAuthority) throw learningError("TRUST", "enrolled trust root changed during this service lifetime");
75
+ pinnedAuthority ??= canonical(value);
76
+ if (canonical(value.enrollment) !== canonical({ version: 1, type: "enrollment", authority: value.id, repo: null, namespace: NAMESPACE, identity: SIGNER_ID, publicKey: value.publicKey, fingerprint: value.fingerprint, signer: value.signer, keyPath: value.keyPath })) throw learningError("TRUST", "enrollment manifest differs from trust root");
77
+ signer.verify(canonical(value.enrollment), value.signature, value);
78
+ return value;
79
+ };
80
+ const qualified = async (repo, profile = null) => {
81
+ const evidence = await qualify({ repo, profile, root: store.root });
82
+ if (evidence?.qualified !== true) throw learningError("UNQUALIFIED", `${evidence?.status || "UNQUALIFIED"}: ${evidence?.reason || "execution isolation unavailable"}; ${evidence?.remedy || "run opt-in qualification on a supported host"}`);
83
+ return evidence;
84
+ };
85
+ const putCandidate = (s, value) => {
86
+ const revision = candidate(value, s.repo), key = ruleKey(value.id, revision.digest);
87
+ if (value.evidence.some(d => !s.observations[d])) throw learningError("EVIDENCE", "candidate references unknown evidence revisions");
88
+ // Exact evidence/text revisions, including rejected ones, cannot be reproposed under another ID.
89
+ const identity = hash({ ...value, id: null, provenance: { ...value.provenance, at: null } });
90
+ const duplicate = Object.values(s.candidates).find(c => c.identity === identity);
91
+ if (duplicate) return { ...duplicate, duplicate: true };
92
+ if (s.candidates[key]) return { ...s.candidates[key], duplicate: true };
93
+ if (Object.keys(s.candidates).length >= 2000) throw learningError("STORAGE", "candidate history reached its 2,000 revision cap; preserve dispositions and review storage before proposing more");
94
+ const record = { ...revision, identity, status: "pending", reason: null, createdAt: at() };
95
+ s.candidates[key] = record;
96
+ return record;
97
+ };
98
+ const selected = (s, id, revision) => {
99
+ candidateId(id);
100
+ if (revision) return s.candidates[ruleKey(id, revision)] ?? (() => { throw learningError("REVISION", "unknown exact revision"); })();
101
+ const versions = Object.values(s.candidates).filter(c => c.content.id === id);
102
+ if (versions.length !== 1) throw learningError("REVISION", "select an exact --revision digest; candidate has zero or multiple revisions");
103
+ return versions[0];
104
+ };
105
+ const conflicts = (s, content) => Object.values(s.candidates).filter(c => c.content.id !== content.id && c.status !== "rejected" && (c.content.paths.length === 0 || content.paths.length === 0 || c.content.paths.some(p => content.paths.some(q => p === q || p.startsWith(q + "/") || q.startsWith(p + "/"))))).map(c => ({ id: c.content.id, digest: c.digest, status: c.status, guidance: c.content.guidance, kind: c.content.guidance === content.guidance ? "repeated-guidance" : "overlapping-scope; contradiction requires human review" }));
106
+ const review = (s, record) => ({ ...record, evidence: record.content.evidence.map(digest => { const o = clone(s.observations[digest]); if (expired(o)) { o.content.excerpt = "[raw excerpt expired]"; o.expired = true; } return { digest, ...o }; }), conflicts: conflicts(s, record.content) });
107
+ const observe = (s, { pr, reviews = [], inline = [], comments = [], completeKinds = [], visibility, reviewExcerpts = {} }, range = null, limit = LEARNING_LIMITS.findings) => {
108
+ expire(s);
109
+ if (!Number.isSafeInteger(pr.number) || pr.number < 1) throw learningError("INPUT", "invalid source PR identity");
110
+ if (!trust) throw learningError("TRUST", "collection requires the shared reviewer eligibility rules");
111
+ const result = { eligible: 0, added: 0, duplicates: 0, capped: false };
112
+ const groups = { review: reviews, inline, command: comments };
113
+ const events = deriveEvents({ pr: { ...pr, headRefOid: null }, reviews, inline, comments, entry: { cursor: { since: "1970-01-01T00:00:00Z", dispatched: [], checks_sha: null } }, trust }).events;
114
+ for (const event of events) {
115
+ if (event.kind === "check") continue;
116
+ const raw = groups[event.kind].find(r => r.id === event.id);
117
+ if (!raw) continue;
118
+ if (!Number.isSafeInteger(event.id) || event.id < 1 || !/^[A-Za-z0-9][A-Za-z0-9-]{0,80}$/.test(event.author)) throw learningError("INPUT", "invalid source identity");
119
+ const updated = raw.updated_at || raw.submitted_at || raw.created_at || event.at;
120
+ if (range && (Date.parse(updated) < Date.parse(range.since) || Date.parse(updated) >= Date.parse(range.until))) continue;
121
+ if (result.eligible >= limit || Object.keys(s.observations).length >= LEARNING_LIMITS.observations) { result.capped = true; break; }
122
+ result.eligible++;
123
+ const excerpt = event.kind === "review" && reviewExcerpts[event.id] ? clone(reviewExcerpts[event.id]) : minimized(event.body);
124
+ let path = null; try { if (event.path) path = literalPath(event.path); } catch { excerpt.incomplete = true; }
125
+ const sha = v => typeof v === "string" && /^[a-f0-9]{40,64}$/.test(v) ? v : null;
126
+ const content = { version: 1, repo: s.repo, source: sourceId(pr.number, event.kind, event.id), pr: pr.number, kind: event.kind, id: String(event.id), reviewId: event.reviewId == null ? null : String(event.reviewId), author: event.author, createdAt: event.at, updatedAt: updated, path, originalSha: sha(raw.original_commit_id || raw.commit_id), fixSha: sha(pr.headRefOid || pr.head?.sha), url: `https://github.com/${repositoryName(pr.repo)}/pull/${pr.number}`, visibility: visibilityFor(s.repo, pr.repo, visibility), disposition: raw.state === "DISMISSED" ? "retracted" : "unconfirmed", ...excerpt };
127
+ iso(content.createdAt); iso(content.updatedAt);
128
+ const digest = hash(content);
129
+ if (s.observations[digest]) { result.duplicates++; continue; }
130
+ const previous = Object.values(s.observations).filter(o => o.content.source === content.source && !o.superseded).map(o => o.content.sourceDigest);
131
+ s.observations[digest] = { content, observedAt: at(), superseded: false, deleted: false, expired: false, previous };
132
+ result.added++;
133
+ }
134
+ reconcileObservations(s.observations);
135
+ // Only a complete, unfiltered source enumeration proves absence. Automatic
136
+ // coding reads are filtered and cannot accidentally tombstone old findings.
137
+ for (const kind of completeKinds) {
138
+ const rawIds = new Set((groups[kind] || []).map(r => sourceId(pr.number, kind, r.id)));
139
+ for (const o of Object.values(s.observations)) if (o.content.pr === pr.number && o.content.kind === kind && !o.superseded && !o.deleted && !rawIds.has(o.content.source)) { o.deleted = true; o.retractedAt ??= at(); }
140
+ }
141
+ for (const raw of reviews) if (raw.state === "DISMISSED") {
142
+ for (const o of Object.values(s.observations)) if (o.content.pr === pr.number && (o.content.kind === "review" && o.content.id === String(raw.id) || o.content.reviewId === String(raw.id))) { o.retracted = true; o.retractedAt ??= at(); }
143
+ }
144
+ return result;
145
+ };
146
+ const verifyState = (s, a) => {
147
+ let active = [], parent = null;
148
+ if (s.sequence > 10_000 || !Number.isSafeInteger(s.sequence) || s.sequence < 0) throw learningError("STATE", "invalid transition sequence");
149
+ for (let sequence = 1; sequence <= s.sequence; sequence++) {
150
+ const t = s.transitions[String(sequence)];
151
+ if (!t) throw learningError("CHECKPOINT", "missing approval history; application blocked");
152
+ const m = t.manifest;
153
+ validateManifest(m, s.repo, a);
154
+ if (m.sequence !== sequence || m.parent !== parent || hash(m) !== t.digest) throw learningError("REPLAY", "approval sequence/parent/content mismatch");
155
+ signer.verify(canonical(m), t.signature, a);
156
+ active = applyManifest(active, m, s, a);
157
+ parent = t.digest;
158
+ }
159
+ if (canonical(active) !== canonical(s.active) || parent !== s.parent || canonical(s.checkpoint) !== canonical(s.sequence ? { authority: a.id, repo: s.repo, sequence: s.sequence, parent, activeDigest: hash(active) } : null)) throw learningError("CHECKPOINT", "active set or monotonic checkpoint inconsistent; application blocked");
160
+ return { active, parent };
161
+ };
162
+ const validateManifest = (m, repo, a) => {
163
+ closed(m, ["version", "type", "namespace", "authority", "repo", "fingerprint", "action", "sequence", "parent", "add", "remove", "activeDigest", "evidenceDigests", "reason", "at"], "transition");
164
+ if (m.version !== 1 || m.type !== "transition" || m.namespace !== NAMESPACE || m.authority !== a.id || m.fingerprint !== a.fingerprint || canonical(m.repo) !== canonical(repo) || !["approve", "replace", "retire"].includes(m.action)) throw learningError("SIGNATURE", "foreign transition or unknown authorization schema");
165
+ iso(m.at); digestField(m.activeDigest); if (m.parent !== null) digestField(m.parent);
166
+ if (!Number.isSafeInteger(m.sequence) || m.sequence < 1 || !Array.isArray(m.add) || m.add.length > LEARNING_LIMITS.candidates || !Array.isArray(m.remove) || m.remove.length > LEARNING_LIMITS.candidates || !Array.isArray(m.evidenceDigests) || m.evidenceDigests.length > 400) throw learningError("SIGNATURE", "invalid transition bounds");
167
+ if (m.action === "retire" ? m.add.length !== 0 || !m.remove.length : !m.add.length) throw learningError("SIGNATURE", "invalid transition action");
168
+ if (m.action === "approve" && m.remove.length) throw learningError("SIGNATURE", "approve cannot hide replacements");
169
+ if (m.action === "replace" && !m.remove.length) throw learningError("SIGNATURE", "replacement must enumerate removals");
170
+ if (m.reason !== null) textField(m.reason, "transition reason");
171
+ if (m.action === "retire" && m.reason === null) throw learningError("SIGNATURE", "retirement requires a reviewed reason");
172
+ m.remove.forEach(r => { closed(r, ["id", "digest"], "removed revision"); ruleKey(r.id, r.digest); });
173
+ m.evidenceDigests.forEach(digestField);
174
+ };
175
+ const applyManifest = (prior, m, s) => {
176
+ let active = clone(prior);
177
+ if (new Set(m.remove.map(refKey)).size !== m.remove.length || new Set(m.add.map(r => r.content.id)).size !== m.add.length) throw learningError("SIGNATURE", "duplicate batch revisions");
178
+ for (const ref of m.remove) {
179
+ if (!active.some(r => refKey(r) === refKey(ref))) throw learningError("REPLAY", "removal is not an exact active revision");
180
+ active = active.filter(r => refKey(r) !== refKey(ref));
181
+ }
182
+ const evidence = [];
183
+ for (const add of m.add) {
184
+ closed(add, ["content", "digest", "evidence"], "added revision");
185
+ const validated = candidate(add.content, s.repo);
186
+ if (validated.digest !== add.digest || !Array.isArray(add.evidence) || add.evidence.length !== add.content.evidence.length) throw learningError("SIGNATURE", "signed revision/evidence differs");
187
+ if (active.some(r => r.id === add.content.id)) throw learningError("SIGNATURE", "replacement omitted exact previous revision");
188
+ for (let i = 0; i < add.evidence.length; i++) {
189
+ if (hash(add.evidence[i]) !== add.content.evidence[i] || canonical(add.evidence[i].repo) !== canonical(s.repo)) throw learningError("SIGNATURE", "signed evidence hash/scope differs");
190
+ evidence.push(hash(add.evidence[i]));
191
+ }
192
+ active.push({ id: add.content.id, digest: add.digest });
193
+ }
194
+ active = refs(active);
195
+ if (hash(active) !== m.activeDigest || canonical(evidence.sort()) !== canonical(m.evidenceDigests)) throw learningError("SIGNATURE", "extra batch content or resulting bundle digest differs");
196
+ return active;
197
+ };
198
+ const commit = async (s, a, manifest, signature) => {
199
+ validateManifest(manifest, s.repo, a);
200
+ const digest = hash(manifest), committed = s.transitions[String(manifest.sequence)];
201
+ if (committed?.digest === digest && committed.signature === signature) { verifyState(s, a); delete s.prepared[digest]; return { digest, sequence: manifest.sequence, idempotent: true }; }
202
+ verifyState(s, a);
203
+ if (manifest.sequence !== s.sequence + 1 || manifest.parent !== s.parent) throw learningError("STALE", "stale/replayed approval; review a new preview against the current parent");
204
+ signer.verify(canonical(manifest), signature, a);
205
+ const active = applyManifest(s.active, manifest, s, a);
206
+ // Only exact pending selected revisions may enter through this writer.
207
+ for (const add of manifest.add) {
208
+ const record = s.candidates[ruleKey(add.content.id, add.digest)];
209
+ if (!record || record.status !== "pending" || canonical(record.content) !== canonical(add.content)) throw learningError("REVISION", "candidate changed or was disposed after preview");
210
+ record.status = "active";
211
+ }
212
+ for (const ref of manifest.remove) s.candidates[refKey(ref)].status = "retired";
213
+ s.transitions[String(manifest.sequence)] = { manifest, signature, digest, committedAt: at() };
214
+ s.active = active; s.sequence = manifest.sequence; s.parent = digest;
215
+ if (manifest.remove.length) revocationIntent(s, manifest.remove, "signed retirement/replacement");
216
+ s.checkpoint = { authority: a.id, repo: s.repo, sequence: s.sequence, parent: digest, activeDigest: hash(active) };
217
+ delete s.prepared[digest];
218
+ return { digest, sequence: s.sequence, idempotent: false };
219
+ };
220
+ const service = {
221
+ async resolveRepo(name, { remember = false, signal } = {}) {
222
+ repositoryName(name); if (!gh) throw learningError("TRANSPORT", "GitHub transport unavailable");
223
+ const r = await gh.learningRepository(name, { signal }), repo = repository({ host: r.host, id: r.id });
224
+ signal?.throwIfAborted();
225
+ resolvedRepositories.set(name.toLowerCase(), { repo, visibility: r.visibility });
226
+ if (remember) store.remember(name, repo, visibilityFor(repo, name));
227
+ return repo;
228
+ },
229
+ identityFor(name) {
230
+ repositoryName(name);
231
+ const remembered = store.identities()[name.toLowerCase()];
232
+ if (!remembered) return null;
233
+ // Older installations stored identity alone. New records retain last-resolved
234
+ // classification outside that identity, without adding transport to a tick.
235
+ const repo = repository(remembered.repo ?? remembered);
236
+ if (remembered.repo && !resolvedRepositories.has(name.toLowerCase())) resolvedRepositories.set(name.toLowerCase(), remembered);
237
+ return repo;
238
+ },
239
+ rememberRepo(name, repo) { repositoryName(name); repository(repo); store.remember(name, repo, visibilityFor(repo, name)); },
240
+ mode(repo) { repository(repo); return store.load(repo).mode; },
241
+ async doctor(repo = null, profile = null) {
242
+ let openssh; try { openssh = signer.probe(); } catch (e) { openssh = { available: false, code: e.code, reason: e.message }; }
243
+ let enrollment; try { enrollment = { enrolled: true, authority: authority().id }; } catch (e) { enrollment = { enrolled: false, reason: e.message }; }
244
+ return { version: 1, platform: process.platform, openssh, enrollment, isolation: { ...await qualify({ repo, profile, root: store.root }), standalone: await probeIsolation() }, apply: "requires enrollment and qualified profile; off by default" };
245
+ },
246
+ async setup({ keyPath, confirm }) {
247
+ // Both the explicit CLI command and a real terminal are required. There is no --yes path.
248
+ signer.terminal(); if (store.readAuthority()) throw learningError("TRUST", "authority already enrolled; rotation/recovery requires separate continuity review");
249
+ const isolation = await qualify({ repo: null, root: store.root });
250
+ if (isolation?.qualified !== true) throw learningError("UNQUALIFIED", "setup refused before key creation: execution isolation is UNQUALIFIED; collection remains available");
251
+ signer.probe();
252
+ safePath(keyPath, { file: true });
253
+ if (inside(keyPath, store.root) || inside(store.root, join(keyPath, ".."))) throw learningError("KEY", "approval key directory must be separate from the learning store");
254
+ const validatePending = intent => {
255
+ if (!intent) return;
256
+ closed(intent, ["version", "id", "keyPath", "key", "signer"], "enrollment intent");
257
+ if (intent.version !== 1 || typeof intent.id !== "string" || !/^[0-9a-f-]{36}$/.test(intent.id) || intent.keyPath !== keyPath || canonical(intent.signer) !== canonical(signer.pinned()) || canonical(intent.key) !== canonical(encryptedKey(keyPath))) throw learningError("TRUST", "pending enrollment key, path or signer changed");
258
+ };
259
+ let intent = store.readEnrollment(); validatePending(intent);
260
+ const expected = canonical(intent);
261
+ if (await confirm(safeDisplay({ action: intent ? "retry signed enrollment of the exact pending encrypted key" : "create dedicated encrypted Ed25519 key and signed enrollment", keyPath, signer: signer.pinned(), isolation, pending: intent })) !== true) throw learningError("CANCELLED", "setup cancelled; no enrollment created");
262
+ if (!intent) {
263
+ const key = signer.createKey(keyPath);
264
+ intent = { version: 1, id: randomUUID(), keyPath, key, signer: signer.pinned() };
265
+ }
266
+ const persist = (expectedIntent, write) => {
267
+ const release = store.lock();
268
+ try {
269
+ if (store.readAuthority() || canonical(store.readEnrollment()) !== expectedIntent) throw learningError("TRUST", "concurrent enrollment changed; refusing trust replacement");
270
+ validatePending(intent); write();
271
+ } finally { release(); }
272
+ };
273
+ persist(expected, () => store.writeEnrollment(intent));
274
+ const frozen = canonical(intent);
275
+ const a = { version: 1, id: intent.id, namespace: NAMESPACE, identity: SIGNER_ID, ...intent.key, signer: intent.signer, keyPath, enrollment: null, signature: null };
276
+ a.enrollment = { version: 1, type: "enrollment", authority: a.id, repo: null, namespace: NAMESPACE, identity: SIGNER_ID, publicKey: a.publicKey, fingerprint: a.fingerprint, signer: a.signer, keyPath };
277
+ a.signature = signer.sign(canonical(a.enrollment), a); signer.verify(canonical(a.enrollment), a.signature, a);
278
+ persist(frozen, () => store.writeAuthority(a));
279
+ pinnedAuthority = canonical(a); return { enrolled: true, authority: a.id, fingerprint: a.fingerprint };
280
+ },
281
+ async configure(repo, mode, profile = null) {
282
+ if (!["off", "collect", "apply"].includes(mode)) throw learningError("INPUT", "mode is off, collect or apply");
283
+ // Remember even repeated disables: comparing only the mode misses off ->
284
+ // off and apply -> off -> apply changes while qualification is pending.
285
+ const generation = mode === "apply" ? store.load(repo).modeGeneration : null;
286
+ let a;
287
+ if (mode === "apply") { a = authority(); await qualified(repo, profile); }
288
+ const { result, intent } = await store.transaction(repo, s => {
289
+ if (mode === "apply") {
290
+ if (s.modeGeneration !== generation) throw learningError("STALE", "learning mode changed during qualification; retry the explicit apply request");
291
+ verifyState(s, a);
292
+ }
293
+ const before = s.mode; s.mode = mode; s.modeGeneration = randomUUID();
294
+ return { result: { before, mode }, intent: mode !== "apply" ? revocationIntent(s, null, "learning application disabled") : null };
295
+ });
296
+ // The durable disabled mode is also the retry intent. Repeat controls even
297
+ // if an earlier invocation committed it before a registry/control failure.
298
+ if (intent) result.controls = await onRevocation(intent);
299
+ return result;
300
+ },
301
+ async observe(repo, snapshot) {
302
+ if (store.load(repo).mode === "off") return { skipped: "learning off" };
303
+ return store.transaction(repo, s => { if (s.mode === "off") return { skipped: "learning off" }; return observe(s, snapshot); });
304
+ },
305
+ async collect(repo, name, { since, until, dryRun = false } = {}) {
306
+ repository(repo); repositoryName(name);
307
+ if (!gh) throw learningError("TRANSPORT", "GitHub transport unavailable");
308
+ const started = clock.now(), request = { since: since ?? null, until: until ?? null };
309
+ const freshRange = dateRange(since, until, started);
310
+ const s = clone(store.load(repo)); expire(s);
311
+ if (!dryRun && s.mode === "off") throw learningError("OFF", "configure collect explicitly before storing feedback");
312
+ // A relative window belongs to its scan, not its retry's wall clock. Legacy
313
+ // reports lack request fields; match their original window conservatively.
314
+ const pending = until == null && s.scans.findLast(scan => !scan.complete && (scan.request
315
+ ? canonical(scan.request) === canonical(request)
316
+ : scan.since === dateRange(since, undefined, Date.parse(scan.until)).since));
317
+ const range = pending ? { since: pending.since, until: pending.until } : freshRange;
318
+ const id = hash({ repo, ...range });
319
+ const old = s.scans.find(scan => scan.id === id && !scan.complete);
320
+ const report = { id, request, ...range, dryRun, prs: 0, pages: 0, eligible: 0, added: 0, duplicates: 0, capped: false, complete: false, retryAt: null, continuation: old?.continuation ?? { page: 1, index: 0 }, coverage: "bounded PR update-order scan with identity-deduplicated listing overlap; concurrent upstream changes may leave coverage incomplete; only fully enumerated sources can establish deletion", caps: LEARNING_LIMITS, extraction: dryRun ? "not run (dry-run)" : "not run; separate explicit allowance required" };
321
+ report.completedPrs = old?.completedPrs ?? [];
322
+ report.listingPage = old?.listingPage ?? 1;
323
+ report.sweepAdded = old?.sweepAdded ?? false;
324
+ const completedPrs = new Set(report.completedPrs);
325
+ const save = async () => {
326
+ if (dryRun) return;
327
+ await store.transaction(repo, current => {
328
+ if (current.mode === "off") throw learningError("OFF", "learning was disabled during collection; no new feedback acknowledged");
329
+ // Merge only observation revisions/dispositions; never overwrite approval/task state from this scan snapshot.
330
+ for (const [key, o] of Object.entries(s.observations)) {
331
+ if (!current.observations[key]) current.observations[key] = clone(o);
332
+ else {
333
+ const existing = current.observations[key];
334
+ for (const flag of ["deleted", "retracted"]) if (o[flag]) existing[flag] = true;
335
+ if (o.retractedAt && (!existing.retractedAt || o.retractedAt < existing.retractedAt)) existing.retractedAt = o.retractedAt;
336
+ }
337
+ }
338
+ reconcileObservations(current.observations);
339
+ expire(current);
340
+ current.backoffUntil = report.retryAt ?? (current.backoffUntil > clock.now() ? current.backoffUntil : null);
341
+ current.scans = [...current.scans.filter(x => x.id !== id).slice(-49), clone(report)];
342
+ expire(current);
343
+ });
344
+ };
345
+ const page = async (kind, pr, n) => {
346
+ if (report.pages >= LEARNING_LIMITS.pages || clock.now() - started >= LEARNING_LIMITS.deadlineMs) { report.capped = true; return null; }
347
+ report.pages++;
348
+ let result;
349
+ try { result = await boundedCall(signal => gh.learningPage(name, kind, { pr, page: n, signal }), { deadlineMs: Math.max(1, LEARNING_LIMITS.deadlineMs - (clock.now() - started)) }); }
350
+ catch (e) { report.error = { code: e.code || "LEARNING_TRANSPORT", message: "bounded transport failed; unfinished PR retained for retry" }; return null; }
351
+ if (result.rateLimited) { report.retryAt = result.retryAt; return null; }
352
+ if (!Array.isArray(result.items) || result.items.length > 100 || typeof result.complete !== "boolean") throw learningError("TRANSPORT", "invalid bounded page response");
353
+ return result;
354
+ };
355
+ const retryAt = Math.max(old?.retryAt || 0, s.backoffUntil || 0);
356
+ if (retryAt > clock.now()) return { ...report, retryAt, extraction: "not run; repository transport backoff retained" };
357
+ // Persist traversal across bounded invocations. A pass that discovers PRs
358
+ // is followed by a deduplicated pass to catch movement ahead of its cursor.
359
+ const firstPage = report.listingPage;
360
+ outer: for (let n = firstPage; ; n++) {
361
+ report.listingPage = n;
362
+ const prs = await page("pulls", null, n); if (!prs) break;
363
+ let reachedEnd = prs.complete;
364
+ const items = n === firstPage && report.continuation.source ? [report.continuation.source.pr, ...prs.items] : prs.items;
365
+ for (let i = 0; i < items.length; i++) {
366
+ const pr = items[i];
367
+ if (completedPrs.has(pr.number)) continue;
368
+ const prior = report.continuation.source?.pr.number === pr.number ? report.continuation.source : null;
369
+ report.continuation = { page: n, index: i, ...(prior ? { source: prior } : {}) };
370
+ if (Date.parse(pr.updated_at) < Date.parse(range.since)) { reachedEnd = true; break; }
371
+ if (completedPrs.size >= LEARNING_LIMITS.observations || report.prs >= LEARNING_LIMITS.prs || report.eligible >= LEARNING_LIMITS.findings) { report.capped = true; break outer; }
372
+ const kinds = [["reviews", "reviews", "review"], ["inline", "inline", "inline"], ["comments", "comments", "command"]];
373
+ const source = prior ?? { pr: { number: pr.number, updated_at: pr.updated_at, headRefOid: pr.headRefOid ?? pr.head?.sha ?? null }, kind: 0, page: 1, missing: null, reviews: [] };
374
+ report.continuation.source = source;
375
+ for (; source.kind < kinds.length;) {
376
+ const [kind, target, eventKind] = kinds[source.kind];
377
+ source.missing ??= Object.entries(s.observations).filter(([, o]) => o.content.pr === pr.number && o.content.kind === eventKind && !o.superseded && !o.deleted).map(([digest]) => digest);
378
+ for (;;) {
379
+ // Reserve a full transport page so no eligible tail can be lost at
380
+ // the invocation cap. Only minimized observations and bounded IDs
381
+ // survive a checkpoint; raw bodies are never buffered across pages.
382
+ if (report.eligible + 200 > LEARNING_LIMITS.findings) { report.capped = true; break outer; }
383
+ const findings = await page(kind, pr.number, source.page); if (!findings) break outer;
384
+ const seen = new Set(findings.items.map(raw => String(raw.id)));
385
+ source.missing = source.missing.filter(d => !seen.has(s.observations[d]?.content.id));
386
+ if (kind === "reviews") {
387
+ for (const raw of findings.items) if ((raw.state === "DISMISSED" || trust.isOwn(actor(raw.user)) || (raw.state === "COMMENTED" && trust.isReviewer(actor(raw.user)))) && !source.reviews.some(r => r.id === raw.id)) source.reviews.push({ id: raw.id, user: raw.user ? { login: raw.user.login ?? null, type: raw.user.type ?? null } : null, state: raw.state ?? null, submitted_at: raw.submitted_at ?? null, updated_at: raw.updated_at ?? null, commit_id: raw.commit_id ?? null, observedAt: at(), excerpt: minimized(raw.body || "") });
388
+ if (source.reviews.length > LEARNING_LIMITS.observations) { report.capped = true; report.error = { code: "LEARNING_STORAGE", message: "review context identity cap reached; source coverage incomplete" }; break outer; }
389
+ }
390
+ const attached = new Set(findings.items.map(raw => raw.pull_request_review_id));
391
+ const context = source.reviews.filter(r => r.state === "DISMISSED" || attached.has(r.id));
392
+ const snapshot = { pr: { ...pr, repo: name },
393
+ reviews: context.map(r => ({ ...r, body: r.excerpt.excerpt, submitted_at: r.state === "COMMENTED" ? r.submitted_at : null })),
394
+ reviewExcerpts: Object.fromEntries(context.map(r => [r.id, r.excerpt])), [target]: findings.items };
395
+ const r = observe(s, snapshot, range, LEARNING_LIMITS.findings - report.eligible);
396
+ for (const key of ["eligible", "added", "duplicates"]) report[key] += r[key];
397
+ if (r.capped) { report.capped = true; await save(); break outer; }
398
+ source.page++;
399
+ if (findings.complete) {
400
+ // Absence is meaningful only after this entire source, including
401
+ // all earlier checkpoints, has been enumerated.
402
+ for (const digest of source.missing) { const o = s.observations[digest]; if (o) { o.deleted = true; o.retractedAt ??= at(); } }
403
+ source.page = 1; source.missing = null;
404
+ break;
405
+ }
406
+ await save();
407
+ }
408
+ // Save the next kind together with the last page's dispositions.
409
+ source.kind++; if (source.kind >= 2) source.reviews = []; await save();
410
+ }
411
+ report.prs++; report.sweepAdded = true; completedPrs.add(pr.number); report.completedPrs = [...completedPrs];
412
+ report.continuation = { page: n, index: i + 1 }; await save();
413
+ }
414
+ if (reachedEnd) {
415
+ if (!report.sweepAdded) { report.complete = true; break; }
416
+ report.sweepAdded = false; report.listingPage = 1; report.continuation = { page: 1, index: 0 };
417
+ await save(); n = 0; continue;
418
+ }
419
+ report.listingPage = n + 1;
420
+ report.continuation = { page: n + 1, index: 0 };
421
+ }
422
+ report.latencyMs = clock.now() - started;
423
+ if (report.complete) report.continuation = null;
424
+ await save(); return report;
425
+ },
426
+ async extract(repo, { allowanceUsd = 0 } = {}) {
427
+ const boundary = await qualify({ repo, extraction: true, root: store.root });
428
+ if (!boundary?.qualified || !extractor?.dataOnly || !extractor?.execute || !(allowanceUsd > 0) || allowanceUsd > 1) return { status: "unavailable", reason: "enforced data-only extraction profile and separate positive learning allowance required", inference: false };
429
+ const reservation = await store.transaction(repo, s => {
430
+ if (s.mode === "off") throw learningError("OFF", "learning off");
431
+ expire(s);
432
+ const reserved = new Set(s.extraction.flatMap(e => e.observationDigests || []));
433
+ let remaining = Object.entries(s.observations).filter(([digest, o]) => !reserved.has(digest) && !o.expired && !o.deleted && !o.superseded && !o.retracted);
434
+ let observations, inputDigest;
435
+ for (;;) {
436
+ if (!remaining.length) return { result: clone(s.extraction.at(-1) ?? { status: "empty", count: 0, inference: false }) };
437
+ observations = remaining.slice(0, LEARNING_LIMITS.findings).map(([digest, o]) => ({ digest, ...o.content }));
438
+ inputDigest = hash(observations);
439
+ const prior = s.extraction.find(e => e.inputDigest === inputDigest);
440
+ if (!prior) break;
441
+ // Legacy exact-batch reservations acquire revision keys without paid work.
442
+ prior.observationDigests ??= observations.map(o => o.digest);
443
+ remaining = remaining.slice(observations.length);
444
+ }
445
+ const intent = { inputDigest, observationDigests: observations.map(o => o.digest), status: "intent", reservedUsd: allowanceUsd, costUsd: null, at: at(), count: 0 };
446
+ s.extraction.push(intent);
447
+ return { intent: clone(intent), observations };
448
+ });
449
+ if (reservation.result) return reservation.result;
450
+ const { intent: reservedIntent, observations } = reservation, { inputDigest } = reservedIntent;
451
+ const started = clock.now();
452
+ let output, failure;
453
+ try {
454
+ output = await boundedCall(signal => extractor.execute({ observations, inputDigest, repo, maxCandidates: LEARNING_LIMITS.candidates, allowanceUsd, signal }), { deadlineMs: LEARNING_LIMITS.extractionMs });
455
+ } catch (e) { failure = e; }
456
+ // Never carry a cloned store across external execution. A failed merge
457
+ // leaves the durable reservation intact, so retries cannot repurchase it.
458
+ return store.transaction(repo, s => {
459
+ const intent = s.extraction.find(e => e.inputDigest === inputDigest);
460
+ if (!intent || canonical(intent) !== canonical(reservedIntent)) throw learningError("EXTRACTION", "extraction reservation changed before result merge");
461
+ expire(s);
462
+ intent.costUsd = Number.isFinite(output?.costUsd) && output.costUsd >= 0 ? output.costUsd : null;
463
+ intent.tokens = Number.isSafeInteger(output?.tokens) && output.tokens >= 0 ? output.tokens : null;
464
+ try {
465
+ if (failure) throw failure;
466
+ if (s.mode === "off") throw learningError("OFF", "learning disabled during extraction");
467
+ const values = parseJson(output.json);
468
+ if (!Array.isArray(values) || values.length > LEARNING_LIMITS.candidates) throw learningError("INPUT", "invalid extraction candidate count");
469
+ const validated = values.map(v => candidate(v, repo));
470
+ if (Object.keys(s.candidates).length + validated.length > 2000) throw learningError("STORAGE", "candidate revision cap reached before extraction proposals were applied");
471
+ for (const v of validated) {
472
+ if (v.content.provenance.inputDigest !== inputDigest) throw learningError("INPUT", "extraction provenance differs");
473
+ if (v.content.evidence.some(d => !intent.observationDigests.includes(d))) throw learningError("EVIDENCE", "extraction references evidence outside the reserved batch");
474
+ }
475
+ for (const v of validated) if (v.content.evidence.some(d => {
476
+ const o = s.observations[d];
477
+ return !o || o.expired || o.deleted || o.superseded || o.retracted;
478
+ })) throw learningError("EVIDENCE", "extraction evidence changed during execution");
479
+ const proposed = validated.map(v => putCandidate(s, v.content));
480
+ intent.status = "completed"; intent.count = proposed.filter(p => !p.duplicate).length;
481
+ } catch (e) { intent.status = "unresolved"; intent.code = e.code || "LEARNING_EXTRACTION"; }
482
+ intent.latencyMs = clock.now() - started; return clone(intent);
483
+ });
484
+ },
485
+ list(repo, status = null) { if (status && !["pending", "rejected", "superseded", "approved", "active", "retired"].includes(status)) throw learningError("INPUT", "unknown candidate disposition"); const s = store.load(repo); return Object.values(s.candidates).filter(c => !status || c.status === status).sort((a, b) => ordered(ruleKey(a.content.id, a.digest), ruleKey(b.content.id, b.digest))).map(c => review(s, c)); },
486
+ show(repo, id, revision = null) { const s = store.load(repo); return review(s, selected(s, id, revision)); },
487
+ async revise(repo, id, value) { candidateId(id); if (value.id !== id) throw learningError("INPUT", "revision ID differs"); return store.transaction(repo, s => putCandidate(s, value)); },
488
+ async reject(repo, id, revision, reason) { textField(reason, "rejection reason"); return store.transaction(repo, s => { const c = selected(s, id, revision); if (!["pending", "rejected"].includes(c.status)) throw learningError("REVISION", "rejection cannot alter active or retired content"); c.status = "rejected"; c.reason = reason; return review(s, c); }); },
489
+ async approve(repo, selections, { action = "approve", reason = null, confirm } = {}) {
490
+ if (!Array.isArray(selections) || !selections.length || selections.length > LEARNING_LIMITS.candidates) throw learningError("INPUT", "enumerate exact revisions for approval");
491
+ if (!["approve", "retire"].includes(action)) throw learningError("INPUT", "invalid approval action");
492
+ selections.forEach(r => { closed(r, ["id", "digest"], "selection"); ruleKey(r.id, r.digest); });
493
+ if (new Set(selections.map(refKey)).size !== selections.length || new Set(selections.map(r => r.id)).size !== selections.length) throw learningError("INPUT", "duplicate selected revisions");
494
+ signer.terminal(); const a = authority(); await qualified(repo);
495
+ // Freeze the complete review under lock; prompts never hold the shared lock.
496
+ let release = store.lock();
497
+ let result, removals = [];
498
+ try {
499
+ let s = store.load(repo); verifyState(s, a);
500
+ const add = [], remove = [];
501
+ for (const ref of selections) {
502
+ const c = selected(s, ref.id, ref.digest);
503
+ if (action === "retire") { if (c.status !== "active") throw learningError("REVISION", "retirement requires exact active revision"); remove.push(ref); }
504
+ else {
505
+ if (c.status !== "pending") throw learningError("REVISION", "approval requires an exact pending revision");
506
+ const old = s.active.find(r => r.id === ref.id); if (old) remove.push(old);
507
+ if (c.content.evidence.some(d => expired(s.observations[d]))) throw learningError("EVIDENCE", "raw evidence expired; collect and review a new evidence revision before approval");
508
+ add.push({ content: clone(c.content), digest: c.digest, evidence: c.content.evidence.map(d => clone(s.observations[d].content)) });
509
+ }
510
+ }
511
+ if (reason !== null) textField(reason, "approval reason");
512
+ const manifest = { version: 1, type: "transition", namespace: NAMESPACE, authority: a.id, repo, fingerprint: a.fingerprint, action: action === "retire" ? "retire" : remove.length ? "replace" : "approve", sequence: s.sequence + 1, parent: s.parent, add, remove, activeDigest: hash(refs([...s.active.filter(r => !remove.some(x => refKey(x) === refKey(r))), ...add.map(r => ({ id: r.content.id, digest: r.digest }))])), evidenceDigests: add.flatMap(r => r.content.evidence).sort(), reason, at: at() };
513
+ validateManifest(manifest, repo, a);
514
+ const bytes = canonical(manifest), digest = hash(bytes);
515
+ const previewFor = state => safeDisplay({ manifest, digest, selected: selections.map(r => review(state, selected(state, r.id, r.digest))), removed: remove.map(r => review(state, selected(state, r.id, r.digest))) });
516
+ const preview = previewFor(s), mode = s.mode;
517
+ const recheck = () => {
518
+ if (canonical(authority()) !== canonical(a)) throw learningError("TRUST", "authority changed while signing");
519
+ s = store.load(repo); verifyState(s, a);
520
+ if (s.mode !== mode || s.sequence + 1 !== manifest.sequence || s.parent !== manifest.parent || previewFor(s) !== preview) throw learningError("REPLAY", "approval state changed; review a new preview");
521
+ };
522
+ release(); release = null;
523
+ if (typeof confirm !== "function" || await confirm(preview) !== true) throw learningError("CANCELLED", "approval cancelled; active bundle unchanged");
524
+ release = store.lock(); recheck();
525
+ s.prepared[digest] = { manifest, signature: null, preparedAt: at() }; store.saveLocked(repo, s);
526
+ release(); release = null;
527
+ const signature = signer.sign(bytes, a);
528
+ release = store.lock(); recheck();
529
+ if (!s.prepared[digest] || canonical(s.prepared[digest].manifest) !== bytes) throw learningError("REPLAY", "approval preparation changed while signing");
530
+ s.prepared[digest].signature = signature; store.saveLocked(repo, s);
531
+ // Verify against the same immutable objects used for the preview, then write
532
+ // history, checkpoint and active pointer together in one fsynced atomic rename.
533
+ if (canonical(authority()) !== canonical(a)) throw learningError("TRUST", "authority changed while signing");
534
+ result = await commit(s, a, manifest, signature); store.saveLocked(repo, s); removals = remove;
535
+ } finally { release?.(); }
536
+ if (removals.length) result.controls = await onRevocation(revocationIntent(store.load(repo), removals, "signed retirement/replacement"));
537
+ return result;
538
+ },
539
+ async recover(repo) {
540
+ const a = authority();
541
+ const committed = await store.transaction(repo, s => {
542
+ verifyState(s, a);
543
+ const removed = [...new Map(Object.values(s.transitions).flatMap(t => t.manifest.remove).map(r => [refKey(r), r])).values()].filter(r => !s.active.some(active => refKey(active) === refKey(r)));
544
+ return { pending: Object.values(s.prepared).some(t => t.signature), intent: removed.length ? revocationIntent(s, removed, "signed retirement/replacement") : null };
545
+ });
546
+ const controls = committed.intent ? [{ controls: await onRevocation(committed.intent) }] : [];
547
+ if (!committed.pending) return controls;
548
+ await qualified(repo);
549
+ const { results, intent } = await store.transaction(repo, async s => {
550
+ verifyState(s, a); const results = [];
551
+ for (const [key, t] of Object.entries(s.prepared).sort((a, b) => a[1].manifest.sequence - b[1].manifest.sequence || ordered(a[0], b[0]))) if (t.signature) {
552
+ validateManifest(t.manifest, repo, a);
553
+ const digest = hash(t.manifest), committed = s.transitions[String(t.manifest.sequence)];
554
+ if (key !== digest) throw learningError("SIGNATURE", "prepared content digest differs");
555
+ signer.verify(canonical(t.manifest), t.signature, a);
556
+ if (t.manifest.sequence <= s.sequence && (committed?.digest !== digest || committed.signature !== t.signature)) {
557
+ // A newer reviewed transition already consumed this sequence. Keep
558
+ // the verified obsolete bytes for audit, never rebase or activate them.
559
+ if (Object.keys(s.preparationHistory).length >= 1000) throw learningError("STORAGE", "obsolete preparation history reached its 1,000 record cap");
560
+ const disposition = { digest, sequence: t.manifest.sequence, disposition: "obsolete", supersededBy: committed.digest };
561
+ s.preparationHistory[digest] = { ...t, ...disposition, retiredAt: at() };
562
+ delete s.prepared[key]; results.push(disposition);
563
+ } else results.push(await commit(s, a, t.manifest, t.signature));
564
+ }
565
+ // Signed history survives failure after commit, including a lost reply
566
+ // from revocation controls. Reconcile outside the learning-store lock.
567
+ const removed = [...new Map(Object.values(s.transitions).flatMap(t => t.manifest.remove).map(r => [refKey(r), r])).values()].filter(r => !s.active.some(active => refKey(active) === refKey(r)));
568
+ return { results, intent: removed.length ? revocationIntent(s, removed, "signed retirement/replacement") : null };
569
+ });
570
+ if (intent) results.push({ controls: await onRevocation(intent) });
571
+ return results;
572
+ },
573
+ async context(repo, { operationId, profile, paths = [], inputDigest, previous = null, taskId = null } = {}) {
574
+ repository(repo); textField(operationId, "dispatch identity", 256); digestField(inputDigest); paths.forEach(literalPath);
575
+ if (taskId !== null) textField(taskId, "task identity", 256);
576
+ const mode = store.load(repo).mode;
577
+ if (mode !== "apply") return { section: "", delivered: false, freshSessionRequired: !!previous?.rules?.length, mode };
578
+ const a = authority(); await qualified(repo, profile);
579
+ return store.transaction(repo, s => {
580
+ if (s.mode !== "apply") return { section: "", delivered: false, freshSessionRequired: !!previous?.rules?.length, mode: s.mode };
581
+ const { active } = verifyState(s, a), key = hash({ operationId, profile, inputDigest });
582
+ const existing = s.deliveries[key];
583
+ if (existing) {
584
+ if (existing.revoked || (existing.taskId ?? null) !== taskId) throw learningError("STALE_CONTEXT", "revoked or differently owned learning intent; prepare a new operation identity");
585
+ if (!existing.delivered && existing.bundleDigest !== s.parent) throw learningError("STALE_CONTEXT", "undelivered learning intent belongs to an older bundle; reconcile the dispatch and prepare a new operation identity");
586
+ return dispatchSnapshot(existing);
587
+ }
588
+ // Never evict exact retry identities or pending revocation targets. Reserve
589
+ // capacity before accepting a new operation, leaving control writes usable.
590
+ if (Object.keys(s.deliveries).length >= LEARNING_LIMITS.deliveries) throw learningError("STORAGE", "delivery audit capacity reached; existing retries and disable/recovery remain available");
591
+ const selectedRules = [], omitted = []; let section = "Reviewed repository advice (advisory only; task gates, Verify commands, permissions and budgets remain authoritative):\n";
592
+ for (const ref of active) {
593
+ // Take one verified copy from signed content; never reread candidate files after validation.
594
+ const t = Object.values(s.transitions).find(t => t.manifest.add.some(a => a.digest === ref.digest && a.content.id === ref.id));
595
+ const c = clone(t.manifest.add.find(a => a.digest === ref.digest && a.content.id === ref.id).content);
596
+ const applies = c.paths.length === 0 || paths.some(p => c.paths.some(scope => p === scope || p.startsWith(scope + "/")));
597
+ const line = `${safeDisplay({ id: ref.id, revision: ref.digest, applicability: c.applicability, guidance: c.guidance })}\n`;
598
+ const reason = !applies ? paths.length ? "unmatched literal scope" : "task paths unknown" : selectedRules.length >= LEARNING_LIMITS.lessons || Buffer.byteLength(section + line) > LEARNING_LIMITS.contextBytes ? "context budget" : null;
599
+ if (reason) omitted.push({ ...ref, reason }); else { selectedRules.push({ ...ref, reason: c.paths.length ? "literal project scope" : "repository-wide" }); section += line; }
600
+ }
601
+ // Selection uses every accepted path; persistent audit detail is sampled
602
+ // by encoded size while its digest commits to the complete input list.
603
+ const auditPaths = auditSample(paths, LEARNING_LIMITS.pathAuditBytes);
604
+ const pathAudit = { digest: hash(paths), count: paths.length, omitted: paths.length - auditPaths.length };
605
+ const omissionAudit = { digest: hash(omitted), count: omitted.length, omitted: omitted.length, reasons: {} };
606
+ for (const item of omitted) omissionAudit.reasons[item.reason] = (omissionAudit.reasons[item.reason] || 0) + 1;
607
+ const delivery = { version: 1, key, taskId, operationId, profile, paths: auditPaths, pathAudit, inputDigest, bundleSequence: s.sequence, bundleDigest: s.parent, activeDigest: hash(active), rules: selectedRules, omitted: [], omissionAudit, section: selectedRules.length ? section : "", contextDigest: hash(selectedRules.length ? section : ""), at: at(), freshSessionRequired: !!previous?.rules?.some(r => !selectedRules.some(x => refKey(x) === refKey(r))), delivered: false, runId: null };
608
+ // Reserve the summary first, then fit detail into both its own budget
609
+ // and the remaining encoded record space. All omissions remain committed
610
+ // by digest/count, even if no individual detail fits.
611
+ const remainingBytes = LEARNING_LIMITS.deliveryBytes - Buffer.byteLength(canonical(delivery));
612
+ delivery.omitted = auditSample(omitted, Math.min(LEARNING_LIMITS.omissionAuditBytes, remainingBytes + 2));
613
+ omissionAudit.omitted -= delivery.omitted.length;
614
+ if (Buffer.byteLength(canonical(delivery)) > LEARNING_LIMITS.deliveryBytes) throw learningError("STORAGE", "delivery detail byte limit exceeded");
615
+ s.deliveries[key] = delivery; return clone(delivery);
616
+ });
617
+ },
618
+ async dispatch(repo, { key, taskId, contextDigest }, launch) {
619
+ digestField(key); digestField(contextDigest); textField(taskId, "task identity", 256);
620
+ await qualified(repo, store.load(repo).deliveries[key]?.profile);
621
+ const release = store.lock();
622
+ try {
623
+ const s = store.load(repo), d = s.deliveries[key];
624
+ if (s.mode !== "apply" || !d || d.revoked || d.taskId !== taskId || d.contextDigest !== contextDigest) throw learningError("STALE_CONTEXT", "learning dispatch was revoked or changed; nothing launched");
625
+ const { active } = verifyState(s, authority());
626
+ if (d.rules.some(r => !active.some(x => refKey(x) === refKey(r)))) throw learningError("STALE_CONTEXT", "learning dispatch includes a retired revision; nothing launched");
627
+ // Serialize launch acceptance with retirement/disable. The prepared task
628
+ // identity remains durable if acceptance or the subsequent registry save
629
+ // is lost; controls can find it without an accepted registry snapshot.
630
+ // No persistence/await follows acceptance here, so an audit failure cannot
631
+ // hide a successfully launched run from the caller.
632
+ return await launch();
633
+ } finally { release(); }
634
+ },
635
+ async delivered(repo, key, runId) { digestField(key); textField(runId, "run ID", 256); return store.transaction(repo, s => {
636
+ const d = s.deliveries[key];
637
+ if (!d) throw learningError("DISPATCH", "unknown learning input intent");
638
+ if (d.runId && d.runId !== runId) throw learningError("DISPATCH", "learning intent already belongs to another attempt");
639
+ // Also reconcile intents written before revocation markers were available.
640
+ if (d.rules.length && (s.mode !== "apply" || d.rules.some(r => !s.active.some(x => refKey(x) === refKey(r))))) d.revoked ??= { reason: s.mode !== "apply" ? "learning application disabled" : "signed retirement/replacement", at: at() };
641
+ d.delivered = true; d.runId = runId; return clone(d);
642
+ }); },
643
+ explain(repo, operationId) { return Object.values(store.load(repo).deliveries).filter(d => d.operationId === operationId || d.runId === operationId); },
644
+ report(repo) {
645
+ const s = clone(store.load(repo)), counts = {}; expire(s);
646
+ for (const c of Object.values(s.candidates)) counts[c.status] = (counts[c.status] || 0) + 1;
647
+ return { version: 1, repo, mode: s.mode, observations: Object.keys(s.observations).length, dispositions: counts, preparations: { pending: Object.keys(s.prepared).length, obsolete: Object.keys(s.preparationHistory).length }, scans: s.scans, extraction: s.extraction, deliveries: Object.values(s.deliveries), deliveryCapacity: { used: Object.keys(s.deliveries).length, limit: LEARNING_LIMITS.deliveries, detailBytes: LEARNING_LIMITS.deliveryBytes, policy: "retain exact audit and retry identities; refuse new intents at capacity" }, eligibleRunDenominator: Object.values(s.deliveries).filter(d => d.delivered).length, denominatorDefinition: "accepted attempts in apply mode processed by this service", confirmedRecurrence: { count: null, evidence: [], status: "unmeasured" }, suggestedRecurrence: Object.values(s.candidates).filter(c => c.content.evidence.length > 1).map(c => ({ id: c.content.id, digest: c.digest, evidenceCount: c.content.evidence.length })), costs: { knownUsd: !s.extraction.length || s.extraction.some(e => e.costUsd === null) ? null : s.extraction.reduce((n, e) => n + e.costUsd, 0), measured: s.extraction.length > 0 }, effectiveness: "unmeasured; unmatched feedback is not zero recurrence; deliveries do not prove compliance", qualification: "UNQUALIFIED until an enforced execution profile is demonstrated" };
648
+ },
649
+ async prune(repo) { return store.transaction(repo, s => ({ expired: expire(s), retained: "minimized signed evidence, dispositions and audit digests" })); },
650
+ display: safeDisplay,
651
+ async reviseFromFile(repo, id, path) { path = resolve(path); safePath(path, { file: true }); if (statSync(path).size > 256 * 1024) throw learningError("INPUT", "candidate file byte limit"); return service.revise(repo, id, parseJson(readFileSync(path))); },
652
+ };
653
+ return service;
654
+ }