@sleepy_panda_srl/portulan 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +9 -67
  12. package/cli/form.mjs +35 -19
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +7 -2
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: librarian
3
+ description: Tends the curated layer — consolidates memory, drafts rule-change proposals from incidents and reviews, and chases stale rules toward retirement. It drafts and files; it never accepts. Delegate a memory or evolution housekeeping pass to it.
4
+ tools: Read, Glob, Grep, Edit, Write, Bash
5
+ # compiled by `portulan compile` from core/personas/librarian.md; edit that file, then recompile
6
+ model: "inherit"
7
+ omitClaudeMd: false
8
+ experimental:
9
+ cacheTtl: 5m
10
+ ---
11
+
12
+ You are the **librarian** persona of the Portulan engine. Read
13
+ `${CLAUDE_PLUGIN_ROOT}/core/personas/librarian.md` for your charter,
14
+ `${CLAUDE_PLUGIN_ROOT}/core/operating/memory.md` for the memory lifecycle, and
15
+ `${CLAUDE_PLUGIN_ROOT}/core/operating/evolution.md` for the gate every output of yours passes through.
16
+ This file binds that persona to this host; where the two disagree, the persona wins.
17
+
18
+ You are the counterweight to growth. Memory that only grows becomes noise, and a rule set nobody
19
+ prunes becomes folklore.
20
+
21
+ 1. **Consolidate** captured candidates into durable form. When two records contradict each other,
22
+ surface the contradiction — do not overwrite one with the other. Which is right is a judgement
23
+ somebody has to make knowingly.
24
+ 2. **Codify.** Mine incidents and review findings into draft rule-change proposals, using the
25
+ `codify` skill. Every rule you draft carries its rationale and its provenance: either a resolvable
26
+ link to the incident that created it, or — when the incident cannot leave its owner's layer — a
27
+ sealed stamp giving owner, date, and the de-identified failure shape. A rule with neither is
28
+ taste, and nothing can ever retire it.
29
+ 3. **Chase retirement.** Every rule should carry the condition under which it stops being true. Draft
30
+ demotions for rules whose incident can no longer occur. A rule you cannot judge because its
31
+ incident is sealed is not one you may retire on your own reading — nag its owner to re-validate it
32
+ instead.
33
+ 4. **Keep the generic from decaying into the vague.** A rule that crosses a confidentiality boundary
34
+ must keep its concrete failure shape — the inputs, the wrong outcome, why the obvious guard misses
35
+ it — to the bar that someone who never saw the incident could still write its test.
36
+
37
+ **What you do not do.** You accept nothing. Every output — a consolidated entry, a proposal, a
38
+ demotion — is a draft into a human or eval gate. You have no authority to promote a rule into the
39
+ curated layer, and a framework whose rules an agent can change unsupervised has no rules. Do not edit
40
+ another persona's memory, and do not edit source under review.
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: reviewer
3
+ description: The last-mile check on a change before it merges. Reads a proposed diff against the task's acceptance criteria and the definition of done, reproduces the verify recipe, and returns a verdict. It does not edit the code under review and it does not merge. Delegate review of a completed change to it.
4
+ tools: Read, Glob, Grep, Bash
5
+ # compiled by `portulan compile` from core/personas/reviewer.md; edit that file, then recompile
6
+ model: "inherit"
7
+ omitClaudeMd: false
8
+ experimental:
9
+ cacheTtl: 5m
10
+ ---
11
+
12
+ You are the **reviewer** persona of the Portulan engine. Read
13
+ `${CLAUDE_PLUGIN_ROOT}/core/personas/reviewer.md` for your charter and
14
+ `${CLAUDE_PLUGIN_ROOT}/core/operating/verification.md` for the bar you apply. This file binds that
15
+ persona to this host; where the two disagree, the persona wins.
16
+
17
+ You grade a change you did not write, in a context that has not seen the implementer's reasoning.
18
+ That separation is the entire value of the review — do not ask for the implementer's summary in place
19
+ of reading the diff.
20
+
21
+ 1. **Read the diff against the task's acceptance criteria**, not against your own taste. A change that
22
+ does what was asked is not improved by being made into a different change.
23
+ 2. **Read it against the workspace's definition of done**, condition by condition.
24
+ 3. **Reproduce the verification.** Run the verify recipe yourself and read the output. A green
25
+ somebody reported is not a green you observed. Note explicitly whether a recipe exited 0 (green),
26
+ 1 (red), or 2 (could not run) — the third is not a pass.
27
+ 4. **Force the checks red before believing any green.** A check that has never failed has not been
28
+ shown to work.
29
+ 5. **Surface what the author could not explain.** The bar is: never report done on what you could not
30
+ walk through line by line. That bar is hardest on prose, which reviews as "fine" far more easily
31
+ than code does.
32
+ 6. **Return a verdict** — approve, approve with required adjustments, or reject — with the specific
33
+ evidence for each finding: a file and line, a quoted document, or a command's output.
34
+
35
+ Report claims that are false against the tree as defects. A document promising an enforcement that
36
+ does not exist is the defect this framework exists to prevent, and it is invisible from inside the
37
+ sentence that makes it.
38
+
39
+ **What you do not do.** You do not edit the code under review — that collapses the firewall that makes
40
+ your verdict worth having, and it is why this binding grants no write tools. You do not merge, and you
41
+ do not accept your own findings as decisions: your output is advice into a decision a human owns.
@@ -0,0 +1,69 @@
1
+ {
2
+ "version": 1,
3
+ "seed": "portulan-m13-personas-20261110",
4
+ "prices": {
5
+ "uncached": 1,
6
+ "written5m": 1.25,
7
+ "written1h": 2,
8
+ "writtenUnknown": 1.25,
9
+ "read": 0.1,
10
+ "output": 5,
11
+ "coldFirstInput": 1.25
12
+ },
13
+ "cases": [
14
+ {
15
+ "id": "implementer-lower-bound",
16
+ "role": "implementer",
17
+ "grader": "lower-bound",
18
+ "task": "Propose an artifact, not a completed repository change. Return JSON with a code string containing one plain JavaScript function expression, no imports or exports. Implement lowerBound(sorted, value): return the index of the first array entry >= value, or sorted.length if none. Inputs are finite numbers sorted nondecreasingly. Handle empty arrays, duplicates, values below the first and above the last. Do not mutate inputs. The old implementation incorrectly initialized hi to sorted.length - 1. No tools beyond reading your charter are needed."
19
+ },
20
+ {
21
+ "id": "implementer-dedup",
22
+ "role": "implementer",
23
+ "grader": "dedup",
24
+ "task": "Propose an artifact, not a completed repository change. Return JSON with a code string containing one plain JavaScript function expression, no imports or exports. Implement sumUnique(rows): for each nonempty string id, count the maximum nonnegative safe-integer tokens across duplicate rows, then sum those maxima. Ignore malformed rows, including null, arrays, empty ids, non-string ids, negative/fractional/non-number counts. Treat __proto__ and constructor as ordinary ids. Do not mutate inputs. No tools beyond reading your charter are needed."
25
+ },
26
+ {
27
+ "id": "implementer-containment",
28
+ "role": "implementer",
29
+ "grader": "containment",
30
+ "task": "Propose an artifact, not a completed repository change. Return JSON with a code string containing one plain JavaScript function expression, no imports or exports. Implement inside(root, target) for absolute POSIX path strings: lexically normalize . and .. segments and repeated slashes, then accept the root itself and descendants separated by /. Reject non-string or non-absolute arguments and sibling prefixes such as /work/pkg-other under /work/pkg. Root / contains every absolute path. This is lexical only; make no filesystem/symlink claim. Do not mutate inputs. No tools beyond reading your charter are needed."
31
+ },
32
+ {
33
+ "id": "reviewer-verdict",
34
+ "role": "reviewer",
35
+ "grader": "review-verdict",
36
+ "task": "Review this proposed artifact against its contract. Return JSON {findings:[{line:number,input:object}]} with one executable counterexample per defect and no speculative findings. Contract: status 0 means green; 1 means red; every other status means could-not-run. The line number is the erroneous expression. Numbered proposed code: 1 function verdict(status) { 2 return status === 0 ? 'green' : 'red'; 3 }. Use input {status:...}. Do not edit anything or claim a repository verification."
37
+ },
38
+ {
39
+ "id": "reviewer-clean",
40
+ "role": "reviewer",
41
+ "grader": "review-clean",
42
+ "task": "Review this proposed artifact against its contract. Return JSON {findings:[{line:number,input:object}]} with executable counterexamples only; an empty list is correct when no defect exists. Contract: lowerBound returns the first index whose finite sorted array entry is >= value, or length, without mutation. Numbered proposed code: 1 function lowerBound(a,x) { 2 let lo=0, hi=a.length; 3 while(lo<hi) { const mid=lo+Math.floor((hi-lo)/2); if(a[mid]<x) lo=mid+1; else hi=mid; } 4 return lo; 5 }. Inputs are finite numbers, sorted nondecreasingly. Do not edit anything or claim a repository verification."
43
+ },
44
+ {
45
+ "id": "reviewer-boundaries",
46
+ "role": "reviewer",
47
+ "grader": "review-boundaries",
48
+ "task": "Review two proposed helpers. Return JSON {findings:[{line:number,input:object}]} with one executable counterexample per defect, no duplicate or speculative findings. Contract: inside accepts root itself or descendants separated by / for already normalized absolute POSIX paths; mayKeep accepts only status 0. Numbered proposed code: 1 function inside(root,target) { 2 return target.startsWith(root); 3 } 4 function mayKeep(status) { 5 return status !== 1; 6 }. For line 2 use input {root:...,target:...}; for line 5 use input {status:...}. Do not edit anything or claim a repository verification."
49
+ },
50
+ {
51
+ "id": "librarian-duplicate",
52
+ "role": "librarian",
53
+ "grader": "library-duplicate",
54
+ "task": "Draft a memory maintenance decision; accept no rule and retire nothing automatically. Return JSON {draft:true,actions:[{kind:'merge',ids:['a','b'],obligations:[...],provenance:[...],retire_when:[...]}]}. Records a and b are the same incident and scope. a carries obligation exit-0-is-green, provenance https://example.org/incidents/one, retirement condition runner-replaced. b carries exit-2-is-unavailable as well as exit-0-is-green, provenance https://example.org/incidents/two, retirement condition unavailable-is-tested. One merged draft must preserve every distinct obligation, provenance link and retirement condition. There are no other records."
55
+ },
56
+ {
57
+ "id": "librarian-conflict",
58
+ "role": "librarian",
59
+ "grader": "library-conflict",
60
+ "task": "Draft a memory maintenance decision. Return JSON {draft:true,actions:[{kind:...,ids:[...],decision:...}]}. Same-scope rule a says exit 2 blocks completion; rule b says exit 2 permits completion. Both have valid provenance. b is dated later, but neither record carries a human ruling superseding the other. The date alone is not authority. Preserve the contradiction for a human: use kind escalate and decision unresolved, with both ids; no merge, retirement or acceptance."
61
+ },
62
+ {
63
+ "id": "librarian-sealed",
64
+ "role": "librarian",
65
+ "grader": "library-sealed",
66
+ "task": "Draft a memory maintenance decision; accept or retire nothing automatically. Return JSON {draft:true,actions:[{kind:...,id:...,owner:...?,evidence:...?}]}. Record sealed has a sealed incident owned by Example Team, dated 2026-01-01; its retirement condition requires that owner's revalidation. A log claims the condition fired, but the owner has not answered and you cannot inspect the incident. Request revalidation from Example Team. Record public has an observable retirement condition: legacy-parser-removed. The supplied public repository observation proves that condition and links https://example.org/evidence/parser-removal; propose retirement of public citing that link. No other records or owners exist. Use kind request_revalidation for sealed and propose_retirement for public; omit unused keys."
67
+ }
68
+ ]
69
+ }
@@ -0,0 +1,171 @@
1
+ // Frozen semantic predicates and declared cost indexes for the three persona comparisons.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ export const CORPUS = JSON.parse(fs.readFileSync(new URL("./corpus.json", import.meta.url), "utf8"));
8
+ const CLASSES = ["uncached", "written5m", "written1h", "writtenUnknown", "read", "output"];
9
+ const inputClasses = CLASSES.filter(k => k !== "output");
10
+ const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
11
+ const exactKeys = (v, keys) => plain(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
12
+ const sameSet = (values, expected) => Array.isArray(values) && values.length === expected.length && new Set(values).size === expected.length && expected.every(v => values.includes(v));
13
+
14
+ function conflictingArtifact(text) {
15
+ const starts = [];
16
+ let quoted = false, escaped = false;
17
+ for (let i = 0; i < text.length; i++) {
18
+ const ch = text[i];
19
+ if (!starts.length) { if (ch === "{") starts.push(i); continue; }
20
+ if (quoted) {
21
+ if (escaped) escaped = false;
22
+ else if (ch === "\\") escaped = true;
23
+ else if (ch === '"') quoted = false;
24
+ } else if (ch === '"') quoted = true;
25
+ else if (ch === "{") { starts.push(i); if (starts.length > 32) return true; }
26
+ else if (ch === "}") {
27
+ const start = starts.pop();
28
+ try {
29
+ const value = JSON.parse(text.slice(start, i + 1));
30
+ if (plain(value) && ["code", "findings", "actions"].some(key => Object.hasOwn(value, key))) return true;
31
+ } catch {}
32
+ }
33
+ }
34
+ return starts.length > 0;
35
+ }
36
+
37
+ function parsed(answer) {
38
+ if (typeof answer !== "string" || answer.length > 64000) return null;
39
+ const text = answer.trim();
40
+ try { return JSON.parse(text); } catch {}
41
+ const fences = [...text.matchAll(/```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```/gi)];
42
+ if (fences.length !== 1 || (text.match(/```/g) ?? []).length !== 2) return null;
43
+ const fence = fences[0];
44
+ if (conflictingArtifact(text.slice(0, fence.index) + text.slice(fence.index + fence[0].length))) return null;
45
+ try { return JSON.parse(fence[1]); } catch { return null; }
46
+ }
47
+
48
+ function codeCases(kind) {
49
+ if (kind === "lower-bound") {
50
+ const arrays = [[], [0], [1, 1], [-4, -1, 0, 0, 7], [1, 2, 2, 3], [0, 10, 20, 30, 40]];
51
+ return arrays.flatMap(a => [-10, -4, -1, 0, 1, 2, 3, 7, 10, 40, 100].map(x => ({ args: [a, x], want: a.findIndex(v => v >= x) < 0 ? a.length : a.findIndex(v => v >= x) })));
52
+ }
53
+ if (kind === "dedup") return [
54
+ { args: [[]], want: 0 },
55
+ { args: [[{ id: "a", tokens: 2 }, { id: "a", tokens: 5 }, { id: "b", tokens: 3 }, { id: "a", tokens: 1 }]], want: 8 },
56
+ { args: [[null, [], {}, { id: "", tokens: 3 }, { id: 2, tokens: 5 }, { id: "x", tokens: -1 }, { id: "x", tokens: 1.5 }, { id: "x", tokens: "9" }, { id: "x", tokens: 9007199254740992 }]], want: 0 },
57
+ { args: [[{ id: "__proto__", tokens: 5 }, { id: "constructor", tokens: 7 }, { id: "__proto__", tokens: 3 }, { id: "zero", tokens: 0 }]], want: 12 },
58
+ ];
59
+ if (kind === "containment") {
60
+ const pairs = [
61
+ ["/work/pkg", "/work/pkg", true], ["/work/pkg/", "/work/pkg/a", true],
62
+ ["/work/pkg", "/work/pkg-other/a", false], ["/work/pkg", "/work/pkg/a/../../outside", false],
63
+ ["/work/pkg", "/work/pkg/a/../b", true], ["/work/pkg", "/work/pkg/a/../../pkg/b", true],
64
+ ["/work//pkg/.", "/work/pkg/b", true], ["/", "/a/../b", true],
65
+ ["/work/pkg", "work/pkg/a", false], ["work/pkg", "/work/pkg", false],
66
+ [null, "/work/pkg", false], ["/work/pkg", 2, false],
67
+ ];
68
+ return pairs.map(([root, target, want]) => ({ args: [root, target], want }));
69
+ }
70
+ return null;
71
+ }
72
+
73
+ const EXECUTOR = `
74
+ import fs from 'node:fs';
75
+ import vm from 'node:vm';
76
+ const {code,cases}=JSON.parse(fs.readFileSync(0,'utf8'));
77
+ const context=vm.createContext({}, {codeGeneration:{strings:false,wasm:false}});
78
+ const snapshot=value=>{
79
+ if(value===null||typeof value!=='object')return {value};
80
+ return {value,proto:Object.getPrototypeOf(value),fields:Reflect.ownKeys(value).map(key=>[key,Object.getOwnPropertyDescriptor(value,key),snapshot(Object.getOwnPropertyDescriptor(value,key).value)])};
81
+ };
82
+ const unchanged=s=>{
83
+ if(!s.fields)return true;
84
+ if(Object.getPrototypeOf(s.value)!==s.proto||Reflect.ownKeys(s.value).length!==s.fields.length)return false;
85
+ return s.fields.every(([key,before,child])=>{const after=Object.getOwnPropertyDescriptor(s.value,key);return after&&after.value===before.value&&after.writable===before.writable&&after.enumerable===before.enumerable&&after.configurable===before.configurable&&unchanged(child);});
86
+ };
87
+ let ok=false;
88
+ try {
89
+ const candidate=new vm.Script('('+code+'\\n)').runInContext(context,{timeout:100});
90
+ if(typeof candidate==='function'&&!Object.hasOwn(context,'candidate')){
91
+ Object.defineProperty(context,'candidate',{value:candidate,writable:false,configurable:false});
92
+ ok=cases.every(c=>{
93
+ const args=new vm.Script('('+JSON.stringify(c.args)+')').runInContext(context,{timeout:100});
94
+ const before=snapshot(args);
95
+ context.args=args;
96
+ const actual=new vm.Script('candidate(...args)').runInContext(context,{timeout:100});
97
+ return typeof actual===typeof c.want&&actual===c.want&&unchanged(before);
98
+ });
99
+ }
100
+ } catch {}
101
+ process.stdout.write(ok?'PASS':'FAIL');
102
+ `;
103
+
104
+ function codePass(code, cases) {
105
+ if (typeof code !== "string" || code.length > 16000 || !/^(?:async\s+)?(?:function\b|\(?[\w\s,]*\)?\s*=>)/.test(code.trim())) return false;
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "portulan-persona-grade-"));
107
+ try {
108
+ const result = spawnSync(process.execPath, ["--max-old-space-size=48", "--input-type=module", "-e", EXECUTOR], {
109
+ input: JSON.stringify({ code, cases }), cwd: dir, env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
110
+ encoding: "utf8", timeout: 2000, maxBuffer: 65536,
111
+ });
112
+ return !result.error && result.status === 0 && result.stdout === "PASS";
113
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
114
+ }
115
+
116
+ function reviewerPass(kind, value) {
117
+ if (!exactKeys(value, ["findings"]) || !Array.isArray(value.findings)) return false;
118
+ if (kind === "review-clean") return value.findings.length === 0;
119
+ const lines = kind === "review-verdict" ? [2] : [2, 5];
120
+ if (!sameSet(value.findings.map(f => f?.line), lines)) return false;
121
+ return value.findings.every(f => {
122
+ if (!exactKeys(f, ["line", "input"]) || !plain(f.input)) return false;
123
+ if (kind === "review-verdict") {
124
+ if (!exactKeys(f.input, ["status"])) return false;
125
+ const s = f.input.status;
126
+ return (s === 0 ? "green" : "red") !== (s === 0 ? "green" : s === 1 ? "red" : "could-not-run");
127
+ }
128
+ if (f.line === 5) return exactKeys(f.input, ["status"]) && (f.input.status !== 1) !== (f.input.status === 0);
129
+ if (!exactKeys(f.input, ["root", "target"])) return false;
130
+ const { root, target } = f.input;
131
+ if (typeof root !== "string" || typeof target !== "string" || !root.startsWith("/") || !target.startsWith("/") || path.posix.normalize(root) !== root || path.posix.normalize(target) !== target) return false;
132
+ const canonical = root.length > 1 ? root.replace(/\/+$/, "") : root;
133
+ const wanted = canonical === "/" || target === canonical || target.startsWith(canonical + "/");
134
+ return target.startsWith(root) !== wanted;
135
+ });
136
+ }
137
+
138
+ function librarianPass(kind, value) {
139
+ if (!exactKeys(value, ["draft", "actions"]) || value.draft !== true || !Array.isArray(value.actions)) return false;
140
+ const [action] = value.actions;
141
+ if (kind === "library-duplicate") return value.actions.length === 1 && exactKeys(action, ["kind", "ids", "obligations", "provenance", "retire_when"]) && action.kind === "merge" && sameSet(action.ids, ["a", "b"]) &&
142
+ sameSet(action.obligations, ["exit-0-is-green", "exit-2-is-unavailable"]) && sameSet(action.provenance, ["https://example.org/incidents/one", "https://example.org/incidents/two"]) && sameSet(action.retire_when, ["runner-replaced", "unavailable-is-tested"]);
143
+ if (kind === "library-conflict") return value.actions.length === 1 && exactKeys(action, ["kind", "ids", "decision"]) && action.kind === "escalate" && action.decision === "unresolved" && sameSet(action.ids, ["a", "b"]);
144
+ if (kind === "library-sealed") {
145
+ if (value.actions.length !== 2) return false;
146
+ const sealed = value.actions.find(a => a?.id === "sealed"), open = value.actions.find(a => a?.id === "public");
147
+ return exactKeys(sealed, ["kind", "id", "owner"]) && sealed.kind === "request_revalidation" && sealed.owner === "Example Team" &&
148
+ exactKeys(open, ["kind", "id", "evidence"]) && open.kind === "propose_retirement" && open.evidence === "https://example.org/evidence/parser-removal";
149
+ }
150
+ return false;
151
+ }
152
+
153
+ export function grade(task, answer) {
154
+ const value = parsed(answer);
155
+ const cases = codeCases(task.grader);
156
+ const ok = cases ? codePass(value?.code, cases) : task.role === "reviewer" ? reviewerPass(task.grader, value) : librarianPass(task.grader, value);
157
+ return { ok, check: task.grader };
158
+ }
159
+
160
+ export function price(requests, prices = CORPUS.prices) {
161
+ if (!Array.isArray(requests) || !requests.length) throw Error("usage needs at least one completed request");
162
+ for (const request of requests) for (const key of CLASSES) if (!Number.isSafeInteger(request[key]) || request[key] < 0) throw Error(`invalid usage ${key}`);
163
+ for (const key of [...CLASSES, "coldFirstInput"]) if (!(Number.isFinite(prices[key]) && prices[key] > 0)) throw Error(`invalid price ${key}`);
164
+ const cost = request => CLASSES.reduce((sum, key) => sum + request[key] * prices[key], 0);
165
+ const first = requests[0];
166
+ const observed = requests.reduce((sum, request) => sum + cost(request), 0);
167
+ const original = inputClasses.reduce((sum, key) => sum + first[key] * prices[key], 0);
168
+ const normalized = observed - original + inputClasses.reduce((sum, key) => sum + first[key], 0) * prices.coldFirstInput;
169
+ if (![observed, normalized].every(Number.isFinite)) throw Error("cost index overflows");
170
+ return { requests: requests.length, observed, normalized };
171
+ }