@sleepy_panda_srl/portulan 0.2.0 → 0.4.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 (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  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 +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  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 +230 -40
  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 +1 -1
  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/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,53 @@
1
+ # Init's spend offer
2
+
3
+ `init` offers `spend.multipliers` and `spend.horizon.requests` independently. Each is
4
+ declared only after accepting its interview answer or providing `--multipliers
5
+ '<JSON>'` / `--horizon <positive integer>`. The answers file takes the same keys as
6
+ strings; flags win. A declined offer leaves the key absent. A pointer has no spend
7
+ policy and refuses these options. Either declaration requires spec 2.12.
8
+
9
+ For Claude Code, the helper reads model IDs, timestamps, repository paths and usage
10
+ presence from local assistant records. It selects the latest main-context record
11
+ for this repository, ignoring synthetic records, sidechains, separate subagents
12
+ and foreign paths. Conflicting latest models, missing models or unreadable records
13
+ give a general offer with a reason. Malformed lines are counted and disclosed.
14
+ Conversation content is not inspected. A configured model does not replace a
15
+ recorded one. The last recorded model need not be the next session's model.
16
+
17
+ The offer tries these sources in order:
18
+
19
+ 1. A matching `modelPricing.overrides` row in the system managed settings file:
20
+ `/Library/Application Support/ClaudeCode/managed-settings.json` on macOS,
21
+ `/etc/claude-code/managed-settings.json` on Linux, or
22
+ `C:\Program Files\ClaudeCode\managed-settings.json` on Windows. Exact model IDs
23
+ precede supported canonical IDs with an eight-digit snapshot suffix removed.
24
+ All four rates must be finite numbers in 0–10000. `input` and `cacheRead` must
25
+ also be positive, the read/input ratio at most one, and write/input at least
26
+ one, for `spend` to represent them. One `cacheWrite` rate supplies both
27
+ lifetimes; a global multiplier cancels in ratios. Unusable rows fall through
28
+ with a reason. User/project/local `modelPricing` settings are ignored, as the
29
+ host ignores them. See the [host contract](https://code.claude.com/docs/en/settings-reference#modelpricing).
30
+ 2. The table embedded in `spend-offer.mjs`, checked **2026-11-09** against the
31
+ [vendor's price page](https://platform.claude.com/docs/en/about-claude/pricing).
32
+ The explicit Claude API model IDs use cache reads at 0.025 for Fable/Mythos
33
+ 5.1/5.2, 0.05 for Opus/Sonnet 5.5, and 0.1 for the other listed IDs; writes are
34
+ 1.25 at five minutes and 2 at one hour. This is a dated offer, updated with the
35
+ product, without a runtime fetch. Unknown IDs and provider-specific aliases
36
+ are not guessed. Only known canonical IDs and their eight-digit snapshots
37
+ normalize; custom gateway overrides require an exact match.
38
+ 3. General figures: read 0.1, writes 1.25/2. The horizon offered is 20 requests
39
+ still to go, which the task or workspace should replace with its own estimate.
40
+
41
+ The file is **not an effective-settings resolver**. A nonempty JSON drop-in set
42
+ at `managed-settings.d` makes the file source unavailable rather than pretending
43
+ to merge it. Remote managed settings, MDM/plist/registry, policy helpers and
44
+ embedding-host sources are not inspected; they can supersede the file. The offer
45
+ names that condition. Native Windows discovery is not demonstrated here. See
46
+ [managed source precedence](https://code.claude.com/docs/en/managed-settings).
47
+ Other hosts and unsupported models get the general offer. A declaration is the
48
+ workspace's value, not a verified billing rate.
49
+
50
+ `spend-offer.test.mjs` uses injected local transcript and managed-file paths. All
51
+ verification is offline; no recipe reads the operator's usage records or managed
52
+ pricing. Accepted drafts are validated by `doctor`, and `compile` carries their
53
+ figures onto advisory commands.
@@ -0,0 +1,93 @@
1
+ // Local evidence for init's optional spend declaration.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { GENERAL_READ, WRITE_BY_LIFETIME, HORIZON, hostPaths, transcriptFiles, readSpend } from "./ledger.mjs";
5
+ import { isInside } from "./inside.mjs";
6
+
7
+ export const PRICE_DATE = "2026-11-09";
8
+ export const PRICE_SOURCE = "https://platform.claude.com/docs/en/about-claude/pricing";
9
+ export const PRICE_READS = {
10
+ "claude-fable-5-2": 0.025, "claude-mythos-5-2": 0.025,
11
+ "claude-fable-5-1": 0.025, "claude-mythos-5-1": 0.025,
12
+ "claude-opus-5-5": 0.05, "claude-sonnet-5-5": 0.05,
13
+ "claude-haiku-5-5": 0.1, "claude-fable-5": 0.1, "claude-mythos-5": 0.1,
14
+ "claude-opus-5": 0.1, "claude-sonnet-5": 0.1,
15
+ "claude-opus-4-8": 0.1, "claude-opus-4-7": 0.1, "claude-opus-4-6": 0.1,
16
+ "claude-opus-4-5": 0.1, "claude-opus-4-1": 0.1, "claude-opus-4": 0.1,
17
+ "claude-sonnet-4-6": 0.1, "claude-sonnet-4-5": 0.1, "claude-sonnet-4": 0.1,
18
+ "claude-haiku-4-5": 0.1,
19
+ };
20
+
21
+ export function recordedModel(target, projects) {
22
+ let latest = -Infinity;
23
+ const models = new Set();
24
+ let malformed = 0;
25
+ try {
26
+ for (const { file, agent } of transcriptFiles(projects, [target])) {
27
+ if (agent !== null) continue;
28
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
29
+ if (!line.trim()) continue;
30
+ let r;
31
+ try { r = JSON.parse(line); } catch { malformed++; continue; }
32
+ if (r?.type !== "assistant" || r.isSidechain || r.agentId || !r.message?.usage || typeof r.message.usage !== "object" || Array.isArray(r.message.usage) || r.message.model === "<synthetic>" || typeof r.timestamp !== "string" || typeof r.cwd !== "string" || !path.isAbsolute(r.cwd) || !isInside(target, r.cwd)) continue;
33
+ const at = Date.parse(r.timestamp);
34
+ if (!Number.isFinite(at) || at < latest) continue;
35
+ if (at > latest) { latest = at; models.clear(); }
36
+ models.add(typeof r.message.model === "string" && r.message.model ? r.message.model : null);
37
+ }
38
+ }
39
+ } catch (error) {
40
+ return { model: null, reason: `recorded model could not be read (${error.code ?? "unreadable"})` };
41
+ }
42
+ const model = models.size === 1 ? [...models][0] : null;
43
+ const reason = model === null ? (models.size > 1 ? "conflicting latest recorded models" : "no recorded model") : `last recorded model ${JSON.stringify(model)}`;
44
+ return { model, reason: reason + (malformed ? `; ignored ${malformed} malformed record(s)` : "") };
45
+ }
46
+
47
+ export function managedPricingFile(platform = process.platform) {
48
+ if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
49
+ if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
50
+ return "/etc/claude-code/managed-settings.json";
51
+ }
52
+
53
+ function hostMultipliers(model, canonical, file) {
54
+ if (file === null) return { reason: "file-based managed pricing not supplied" };
55
+ try {
56
+ const dropins = path.join(path.dirname(file), "managed-settings.d");
57
+ if (fs.existsSync(dropins) && fs.readdirSync(dropins).some(name => name.endsWith(".json"))) return { reason: "managed-settings.d may supersede file pricing; drop-ins are not resolved" };
58
+ if (!fs.statSync(file).isFile()) return { reason: "managed pricing is not a regular file" };
59
+ const pricing = JSON.parse(fs.readFileSync(file, "utf8"))?.modelPricing;
60
+ const overrides = pricing?.overrides;
61
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return { reason: "no file-based modelPricing override" };
62
+ const row = Object.hasOwn(overrides, model) ? overrides[model] : canonical !== null && Object.hasOwn(overrides, canonical) ? overrides[canonical] : null;
63
+ if (row === null) return { reason: "no matching file-based modelPricing override" };
64
+ if (!row || ["input", "output", "cacheRead", "cacheWrite"].some(key => typeof row[key] !== "number" || !Number.isFinite(row[key]) || row[key] < 0 || row[key] > 10000)) return { reason: "invalid file-based modelPricing override" };
65
+ try {
66
+ const multipliers = { read: row.cacheRead / row.input, write: { "5m": row.cacheWrite / row.input, "1h": row.cacheWrite / row.input } };
67
+ readSpend({ multipliers }, "host offer");
68
+ return { multipliers };
69
+ } catch { return { reason: "file-based modelPricing override is unrepresentable by spend" }; }
70
+ } catch (error) {
71
+ return { reason: error.code === "ENOENT" ? "no file-based managed pricing" : `file-based managed pricing unreadable (${error.code ?? "invalid JSON"})` };
72
+ }
73
+ }
74
+
75
+ export function spendOffer(target, options = {}) {
76
+ const paths = hostPaths(options.env, options.home);
77
+ const recorded = options.projects !== undefined ? recordedModel(target, options.projects) : paths.why ? { model: null, reason: "host record path is not absolute" } : recordedModel(target, paths.projects);
78
+ const { model } = recorded;
79
+ const normalized = model?.replace(/-\d{8}$/, "");
80
+ const canonical = normalized && Object.hasOwn(PRICE_READS, normalized) ? normalized : null;
81
+ const host = model === null ? { reason: "host pricing needs a recorded model" } : hostMultipliers(model, canonical, options.managedFile === undefined ? managedPricingFile(options.platform) : options.managedFile);
82
+ const source = host.multipliers ? "file-based managed modelPricing (conditional on this file being active)" : canonical !== null ? `Claude Code table ${PRICE_DATE}, ${PRICE_SOURCE}` : "general multipliers";
83
+ const multipliers = host.multipliers ?? { read: canonical === null ? GENERAL_READ : PRICE_READS[canonical], write: { ...WRITE_BY_LIFETIME } };
84
+ return { multipliers, horizon: HORIZON, source, reason: `${recorded.reason}; ${host.reason ?? "one cacheWrite rate supplies both lifetimes; a global multiplier cancels"}` };
85
+ }
86
+
87
+ export function spendOfferLines(offer) {
88
+ return [
89
+ `spend.multipliers offer ${JSON.stringify(offer.multipliers)} — ${offer.source}; ${offer.reason}`,
90
+ `spend.horizon offer {"requests":${offer.horizon}} — requests still to go, not requests already used; choose a task's expected remainder`,
91
+ "Only accepted answers or explicit --multipliers '<JSON>' / --horizon <requests> declare these at spec 2.12. Remote, MDM/registry, helper and embedding-host pricing are not inspected; other providers and unsupported model IDs use general figures. Confirm against your rates and next model.",
92
+ ];
93
+ }
@@ -0,0 +1,114 @@
1
+ // Bounded streaming failure excerpts for the Stop recipe relay.
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export const STOP_DIAGNOSTIC_BYTES = 4096;
5
+ export const STOP_RECIPE_BYTES = 6144;
6
+ const LINE_BYTES = 1024;
7
+ const SUCCESS = /^(?:PASS(?:\b|[-_])|GREEN\b|ok\b|[✔✓▶﹣]|TAP version \d+\b|\d+\.\.\d+(?:\s|$))|^#\s*Subtest:|^(?:#|ℹ)\s*(?:(?:pass|tests|suites|duration_ms|skipped|todo)\b|(?:fail|cancelled)\s+0\b)/i;
8
+ const FAILURE = /^(?:#\s*)?(?:FAIL(?:ED|URE|URES)?(?:\b|[-_])|RED\b|ERROR\b|ERR_[A-Z_]+\b|(?:Assertion|Type|Syntax|Range|Reference|Eval)?Error\b|not ok\b|[✗✖×])|^(?:#|ℹ)\s*(?:fail|cancelled)\s+[1-9]\d*\b|(?:^|\s)(?:error:|command not found\b|permission denied\b|No such file or directory\b)|^(?:verify|tests):.*(?:failed|failing|could not|cannot|refused|missing)/i;
9
+
10
+ export function clipDiagnostic(value, limit) {
11
+ const text = String(value);
12
+ if (Buffer.byteLength(text) <= limit) return text;
13
+ const notice = " [clipped]";
14
+ const decoder = new StringDecoder("utf8");
15
+ return decoder.write(Buffer.from(text).subarray(0, limit - Buffer.byteLength(notice))) + notice;
16
+ }
17
+
18
+ function lines() {
19
+ const kept = [];
20
+ let bytes = 0, omitted = 0;
21
+ return {
22
+ add(line) {
23
+ const size = Buffer.byteLength(line) + 1;
24
+ if (bytes + size <= 2800) { kept.push(line); bytes += size; }
25
+ else omitted++;
26
+ },
27
+ get present() { return kept.length > 0; },
28
+ text: () => kept.join("\n") + (omitted ? `\n[${omitted} diagnostic lines omitted]` : ""),
29
+ };
30
+ }
31
+
32
+ export function stopDiagnostics() {
33
+ const failures = lines(), fallback = lines();
34
+ const streams = new Map();
35
+ let observed = false;
36
+ function flush(state) {
37
+ const decoder = new StringDecoder("utf8");
38
+ const text = decoder.write(Buffer.concat(state.chunks)) + (state.clipped ? " [line clipped]" : decoder.end());
39
+ const line = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
40
+ const trimmed = line.trim();
41
+ state.chunks = []; state.bytes = 0; state.clipped = false;
42
+ if (!trimmed) return;
43
+ const indent = line.length - line.trimStart().length;
44
+ if (state.todoIndent !== null) {
45
+ if (indent === state.todoIndent + 2 && trimmed === state.todoEnd) { state.todoIndent = null; return; }
46
+ if (indent > state.todoIndent) return;
47
+ state.todoIndent = null;
48
+ }
49
+ const tapExpected = /^(?:ok|not ok)\s+\d+\b.*(?<!\\)#\s*(?:TODO|SKIP)\b/i.test(trimmed);
50
+ const specAnnotated = /^[⚠✖✗].*\(\d+(?:\.\d+)?ms\)\s+#\s*\S/.test(trimmed);
51
+ const specExpected = /^⚠.*#\s*TODO\b/i.test(trimmed)
52
+ || (specAnnotated && (trimmed.startsWith("⚠") || !/\(\d+(?:\.\d+)?ms\)$/.test(trimmed)));
53
+ if (tapExpected || specExpected) {
54
+ state.todoIndent = indent; state.todoEnd = tapExpected ? "..." : "}"; state.context = 0; state.location = null;
55
+ return;
56
+ }
57
+ if (/^(?:---|\.\.\.)$|^duration_ms:\s*\d+(?:\.\d+)?$|^type:\s*['"](?:test|suite)['"]$/.test(trimmed)) return;
58
+ if (trimmed === "✖ failing tests:") { state.context = 0; state.location = null; return; }
59
+ if (/^test at .+:\d+(?::\d+)?$/.test(trimmed)) { state.location = line; return; }
60
+ if (SUCCESS.test(trimmed)) { state.context = 0; state.location = null; return; }
61
+ if (FAILURE.test(trimmed)) {
62
+ if (state.location) failures.add(state.location);
63
+ state.location = null;
64
+ failures.add(line); state.context = 8;
65
+ }
66
+ else if (state.context && /^\s|^#\s/.test(line)) { failures.add(line); state.context--; }
67
+ else {
68
+ if (state.location) fallback.add(state.location);
69
+ state.location = null;
70
+ fallback.add(line); state.context = 0;
71
+ }
72
+ }
73
+ function add(name, chunk) {
74
+ observed = true;
75
+ name = name === "stderr" ? "stderr" : "stdout";
76
+ if (!streams.has(name)) streams.set(name, { chunks: [], bytes: 0, clipped: false, context: 0, todoIndent: null, location: null });
77
+ const state = streams.get(name);
78
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
79
+ let start = 0;
80
+ while (start < buffer.length) {
81
+ const newline = buffer.indexOf(10, start);
82
+ const end = newline < 0 ? buffer.length : newline;
83
+ const size = Math.min(LINE_BYTES - state.bytes, end - start);
84
+ if (size) { state.chunks.push(Buffer.from(buffer.subarray(start, start + size))); state.bytes += size; }
85
+ if (end - start > size) state.clipped = true;
86
+ if (newline < 0) break;
87
+ flush(state);
88
+ start = newline + 1;
89
+ }
90
+ }
91
+ return {
92
+ add,
93
+ finish(result) {
94
+ const raw = Array.isArray(result.diagnostics) ? result.diagnostics : [];
95
+ if (!observed && result.output) {
96
+ const suffix = raw.length ? `\n${raw.join("\n")}` : "";
97
+ const output = suffix && result.output.endsWith(suffix) ? result.output.slice(0, -suffix.length) : result.output;
98
+ if (output) add("stdout", output);
99
+ }
100
+ for (const state of streams.values()) if (state.bytes || state.clipped) flush(state);
101
+ for (const state of streams.values()) if (state.location) fallback.add(state.location);
102
+ const structured = [
103
+ ...(result.interrupted ? [`interrupted by ${clipDiagnostic(result.interrupted, 64)}`] : []),
104
+ ...(result.timedOut ? ["recipe time limit reached"] : []),
105
+ ];
106
+ const reasons = [...structured, ...raw.filter(reason => !structured.includes(reason))];
107
+ const runner = reasons.length
108
+ ? `executor: ${reasons.slice(0, 3).map(reason => clipDiagnostic(reason, 192)).join("; ")}${reasons.length > 3 ? ` [${reasons.length - 3} executor reasons omitted]` : ""}\n` : "";
109
+ const body = failures.present ? failures.text() : fallback.present
110
+ ? `unrecognized output (no failure marker identified):\n${fallback.text()}` : "no failure diagnostic identified";
111
+ return clipDiagnostic(`${runner}${body}\nRun verification explicitly for complete output.`, STOP_DIAGNOSTIC_BYTES);
112
+ },
113
+ };
114
+ }
package/cli/stop-gate.mjs CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The Stop-gate runner — "done" is not a thing an agent may simply declare.
3
- //
4
- // A crashed hook fails open (Claude Code 2.1.220), so every failure path here returns a verdict instead of throwing.
2
+ // Stop-gate: bounded recipe and work checks. Hook failures need a verdict, since a crashed hook fails open.
5
3
 
6
4
  import fs from "node:fs";
7
5
  import os from "node:os";
@@ -11,6 +9,9 @@ import { fileURLToPath, pathToFileURL } from "node:url";
11
9
 
12
10
  import { isInside } from "./inside.mjs";
13
11
  import { recipeSet } from "./recipe-set.mjs";
12
+ import { runRecipe } from "./recipe-runner.mjs";
13
+ import { readStopPolicy } from "./stop-policy.mjs";
14
+ import { stopDiagnostics, clipDiagnostic, STOP_RECIPE_BYTES } from "./stop-diagnostics.mjs";
14
15
 
15
16
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
16
17
  const WORKSPACE = path.resolve(PROJECT, process.env.PORTULAN_WORKSPACE || ".portulan");
@@ -87,9 +88,6 @@ export const MAX_TOTAL_BLOCKS = 9;
87
88
  // restarting both caps. Set above MAX_TOTAL_BLOCKS, so a session whose id holds meets the other bounds first.
88
89
  export const MAX_CHAIN_BLOCKS = 12;
89
90
 
90
- // Well inside the host's 600 s hook timeout, so a hung recipe meets a verdict here first.
91
- const RECIPE_TIMEOUT_MS = 90_000;
92
-
93
91
  function counterFile(sessionId, dir, root = REPO) {
94
92
  const digest = (v) => {
95
93
  let h = 0;
@@ -189,11 +187,6 @@ function allow() {
189
187
  process.exit(0);
190
188
  }
191
189
 
192
- function block(reason) {
193
- process.stdout.write(`${JSON.stringify({ decision: "block", reason })}\n`);
194
- process.exit(0);
195
- }
196
-
197
190
  function defaultRecipe() {
198
191
  try {
199
192
  const manifest = JSON.parse(fs.readFileSync(path.join(WORKSPACE, "workspace.json"), "utf8"));
@@ -201,9 +194,28 @@ function defaultRecipe() {
201
194
  const set = recipeSet(manifest, { packs: [] });
202
195
  if (!set.ok) return null;
203
196
  const recipe = set.recipes.find((r) => r.id === set.default);
204
- return recipe ? { id: recipe.id, run: recipe.run } : null;
205
- } catch {
206
- return null;
197
+ return recipe ? { id: recipe.id, run: recipe.run, timeout: readStopPolicy(manifest).seconds } : null;
198
+ } catch (error) {
199
+ return { error: error.message };
200
+ }
201
+ }
202
+
203
+ function handoffPolicy(tree) {
204
+ const file = path.join(tree.workspace, "workspace.json");
205
+ try {
206
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
207
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("the manifest must be an object");
208
+ const slots = manifest.slots;
209
+ if (slots === undefined) return { directory: null };
210
+ if (!slots || typeof slots !== "object" || Array.isArray(slots)) throw new Error("slots must be an object");
211
+ if (!Object.hasOwn(slots, "handoffs")) return { directory: null };
212
+ if (typeof slots.handoffs !== "string" || !/^[^#?:/][^#?:]*\/$/.test(slots.handoffs)) {
213
+ throw new Error("slots.handoffs must be a directory path ending in /, without a fragment, query or colon");
214
+ }
215
+ return { directory: path.resolve(tree.workspace, slots.handoffs) };
216
+ } catch (error) {
217
+ const why = error instanceof SyntaxError ? "the manifest is not valid JSON" : error.code ?? error.message;
218
+ return { directory: null, error: `could not read the record policy from ${file}: ${why}. Fix the manifest before ending this work.` };
207
219
  }
208
220
  }
209
221
 
@@ -213,8 +225,8 @@ function didWork(root = REPO) {
213
225
  try {
214
226
  if (git(["status", "--porcelain"]).trim() !== "") return true;
215
227
  } catch {
216
- process.stderr.write("portulan stop-gate: cannot read git status — the handoff check is not running.\n");
217
- return false;
228
+ process.stderr.write("portulan stop-gate: cannot read git status — the work check is not running.\n");
229
+ return null;
218
230
  }
219
231
 
220
232
  try {
@@ -249,7 +261,7 @@ function didWork(root = REPO) {
249
261
  const degraded = (why, check) => {
250
262
  process.stderr.write(
251
263
  `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
252
- "being read as work. If this branch was rebase-merged the handoff demand may be spurious — " +
264
+ "being read as work. If this branch was rebase-merged the work demand may be spurious — " +
253
265
  `${check} and say so rather than working around the gate.\n`,
254
266
  );
255
267
  return true;
@@ -272,25 +284,27 @@ function didWork(root = REPO) {
272
284
  }
273
285
  return cherry.split("\n").some((line) => line.startsWith("+"));
274
286
  } catch {
275
- process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the handoff check is not running.\n");
276
- return false;
287
+ process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the work check is not running.\n");
288
+ return null;
277
289
  }
278
290
  }
279
291
 
280
- /** Today's handoffs as `{ own, recorded }`, `recorded` being clean and held by a remote at that path. */
281
- function handoffToday(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
292
+ /** Today's handoffs as `{ own, recorded }`, with remote content checked since two UTC days before their date. */
293
+ function handoffToday(stamp, tree, handoffsDir) {
282
294
  let dated;
283
295
  try {
284
- dated = fs.readdirSync(path.join(tree.workspace, "handoffs")).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
285
- } catch {
296
+ dated = fs.readdirSync(handoffsDir).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
297
+ } catch (error) {
298
+ if (error.code !== "ENOENT") process.stderr.write(`portulan stop-gate: could not read the declared handoff series (${error.code ?? "read failed"}); no handoff was found.\n`);
286
299
  return { own: [], recorded: [] };
287
300
  }
288
- const handoffsDir = path.join(tree.workspace, "handoffs");
289
301
  const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
290
302
  if (dir === "" || !isInside(tree.root, handoffsDir)) return { own: dated, recorded: [] };
291
303
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
292
304
  const own = [];
293
305
  const recorded = [];
306
+ const cutoff = new Date(`${stamp}T00:00:00Z`);
307
+ cutoff.setUTCDate(cutoff.getUTCDate() - 2);
294
308
  for (const name of dated) {
295
309
  const file = `:(literal)${dir}/${name}`;
296
310
  let unrecorded;
@@ -300,7 +314,7 @@ function handoffToday(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
300
314
  // By content, not commit: a rebase or squash merge pushes a handoff in a commit of its own.
301
315
  // `--root`, or `log.showRoot=false` skips a root commit.
302
316
  const blob = git(["rev-parse", `HEAD:./${dir}/${name}`]).trim();
303
- unrecorded = git(["log", "-1", "--root", "--format=%h", "--remotes", `--find-object=${blob}`, "--", file]).trim() === "";
317
+ unrecorded = git(["log", "-1", "--root", "--format=%h", "--remotes", `--since=${cutoff.toISOString()}`, `--find-object=${blob}`, "--", file]).trim() === "";
304
318
  }
305
319
  } catch {
306
320
  process.stderr.write(`portulan stop-gate: could not tell whether ${name} is committed and pushed, so it counts as this tree's own.\n`);
@@ -325,14 +339,14 @@ function treeIdentity(root = REPO) {
325
339
  }
326
340
 
327
341
  // Refs on disk only: a fetch would put a round trip, or an offline host's timeout, inside every Stop.
328
- function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }, held = []) {
342
+ function handoffInHistory(stamp, tree, handoffsDir, held = []) {
329
343
  try {
330
- const handoffsDir = path.join(tree.workspace, "handoffs");
331
344
  const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
332
345
  if (dir === "" || !isInside(tree.root, handoffsDir)) return null;
346
+ const prefix = dir.replace(/[\\*?[\]]/g, "\\$&");
333
347
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
334
348
  // `:(glob)`, since git reads a bare `*` literally under `GIT_NOGLOB_PATHSPECS=1` (git 2.50.1).
335
- const commit = git(["log", "-1", "--format=%H", "--all", "--not", "HEAD", "--", `:(glob)${dir}/${stamp}*`,
349
+ const commit = git(["log", "-1", "--format=%H", "--all", "--not", "HEAD", "--", `:(glob)${prefix}/${stamp}*`,
336
350
  ...held.map((name) => `:(exclude,literal)${dir}/${name}`)]).trim();
337
351
  if (!commit) return null;
338
352
  const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
@@ -343,47 +357,45 @@ function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }, he
343
357
  }
344
358
  }
345
359
 
346
- function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
360
+ export function workNotice({ stamp, directory, identity, answeredElsewhere = "", recorded = [], found = "" }) {
361
+ if (directory === null) return `${identity} holds work that is not committed and pushed. Commit and push it, its why in the commit message.`;
362
+ const notCounted = recorded.length
363
+ ? `${recorded.map((f) => `\`${f}\``).join(", ")} ${recorded.length === 1 ? "is" : "are"} committed and pushed already, ` +
364
+ `so ${recorded.length === 1 ? "it records" : "they record"} work already recorded. `
365
+ : "";
366
+ return `no handoff dated ${stamp}${recorded.length ? " of this tree's own" : ""} in ${directory}, read from ` +
367
+ `${identity}${answeredElsewhere}. ${notCounted}` +
368
+ "This tree holds work that is not committed and pushed. Commit and push it, its why in the commit " +
369
+ "message, or end with a dated handoff naming what is open: where things stand, the open questions " +
370
+ `and the next action. Five lines is enough; absent is not.${found}`;
371
+ }
372
+
373
+ function workProblems(tree) {
347
374
  const problems = [];
348
- let recipeGreen = false;
349
375
 
350
- // Only the told workspace's recipe, run in the told root: one read from a tree the payload names would run what stdin chose.
351
- const recipe = defaultRecipe();
352
- if (!recipe) {
353
- problems.push({
354
- reason: "recipe",
355
- text:
356
- "could not read the workspace's default verify recipe from .portulan/workspace.json, so nothing " +
357
- "verified this work. That is 'could not run', which blocks exactly as red does.",
358
- });
359
- } else {
360
- try {
361
- execFileSync("bash", ["-c", recipe.run], {
362
- cwd: REPO,
363
- encoding: "utf8",
364
- timeout: RECIPE_TIMEOUT_MS,
365
- stdio: ["ignore", "pipe", "pipe"],
366
- });
367
- recipeGreen = true;
368
- } catch (error) {
369
- const code = error.status;
370
- const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
371
- const CANNOT_RUN = new Set([2, 126, 127]);
372
- const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
373
- ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
374
- : `RED (exit ${code})`;
376
+ const policy = handoffPolicy(tree);
377
+ const handoffEnabled = policy.directory !== null;
378
+ if (policy.error) {
379
+ problems.push({ reason: "handoff", text: policy.error });
380
+ return { problems, handoffEnabled, handoffCleared: false };
381
+ }
382
+ if (!handoffEnabled) {
383
+ const work = didWork(tree.root);
384
+ if (work === true) {
375
385
  problems.push({
376
- reason: "recipe",
377
- text: `verify recipe \`${recipe.id}\` in ${REPO} — ${outcome}\n${output}`,
386
+ reason: "handoff",
387
+ text: workNotice({ identity: treeIdentity(tree.root), directory: null }),
378
388
  });
379
389
  }
390
+ return { problems, handoffEnabled, handoffCleared: work === false };
380
391
  }
381
392
 
382
393
  const stamp = today();
383
- const handoffs = handoffToday(stamp, tree);
394
+ const handoffs = handoffToday(stamp, tree, policy.directory);
384
395
  const handoffPresent = handoffs.own.length > 0;
385
396
  // `handoffPresent` first: `didWork()` runs git several times and may warn about a handoff that is not owed.
386
- if (!handoffPresent && didWork(tree.root)) {
397
+ const work = handoffPresent ? null : didWork(tree.root);
398
+ if (work === true) {
387
399
  const real = (dir) => {
388
400
  try {
389
401
  return fs.realpathSync(dir);
@@ -395,39 +407,63 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
395
407
  ? ` — the tree this session worked in, not the one this hook was told (${REPO})`
396
408
  : "";
397
409
  const { recorded } = handoffs;
398
- const elsewhere = handoffInHistory(stamp, tree, recorded);
410
+ const elsewhere = handoffInHistory(stamp, tree, policy.directory, recorded);
399
411
  const found = elsewhere
400
412
  ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
401
413
  `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
402
414
  "the tree that did the work. Check before writing a second one."
403
415
  : "";
404
- const notCounted = recorded.length
405
- ? `${recorded.map((f) => `\`${f}\``).join(", ")} ${recorded.length === 1 ? "is" : "are"} committed and pushed already, ` +
406
- `so ${recorded.length === 1 ? "it records" : "they record"} work already recorded. `
407
- : "";
408
416
  problems.push({
409
417
  reason: "handoff",
410
- text:
411
- `no handoff dated ${stamp}${recorded.length ? " of this tree's own" : ""} in ${path.join(tree.workspace, "handoffs")}, read from ` +
412
- `${treeIdentity(tree.root)}${answeredElsewhere}. ${notCounted}` +
413
- "This tree holds work that is not committed and pushed. Commit and push it, its why in the commit " +
414
- "message, or end with a dated handoff naming what is open: where things stand, the open questions " +
415
- `and the next action. Five lines is enough; absent is not.${found}`,
418
+ text: workNotice({ stamp, directory: policy.directory, identity: treeIdentity(tree.root), answeredElsewhere, recorded, found }),
416
419
  });
417
420
  }
418
421
 
419
- return { problems, recipeGreen };
422
+ return { problems, handoffEnabled, handoffCleared: handoffPresent || work === false };
423
+ }
424
+
425
+ export async function runStopRecipe(recipe, execute = runRecipe) {
426
+ const problem = text => ({ reason: "recipe", text: clipDiagnostic(text, STOP_RECIPE_BYTES) });
427
+ if (!recipe || recipe.error) return {
428
+ reason: "recipe",
429
+ text: clipDiagnostic(`could not read the workspace's default verify recipe from ${clipDiagnostic(path.join(WORKSPACE, "workspace.json"), 512)}: ${clipDiagnostic(recipe?.error ?? "no runnable default", 4096)}. Nothing verified this work.`, STOP_RECIPE_BYTES),
430
+ };
431
+ const label = clipDiagnostic(`verify recipe \`${clipDiagnostic(recipe.id, 512)}\` in ${clipDiagnostic(REPO, 512)}`, 1100);
432
+ try {
433
+ const diagnostics = stopDiagnostics();
434
+ const result = await execute(recipe, { root: REPO, env: process.env, timeout: recipe.timeout * 1000, mergeOutput: false, onOutput: diagnostics.add });
435
+ if (result.outcome === "green") return null;
436
+ const outcome = result.timedOut
437
+ ? `could not run: timed out after ${clipDiagnostic(recipe.timeout, 64)} s — the gate could not judge`
438
+ : result.outcome === "red" ? `RED (exit ${clipDiagnostic(result.code, 64)})` : `could not run (exit ${clipDiagnostic(result.code ?? "no status", 64)}) — the gate could not judge`;
439
+ return problem(`${label} — ${outcome}\n${diagnostics.finish(result)}`);
440
+ } catch (error) {
441
+ return problem(`${label} could not run — ${clipDiagnostic(error?.message ?? error, 4096)}; the gate could not judge. Run verification explicitly.`);
442
+ }
443
+ }
444
+
445
+ async function collectProblems(tree, state, chain) {
446
+ const work = workProblems(tree);
447
+ const recipeCapped = (state.counts.recipe ?? 0) >= MAX_BLOCKS;
448
+ const handoffCapped = work.problems.some(p => p.reason === "handoff") && (state.counts.handoff ?? 0) >= MAX_BLOCKS;
449
+ const skip = recipeCapped || handoffCapped || state.total >= MAX_TOTAL_BLOCKS || chain > MAX_CHAIN_BLOCKS;
450
+ const recipeProblem = skip ? {
451
+ reason: "recipe", charge: false, capped: recipeCapped,
452
+ text: "verify recipe not rerun: this Stop is released at a refusal bound. No result was measured on this Stop; existing obligations remain unresolved. Run verification explicitly before claiming done.",
453
+ } : await runStopRecipe(defaultRecipe());
454
+ return { ...work, problems: [...(recipeProblem ? [recipeProblem] : []), ...work.problems], recipeGreen: !skip && recipeProblem === null };
420
455
  }
421
456
 
422
- export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
457
+ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS, handoffEnabled = true }) {
423
458
  if (problems.length === 0) return { action: "allow" };
424
459
 
425
460
  const text = problems.map((p) => p.text).join("\n\n");
426
- const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
461
+ const spent = problems.filter((p) => p.capped || (counts[p.reason] ?? 0) > max).map((p) => p.reason);
462
+ const label = (reason) => !handoffEnabled && reason === "handoff" ? "work" : reason;
427
463
 
428
464
  if (spent.length || total > maxTotal || chain > maxChain) {
429
465
  const bound = spent.length
430
- ? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
466
+ ? `the cap of ${max} consecutive refusals for \`${spent.map(label).join("`, `")}\` was reached`
431
467
  : total > maxTotal
432
468
  ? `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`
433
469
  : `${chain} consecutive hook-provoked stops were seen in this tree, past the chain bound of ${maxChain} — ` +
@@ -437,13 +473,13 @@ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX
437
473
  action: "release",
438
474
  message:
439
475
  `PORTULAN STOP-GATE — ${bound}. This session is ending **RED**, not done.\n` +
440
- `Nothing below was fixed, and the session ending does not fix it. Say so in the handoff and in any\n` +
476
+ `Nothing below was fixed, and the session ending does not fix it. Say so in ${handoffEnabled ? "the handoff and in any" : "any"}\n` +
441
477
  `report of this work; a task that ends at the cap is an unfinished task with a stop attached.\n\n` +
442
478
  `${text}\n`,
443
479
  };
444
480
  }
445
481
 
446
- const tally = problems.map((p) => `${p.reason} ${counts[p.reason] ?? 0}/${max}`).join(", ");
482
+ const tally = problems.map((p) => `${label(p.reason)} ${counts[p.reason] ?? 0}/${max}`).join(", ");
447
483
  return {
448
484
  action: "block",
449
485
  message:
@@ -453,7 +489,14 @@ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX
453
489
  };
454
490
  }
455
491
 
456
- function main() {
492
+ export function stopOutput(result) {
493
+ return {
494
+ stdout: result.action === "block" ? JSON.stringify({ decision: "block", reason: result.message }) : "",
495
+ stderr: result.action === "release" ? result.message : "",
496
+ };
497
+ }
498
+
499
+ async function main() {
457
500
  let payload = {};
458
501
  try {
459
502
  payload = JSON.parse(fs.readFileSync(0, "utf8"));
@@ -466,26 +509,26 @@ function main() {
466
509
  const tree = resolveSessionTree(payload.cwd);
467
510
  if (tree.note) process.stderr.write(`portulan stop-gate: ${tree.note}; answering about ${REPO} instead.\n`);
468
511
 
469
- const { problems, recipeGreen } = collectProblems(tree);
512
+ const prior = readCount(counterFile(sessionId, os.tmpdir()));
513
+ const chain = bumpChain(payload.stop_hook_active === true, os.tmpdir(), tree.root);
514
+ const { problems, recipeGreen, handoffEnabled, handoffCleared } = await collectProblems(tree, prior, chain);
470
515
 
471
516
  // The session's counts stay on the told root, so a `cwd` that moves mid-session cannot restart them.
472
517
  if (recipeGreen) clearReason(sessionId, "recipe");
473
- if (!problems.some((p) => p.reason === "handoff")) clearReason(sessionId, "handoff");
518
+ if (handoffCleared) clearReason(sessionId, "handoff");
474
519
 
475
520
  const state = problems.length === 0
476
521
  ? { counts: {}, total: 0 }
477
- : bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
478
- const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
479
- const result = verdict({ problems, counts: state.counts, total: state.total, chain });
480
-
481
- if (result.action === "allow") allow();
482
- if (result.action === "release") {
483
- process.stderr.write(result.message);
484
- allow();
485
- }
486
- block(result.message);
522
+ : bumpCount(sessionId, [...new Set(problems.filter(p => p.charge !== false).map((p) => p.reason))]);
523
+ if (problems.length === 0) bumpChain(false, os.tmpdir(), tree.root);
524
+ const result = verdict({ problems, counts: state.counts, total: state.total, chain, handoffEnabled });
525
+
526
+ const output = stopOutput(result);
527
+ if (output.stderr) process.stderr.write(output.stderr);
528
+ if (output.stdout) process.stdout.write(`${output.stdout}\n`);
529
+ allow();
487
530
  }
488
531
 
489
532
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
490
- main();
533
+ await main();
491
534
  }