@vegastack/vegafactory 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,1182 @@
1
+ /*
2
+ ## Scoped approval records
3
+
4
+ The owner is dev-implement's `scripts/lib/approval.mjs`. Use its `artifactRef`, `scopeDigest`, `parseApproval` and approval evaluators; consumers do not implement another interpretation. Refresh complete issue/comment/dependency reads and current operator policy before recording or consuming intent. Unreadable history refuses execution.
5
+
6
+ An `ArtifactRef` has exactly `repo`, `issue`, `kind` (`brief`, `plan` or `protocol`), `artifactId`, `rev` and `digest`. The issue node ID identifies a brief; the unique canonical comment node ID identifies a plan/protocol. Digest is lowercase SHA-256 of canonical UTF-8 scope, with CRLF normalized to LF. Resolve these fields from current source, never from an index or a copied approval.
7
+
8
+ An ordinary approval opens with `<!-- vsk:v1 type=approval scope=brief+plan -->` (or its exact `brief`/`plan` scope), followed by one fenced JSON payload:
9
+
10
+ ```ts
11
+ type ApprovalRecord = {
12
+ schemaVersion: 2; id: string; operator: string;
13
+ scope: "brief" | "plan" | "brief+plan";
14
+ source: {kind: "github-comment" | "session"; ref: string; quote: string};
15
+ artifacts: ArtifactRef[]; supersedes: string[]; revokes: string[];
16
+ };
17
+ ```
18
+
19
+ Record the operator's actual words and source, then the exact approved bindings. A fresh provider-envelope publisher in current operator policy may attest session intent; a recorder's claim alone does not authenticate the speech. An untrusted publisher may only relay an independently read operator-published grant in the same complete authority history, with identical operator, quote and scope and no supersedes/revokes. A proven relay supplies its source's canonical authority ID and inherits that source's lifecycle; it creates no independent grant. Quick-build implementation requires both brief and plan; full-plan planning requires brief approval, and implementation requires both. Research execution additionally binds its current immutable protocol through the consolidated event. Brief-only research preparation does not launch a trial. Shipping actions retain their own authority.
20
+
21
+ Events have unique IDs. A changed scope needs fresh intent; conflicting grants require explicit `supersedes`, never newest-wins. A revocation event names exact earlier IDs and grants no new binding when its artifact list is empty. Preserve originals. Legacy marker-only or malformed records refuse. An explicitly authorized `scope=none` correction can neutralize exact malformed or demonstrably invalid-source targets, and a separate valid approval still supplies current intent. Missing, ambiguous or inconsistent publisher/source reads are unavailable evidence, never correction-eligible invalidity; resolve required source facts before applying corrections. The correction JSON has exactly `schemaVersion:2`, `kind:"correction"`, `scope:"none"`, `operator`, `source`, `targets:[{commentId,bodySha256}]`, `supersedes:[]`, `revokes:[]`. Changed/missing targets, invalid operator/source, self-reference and malformed corrections refuse.
22
+
23
+ For a reviewed parent scope, use a separate `scope=consolidated` event on the parent issue. Its closed JSON has `schemaVersion:2`, `kind:"consolidated"`, `id`, `operator`, `scope:"consolidated"`, `source`, `manifest`, `items`, `actions`, `supersedes` and `revokes`. Each item names exact repository/issue/mode (`code`, `preparation`, `research`), artifact bindings, task IDs and action IDs. Each action is one reviewed local operation set, an exact parent source-checkpoint ref/scope, one exact child-source-checkpoint tuple, or a protocol-bound research allowance. The child action binds one selected code item's full task/file scope and exact prepared branch/ref/base; it cannot derive authority from the local action or parent checkpoint. Unknown keys/kinds and excess or missing selections refuse. The completion index is not a plan.
24
+
25
+ The manifest locator is `{sha256,source:{kind:"inline",utf8}}` or `{sha256,source:{kind:"git-blob",repositoryId,commitSha,path,blobSha256}}`. Keep the reviewed manifest bytes frozen; authority lives in the separate actual approval event. A Git locator requires its own authorized immutable destination; a local filename alone cannot locate runtime authority. The parent ledger pins the approval's comment ID/body SHA-256 and manifest SHA-256. Children consume that exact record, without synthetic child approval comments. Source checkpoints still require the complete export proof and durable delivery intent; research still requires the protocol's candidate, prerequisite and shared allowance checks. Local approval never supplies production/private-state, service/reboot, merge, publication or destructive-cleanup authority.
26
+
27
+ The canonicalizer reads artifact/task markers outside fenced examples. Plan task IDs/order, requirements, interfaces, action bounds, revisions and all other bytes remain scope. Only existing structural task-header checkboxes normalize to unchecked. A plan may also carry one progress block whose body is strict JSON `{tasks:[{id,evidenceUrls}]}` between `<!-- vsk:progress:start -->` and `<!-- vsk:progress:end -->`; IDs must name existing tasks and evidence URLs must be HTTP(S). The entire validated block is excluded from scope. Unknown fields, duplicates, invalid URLs and malformed blocks refuse. Briefs permit no mutable block. Protocols retain their entire normalized body, including checkboxes. Fenced examples remain immutable bytes and supply no metadata authority.
28
+
29
+ ### Inspect, reconfirm and consume
30
+
31
+ Keep old comments verbatim. In report-only inventory, read every open issue and all comment pages, call `artifactRef` for its current brief/unique plan, and call `evaluateApprovals` for the intended stage. Report refusal reasons plus current artifact IDs/revisions/digests; never emit a fabricated grant or alter labels. Ask for the operator’s exact current-scope intent and, when needed, their exact malformed-target correction. Record each new event separately, refresh again and run the evaluator before work starts.
32
+
33
+ `preflight.mjs --stage plan` requires brief intent; implementation requires brief+plan, including quick-build’s separate plan comment. Research preparation does not start a vendor process. `--consolidated-request <json>` accepts `{parentRepo,parentIssue,approvalBinding:{commentId,bodySha256},requested:{repo,issue,taskIds,actionId,branch,ref?,baseSha,paths,operation}}`; `ref` is required only for the exact child checkpoint action. The approved manifest is fetched from the pinned record, never a local path. Approval bodies/source comments, canonical singleton comments, manifests and dependencies are fresh reads. Successful scope results expose canonical `approvalIds`, `approvalBindings` and artifact `bindings`; each authority tuple is `{approvalId,commentId,bodySha256}`, with a positive safe-integer comment ID and exact source-body hash. Consolidated `recordBinding` separately preserves the requested record for audit, while the input `approvalBinding:{commentId,bodySha256}` pins that requested record. Neither a relay pin nor recordBinding replaces canonical authority. Preflight/RunReport consumers preserve the canonical tuples; fresh recovery admission deliberately selects the source comment-ID/body-hash pair.
34
+
35
+ Preparation adds `requested.preparation:{commentId,bodySha256}` and the #144 owner’s exact source-bound projection: parent identity, current plan ref, selected task IDs/files/prerequisite issues and accepted code-contract receipts with child/parent SHA and evidence pins. These are checked against the canonical selected scope; the recovery owner establishes mapping completeness and actual accepted integration. Missing receipts or the recovery owner’s readTaskPrerequisites/inspectAcceptedIntegration adapter refuse. The evaluated preparation carries canonical `approvalBinding`; `readTaskPrerequisites` receives and returns that same tuple alongside parent/plan/tasks, and an inconsistent outer result or receipt refuses before integration inspection. Fetched receipt hashes alone are not proof of accepted code. Full issue execution keeps every native blocker and is not authorized by preparation.
36
+
37
+ Research adds its exact scenario and pinned reservation reference. A reservation requires canonical `approvalId` and `approvalBinding`; the pooled suite, every historical allowance attempt and the evaluated research result also carry `approvalBinding`. The suite digest includes that tuple. The selected reserved attempt must match current canonical source authority; older attempt tuples remain immutable and spent, without guessed backfill or refunds. The protocol owner supplies fetched ledger projections binding owner task/changed skill, protocol, source/tree and packed SHA-256/SRI, subscription harness/model/account/effort/config/policy, and complete phase/overall attempt history. Failed, child and resumed starts remain counted; a changed skill never gets a fresh allowance merely because another issue owns its next edit. `protocolLimits` recognizes only unambiguous envelope clauses from the bound protocol outside fenced examples (core/skill/reserve/total/active-time/per-process, or the top-level trial envelope); unsupported clauses refuse. Numeric limits beside a protocol digest are not authority.
38
+
39
+ `evaluateConsolidatedApproval` evaluates scope and returns research `pendingEffects`; `gatherConsolidatedApproval` will not admit a process without `admitConsolidatedResearch`’s protocol-owned `inspectCandidate` and atomic `consumeReservation` adapter. The former verifies actual clean SHA/tree, approved-base/integration ancestry and packed bytes; the latter consumes the exact revision/attempt once before launch. Its request and receipt preserve the canonical `approvalBinding`, along with existing ledger revision, attempt, candidate, execution and optional suite digest checks. Outer approvalIds/approvalBindings, evaluated research and the reservation must agree before consumption; a relay-bound receipt refuses. A saved ok:true result never authorizes another effect: fresh complete source/history reads must reconfirm current activity/body and scope before recovery or another consumption. Legacy missing tuples remain unsupported/unverified. An absent adapter, changed identity or replay refuses. Checkpoint scope likewise retains #138’s complete export proof/durable delivery intent. These APIs do not supply production/service/private-state/publication authority.
40
+
41
+ */
42
+ import { createHash } from 'node:crypto';
43
+
44
+ const hash = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
45
+ const fail = (reason) => { throw new Error(reason); };
46
+ const check = (condition, reason) => { if (!condition) fail(reason); };
47
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
48
+ const text = (value) => typeof value === 'string' && value.trim().length > 0;
49
+ const digest = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
50
+ const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
51
+ const integer = (value) => Number.isSafeInteger(value) && value > 0;
52
+ const repo = (value) => typeof value === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value);
53
+ const taskId = (value) => typeof value === 'string' && /^[1-9]\d*-T[1-9]\d*$/.test(value);
54
+ const branch = (value) => text(value) && !/\s|[~^:?*\[\\]|\.\.|@\{|\/\/|^\/|\/$|\.$|\.lock(?:\/|$)/.test(value) && value !== '@' && !value.startsWith('-');
55
+ const literalBranch = (value) => branch(value) && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
56
+ const keys = (value, names, optional = []) => {
57
+ check(isObject(value), 'expected an object');
58
+ check(Object.keys(value).every((key) => names.includes(key) || optional.includes(key)), 'unknown record field');
59
+ check(names.every((key) => Object.hasOwn(value, key)), 'missing record field');
60
+ };
61
+ const list = (value, predicate, name, { nonempty = false } = {}) => {
62
+ check(Array.isArray(value) && (!nonempty || value.length > 0) && value.every(predicate), 'invalid ' + name);
63
+ check(new Set(value.map((item) => JSON.stringify(item))).size === value.length, 'duplicate ' + name);
64
+ };
65
+
66
+ // JSON.parse alone silently accepts duplicate keys. Tokenize only after grammar
67
+ // validation, then inspect each object, including escaped spellings of keys.
68
+ export function parseStrictJson(raw) {
69
+ check(typeof raw === 'string' && Buffer.byteLength(raw, 'utf8') <= 1024 * 1024, 'invalid JSON size');
70
+ const value = JSON.parse(raw);
71
+ const tokens = raw.match(/"(?:[^"\\]|\\.)*"|[{}\[\],:]|[^\s{}\[\],:]+/g) ?? [];
72
+ let cursor = 0;
73
+ function visit(depth = 0) {
74
+ check(depth <= 64, 'JSON nesting too deep');
75
+ const token = tokens[cursor++];
76
+ if (token === '{') {
77
+ const found = new Set();
78
+ while (tokens[cursor] !== '}') {
79
+ const key = JSON.parse(tokens[cursor++]);
80
+ check(!found.has(key), 'duplicate JSON key');
81
+ check(!['__proto__', 'prototype', 'constructor'].includes(key), 'unsafe JSON key');
82
+ found.add(key);
83
+ cursor++; // colon; syntax was already validated
84
+ visit(depth + 1);
85
+ if (tokens[cursor] === ',') cursor++;
86
+ }
87
+ cursor++;
88
+ } else if (token === '[') {
89
+ while (tokens[cursor] !== ']') {
90
+ visit(depth + 1);
91
+ if (tokens[cursor] === ',') cursor++;
92
+ }
93
+ cursor++;
94
+ }
95
+ }
96
+ visit();
97
+ check(cursor === tokens.length, 'invalid JSON tokens');
98
+ return value;
99
+ }
100
+
101
+ function linesOf(body) {
102
+ check(typeof body === 'string' && body.isWellFormed() && Buffer.byteLength(body, 'utf8') <= 1024 * 1024, 'invalid artifact body');
103
+ let fence = null;
104
+ return body.replaceAll('\r\n', '\n').match(/[^\n]*\n|[^\n]+$/g)?.map((line, index) => {
105
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*?)(?:\n)?$/.exec(line);
106
+ const structural = fence === null && opening === null && !/^(?: {4}|\t)/.test(line);
107
+ const fenceOpen = fence === null && opening ? opening[2].trim() : null;
108
+ const fenceClose = fence !== null && opening && opening[1][0] === fence[0] && opening[1].length >= fence.length && opening[2].trim() === '';
109
+ if (opening) {
110
+ if (fence === null) fence = opening[1];
111
+ else if (opening[1][0] === fence[0] && opening[1].length >= fence.length && opening[2].trim() === '') fence = null;
112
+ }
113
+ return { line, index, structural, fenceOpen, fenceClose: Boolean(fenceClose) };
114
+ }) ?? [];
115
+ }
116
+
117
+ export function parseJsonSections(body) {
118
+ const sections = [];
119
+ let payload = null;
120
+ let startIndex = null;
121
+ for (const row of linesOf(body)) {
122
+ if (row.fenceOpen === 'json') {
123
+ check(payload === null, 'nested JSON payload');
124
+ payload = '';
125
+ startIndex = row.index;
126
+ } else if (row.fenceClose && payload !== null) {
127
+ sections.push({ value: parseStrictJson(payload), startIndex });
128
+ payload = null;
129
+ startIndex = null;
130
+ } else if (payload !== null) payload += row.line;
131
+ }
132
+ check(payload === null, 'unclosed JSON payload');
133
+ return sections;
134
+ }
135
+
136
+ function structuralMarker(line) {
137
+ const match = /^\s*<!--\s*vsk:v1\s+([^>]+?)\s*-->\s*$/.exec(line);
138
+ if (!match) return null;
139
+ const result = {};
140
+ for (const pair of match[1].trim().split(/\s+/)) {
141
+ const split = pair.indexOf('=');
142
+ check(split > 0 && split < pair.length - 1, 'malformed artifact marker');
143
+ const key = pair.slice(0, split);
144
+ check(!Object.hasOwn(result, key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'duplicate marker key');
145
+ result[key] = pair.slice(split + 1);
146
+ }
147
+ return result;
148
+ }
149
+
150
+ export function canonicalScope(body, kind) {
151
+ check(['brief', 'plan', 'protocol'].includes(kind), 'unknown artifact kind');
152
+ const lines = linesOf(body);
153
+ if (kind === 'protocol') return body.replaceAll('\r\n', '\n');
154
+ const markers = [];
155
+ const tasks = new Set();
156
+ let progress = null;
157
+ let progressSeen = false;
158
+ let progressBody = '';
159
+ let result = '';
160
+ for (const { line, structural } of lines) {
161
+ if (structural && line.trim() === '<!-- vsk:progress:start -->') {
162
+ check(kind === 'plan' && !progressSeen, 'duplicate or forbidden progress block');
163
+ progressSeen = true;
164
+ progress = true;
165
+ continue;
166
+ }
167
+ if (structural && line.trim() === '<!-- vsk:progress:end -->') {
168
+ check(progress === true, 'unmatched progress end');
169
+ progress = false;
170
+ continue;
171
+ }
172
+ if (progress === true) { progressBody += line; continue; }
173
+ if (structural) {
174
+ const marker = structuralMarker(line);
175
+ if (marker && ['brief', 'plan'].includes(marker.type)) markers.push(marker);
176
+ const ids = [...line.matchAll(/<!-- task-id:([^>]+) -->/g)].map((match) => match[1]);
177
+ if (ids.length > 0) {
178
+ check(kind === 'plan' && /^- \[[ xX]\] \*\*Task /.test(line) && ids.length === 1 && taskId(ids[0]), 'invalid structural task identity');
179
+ check(!tasks.has(ids[0]), 'duplicate task identity');
180
+ tasks.add(ids[0]);
181
+ result += line.replace(/^- \[[xX]\]/, '- [ ]');
182
+ continue;
183
+ }
184
+ check(!/^- \[[ xX]\] \*\*Task /.test(line), 'task header lacks stable identity');
185
+ }
186
+ result += line;
187
+ }
188
+ check(markers.length === 1 && markers[0].type === kind && /^[1-9]\d*$/.test(markers[0].rev), 'missing or duplicate artifact marker');
189
+ check(progress !== true, 'unclosed progress block');
190
+ if (progressSeen) {
191
+ const value = parseStrictJson(progressBody);
192
+ keys(value, ['tasks']);
193
+ list(value.tasks, isObject, 'progress tasks');
194
+ const seen = new Set();
195
+ for (const item of value.tasks) {
196
+ keys(item, ['id', 'evidenceUrls']);
197
+ check(tasks.has(item.id) && !seen.has(item.id), 'unknown or duplicate progress task');
198
+ seen.add(item.id);
199
+ list(item.evidenceUrls, (url) => {
200
+ try { const parsed = new URL(url); return typeof url === 'string' && ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; } catch { return false; }
201
+ }, 'evidence URLs');
202
+ }
203
+ }
204
+ return result;
205
+ }
206
+
207
+ export const scopeDigest = (body, kind) => hash(canonicalScope(body, kind));
208
+
209
+ function validateSource(value) {
210
+ keys(value, ['kind', 'ref', 'quote']);
211
+ check(['github-comment', 'session'].includes(value.kind) && text(value.ref) && text(value.quote), 'invalid approval source or quotation');
212
+ }
213
+
214
+ function validateArtifact(value) {
215
+ keys(value, ['repo', 'issue', 'kind', 'artifactId', 'rev', 'digest']);
216
+ check(repo(value.repo) && integer(value.issue) && ['brief', 'plan', 'protocol'].includes(value.kind) && text(value.artifactId) && integer(value.rev) && digest(value.digest), 'invalid artifact binding');
217
+ }
218
+
219
+ function validateArtifacts(values) {
220
+ list(values, isObject, 'artifacts');
221
+ const seen = new Set();
222
+ for (const value of values) {
223
+ validateArtifact(value);
224
+ const id = [value.repo, value.issue, value.kind].join('#');
225
+ check(!seen.has(id), 'duplicate artifact kind');
226
+ seen.add(id);
227
+ }
228
+ }
229
+
230
+ function validateManifestSource(value) {
231
+ if (value?.kind === 'inline') {
232
+ keys(value, ['kind', 'utf8']);
233
+ check(text(value.utf8), 'empty inline manifest');
234
+ } else {
235
+ keys(value, ['kind', 'repositoryId', 'commitSha', 'path', 'blobSha256']);
236
+ check(value.kind === 'git-blob' && text(value.repositoryId) && sha(value.commitSha) && safePath(value.path) && digest(value.blobSha256), 'invalid manifest locator');
237
+ }
238
+ }
239
+
240
+ function safePath(value) {
241
+ return text(value) && !value.startsWith('/') && !/[\\\x00-\x1f]/.test(value) && value.split('/').every((part) => part !== '' && part !== '.' && part !== '..') && !/^[a-z]:/i.test(value);
242
+ }
243
+
244
+ function literalPath(value) {
245
+ return safePath(value) && !/[*?\[\]{}]/.test(value);
246
+ }
247
+
248
+ export function validateChildSourceCheckpointAction(value) {
249
+ keys(value, ['id', 'kind', 'repo', 'parent', 'child']);
250
+ check(text(value.id) && value.kind === 'child-source-checkpoint' && repo(value.repo), 'invalid child checkpoint action');
251
+ keys(value.parent, ['issue', 'branch', 'baseSha']);
252
+ check(integer(value.parent.issue) && literalBranch(value.parent.branch) && sha(value.parent.baseSha), 'invalid child checkpoint parent');
253
+ keys(value.child, ['issue', 'branch', 'ref', 'baseSha', 'taskIds', 'paths']);
254
+ check(integer(value.child.issue) && value.child.issue !== value.parent.issue && literalBranch(value.child.branch) && sha(value.child.baseSha), 'invalid child checkpoint source');
255
+ check(value.child.ref === 'refs/heads/' + value.child.branch && value.child.branch !== value.parent.branch, 'invalid child checkpoint ref or parent substitution');
256
+ list(value.child.taskIds, (id) => taskId(id) && id.startsWith(value.child.issue + '-T'), 'child checkpoint tasks', { nonempty: true });
257
+ list(value.child.paths, literalPath, 'child checkpoint paths', { nonempty: true });
258
+ return value;
259
+ }
260
+
261
+ function validateAction(value, frozen = false) {
262
+ if (value?.kind === 'local') {
263
+ keys(value, ['id', 'kind', 'repo', 'parentBranch', 'operations']);
264
+ check(repo(value.repo) && branch(value.parentBranch), 'invalid local target');
265
+ list(value.operations, (op) => ['edit', 'check', 'review', 'integrate'].includes(op), 'local operations', { nonempty: true });
266
+ } else if (value?.kind === 'checkpoint') {
267
+ keys(value, ['id', 'kind', 'repo', 'branch', 'sourceScopeDigest']);
268
+ check(repo(value.repo) && branch(value.branch) && digest(value.sourceScopeDigest), 'invalid checkpoint target');
269
+ } else if (value?.kind === 'child-source-checkpoint') {
270
+ validateChildSourceCheckpointAction(value);
271
+ } else {
272
+ keys(value, ['id', 'kind', 'issue', 'protocolArtifactId', 'protocolDigest', 'candidateRule', 'scenarioIds', 'maxStarts', 'aggregateActiveMs', 'providerMode']);
273
+ check(value.kind === 'research-tests' && integer(value.issue) && text(value.protocolArtifactId) && digest(value.protocolDigest), 'invalid research protocol');
274
+ const rule = value.candidateRule;
275
+ keys(rule, ['kind', 'repo', 'parentBranch', 'baseSha', ...(frozen ? [] : ['manifestSha256'])]);
276
+ check(rule.kind === 'approved-parent-candidate' && repo(rule.repo) && branch(rule.parentBranch) && sha(rule.baseSha) && (frozen || digest(rule.manifestSha256)), 'invalid candidate rule');
277
+ list(value.scenarioIds, text, 'scenarios', { nonempty: true });
278
+ check(integer(value.maxStarts) && (value.aggregateActiveMs === null || integer(value.aggregateActiveMs)) && value.providerMode === 'subscription-only', 'invalid research allowance');
279
+ }
280
+ check(text(value.id), 'missing action identity');
281
+ }
282
+
283
+ function validateItem(value) {
284
+ keys(value, ['repo', 'issue', 'mode', 'artifacts', 'taskIds', 'actionIds']);
285
+ check(repo(value.repo) && integer(value.issue) && ['code', 'preparation', 'research'].includes(value.mode), 'invalid selection');
286
+ validateArtifacts(value.artifacts);
287
+ check(value.artifacts.every((ref) => ref.repo === value.repo && ref.issue === value.issue), 'cross-issue artifact binding');
288
+ check(value.artifacts.length === 2 && value.artifacts.some((ref) => ref.kind === 'brief') && value.artifacts.some((ref) => ref.kind === (value.mode === 'research' ? 'protocol' : 'plan')), 'missing selection artifact');
289
+ list(value.taskIds, (id) => taskId(id) && id.startsWith(value.issue + '-T'), 'selected tasks', { nonempty: value.mode !== 'research' });
290
+ check(value.mode !== 'research' || value.taskIds.length === 0, 'research cannot select code tasks');
291
+ list(value.actionIds, text, 'selected actions', { nonempty: true });
292
+ }
293
+
294
+ export function parseApproval(comment) {
295
+ try {
296
+ const lines = linesOf(comment?.body);
297
+ const markers = lines.filter((line) => line.structural).map((row) => ({ marker: structuralMarker(row.line), index: row.index })).filter((row) => row.marker?.type === 'approval');
298
+ check(markers.length === 1, 'missing or duplicate type=approval marker');
299
+ const sections = parseJsonSections(comment?.body);
300
+ check(sections.length === 1, 'approval requires one JSON payload');
301
+ check(sections[0].startIndex > markers[0].index, 'approval payload precedes its marker');
302
+ const value = sections[0].value;
303
+ check(value.schemaVersion === 2 && value.scope === markers[0].marker.scope && text(value.operator), 'invalid approval version, scope or operator');
304
+ validateSource(value.source);
305
+ list(value.supersedes, text, 'superseded IDs');
306
+ list(value.revokes, text, 'revoked IDs');
307
+ if (value.kind === 'correction') {
308
+ keys(value, ['schemaVersion', 'kind', 'scope', 'operator', 'source', 'targets', 'supersedes', 'revokes']);
309
+ check(value.scope === 'none' && value.supersedes.length === 0 && value.revokes.length === 0, 'correction grants no scope');
310
+ list(value.targets, isObject, 'correction targets', { nonempty: true });
311
+ const ids = new Set();
312
+ for (const target of value.targets) {
313
+ keys(target, ['commentId', 'bodySha256']);
314
+ check((text(target.commentId) || integer(target.commentId)) && digest(target.bodySha256) && !ids.has(String(target.commentId)) && String(target.commentId) !== String(comment.id), 'invalid correction target');
315
+ ids.add(String(target.commentId));
316
+ }
317
+ } else if (value.kind === 'consolidated') {
318
+ keys(value, ['schemaVersion', 'kind', 'id', 'operator', 'scope', 'source', 'manifest', 'items', 'actions', 'supersedes', 'revokes']);
319
+ check(value.scope === 'consolidated' && value.revokes.length === 0, 'consolidated grants cannot also revoke; use a separate empty-artifact event');
320
+ keys(value.manifest, ['sha256', 'source']);
321
+ check(digest(value.manifest.sha256), 'invalid manifest digest');
322
+ validateManifestSource(value.manifest.source);
323
+ list(value.items, isObject, 'selections', { nonempty: true });
324
+ value.items.forEach(validateItem);
325
+ check(new Set(value.items.map((item) => item.repo + '#' + item.issue)).size === value.items.length, 'duplicate selection');
326
+ list(value.actions, isObject, 'actions', { nonempty: true });
327
+ value.actions.forEach((action) => validateAction(action));
328
+ check(new Set(value.actions.map((action) => action.id)).size === value.actions.length, 'duplicate action ID');
329
+ } else {
330
+ keys(value, ['schemaVersion', 'id', 'operator', 'scope', 'source', 'artifacts', 'supersedes', 'revokes']);
331
+ check(['brief', 'plan', 'brief+plan'].includes(value.scope), 'unknown approval scope');
332
+ validateArtifacts(value.artifacts);
333
+ check(value.revokes.length === 0 || value.artifacts.length === 0, 'revocation grants no new binding');
334
+ check(value.artifacts.length > 0 || value.revokes.length > 0, 'approval grants or revokes no binding');
335
+ const permitted = value.scope === 'brief+plan' ? ['brief', 'plan'] : [value.scope];
336
+ check(value.artifacts.every((ref) => permitted.includes(ref.kind)), 'artifact outside approval scope');
337
+ }
338
+ if (value.kind !== 'correction') {
339
+ check(text(value.id) && !value.supersedes.includes(value.id) && !value.revokes.includes(value.id), 'invalid approval event identity');
340
+ check(!value.supersedes.some((id) => value.revokes.includes(id)), 'conflicting supersede/revoke');
341
+ }
342
+ return value;
343
+ } catch (error) {
344
+ return { ok: false, blocks: ['approval: ' + error.message] };
345
+ }
346
+ }
347
+
348
+ function hasArtifactMarker(comment, kind) {
349
+ return linesOf(comment?.body).some(({ line, structural }) => structural && structuralMarker(line)?.type === kind);
350
+ }
351
+
352
+ // Trusted publishers attest session intent. An untrusted publisher can only
353
+ // relay an operator-published grant in this same complete authority history.
354
+ // Relays create no independent authority and inherit their source lifecycle.
355
+ function grantProjection(event) {
356
+ return event.kind === 'consolidated'
357
+ ? { schemaVersion: event.schemaVersion, kind: event.kind, operator: event.operator, scope: event.scope, manifest: event.manifest, items: event.items, actions: event.actions }
358
+ : { schemaVersion: event.schemaVersion, operator: event.operator, scope: event.scope, artifacts: event.artifacts };
359
+ }
360
+
361
+ class InvalidApprovalSource extends Error {}
362
+ class ApprovalSourceUnavailable extends Error {}
363
+
364
+ function validateHistorySources(parsed, comments, operators, sourceComments) {
365
+ const known = (condition, reason) => { if (!condition) throw new ApprovalSourceUnavailable('approval source unavailable: ' + reason); };
366
+ const valid = (condition, reason) => { if (!condition) throw new InvalidApprovalSource(reason); };
367
+ const publishers = new Map();
368
+ const sources = new Map();
369
+ const publisherTrusted = (entry) => operators.includes(publishers.get(entry));
370
+ const resolveSource = (event) => {
371
+ known(Array.isArray(sourceComments), 'source reads missing');
372
+ const matches = sourceComments.filter((comment) => comment.html_url === event.source.ref);
373
+ known(matches.length === 1, 'source comment missing or ambiguous');
374
+ const source = matches[0];
375
+ known(integer(source.id) && typeof source.body === 'string' && text(source.user?.login), 'source identity/body metadata missing');
376
+ const locatorId = /#issuecomment-([1-9]\d*)$/.exec(event.source.ref)?.[1];
377
+ known(String(source.id) === locatorId, 'source comment identity differs from locator');
378
+ const history = comments.filter((comment) => String(comment?.id) === String(source.id) || comment?.html_url === event.source.ref);
379
+ known(history.length <= 1, 'source identity ambiguous in history');
380
+ if (history.length) {
381
+ const current = history[0];
382
+ known(integer(current.id) && typeof current.body === 'string' && text(current.user?.login), 'history source identity/body metadata missing');
383
+ known(current.id === source.id && current.body === source.body && current.user.login === source.user.login
384
+ && (current.html_url === undefined || current.html_url === source.html_url), 'source changed between history and direct read');
385
+ }
386
+ return { source, root: parsed.find((entry) => entry.comment === history[0]) };
387
+ };
388
+ // Resolve all required facts before any event can become correction-eligible.
389
+ // Unknown reads never become invalid evidence merely because another fault
390
+ // or an exact correction is also present in the same history.
391
+ for (const entry of parsed) {
392
+ known(integer(entry.comment.id) && text(entry.comment.user?.login), 'publisher/comment identity metadata missing');
393
+ publishers.set(entry, entry.comment.user.login);
394
+ if (entry.event.ok === false) continue;
395
+ if (entry.event.source.kind === 'github-comment') sources.set(entry, resolveSource(entry.event));
396
+ }
397
+ const refuseKnownInvalid = (entry, error) => {
398
+ if (!(error instanceof InvalidApprovalSource)) throw error;
399
+ entry.event = { ok: false, blocks: ['approval source invalid: ' + error.message] };
400
+ };
401
+ for (const entry of parsed) {
402
+ if (entry.event.ok === false || !publisherTrusted(entry)) continue;
403
+ try {
404
+ valid(operators.includes(entry.event.operator), 'named operator is not authorized');
405
+ if (entry.event.source.kind === 'github-comment') {
406
+ const { source } = sources.get(entry);
407
+ valid(source.user.login === entry.event.operator && source.body.includes(entry.event.source.quote), 'operator source quotation does not match');
408
+ }
409
+ entry.authorityId = entry.event.id;
410
+ } catch (error) { refuseKnownInvalid(entry, error); }
411
+ }
412
+ for (const entry of parsed) {
413
+ if (entry.event.ok === false || publisherTrusted(entry)) continue;
414
+ try {
415
+ const event = entry.event;
416
+ valid(operators.includes(event.operator), 'named operator is not authorized');
417
+ valid(event.source.kind === 'github-comment', 'session assertion requires a current policy operator publisher');
418
+ const { root } = sources.get(entry);
419
+ valid(event.kind !== 'correction' && event.revokes.length === 0 && event.supersedes.length === 0, 'untrusted relay cannot change approval history');
420
+ valid(root && root !== entry, 'source grant is not in this complete authority history');
421
+ valid(publisherTrusted(root) && root.event.ok !== false && root.authorityId === root.event.id && text(root.event.id), 'source is not an operator-published authority');
422
+ valid(root.event.kind !== 'correction' && root.event.revokes.length === 0 && (root.event.kind === 'consolidated' || root.event.artifacts.length > 0), 'source grants no scope');
423
+ valid(event.source.quote === root.event.source.quote && sortedJson(grantProjection(event)) === sortedJson(grantProjection(root.event)), 'relay differs from operator-approved scope or quotation');
424
+ entry.authorityId = root.event.id;
425
+ } catch (error) { refuseKnownInvalid(entry, error); }
426
+ }
427
+ }
428
+
429
+ function approvalBinding(entry) {
430
+ if (!integer(entry.comment.id) || !text(entry.event.id)) throw new ApprovalSourceUnavailable('approval source unavailable: canonical comment/event identity missing');
431
+ return { approvalId: entry.event.id, commentId: entry.comment.id, bodySha256: hash(entry.comment.body) };
432
+ }
433
+
434
+ function approvalHistory(comments, operators, sourceComments = []) {
435
+ check(Array.isArray(comments), 'approval history is unavailable');
436
+ list(operators, text, 'operators', { nonempty: true });
437
+ const marked = comments.filter((comment) => linesOf(comment?.body).some(({ line, structural }) => structural && /<!--\s*vsk:v1\b[^>]*\btype=approval(?:\s|>|$)/.test(line)));
438
+ const parsed = marked.map((comment) => ({ comment, event: parseApproval(comment) }));
439
+ const commentIds = marked.map((comment) => String(comment.id));
440
+ check(new Set(commentIds).size === commentIds.length, 'duplicate approval comment identity');
441
+ validateHistorySources(parsed, comments, operators, sourceComments);
442
+ const neutralized = new Set();
443
+ for (const { comment, event } of parsed) {
444
+ if (event.kind !== 'correction') continue;
445
+ check(operators.includes(event.operator), 'correction operator is not authorized');
446
+ for (const target of event.targets) {
447
+ const matches = parsed.filter((entry) => String(entry.comment.id) === String(target.commentId));
448
+ check(matches.length === 1 && matches[0].comment !== comment && hash(matches[0].comment.body) === target.bodySha256, 'correction target is absent, duplicate or changed');
449
+ check(matches[0].event.ok === false, 'correction target is not a malformed approval');
450
+ check(!neutralized.has(String(target.commentId)), 'conflicting corrections');
451
+ neutralized.add(String(target.commentId));
452
+ }
453
+ }
454
+ const events = [];
455
+ const byId = new Map();
456
+ const removed = new Set();
457
+ for (const entry of parsed) {
458
+ if (neutralized.has(String(entry.comment.id))) continue;
459
+ const event = entry.event;
460
+ check(event.ok !== false, event.blocks?.join('; ') ?? 'malformed approval');
461
+ check(operators.includes(event.operator), 'approval operator is not authorized');
462
+ if (event.kind === 'correction') continue;
463
+ check(!byId.has(event.id), 'duplicate approval ID');
464
+ for (const id of [...event.supersedes, ...event.revokes]) {
465
+ check(byId.has(id), 'approval references an absent or later event');
466
+ removed.add(id);
467
+ }
468
+ byId.set(event.id, entry);
469
+ events.push(entry);
470
+ }
471
+ const active = events.filter((entry) => !removed.has(entry.event.id) && !removed.has(entry.authorityId));
472
+ const authorities = [...new Map(active.map((entry) => [entry.authorityId, byId.get(entry.authorityId)])).values()];
473
+ return { events, active, authorities, removed };
474
+ }
475
+
476
+ export function artifactRef({ repo: repository, issue, kind, artifact }) {
477
+ check(repo(repository) && integer(issue) && text(artifact?.node_id), 'missing current repository/issue/artifact identity');
478
+ const body = canonicalScope(artifact.body, kind);
479
+ const marker = kind === 'protocol'
480
+ ? linesOf(body).filter((line) => line.structural).map(({ line }) => /^\s*<!--\s*[^>]+\bissue=([1-9]\d*)\s+rev=([1-9]\d*)\s*-->\s*$/.exec(line)).filter(Boolean)
481
+ : linesOf(body).filter((line) => line.structural).map(({ line }) => structuralMarker(line)).filter((value) => value?.type === kind);
482
+ check(marker.length === 1, 'ambiguous current artifact revision');
483
+ if (kind === 'protocol') check(Number(marker[0][1]) === issue, 'protocol issue identity mismatch');
484
+ return { repo: repository, issue, kind, artifactId: artifact.node_id, rev: Number(kind === 'protocol' ? marker[0][2] : marker[0].rev), digest: hash(body) };
485
+ }
486
+
487
+ const sameRef = (left, right) => ['repo', 'issue', 'kind', 'artifactId', 'rev', 'digest'].every((key) => left[key] === right[key]);
488
+
489
+ export function evaluateApprovals({ repo: repository, issue, brief, plan, comments, operators, requiredScope, sourceComments = [] }) {
490
+ try {
491
+ check(['brief', 'plan', 'brief+plan'].includes(requiredScope), 'unsupported required approval scope');
492
+ const history = approvalHistory(comments, operators, sourceComments);
493
+ const kinds = requiredScope === 'brief+plan' ? ['brief', 'plan'] : [requiredScope];
494
+ const bindings = [];
495
+ for (const kind of kinds) {
496
+ let current = brief;
497
+ if (kind === 'plan') {
498
+ const plans = comments.filter((comment) => hasArtifactMarker(comment, 'plan'));
499
+ check(plans.length === 1 && (!plan || plan.node_id === plans[0].node_id && plan.body === plans[0].body), 'missing or duplicate canonical plan');
500
+ current = plans[0];
501
+ }
502
+ bindings.push(artifactRef({ repo: repository, issue, kind, artifact: current }));
503
+ }
504
+ const applicable = history.authorities.filter(({ event }) => event.kind !== 'consolidated' && event.artifacts.length > 0);
505
+ for (const { event } of applicable) {
506
+ check(event.artifacts.every((ref) => ref.repo === repository && ref.issue === issue), 'approval belongs to an unrelated issue');
507
+ }
508
+ const ids = new Set();
509
+ for (const binding of bindings) {
510
+ const matching = applicable.filter(({ event }) => event.artifacts.some((ref) => ref.kind === binding.kind));
511
+ check(matching.length === 1, matching.length > 1 ? 'conflicting approvals require explicit supersedes' : 'missing current ' + binding.kind + ' approval');
512
+ check(matching[0].event.artifacts.some((ref) => sameRef(ref, binding)), 'approval scope or artifact identity changed');
513
+ ids.add(matching[0].event.id);
514
+ }
515
+ return { ok: true, bindings, approvalIds: [...ids], approvalBindings: [...ids].map((id) => approvalBinding(history.authorities.find((entry) => entry.event.id === id))), blocks: [] };
516
+ } catch (error) {
517
+ return { ok: false, bindings: [], approvalIds: [], blocks: ['approval: ' + error.message] };
518
+ }
519
+ }
520
+
521
+ const sortedJson = (value) => JSON.stringify(value, (_, item) => isObject(item)
522
+ ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item);
523
+
524
+ export function validateExecutionManifest(bytes) {
525
+ const value = parseStrictJson(bytes);
526
+ keys(value, ['schemaVersion', 'parent', 'codeIssues', 'preparationTaskIds', 'candidateProtocols', 'excludedIssues', 'laterResearch', 'selections', 'actionBounds']);
527
+ check(value.schemaVersion === 1, 'unknown execution manifest version');
528
+ keys(value.parent, ['repo', 'issue', 'branch', 'baseSha']);
529
+ check(repo(value.parent.repo) && integer(value.parent.issue) && branch(value.parent.branch) && sha(value.parent.baseSha), 'invalid manifest parent');
530
+ for (const field of ['codeIssues', 'candidateProtocols', 'excludedIssues', 'laterResearch']) list(value[field], integer, field);
531
+ list(value.preparationTaskIds, taskId, 'preparation IDs');
532
+ list(value.selections, isObject, 'manifest selections', { nonempty: true });
533
+ value.selections.forEach(validateItem);
534
+ check(new Set(value.selections.map((item) => item.repo + '#' + item.issue)).size === value.selections.length, 'duplicate manifest issue');
535
+ check(value.selections.every((item) => item.repo === value.parent.repo && !value.excludedIssues.includes(item.issue) && !value.laterResearch.includes(item.issue)), 'excluded or foreign selection');
536
+ const matches = (left, right) => sortedJson([...left].sort()) === sortedJson([...right].sort());
537
+ check(matches(value.codeIssues, value.selections.filter((item) => item.mode === 'code').map((item) => item.issue)), 'code selection mismatch');
538
+ check(matches(value.candidateProtocols, value.selections.filter((item) => item.mode === 'research').map((item) => item.issue)), 'research selection mismatch');
539
+ check(matches(value.preparationTaskIds, value.selections.filter((item) => item.mode === 'preparation').flatMap((item) => item.taskIds)), 'preparation selection mismatch');
540
+ check(isObject(value.actionBounds), 'missing action bounds');
541
+ for (const [id, action] of Object.entries(value.actionBounds)) {
542
+ validateAction(action, true);
543
+ check(id === action.id, 'action key differs from identity');
544
+ if (action.kind === 'local' || action.kind === 'checkpoint') {
545
+ check(action.repo === value.parent.repo && (action.parentBranch ?? action.branch) === value.parent.branch, 'action escapes parent');
546
+ if (action.kind === 'checkpoint') check(action.sourceScopeDigest === hash(sortedJson({ parent: value.parent, selections: value.selections.filter((item) => item.mode !== 'research') })), 'checkpoint source scope mismatch');
547
+ } else if (action.kind === 'child-source-checkpoint') {
548
+ check(action.repo === value.parent.repo && sortedJson(action.parent) === sortedJson({ issue: value.parent.issue, branch: value.parent.branch, baseSha: value.parent.baseSha }), 'child checkpoint parent differs from manifest');
549
+ check(action.child.baseSha === value.parent.baseSha, 'child checkpoint source base differs from frozen parent');
550
+ const selectedBy = value.selections.filter((item) => item.actionIds.includes(action.id));
551
+ check(selectedBy.length === 1 && selectedBy[0].mode === 'code' && selectedBy[0].repo === action.repo && selectedBy[0].issue === action.child.issue, 'child checkpoint action is not selected only by one code child');
552
+ check(JSON.stringify(action.child.taskIds) === JSON.stringify(selectedBy[0].taskIds), 'child checkpoint tasks differ from selected child');
553
+ } else {
554
+ check(action.candidateRule.repo === value.parent.repo && action.candidateRule.parentBranch === value.parent.branch && action.candidateRule.baseSha === value.parent.baseSha, 'research candidate escapes parent');
555
+ const selection = value.selections.find((item) => item.issue === action.issue && item.mode === 'research');
556
+ check(selection?.artifacts.some((ref) => ref.kind === 'protocol' && ref.artifactId === action.protocolArtifactId && ref.digest === action.protocolDigest), 'research action lacks selected protocol');
557
+ }
558
+ }
559
+ check(value.selections.every((item) => item.actionIds.every((id) => Object.hasOwn(value.actionBounds, id))), 'selection references absent action');
560
+ return value;
561
+ }
562
+
563
+ function selectedTaskFiles(body, ids) {
564
+ canonicalScope(body, 'plan');
565
+ const result = new Map();
566
+ let current = null;
567
+ for (const { line, structural } of linesOf(body)) {
568
+ if (!structural) continue;
569
+ const match = /^- \[[ xX]\] \*\*Task .*?<!-- task-id:([1-9]\d*-T[1-9]\d*) -->/.exec(line);
570
+ if (match) { current = match[1]; result.set(current, []); }
571
+ if (/^#{1,6}\s/.test(line)) current = null;
572
+ if (current && /^\s*(?:- )?Files —/.test(line)) {
573
+ check(!line.includes('``'), 'empty task file path');
574
+ result.get(current).push([...line.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]));
575
+ }
576
+ }
577
+ check(ids.every((id) => result.has(id)), 'selected task is absent from canonical plan');
578
+ check(ids.every((id) => result.get(id).length === 1 && result.get(id)[0].length > 0), 'selected task lacks one canonical Files clause');
579
+ const files = ids.flatMap((id) => result.get(id)[0]);
580
+ check(files.length > 0 && files.every(literalPath), 'unverifiable task file scope');
581
+ return [...new Set(files)];
582
+ }
583
+
584
+ // currentArtifacts contains independently fetched issue/comment records plus
585
+ // the parent ledger's exact approval-comment binding. The fetched parent
586
+ // approval history is distinct from historical child planning permissions.
587
+ export function evaluateConsolidatedApproval({ record, manifestBytes, currentArtifacts, operators, currentDependencies, requested }) {
588
+ try {
589
+ const manifest = validateExecutionManifest(manifestBytes);
590
+ check(record?.kind === 'consolidated' && hash(manifestBytes) === record.manifest?.sha256, 'manifest hash differs from approval');
591
+ const context = currentArtifacts;
592
+ check(isObject(context) && Array.isArray(context.artifacts) && isObject(context.approvalBinding), 'current artifact or approval binding unavailable');
593
+ const history = approvalHistory(context.approvalComments, operators, context.sourceComments);
594
+ const matching = history.active.filter(({ event }) => event.id === record.id);
595
+ check(matching.length === 1 && sortedJson(matching[0].event) === sortedJson(record), 'approval is absent, revoked or altered');
596
+ const authority = history.authorities.find((entry) => entry.event.id === matching[0].authorityId);
597
+ check(authority, 'canonical source authority unavailable');
598
+ const canonicalRecord = authority.event;
599
+ const canonicalBinding = approvalBinding(authority);
600
+ const recordBinding = approvalBinding(matching[0]);
601
+ check(String(matching[0].comment.id) === String(context.approvalBinding.commentId) && hash(matching[0].comment.body) === context.approvalBinding.bodySha256, 'approval quotation/body binding changed');
602
+ if (record.manifest.source.kind === 'inline') check(record.manifest.source.utf8 === manifestBytes, 'inline manifest differs');
603
+ else check(context.manifestBlob?.repositoryId === record.manifest.source.repositoryId && context.manifestBlob?.commitSha === record.manifest.source.commitSha && context.manifestBlob?.path === record.manifest.source.path && hash(manifestBytes) === record.manifest.source.blobSha256, 'immutable manifest blob unavailable or changed');
604
+ for (const item of record.items) {
605
+ const selection = manifest.selections.find((entry) => entry.repo === item.repo && entry.issue === item.issue);
606
+ check(selection && sortedJson(selection) === sortedJson(item), 'approval selection differs from frozen manifest');
607
+ for (const ref of item.artifacts) {
608
+ const found = context.artifacts.filter((entry) => entry.repo === ref.repo && entry.issue === ref.issue && entry.kind === ref.kind);
609
+ check(found.length === 1, 'current canonical artifact absent or duplicate');
610
+ const actual = artifactRef({ repo: ref.repo, issue: ref.issue, kind: ref.kind, artifact: found[0].artifact });
611
+ check(sameRef(ref, actual), 'current canonical scope, revision or identity changed');
612
+ }
613
+ }
614
+ const usedActions = [...new Set(record.items.flatMap((item) => item.actionIds))].sort();
615
+ check(sortedJson(usedActions) === sortedJson(record.actions.map((action) => action.id).sort()), 'approval action inventory differs from selections');
616
+ for (const action of record.actions) {
617
+ const bound = manifest.actionBounds[action.id];
618
+ check(bound, 'unreviewed action');
619
+ const expected = structuredClone(bound);
620
+ if (expected.kind === 'research-tests') expected.candidateRule.manifestSha256 = record.manifest.sha256;
621
+ check(sortedJson(action) === sortedJson(expected), 'action bounds changed');
622
+ }
623
+ check(isObject(requested), 'missing requested scope');
624
+ const item = record.items.find((entry) => entry.repo === requested.repo && entry.issue === requested.issue);
625
+ check(item, 'requested issue is not selected');
626
+ for (const { event, authorityId } of history.active) {
627
+ if (authorityId === matching[0].authorityId) continue;
628
+ const bindings = event.kind === 'consolidated' ? event.items : event.artifacts;
629
+ check(!bindings.some((entry) => entry.repo === item.repo && entry.issue === item.issue), 'conflicting consolidated approval needs explicit supersedes');
630
+ }
631
+ keys(requested, ['repo', 'issue', 'taskIds', 'actionId', 'branch', 'baseSha', 'paths', 'operation'], ['ref', 'scenarioId', 'research', 'preparation']);
632
+ check(item.actionIds.includes(requested.actionId), 'action is not selected for this issue');
633
+ const action = record.actions.find((entry) => entry.id === requested.actionId);
634
+ list(requested.taskIds, taskId, 'requested tasks', { nonempty: item.mode !== 'research' });
635
+ if (action.kind === 'child-source-checkpoint') {
636
+ check(item.mode === 'code' && action.repo === item.repo && action.child.issue === item.issue, 'child checkpoint target differs from selected code child');
637
+ check(requested.branch === action.child.branch && requested.ref === action.child.ref && requested.baseSha === action.child.baseSha, 'requested child branch/ref/base differs');
638
+ check(JSON.stringify(requested.taskIds) === JSON.stringify(action.child.taskIds) && JSON.stringify(requested.taskIds) === JSON.stringify(item.taskIds), 'requested child tasks differ');
639
+ } else {
640
+ check(requested.ref === undefined, 'unexpected source ref on parent request');
641
+ check(requested.branch === manifest.parent.branch && requested.baseSha === manifest.parent.baseSha, 'requested parent branch/base differs');
642
+ check(requested.taskIds.every((id) => item.taskIds.includes(id)), 'requested task is outside selected subset');
643
+ }
644
+ check(Array.isArray(currentDependencies), 'current dependency reads unavailable');
645
+ const dependencies = currentDependencies.filter((entry) => entry.repo === item.repo && entry.issue === item.issue);
646
+ check(dependencies.length === 1 && Array.isArray(dependencies[0].blockedBy), 'current dependency identity unavailable');
647
+ check(item.mode === 'preparation' || !requested.preparation, 'preparation evidence on another mode');
648
+ const preparation = item.mode === 'preparation' ? { ...validatePreparationEvidence(context, item, requested, manifest), approvalBinding: canonicalBinding } : null;
649
+ if (item.mode !== 'preparation') check(dependencies[0].blockedBy.every((dependency) => dependency.state === 'closed'), 'open native prerequisites');
650
+ if (action.kind === 'research-tests') {
651
+ check(action.scenarioIds.includes(requested.scenarioId), 'unreviewed research scenario');
652
+ check(item.mode === 'research' || requested.scenarioId === 'SKILL-EVAL', 'predecessor cannot admit this research phase');
653
+ const research = validateResearchEvidence(context, item, requested, action, canonicalRecord, manifest, currentDependencies, canonicalBinding);
654
+ return { ok: true, bindings: item.artifacts, approvalIds: [canonicalRecord.id], approvalBindings: [canonicalBinding], recordBinding, manifestSha256: record.manifest.sha256,
655
+ taskIds: requested.taskIds, action, research, preparation, blocks: [] };
656
+ }
657
+ check(!requested.research && !requested.scenarioId, 'research fields on local request');
658
+ check(item.mode !== 'research', 'research selection cannot execute code');
659
+ const currentPlan = context.artifacts.find((entry) => entry.repo === item.repo && entry.issue === item.issue && entry.kind === 'plan');
660
+ const files = selectedTaskFiles(currentPlan.artifact.body, requested.taskIds);
661
+ list(requested.paths, literalPath, 'requested paths');
662
+ if (action.kind === 'child-source-checkpoint') {
663
+ check(requested.operation === 'checkpoint', 'child checkpoint action permits only checkpoint');
664
+ check(JSON.stringify(action.child.paths) === JSON.stringify(files) && JSON.stringify(requested.paths) === JSON.stringify(files), 'child checkpoint paths differ from canonical task files');
665
+ } else {
666
+ check(requested.paths.every((path) => files.includes(path)), 'requested file escapes selected task scope');
667
+ }
668
+ if (action.kind === 'local') check(action.operations.includes(requested.operation), 'unapproved local operation');
669
+ else if (action.kind === 'checkpoint') {
670
+ check(requested.operation === 'checkpoint' && requested.paths.length > 0, 'invalid checkpoint request');
671
+ // This result validates intent scope only. #138 must separately prove
672
+ // complete exported history and persist delivery intent before pushing.
673
+ }
674
+ return { ok: true, bindings: item.artifacts, approvalIds: [canonicalRecord.id], approvalBindings: [canonicalBinding], recordBinding, manifestSha256: record.manifest.sha256, taskIds: requested.taskIds, files, action, preparation, blocks: [] };
675
+ } catch (error) {
676
+ return { ok: false, bindings: [], approvalIds: [], blocks: ['consolidated approval: ' + error.message] };
677
+ }
678
+ }
679
+
680
+ // These projections are owned by the parent recovery/protocol adapters. Each
681
+ // comes from an exact fetched ledger comment, never a caller-supplied pass flag.
682
+ function boundEvidence(context, reference, kind) {
683
+ keys(reference, ['commentId', 'bodySha256']);
684
+ check(integer(reference.commentId) && digest(reference.bodySha256), 'invalid evidence reference');
685
+ const matches = (context.admissionEvidence ?? []).filter((entry) => entry.comment.id === reference.commentId);
686
+ check(matches.length === 1 && hash(matches[0].comment.body) === reference.bodySha256, 'admission evidence absent or changed');
687
+ const evidence = matches[0];
688
+ check(evidence.kind === kind && isObject(evidence.payload), 'wrong admission evidence kind');
689
+ // The projection must be literally present in the fetched ledger, not a
690
+ // separate mutable object next to it. Keep all other ledger prose intact.
691
+ const payloads = parseJsonSections(evidence.comment.body).map((section) => section.value);
692
+ check(payloads.some((payload) => sortedJson(payload) === sortedJson(evidence.payload)), 'evidence projection differs from source');
693
+ return evidence.payload;
694
+ }
695
+
696
+ function validatePreparationEvidence(context, item, requested, manifest) {
697
+ check(['edit', 'check', 'review', 'integrate', 'checkpoint', 'research-test'].includes(requested.operation), 'preparation permits selected local/test actions only');
698
+ const evidence = boundEvidence(context, requested.preparation, 'preparation');
699
+ keys(evidence, ['schemaVersion', 'kind', 'parent', 'plan', 'tasks', 'acceptedContracts']);
700
+ check(evidence.schemaVersion === 1 && evidence.kind === 'preparation' && sortedJson(evidence.parent) === sortedJson(manifest.parent), 'invalid preparation parent');
701
+ const plan = item.artifacts.find((ref) => ref.kind === 'plan');
702
+ check(sameRef(evidence.plan, plan), 'preparation plan scope changed');
703
+ list(evidence.tasks, isObject, 'preparation task contracts', { nonempty: true });
704
+ list(evidence.acceptedContracts, isObject, 'accepted code contracts');
705
+ const taskIds = new Set();
706
+ for (const task of evidence.tasks) {
707
+ keys(task, ['id', 'files', 'prerequisiteIssues']);
708
+ check(item.taskIds.includes(task.id) && !taskIds.has(task.id), 'unselected or duplicate preparation task');
709
+ taskIds.add(task.id);
710
+ list(task.files, safePath, 'preparation files', { nonempty: true });
711
+ list(task.prerequisiteIssues, integer, 'preparation prerequisites', { nonempty: true });
712
+ const current = context.artifacts.find((entry) => entry.repo === item.repo && entry.issue === item.issue && entry.kind === 'plan');
713
+ check(sortedJson([...task.files].sort()) === sortedJson(selectedTaskFiles(current.artifact.body, [task.id]).sort()), 'preparation file contract differs from approved plan');
714
+ for (const prerequisite of task.prerequisiteIssues) {
715
+ const records = evidence.acceptedContracts.filter((entry) => entry.issue === prerequisite);
716
+ check(records.length === 1, 'missing or ambiguous accepted preparation prerequisite');
717
+ const accepted = records[0];
718
+ keys(accepted, ['repo', 'issue', 'plan', 'childHead', 'parentHead', 'acceptance', 'evidence']);
719
+ check(accepted.repo === item.repo && sha(accepted.childHead) && sha(accepted.parentHead) && accepted.acceptance === 'implemented', 'preparation prerequisite is not accepted code');
720
+ const selected = manifest.selections.find((entry) => entry.issue === prerequisite && entry.repo === item.repo);
721
+ check(selected?.mode === 'code' && selected.artifacts.some((ref) => sameRef(ref, accepted.plan)), 'accepted prerequisite scope differs from manifest');
722
+ const receipt = boundEvidence(context, accepted.evidence, 'accepted-contract');
723
+ check(receipt.repo === accepted.repo && receipt.issue === accepted.issue && receipt.childHead === accepted.childHead && receipt.parentHead === accepted.parentHead && receipt.acceptance === 'implemented' && sameRef(receipt.plan, accepted.plan), 'accepted contract receipt differs');
724
+ }
725
+ }
726
+ check(requested.taskIds.every((id) => taskIds.has(id)), 'preparation task mapping missing');
727
+ return { ...evidence, taskIds: requested.taskIds, requiredTasks: evidence.acceptedContracts.map((entry) => ({ issue: entry.issue, taskIds: manifest.selections.find((selection) => selection.repo === entry.repo && selection.issue === entry.issue).taskIds })), pendingEffects: ['verify-preparation-prerequisites-and-integrations'] };
728
+ }
729
+
730
+ const skillName = (value) => typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
731
+ const checkpointId = (value) => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9-]*$/.test(value);
732
+
733
+ function checkpointEnvelope(body) {
734
+ const blocks = [];
735
+ let collecting = false;
736
+ let raw = '';
737
+ for (const row of linesOf(body)) {
738
+ if (row.fenceOpen?.startsWith('vsk-research')) {
739
+ check(row.fenceOpen === 'vsk-research', 'unknown research block format');
740
+ collecting = true;
741
+ raw = '';
742
+ } else if (collecting && row.fenceClose) {
743
+ blocks.push(parseStrictJson(raw));
744
+ collecting = false;
745
+ } else if (collecting) raw += row.line;
746
+ }
747
+ check(!collecting && blocks.length <= 1, 'unclosed or competing research envelopes');
748
+ if (blocks.length === 0) return null;
749
+ const value = blocks[0];
750
+ keys(value, ['schemaVersion', 'kind', 'phase', 'maxStarts', 'initialStartsPerCheckpoint', 'repeatAndSplitStarts', 'maxProcessMs', 'maxConcurrentProcesses', 'overallMaxStarts', 'overallActiveMs', 'coreMaxStarts', 'requalifyMaxStarts', 'caseKinds', 'arms', 'checkpoints', 'caseBindings', 'ownerAssertions', 'maxSkills']);
751
+ check(value.schemaVersion === 1 && value.kind === 'skill-eval-checkpoints' && value.phase === 'SKILL-EVAL', 'unknown research checkpoint envelope');
752
+ for (const key of ['maxStarts', 'initialStartsPerCheckpoint', 'maxProcessMs', 'maxConcurrentProcesses', 'overallMaxStarts', 'overallActiveMs', 'coreMaxStarts', 'requalifyMaxStarts', 'maxSkills']) check(integer(value[key]), 'invalid checkpoint limit');
753
+ check(Number.isSafeInteger(value.repeatAndSplitStarts) && value.repeatAndSplitStarts >= 0, 'invalid repeat/split allowance');
754
+ check(sortedJson(value.caseKinds) === sortedJson(['positive', 'negative']) && sortedJson(value.arms) === sortedJson(['claude-baseline', 'claude-current', 'codex-baseline', 'codex-current']), 'unsupported evaluation cases or arms');
755
+ check(value.initialStartsPerCheckpoint === value.arms.length && value.maxConcurrentProcesses <= value.arms.length && value.maxStarts + value.coreMaxStarts + value.requalifyMaxStarts === value.overallMaxStarts, 'inconsistent pooled allocation');
756
+ list(value.checkpoints, isObject, 'evaluation checkpoints', { nonempty: true });
757
+ const known = new Set();
758
+ const skills = new Set();
759
+ for (const entry of value.checkpoints) {
760
+ keys(entry, ['id', 'codeOwners', 'preparationTasks', 'skills', 'after'], ['interveningCodeAcceptance', 'livePrerequisite', 'reservation']);
761
+ check(checkpointId(entry.id) && !known.has(entry.id), 'duplicate or invalid checkpoint identity');
762
+ list(entry.codeOwners, integer, 'checkpoint code owners');
763
+ list(entry.preparationTasks, taskId, 'checkpoint preparation tasks');
764
+ check(entry.codeOwners.length + entry.preparationTasks.length > 0, 'checkpoint has no owners');
765
+ list(entry.skills, skillName, 'checkpoint skills', { nonempty: true });
766
+ list(entry.after, (id) => known.has(id), 'checkpoint dependencies');
767
+ if (entry.interveningCodeAcceptance !== undefined) list(entry.interveningCodeAcceptance, integer, 'intervening acceptance', { nonempty: true });
768
+ for (const key of ['livePrerequisite', 'reservation']) if (entry[key] !== undefined) check(text(entry[key]), 'empty checkpoint prerequisite');
769
+ known.add(entry.id);
770
+ entry.skills.forEach((name) => skills.add(name));
771
+ }
772
+ check(skills.size <= value.maxSkills && value.checkpoints.length * value.initialStartsPerCheckpoint + value.repeatAndSplitStarts === value.maxStarts, 'checkpoint allocation exceeds shared pool');
773
+ list(value.caseBindings, isObject, 'checkpoint case bindings', { nonempty: true });
774
+ const cases = new Set();
775
+ const owners = new Set();
776
+ for (const binding of value.caseBindings) {
777
+ keys(binding, ['checkpoint', 'skill', 'owners']);
778
+ const entry = value.checkpoints.find((candidate) => candidate.id === binding.checkpoint);
779
+ const id = binding.checkpoint + '.' + binding.skill;
780
+ check(entry?.skills.includes(binding.skill) && !cases.has(id), 'unknown or duplicate checkpoint skill binding');
781
+ cases.add(id);
782
+ list(binding.owners, isObject, 'case owners', { nonempty: true });
783
+ const seen = new Set();
784
+ for (const owner of binding.owners) {
785
+ keys(owner, ['issue', 'taskIds']);
786
+ check(integer(owner.issue) && !seen.has(owner.issue), 'invalid or duplicate case owner');
787
+ seen.add(owner.issue);
788
+ list(owner.taskIds, (id) => taskId(id) && id.startsWith(owner.issue + '-T'), 'case owner tasks', { nonempty: true });
789
+ check(entry.codeOwners.includes(owner.issue) || owner.taskIds.every((id) => entry.preparationTasks.includes(id)), 'case owner is outside checkpoint');
790
+ owners.add(String(owner.issue));
791
+ }
792
+ }
793
+ check(cases.size === value.checkpoints.reduce((sum, entry) => sum + entry.skills.length, 0), 'checkpoint skill case coverage missing');
794
+ check(isObject(value.ownerAssertions) && sortedJson(Object.keys(value.ownerAssertions).sort()) === sortedJson([...owners].sort()), 'owner assertion coverage differs');
795
+ for (const assertion of Object.values(value.ownerAssertions)) {
796
+ keys(assertion, value.caseKinds);
797
+ check(value.caseKinds.every((kind) => text(assertion[kind])), 'empty owner assertion');
798
+ }
799
+ return value;
800
+ }
801
+
802
+ // Supported protocol envelope grammar. Unknown/ambiguous prose is a refusal,
803
+ // never a semantic guess. Values come from the immutable bound source, not the
804
+ // reservation record. This parser has no repository or issue-number defaults.
805
+ export function protocolLimits(body) {
806
+ const source = linesOf(canonicalScope(body, 'protocol')).filter((row) => row.structural).map((row) => row.line).join('');
807
+ const envelope = checkpointEnvelope(body);
808
+ if (envelope) {
809
+ for (const [pattern, expected] of [[/Overall maximum\s*(\d+) processes/g, envelope.overallMaxStarts], [/Overall maximum\s*\d+ processes and\s*(\d+) hours/g, envelope.overallActiveMs / 3600000], [/Up to\s*(\d+) vendor processes/g, envelope.coreMaxStarts], [/Reserve\s*(\d+) additional processes/g, envelope.requalifyMaxStarts]]) {
810
+ check([...source.matchAll(pattern)].every((match) => Number(match[1]) === expected), 'prose conflicts with closed research envelope');
811
+ }
812
+ return { total: envelope.overallMaxStarts, activeMs: envelope.overallActiveMs, trialMaxMs: envelope.maxProcessMs,
813
+ phases: { core: envelope.coreMaxStarts, 'SKILL-EVAL': envelope.maxStarts, REQUALIFY: envelope.requalifyMaxStarts },
814
+ skillMaxStarts: null, maxSkills: envelope.maxSkills, checkpointEnvelope: envelope };
815
+ }
816
+ const numbers = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, fifteen: 15, twenty: 20 };
817
+ const value = (word) => /^\d+$/.test(word) ? Number(word) : numbers[word.toLowerCase()];
818
+ const read = (pattern, group = 1) => {
819
+ const found = [...source.matchAll(pattern)].map((match) => value(match[group]));
820
+ check(found.length > 0 && found.every(integer) && new Set(found).size === 1, 'missing or ambiguous protocol limit');
821
+ return found[0];
822
+ };
823
+ if (/top-level subscription vendor launches/.test(source)) {
824
+ const total = read(/at most\s*(\d+) top-level subscription vendor launches/g);
825
+ return { total, activeMs: null, trialMaxMs: read(/(\d+) minutes each as a bounded trial control/g) * 60000,
826
+ phases: { dogfood: total }, skillMaxStarts: null, maxSkills: null };
827
+ }
828
+ return { total: read(/Overall maximum\s*(\d+) processes/g), activeMs: read(/Overall maximum\s*\d+ processes and\s*(\d+) hours/g) * 3600000,
829
+ trialMaxMs: read(/([A-Za-z]+|\d+) minutes per qualification process/g) * 60000,
830
+ phases: { core: read(/Up to\s*(\d+) vendor processes/g), 'SKILL-EVAL': read(/maximum\s*\d+ skills\/(\d+) processes/g), REQUALIFY: read(/Reserve\s*(\d+) additional processes/g) },
831
+ skillMaxStarts: read(/at most ([A-Za-z]+|\d+) vendor processes per changed authored skill/g), maxSkills: read(/maximum\s*(\d+) skills\/\d+ processes/g) };
832
+ }
833
+
834
+ // Pooled SKILL-EVAL receipts add suite:{checkpointId,arm,cases,contributors,
835
+ // acceptedCheckpoints,interveningAcceptance,livePrerequisiteEvidence}. Cases pin
836
+ // fixture/prompt and prior/current skill hashes plus owner assertion identities.
837
+ // Contributors pin exact plan/task/source/check evidence. Every attempt records
838
+ // checkpoint/arm/purpose, case IDs/digests and source/runtime digests; failures,
839
+ // child starts and resumes remain in the same pool. Only initial arms require
840
+ // the complete checkpoint case set; explicit repeat/split starts may be subsets.
841
+ // The protocol owner must inspect actual source/check/prerequisite/fixture bytes
842
+ // through inspectCheckpoint before consuming the shared reservation. No request
843
+ // JSON can supply that executable adapter or establish acceptance by itself.
844
+ export const checkpointSuiteDigest = (suite) => hash(sortedJson(suite));
845
+
846
+ function validateCheckpointSuite(context, suite, limits, item, requested, record, manifest, candidate, execution, dependencies, purpose, canonicalBinding) {
847
+ const envelope = limits.checkpointEnvelope;
848
+ check(sortedJson(suite?.approvalBinding) === sortedJson(canonicalBinding), 'checkpoint authority binding differs');
849
+ keys(suite, ['approvalBinding', 'checkpointId', 'arm', 'cases', 'contributors', 'acceptedCheckpoints', 'interveningAcceptance', 'livePrerequisiteEvidence']);
850
+ const checkpoint = envelope.checkpoints.find((entry) => entry.id === suite.checkpointId);
851
+ check(checkpoint && envelope.arms.includes(suite.arm) && suite.arm.startsWith(execution.harness + '-'), 'unselected evaluation checkpoint or arm');
852
+ const bindings = envelope.caseBindings.filter((entry) => entry.checkpoint === checkpoint.id);
853
+ if (item.mode !== 'research') {
854
+ const ownTasks = [...new Set(bindings.flatMap((entry) => entry.owners.filter((owner) => owner.issue === item.issue).flatMap((owner) => owner.taskIds)))].sort();
855
+ check(ownTasks.length > 0 && sortedJson(ownTasks) === sortedJson([...requested.taskIds].sort()), 'checkpoint invocation lacks exact owner tasks');
856
+ }
857
+ list(suite.contributors, isObject, 'checkpoint contributors', { nonempty: true });
858
+ const expectedOwners = [...new Set([...checkpoint.codeOwners, ...checkpoint.preparationTasks.map((id) => Number(id.split('-')[0]))])].sort((a, b) => a - b);
859
+ check(sortedJson(suite.contributors.map((entry) => entry.issue).sort((a, b) => a - b)) === sortedJson(expectedOwners), 'checkpoint contributor inventory differs');
860
+ for (const contributor of suite.contributors) {
861
+ keys(contributor, ['issue', 'taskIds', 'artifact', 'sourceSha', 'checks']);
862
+ const selected = record.items.find((entry) => entry.repo === manifest.parent.repo && entry.issue === contributor.issue);
863
+ const expectedTasks = checkpoint.codeOwners.includes(contributor.issue) ? selected?.taskIds : checkpoint.preparationTasks.filter((id) => id.startsWith(contributor.issue + '-T'));
864
+ check(selected && selected.artifacts.some((ref) => ref.kind === 'plan' && sameRef(ref, contributor.artifact)) && contributor.sourceSha === candidate.sourceSha && sortedJson([...contributor.taskIds].sort()) === sortedJson([...expectedTasks].sort()), 'checkpoint contributor scope/source differs');
865
+ const current = dependencies.filter((entry) => entry.repo === selected.repo && entry.issue === selected.issue);
866
+ check(current.length === 1 && Array.isArray(current[0].blockedBy), 'checkpoint contributor dependencies unavailable');
867
+ if (selected.mode === 'code') check(current[0].blockedBy.every((dependency) => dependency.state === 'closed'), 'checkpoint contributor has open native prerequisites');
868
+ else check(selected.mode === 'preparation' && contributor.taskIds.every((id) => checkpoint.preparationTasks.includes(id)), 'checkpoint preparation scope differs');
869
+ const receipt = boundEvidence(context, contributor.checks, 'checkpoint-source');
870
+ keys(receipt, ['kind', 'issue', 'taskIds', 'artifact', 'sourceSha', 'checks']);
871
+ check(receipt.issue === contributor.issue && sortedJson(receipt.taskIds) === sortedJson(contributor.taskIds) && sameRef(receipt.artifact, contributor.artifact) && receipt.sourceSha === candidate.sourceSha, 'checkpoint check receipt differs');
872
+ list(receipt.checks, isObject, 'checkpoint checks', { nonempty: true });
873
+ for (const result of receipt.checks) { keys(result, ['command', 'resultSha256', 'exitCode']); check(text(result.command) && digest(result.resultSha256) && result.exitCode === 0, 'checkpoint checks are not successful evidence'); }
874
+ }
875
+ list(suite.cases, isObject, 'checkpoint cases', { nonempty: true });
876
+ const expectedCases = bindings.flatMap((binding) => envelope.caseKinds.map((kind) => checkpoint.id + '.' + binding.skill + '.' + kind)).sort();
877
+ const caseIds = suite.cases.map((entry) => entry.id);
878
+ check(new Set(caseIds).size === caseIds.length && caseIds.every((id) => expectedCases.includes(id)) && (purpose !== 'initial' || sortedJson([...caseIds].sort()) === sortedJson(expectedCases)), 'checkpoint case coverage differs');
879
+ for (const entry of suite.cases) {
880
+ keys(entry, ['id', 'skill', 'kind', 'owners', 'fixtureSha256', 'promptSha256', 'priorSourceSha256', 'currentSourceSha256', 'assertions']);
881
+ const binding = bindings.find((value) => value.skill === entry.skill);
882
+ check(binding && envelope.caseKinds.includes(entry.kind) && entry.id === checkpoint.id + '.' + entry.skill + '.' + entry.kind, 'checkpoint case identity differs');
883
+ check(['fixtureSha256', 'promptSha256', 'priorSourceSha256', 'currentSourceSha256'].every((key) => digest(entry[key])), 'checkpoint fixture/prompt/skill source identity unavailable');
884
+ check(sortedJson(entry.owners) === sortedJson(binding.owners), 'checkpoint case owner/tasks differ');
885
+ for (const owner of binding.owners) {
886
+ const contributor = suite.contributors.find((value) => value.issue === owner.issue);
887
+ check(contributor && owner.taskIds.every((id) => contributor.taskIds.includes(id)), 'case task is outside contributor scope');
888
+ }
889
+ list(entry.assertions, isObject, 'case assertions', { nonempty: true });
890
+ check(new Set(entry.assertions.map((assertion) => assertion.id)).size === entry.assertions.length, 'duplicate assertion identity');
891
+ for (const assertion of entry.assertions) {
892
+ keys(assertion, ['id', 'issue', 'contractSha256']);
893
+ check(text(assertion.id) && binding.owners.some((owner) => owner.issue === assertion.issue) && assertion.contractSha256 === hash(envelope.ownerAssertions[assertion.issue][entry.kind]), 'case assertion is outside approved owner contract');
894
+ }
895
+ check(binding.owners.every((owner) => entry.assertions.some((assertion) => assertion.issue === owner.issue)), 'case owner assertion evidence missing');
896
+ }
897
+ const receipts = (entries, expected, kind, field) => {
898
+ list(entries, isObject, kind);
899
+ check(sortedJson(entries.map((entry) => entry[field]).sort()) === sortedJson([...expected].sort()), 'checkpoint prerequisite receipts differ');
900
+ for (const entry of entries) {
901
+ keys(entry, [field, 'evidence']);
902
+ const receipt = boundEvidence(context, entry.evidence, kind);
903
+ check(receipt[field] === entry[field] && receipt.acceptance === 'accepted' && sha(receipt.sourceSha), 'checkpoint prerequisite is not accepted');
904
+ }
905
+ };
906
+ receipts(suite.acceptedCheckpoints, checkpoint.after, 'checkpoint-acceptance', 'id');
907
+ receipts(suite.interveningAcceptance, checkpoint.interveningCodeAcceptance ?? [], 'code-acceptance', 'issue');
908
+ if (checkpoint.livePrerequisite) {
909
+ const receipt = boundEvidence(context, suite.livePrerequisiteEvidence, 'live-prerequisite');
910
+ check(receipt.contractSha256 === hash(checkpoint.livePrerequisite) && receipt.acceptance === 'accepted', 'checkpoint live prerequisite unresolved');
911
+ } else check(suite.livePrerequisiteEvidence === null, 'unexpected checkpoint live prerequisite');
912
+ return { suite, digest: checkpointSuiteDigest(suite), checkpoint, envelope };
913
+ }
914
+
915
+ function validateCheckpointAttempts(attempts, envelope, selected, execution) {
916
+ const skillAttempts = attempts.filter((entry) => entry.phase === 'SKILL-EVAL');
917
+ const initial = new Set();
918
+ let repeats = 0;
919
+ const skills = new Set();
920
+ for (const attempt of skillAttempts) {
921
+ const checkpoint = envelope.checkpoints.find((entry) => entry.id === attempt.checkpointId);
922
+ check(checkpoint && envelope.arms.includes(attempt.arm) && ['initial', 'repeat', 'split'].includes(attempt.purpose) && sha(attempt.sourceSha) && digest(attempt.suiteSha256) && digest(attempt.executionSha256), 'invalid pooled attempt binding');
923
+ const expectedCases = checkpoint.skills.flatMap((skill) => envelope.caseKinds.map((kind) => checkpoint.id + '.' + skill + '.' + kind)).sort();
924
+ list(attempt.caseIds, text, 'attempt cases', { nonempty: true });
925
+ list(attempt.caseDigests, isObject, 'attempt case digests', { nonempty: true });
926
+ check(sortedJson(attempt.caseDigests.map((entry) => entry.id).sort()) === sortedJson([...attempt.caseIds].sort()), 'attempt case digest inventory differs');
927
+ for (const entry of attempt.caseDigests) { keys(entry, ['id', 'sha256']); check(digest(entry.sha256), 'invalid attempt case digest'); }
928
+ check(attempt.caseIds.every((id) => expectedCases.includes(id)), 'attempt includes an unselected case');
929
+ if (attempt.purpose === 'initial') {
930
+ const key = attempt.checkpointId + ':' + attempt.arm;
931
+ check(attempt.kind === 'initial' && !initial.has(key) && sortedJson([...attempt.caseIds].sort()) === sortedJson(expectedCases), 'duplicate or incomplete initial checkpoint arm');
932
+ initial.add(key);
933
+ } else repeats++;
934
+ checkpoint.skills.forEach((skill) => skills.add(skill));
935
+ }
936
+ check(repeats <= envelope.repeatAndSplitStarts && skills.size <= envelope.maxSkills, 'shared repeat/split or skill inventory allowance exceeded');
937
+ if (selected) {
938
+ const current = skillAttempts.find((entry) => entry.id === selected.attemptId);
939
+ check(current && current.checkpointId === selected.checkpoint.id && current.arm === selected.suite.arm && current.suiteSha256 === selected.digest && current.sourceSha === selected.sourceSha && current.executionSha256 === hash(sortedJson(execution)), 'reserved pooled attempt differs from frozen suite/runtime');
940
+ check(sortedJson(current.caseDigests) === sortedJson(selected.suite.cases.map((entry) => ({ id: entry.id, sha256: checkpointSuiteDigest(entry) }))), 'reserved case bytes differ');
941
+ for (const attempt of skillAttempts.filter((entry) => entry.checkpointId === current.checkpointId && entry.sourceSha === current.sourceSha)) {
942
+ if (attempt.arm.split('-')[0] === execution.harness) check(attempt.executionSha256 === current.executionSha256, 'matched arms changed runtime/model/account/config');
943
+ for (const prior of attempt.caseDigests) {
944
+ const now = current.caseDigests.find((entry) => entry.id === prior.id);
945
+ check(!now || now.sha256 === prior.sha256, 'matched arms changed fixture/prompt/source/assertions');
946
+ }
947
+ }
948
+ }
949
+ if (selected) check(skillAttempts.filter((entry) => entry.status === 'running').length + 1 <= envelope.maxConcurrentProcesses, 'checkpoint process concurrency limit exceeded');
950
+ return skills;
951
+ }
952
+
953
+ function validateResearchEvidence(context, item, requested, action, record, manifest, dependencies, canonicalBinding) {
954
+ check(requested.operation === 'research-test' && requested.paths.length === 0, 'research grant cannot authorize other effects');
955
+ const evidence = boundEvidence(context, requested.research, 'research-reservation');
956
+ keys(evidence, ['schemaVersion', 'kind', 'approvalId', 'approvalBinding', 'manifestSha256', 'actionId', 'owner', 'protocol', 'scenarioId', 'candidate', 'execution', 'allowance'], ['suite']);
957
+ check(evidence.schemaVersion === 1 && evidence.kind === 'research-reservation' && evidence.approvalId === record.id && evidence.manifestSha256 === record.manifest.sha256 && evidence.actionId === action.id && evidence.scenarioId === requested.scenarioId, 'research reservation scope differs');
958
+ check(sortedJson(evidence.approvalBinding) === sortedJson(canonicalBinding), 'research authority comment/body binding differs');
959
+ keys(evidence.owner, ['repo', 'issue', 'taskIds', 'artifact', 'skill']);
960
+ check(evidence.owner.repo === item.repo && evidence.owner.issue === item.issue && sortedJson(evidence.owner.taskIds) === sortedJson(requested.taskIds), 'research owner differs');
961
+ const protocol = manifest.selections.find((entry) => entry.issue === action.issue && entry.mode === 'research')?.artifacts.find((ref) => ref.kind === 'protocol');
962
+ check(protocol && sameRef(evidence.protocol, protocol), 'research protocol differs');
963
+ const currentProtocol = context.artifacts.find((entry) => entry.repo === protocol.repo && entry.issue === protocol.issue && entry.kind === 'protocol');
964
+ const limits = protocolLimits(currentProtocol.artifact.body);
965
+ const pooled = limits.checkpointEnvelope && requested.scenarioId === 'SKILL-EVAL';
966
+ if (pooled) {
967
+ check(evidence.owner.skill === null && item.artifacts.some((ref) => sameRef(ref, evidence.owner.artifact)), 'pooled invocation owner binding differs');
968
+ } else if (item.mode !== 'research') {
969
+ check(requested.taskIds.length === 1 && /^skills\/[^/]+\/[^/]+$/.test(evidence.owner.skill), 'skill evaluation needs one selected task and authored skill');
970
+ check(item.artifacts.some((ref) => sameRef(ref, evidence.owner.artifact)), 'skill owner scope changed');
971
+ const current = context.artifacts.find((entry) => entry.repo === item.repo && entry.issue === item.issue && entry.kind === 'plan');
972
+ check(selectedTaskFiles(current.artifact.body, requested.taskIds).some((path) => path.startsWith(evidence.owner.skill + '/')), 'skill is outside changed task scope');
973
+ } else check(evidence.owner.skill === null && sameRef(evidence.owner.artifact, protocol), 'invalid research owner artifact');
974
+ const candidate = evidence.candidate;
975
+ keys(candidate, ['repo', 'parentBranch', 'baseSha', 'sourceSha', 'treeSha', 'acceptedIntegrations', 'packedArtifacts']);
976
+ check(candidate.repo === manifest.parent.repo && candidate.parentBranch === manifest.parent.branch && candidate.baseSha === manifest.parent.baseSha && sha(candidate.sourceSha) && sha(candidate.treeSha), 'research candidate identity differs');
977
+ list(candidate.acceptedIntegrations, (value) => sha(value), 'accepted integrations');
978
+ list(candidate.packedArtifacts, isObject, 'packed artifacts', { nonempty: true });
979
+ for (const packed of candidate.packedArtifacts) {
980
+ keys(packed, ['name', 'sha256', 'integrity']);
981
+ check(text(packed.name) && digest(packed.sha256) && /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(packed.integrity), 'invalid packed identity');
982
+ }
983
+ keys(evidence.execution, ['harness', 'version', 'model', 'accountRef', 'effort', 'platform', 'runtime', 'configDigest', 'policyDigest', 'providerMode']);
984
+ check(['harness', 'version', 'model', 'accountRef', 'effort', 'platform', 'runtime'].every((key) => text(evidence.execution[key])) && digest(evidence.execution.configDigest) && digest(evidence.execution.policyDigest) && evidence.execution.providerMode === action.providerMode, 'execution identity unavailable');
985
+ const checkpoint = pooled ? validateCheckpointSuite(context, evidence.suite, limits, item, requested, record, manifest, candidate, evidence.execution, dependencies, evidence.allowance?.attempts?.find((entry) => entry.id === evidence.allowance.attemptId)?.purpose, canonicalBinding) : null;
986
+ check(pooled || evidence.suite === undefined, 'checkpoint suite on another research phase');
987
+ check(action.maxStarts <= limits.total && (limits.activeMs === null || action.aggregateActiveMs !== null && action.aggregateActiveMs <= limits.activeMs), 'grant exceeds protocol envelope');
988
+ const allowance = evidence.allowance;
989
+ keys(allowance, ['id', 'reservationId', 'attemptId', 'phase', 'ledgerRevision', 'status', 'totalStarts', 'phaseStarts', 'phaseMaxStarts', 'activeMs', 'reservedActiveMs', 'trialMaxMs', 'skillStarts', 'skillMaxStarts', 'skillCount', 'maxSkills', 'attempts']);
990
+ check(['id', 'reservationId', 'attemptId', 'phase'].every((key) => text(allowance[key])) && integer(allowance.ledgerRevision) && allowance.status === 'reserved', 'research allowance is not a reserved attempt');
991
+ for (const key of ['totalStarts', 'phaseStarts', 'phaseMaxStarts', 'trialMaxMs']) check(integer(allowance[key]), 'invalid allowance counter');
992
+ for (const key of ['activeMs', 'reservedActiveMs', 'skillStarts', 'skillCount']) check(Number.isSafeInteger(allowance[key]) && allowance[key] >= 0, 'invalid allowance usage');
993
+ const phase = requested.scenarioId === 'SKILL-EVAL' ? 'SKILL-EVAL' : requested.scenarioId === 'REQUALIFY' ? 'REQUALIFY' : Object.hasOwn(limits.phases, 'dogfood') ? 'dogfood' : 'core';
994
+ check(allowance.phase === phase && allowance.phaseMaxStarts === limits.phases[phase] && allowance.trialMaxMs <= limits.trialMaxMs && allowance.skillMaxStarts === limits.skillMaxStarts && allowance.maxSkills === limits.maxSkills, 'reservation bounds differ from canonical protocol');
995
+ check(allowance.totalStarts <= action.maxStarts && allowance.phaseStarts <= allowance.phaseMaxStarts && allowance.reservedActiveMs >= allowance.trialMaxMs, 'research start allowance exceeded');
996
+ check(action.aggregateActiveMs === null || allowance.activeMs + allowance.reservedActiveMs <= action.aggregateActiveMs, 'research aggregate active allowance exceeded');
997
+ if (requested.scenarioId === 'SKILL-EVAL' && !pooled) check(allowance.phase === 'SKILL-EVAL' && integer(allowance.skillMaxStarts) && integer(allowance.maxSkills) && allowance.skillStarts > 0 && allowance.skillStarts <= allowance.skillMaxStarts && allowance.skillCount > 0 && allowance.skillCount <= allowance.maxSkills, 'skill phase allowance exceeded');
998
+ list(allowance.attempts, isObject, 'shared attempt history', { nonempty: true });
999
+ const attempts = allowance.attempts;
1000
+ check(new Set(attempts.map((entry) => entry.id)).size === attempts.length, 'duplicate shared attempt identity');
1001
+ for (const attempt of attempts) {
1002
+ keys(attempt, ['id', 'phase', 'skill', 'kind', 'status', 'activeMs', 'reservedActiveMs', 'approvalBinding'], limits.checkpointEnvelope && attempt.phase === 'SKILL-EVAL' ? ['checkpointId', 'arm', 'purpose', 'caseIds', 'sourceSha', 'suiteSha256', 'executionSha256', 'caseDigests'] : []);
1003
+ keys(attempt.approvalBinding, ['approvalId', 'commentId', 'bodySha256']);
1004
+ check(text(attempt.approvalBinding.approvalId) && integer(attempt.approvalBinding.commentId) && digest(attempt.approvalBinding.bodySha256), 'invalid historical attempt authority binding');
1005
+ check(text(attempt.id) && Object.hasOwn(limits.phases, attempt.phase) && (attempt.skill === null || text(attempt.skill)) && ['initial', 'child', 'resume'].includes(attempt.kind) && ['reserved', 'running', 'passed', 'failed', 'cancelled'].includes(attempt.status), 'invalid shared attempt');
1006
+ check(['activeMs', 'reservedActiveMs'].every((key) => Number.isSafeInteger(attempt[key]) && attempt[key] >= 0), 'invalid shared attempt duration');
1007
+ check(attempt.phase !== 'SKILL-EVAL' || (limits.checkpointEnvelope ? attempt.skill === null : text(attempt.skill)), 'skill attempt identity differs from protocol');
1008
+ }
1009
+ const reserved = attempts.filter((entry) => entry.id === allowance.attemptId);
1010
+ check(reserved.length === 1 && reserved[0].status === 'reserved' && reserved[0].phase === phase && reserved[0].skill === evidence.owner.skill && reserved[0].reservedActiveMs >= allowance.trialMaxMs, 'attempt reservation absent or consumed');
1011
+ check(sortedJson(reserved[0].approvalBinding) === sortedJson(canonicalBinding), 'reserved attempt has wrong canonical authority');
1012
+ const totalActive = attempts.reduce((sum, entry) => sum + entry.activeMs, 0);
1013
+ const totalReserved = attempts.reduce((sum, entry) => sum + entry.reservedActiveMs, 0);
1014
+ check(allowance.totalStarts === attempts.length && allowance.phaseStarts === attempts.filter((entry) => entry.phase === phase).length && allowance.activeMs === totalActive && allowance.reservedActiveMs === totalReserved, 'shared counters differ from complete attempt history');
1015
+ for (const [name, maximum] of Object.entries(limits.phases)) check(attempts.filter((entry) => entry.phase === name).length <= maximum, 'shared phase allowance exceeded');
1016
+ const skills = [...new Set(attempts.filter((entry) => entry.phase === 'SKILL-EVAL').map((entry) => entry.skill))];
1017
+ if (limits.checkpointEnvelope) {
1018
+ const inventory = validateCheckpointAttempts(attempts, limits.checkpointEnvelope, pooled ? { ...checkpoint, attemptId: allowance.attemptId, sourceSha: candidate.sourceSha } : null, evidence.execution);
1019
+ check(allowance.skillStarts === 0 && allowance.skillCount === inventory.size, 'pooled counters differ from shared checkpoint attempts');
1020
+ } else if (limits.maxSkills !== null && !limits.checkpointEnvelope) {
1021
+ check(skills.length <= limits.maxSkills && allowance.skillCount === skills.length, 'shared changed-skill inventory exceeded');
1022
+ for (const skill of skills) check(attempts.filter((entry) => entry.phase === 'SKILL-EVAL' && entry.skill === skill).length <= limits.skillMaxStarts, 'shared per-skill allowance exceeded');
1023
+ check(allowance.skillStarts === attempts.filter((entry) => entry.phase === 'SKILL-EVAL' && entry.skill === evidence.owner.skill).length, 'per-skill counter differs from shared attempts');
1024
+ }
1025
+ // The protocol owner must atomically consume this reservation immediately at
1026
+ // launch and verify candidate bytes. Scope approval cannot replace that CAS.
1027
+ return { approvalId: record.id, approvalBinding: canonicalBinding, reservationId: allowance.reservationId, attemptId: allowance.attemptId, ledgerRevision: allowance.ledgerRevision, candidate, execution: evidence.execution, trialMaxMs: allowance.trialMaxMs, checkpoint,
1028
+ pendingEffects: ['verify-clean-candidate-and-packed-bytes', 'consume-shared-reservation-before-process-start'] };
1029
+ }
1030
+
1031
+ // Transport adapters return provider records, not precomputed permission flags.
1032
+ // Source reads are shared by the ordinary and consolidated launch paths.
1033
+ export async function readPages(readJson, args) {
1034
+ const value = await readJson([...args, '--paginate', '--slurp']);
1035
+ check(Array.isArray(value) && value.every(Array.isArray), 'unreadable complete paginated history');
1036
+ return value.flat();
1037
+ }
1038
+
1039
+ export async function readApprovalSources(comments, readJson) {
1040
+ const sources = new Map();
1041
+ for (const comment of comments) {
1042
+ const event = parseApproval(comment);
1043
+ if (event.ok === false || event.source.kind !== 'github-comment') continue;
1044
+ const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/\d+#issuecomment-(\d+)$/.exec(event.source.ref);
1045
+ check(match && repo(match[1]), 'unsupported GitHub approval source locator');
1046
+ if (!sources.has(event.source.ref)) sources.set(event.source.ref, await readJson(['api', 'repos/' + match[1] + '/issues/comments/' + match[2]]));
1047
+ }
1048
+ return [...sources.values()];
1049
+ }
1050
+
1051
+ export async function gatherConsolidatedApproval({ parentRepo, parentIssue, approvalBinding, requested, operators, readJson, admissionEvidence = [], researchAdapter, preparationAdapter }) {
1052
+ const pages = (path) => readPages(readJson, ['api', path]);
1053
+ check(repo(parentRepo) && integer(parentIssue), 'invalid approval parent');
1054
+ keys(approvalBinding, ['commentId', 'bodySha256']);
1055
+ check(integer(approvalBinding.commentId) && digest(approvalBinding.bodySha256), 'invalid approval comment binding');
1056
+ const approvalComments = await pages('repos/' + parentRepo + '/issues/' + parentIssue + '/comments');
1057
+ const matches = approvalComments.filter((comment) => comment.id === approvalBinding.commentId);
1058
+ check(matches.length === 1 && hash(matches[0].body) === approvalBinding.bodySha256, 'pinned approval comment unavailable or changed');
1059
+ const record = parseApproval(matches[0]);
1060
+ check(record.kind === 'consolidated', 'pinned comment is not consolidated intent');
1061
+ let manifestBytes;
1062
+ let manifestBlob;
1063
+ const locator = record.manifest.source;
1064
+ if (locator.kind === 'inline') manifestBytes = locator.utf8;
1065
+ else {
1066
+ let databaseId = locator.repositoryId;
1067
+ let node;
1068
+ if (!/^[1-9]\d*$/.test(databaseId)) {
1069
+ const result = await readJson(['api', 'graphql', '-f', 'query=query($id:ID!){node(id:$id){__typename ... on Repository{id databaseId nameWithOwner}}}', '-f', 'id=' + locator.repositoryId]);
1070
+ node = result.data?.node;
1071
+ check(!result.errors && node?.__typename === 'Repository' && node.id === locator.repositoryId && integer(node.databaseId) && repo(node.nameWithOwner), 'manifest repository node unavailable or wrong type');
1072
+ databaseId = String(node.databaseId);
1073
+ }
1074
+ const repository = await readJson(['api', 'repositories/' + databaseId]);
1075
+ check(String(repository.id) === databaseId && (!node || repository.node_id === node.id && repository.full_name === node.nameWithOwner), 'immutable manifest repository identity changed');
1076
+ check(repo(repository.full_name), 'manifest repository unavailable');
1077
+ const blob = await readJson(['api', 'repos/' + repository.full_name + '/contents/' + locator.path.split('/').map(encodeURIComponent).join('/') + '?ref=' + locator.commitSha]);
1078
+ check(blob.type === 'file' && blob.encoding === 'base64' && typeof blob.content === 'string', 'manifest is not a readable immutable file');
1079
+ const bytes = Buffer.from(blob.content.replaceAll('\n', ''), 'base64');
1080
+ manifestBytes = bytes.toString('utf8');
1081
+ check(Buffer.from(manifestBytes, 'utf8').equals(bytes) && hash(manifestBytes) === locator.blobSha256, 'manifest blob bytes changed');
1082
+ manifestBlob = locator;
1083
+ }
1084
+ const manifest = validateExecutionManifest(manifestBytes);
1085
+ check(manifest.parent.repo === parentRepo && manifest.parent.issue === parentIssue, 'approval parent differs from frozen manifest');
1086
+ const artifacts = [];
1087
+ const currentDependencies = [];
1088
+ for (const item of record.items) {
1089
+ const path = 'repos/' + item.repo + '/issues/' + item.issue;
1090
+ const issue = await readJson(['api', path]);
1091
+ check(issue.number === item.issue, 'current issue identity differs');
1092
+ if (item.repo === requested.repo && item.issue === requested.issue) check(issue.state === 'open', 'requested issue is not open');
1093
+ const comments = await pages(path + '/comments');
1094
+ artifacts.push({ repo: item.repo, issue: item.issue, kind: 'brief', artifact: issue });
1095
+ for (const binding of item.artifacts.filter((ref) => ref.kind !== 'brief')) {
1096
+ const candidates = binding.kind === 'plan'
1097
+ ? comments.filter((comment) => hasArtifactMarker(comment, 'plan'))
1098
+ : comments.filter((comment) => {
1099
+ try { artifactRef({ repo: item.repo, issue: item.issue, kind: 'protocol', artifact: comment }); return true; } catch { return false; }
1100
+ });
1101
+ check(candidates.length === 1, 'missing or duplicate canonical ' + binding.kind);
1102
+ artifacts.push({ repo: item.repo, issue: item.issue, kind: binding.kind, artifact: candidates[0] });
1103
+ }
1104
+ currentDependencies.push({ repo: item.repo, issue: item.issue, blockedBy: await pages(path + '/dependencies/blocked_by') });
1105
+ }
1106
+ const fetchedEvidence = [];
1107
+ for (const entry of admissionEvidence) {
1108
+ const comment = await readJson(['api', 'repos/' + parentRepo + '/issues/comments/' + entry.comment.id]);
1109
+ fetchedEvidence.push({ ...entry, comment });
1110
+ }
1111
+ let result = evaluateConsolidatedApproval({ record, manifestBytes, operators, requested, currentDependencies,
1112
+ currentArtifacts: { artifacts, approvalComments, approvalBinding, manifestBlob,
1113
+ sourceComments: await readApprovalSources(approvalComments, readJson), admissionEvidence: fetchedEvidence } });
1114
+ if (result.ok && result.preparation) result = await admitConsolidatedPreparation(result, preparationAdapter);
1115
+ if (result.ok && result.research) return admitConsolidatedResearch(result, researchAdapter);
1116
+ return result;
1117
+ }
1118
+
1119
+ // The production adapter belongs to the protocol owner. No adapter, no launch;
1120
+ // tests exercise this seam with actual isolated candidate/one-use ledger reads.
1121
+ export async function admitConsolidatedResearch(scope, adapter) {
1122
+ try {
1123
+ check(scope.ok && scope.research, 'missing validated research scope');
1124
+ check(!scope.preparation || scope.preparation.pendingEffects.length === 0, 'preparation prerequisite admission is still pending');
1125
+ check(typeof adapter?.inspectCandidate === 'function' && typeof adapter?.consumeReservation === 'function', 'research candidate/shared-ledger production adapter unavailable');
1126
+ const expected = scope.research;
1127
+ check(expected.approvalBinding?.approvalId === expected.approvalId, 'research authority ID differs from its comment binding');
1128
+ check(sortedJson(scope.approvalIds) === sortedJson([expected.approvalId]) && sortedJson(scope.approvalBindings) === sortedJson([expected.approvalBinding]), 'research scope carries inconsistent canonical authority');
1129
+ if (expected.checkpoint) {
1130
+ check(typeof adapter.inspectCheckpoint === 'function', 'pooled checkpoint production adapter unavailable');
1131
+ const currentSuite = await adapter.inspectCheckpoint(expected.checkpoint.suite);
1132
+ keys(currentSuite, ['suite', 'sourceDigests', 'assertionIds']);
1133
+ check(sortedJson(currentSuite.suite) === sortedJson(expected.checkpoint.suite), 'actual checkpoint eligibility/case receipts differ');
1134
+ const sourceDigests = expected.checkpoint.suite.cases.map((entry) => ({ id: entry.id, fixtureSha256: entry.fixtureSha256, promptSha256: entry.promptSha256, priorSourceSha256: entry.priorSourceSha256, currentSourceSha256: entry.currentSourceSha256 }));
1135
+ check(sortedJson(currentSuite.sourceDigests) === sortedJson(sourceDigests) && sortedJson(currentSuite.assertionIds) === sortedJson(expected.checkpoint.suite.cases.map((entry) => ({ id: entry.id, assertionIds: entry.assertions.map((assertion) => assertion.id) }))), 'actual checkpoint fixture/prompt/source/assertion bytes differ');
1136
+ }
1137
+ const current = await adapter.inspectCandidate(expected.candidate);
1138
+ keys(current, ['candidate', 'clean', 'ancestorShas']);
1139
+ check(current.clean === true && sortedJson(current.candidate) === sortedJson(expected.candidate), 'actual candidate or packed bytes differ');
1140
+ list(current.ancestorShas, sha, 'candidate ancestry', { nonempty: true });
1141
+ check([expected.candidate.baseSha, ...expected.candidate.acceptedIntegrations].every((commit) => current.ancestorShas.includes(commit)), 'candidate lacks approved base/integrations');
1142
+ const receipt = await adapter.consumeReservation({ reservationId: expected.reservationId, attemptId: expected.attemptId,
1143
+ ledgerRevision: expected.ledgerRevision, candidate: expected.candidate, execution: expected.execution,
1144
+ approvalIds: scope.approvalIds, approvalBinding: expected.approvalBinding, manifestSha256: scope.manifestSha256, trialMaxMs: expected.trialMaxMs, ...(expected.checkpoint ? { suiteSha256: expected.checkpoint.digest } : {}) });
1145
+ keys(receipt, ['reservationId', 'attemptId', 'previousRevision', 'revision', 'state', 'candidateSha', 'execution', 'approvalBinding'], expected.checkpoint ? ['suiteSha256'] : []);
1146
+ check(receipt.reservationId === expected.reservationId && receipt.attemptId === expected.attemptId && receipt.previousRevision === expected.ledgerRevision && receipt.revision === expected.ledgerRevision + 1 && receipt.state === 'consumed' && receipt.candidateSha === expected.candidate.sourceSha && sortedJson(receipt.execution) === sortedJson(expected.execution), 'research reservation consume failed or changed');
1147
+ check(sortedJson(receipt.approvalBinding) === sortedJson(expected.approvalBinding), 'consumed reservation carries wrong authority');
1148
+ check(!expected.checkpoint || receipt.suiteSha256 === expected.checkpoint.digest, 'consumed reservation belongs to another checkpoint suite');
1149
+ return { ...scope, research: { ...expected, pendingEffects: [], receipt } };
1150
+ } catch (error) {
1151
+ return { ok: false, bindings: [], approvalIds: [], blocks: ['research launch: ' + error.message] };
1152
+ }
1153
+ }
1154
+
1155
+ // The recovery owner verifies the mapping against its canonical task contract
1156
+ // and actual accepted-parent history. Comment hashes alone cannot prove either.
1157
+ export async function admitConsolidatedPreparation(scope, adapter) {
1158
+ try {
1159
+ check(scope.ok && scope.preparation, 'missing validated preparation scope');
1160
+ check(typeof adapter?.readTaskPrerequisites === 'function' && typeof adapter?.inspectAcceptedIntegration === 'function', 'preparation recovery-owner production adapter unavailable');
1161
+ const expected = scope.preparation;
1162
+ check(sortedJson(scope.approvalIds) === sortedJson([expected.approvalBinding.approvalId]) && sortedJson(scope.approvalBindings) === sortedJson([expected.approvalBinding]), 'preparation scope carries inconsistent canonical authority');
1163
+ const mapping = await adapter.readTaskPrerequisites({ parent: expected.parent, plan: expected.plan, taskIds: expected.taskIds, approvalBinding: expected.approvalBinding });
1164
+ keys(mapping, ['parent', 'plan', 'tasks', 'approvalBinding']);
1165
+ check(sortedJson(mapping.approvalBinding) === sortedJson(expected.approvalBinding), 'preparation adapter carries wrong authority');
1166
+ check(sortedJson(mapping.parent) === sortedJson(expected.parent) && sameRef(mapping.plan, expected.plan) && sortedJson(mapping.tasks) === sortedJson(expected.tasks), 'authoritative preparation prerequisite mapping differs');
1167
+ const integrations = [];
1168
+ for (const contract of expected.acceptedContracts) {
1169
+ const current = await adapter.inspectAcceptedIntegration(contract);
1170
+ keys(current, ['contract', 'ancestorShas', 'reviewedHead', 'acceptedTaskIds']);
1171
+ check(sortedJson(current.contract) === sortedJson(contract) && current.reviewedHead === contract.childHead, 'preparation accepted contract/review differs');
1172
+ list(current.ancestorShas, sha, 'accepted parent ancestry', { nonempty: true });
1173
+ check(current.ancestorShas.includes(contract.childHead) && current.ancestorShas.includes(expected.parent.baseSha) && current.ancestorShas[0] === contract.parentHead, 'preparation code is not integrated into approved parent');
1174
+ list(current.acceptedTaskIds, taskId, 'accepted prerequisite tasks', { nonempty: true });
1175
+ check(sortedJson([...current.acceptedTaskIds].sort()) === sortedJson([...expected.requiredTasks.find((entry) => entry.issue === contract.issue).taskIds].sort()), 'preparation prerequisite acceptance is incomplete');
1176
+ integrations.push(current);
1177
+ }
1178
+ return { ...scope, preparation: { ...expected, pendingEffects: [], integrations } };
1179
+ } catch (error) {
1180
+ return { ok: false, bindings: [], approvalIds: [], blocks: ['preparation launch: ' + error.message] };
1181
+ }
1182
+ }