@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.
- package/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- 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
|
-
//
|
|
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
|
|
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
|
|
217
|
-
return
|
|
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
|
|
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
|
|
276
|
-
return
|
|
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 }`,
|
|
281
|
-
function handoffToday(stamp, tree
|
|
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(
|
|
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
|
|
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)${
|
|
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
|
|
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
|
-
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
problems.push({
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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: "
|
|
377
|
-
text:
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
479
|
-
const result = verdict({ problems, counts: state.counts, total: state.total, chain });
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
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
|
}
|