@sleepy_panda_srl/portulan 0.2.0 → 0.3.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 +2 -2
- package/cli/README.md +6 -3
- package/cli/compile.mjs +318 -13
- package/cli/doctor.mjs +46 -7
- package/cli/drills.mjs +26 -4
- package/cli/ledger.mjs +110 -6
- package/cli/portulan.mjs +1 -1
- package/cli/review-meter.mjs +14 -9
- package/cli/telemetry.mjs +57 -15
- package/core/operating/context.md +79 -4
- package/core/personas/README.md +33 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/evals/README.md +23 -7
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/package.json +2 -1
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +136 -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 +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +1 -0
- package/spec/slots.md +10 -6
package/cli/drills.mjs
CHANGED
|
@@ -54,7 +54,7 @@ export const NOT_DRILLED = [
|
|
|
54
54
|
why: "deliberately not a verify recipe — `../.portulan/identity.md` argues why: CI installs nothing, so declaring it would make a recipe that exits 2 on every run. It is checked at the supervised checkpoints and before a release, which is a rail on no calendar, and that cost is already stated in `../.portulan/verify/README.md`.",
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
|
-
rail: "Dependabot
|
|
57
|
+
rail: "Dependabot and `pr-labels`",
|
|
58
58
|
why: "platform watchers whose triggering condition is an event this repository cannot manufacture on a schedule. `0007`'s own honest limit applies — not every watcher can be forced red safely — and each carries its own recorded observation instead.",
|
|
59
59
|
},
|
|
60
60
|
{
|
|
@@ -163,7 +163,7 @@ export const DRILLS = [
|
|
|
163
163
|
},
|
|
164
164
|
exit: 1,
|
|
165
165
|
tell: "fixture(s) failed",
|
|
166
|
-
why: "The jq programs the
|
|
166
|
+
why: "The jq programs the label gate branches on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
169
|
rail: "index",
|
|
@@ -199,6 +199,17 @@ export const DRILLS = [
|
|
|
199
199
|
tell: "does not reproduce",
|
|
200
200
|
why: "The host writes one usage record per content block, so a reader that stops deduplicating by message id multiplies every figure by the blocks per request and still prints plausible numbers. The fixture's known totals are the only thing that tells those numbers from the true ones.",
|
|
201
201
|
},
|
|
202
|
+
{
|
|
203
|
+
rail: "ledger",
|
|
204
|
+
perturb: {
|
|
205
|
+
file: "cli/ledger.mjs",
|
|
206
|
+
find: "if (!calls.has(result.call)) continue;",
|
|
207
|
+
replace: "if (result.call === null) continue;",
|
|
208
|
+
},
|
|
209
|
+
exit: 1,
|
|
210
|
+
tell: "✗ fixture: local is",
|
|
211
|
+
why: "A local worker's subagent reports its receipt back to the session that delegated it, so a reader that takes a receipt from any tool result counts the same local work twice, once from the Bash call that ran it and once from the report quoting it, and the hybrid line still reads like a figure. Only the hybrid fixture's known receipt count tells the two apart.",
|
|
212
|
+
},
|
|
202
213
|
{
|
|
203
214
|
rail: "control-chars",
|
|
204
215
|
perturb: {
|
|
@@ -248,7 +259,7 @@ export const DRILLS = [
|
|
|
248
259
|
rail: "pack-identity",
|
|
249
260
|
perturb: {
|
|
250
261
|
file: "README.md",
|
|
251
|
-
find: "**Current release: `0.
|
|
262
|
+
find: "**Current release: `0.3.0`**",
|
|
252
263
|
replace: "**Current release: `0.1.3`** <!-- the drill edited this and did not stage it -->",
|
|
253
264
|
},
|
|
254
265
|
stage: false,
|
|
@@ -332,7 +343,7 @@ export const DRILLS = [
|
|
|
332
343
|
rail: "version-carriers",
|
|
333
344
|
perturb: {
|
|
334
345
|
file: "README.md",
|
|
335
|
-
find: "**Current release: `0.
|
|
346
|
+
find: "**Current release: `0.3.0`**",
|
|
336
347
|
replace: "**Current release: `0.1.3`**",
|
|
337
348
|
},
|
|
338
349
|
// Staged: this rail reads the index (`git show :<path>`), so an unstaged edit is invisible to it.
|
|
@@ -458,6 +469,17 @@ export const DRILLS = [
|
|
|
458
469
|
tell: "is a tag or branch, not a commit",
|
|
459
470
|
why: "The composed pack's own rail, drilled from the adopting workspace exactly as a workspace-owned one is — which is the property milestone 7's composition amendment bought and nothing else here re-checks.",
|
|
460
471
|
},
|
|
472
|
+
{
|
|
473
|
+
rail: "tools/local-llm:runner-rails",
|
|
474
|
+
perturb: {
|
|
475
|
+
file: "packs/tools/local-llm/runner/local-llm.mjs",
|
|
476
|
+
find: " if (!allowRemote && !LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) {",
|
|
477
|
+
replace: " if (!allowRemote && false) {",
|
|
478
|
+
},
|
|
479
|
+
exit: 1,
|
|
480
|
+
tell: "non-loopback endpoint was not refused by assertLoopback",
|
|
481
|
+
why: "Loopback is the rail that keeps a local worker's context on the machine, and the runner holds it in code rather than in any permission rule the compiler emits, so a guard that stopped refusing would leave every other rail green. The drill removes the refusal and requires the pack's own recipe to see it.",
|
|
482
|
+
},
|
|
461
483
|
|
|
462
484
|
// ------------------------------------------------------- the coverage rail, which is itself a rail
|
|
463
485
|
{
|
package/cli/ledger.mjs
CHANGED
|
@@ -120,10 +120,60 @@ const writtenOf = (r) => r.written1h + r.written5m + r.writtenUnknown;
|
|
|
120
120
|
|
|
121
121
|
const chainOf = (r) => (r.sidechain ? `agent:${r.agent ?? ""}` : "session");
|
|
122
122
|
|
|
123
|
+
/** The local runner's receipt format, a whole line or nothing: a format, so any line that matches it is read as one. */
|
|
124
|
+
const RECEIPT =
|
|
125
|
+
/^OK mode=([a-z]+) model=(\S+)(?: target=.+?)? attempts=([1-9]\d*) in=(?:(0|[1-9]\d*)t|\?) out=(?:(0|[1-9]\d*)t|\?) time=(?:(0|[1-9]\d*)ms|\?) rate=(?:(?:0|[1-9]\d*)(?:\.\d)?tok\/s|\?) spend=\$0\.00$/;
|
|
126
|
+
|
|
127
|
+
/** A figure the runner printed as `?` is null, never 0. */
|
|
128
|
+
export function receiptOf(line) {
|
|
129
|
+
const m = RECEIPT.exec(line);
|
|
130
|
+
if (m === null) return null;
|
|
131
|
+
const [attempts, input, output, time] = [m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? null : Number(v)));
|
|
132
|
+
if (![attempts, input, output, time].every((v) => v === null || Number.isSafeInteger(v))) return null;
|
|
133
|
+
return { mode: m[1], model: m[2], attempts, in: input, out: output, time };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The ids of the Bash calls a record makes: each block's type, name and id, and nothing it says. */
|
|
137
|
+
function bashCallsOf(record) {
|
|
138
|
+
const content = record.message?.content;
|
|
139
|
+
if (!Array.isArray(content)) return [];
|
|
140
|
+
return content.filter((b) => b !== null && typeof b === "object" && b.type === "tool_use" && b.name === "Bash" && text(b.id) !== null).map((b) => b.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A tool result's output as the host writes it: a string, or text blocks and nothing else. */
|
|
144
|
+
function outputOf(content) {
|
|
145
|
+
if (typeof content === "string") return content;
|
|
146
|
+
if (!Array.isArray(content) || !content.length || !content.every((b) => b?.type === "text" && typeof b.text === "string")) return null;
|
|
147
|
+
return content.map((b) => b.text).join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The receipts in a user record's tool results, each still to be matched to the Bash call it answers. */
|
|
151
|
+
function resultsOf(record) {
|
|
152
|
+
if (record === null || typeof record !== "object" || record.type !== "user" || !Array.isArray(record.message?.content)) return [];
|
|
153
|
+
const at = Date.parse(record.timestamp);
|
|
154
|
+
const found = [];
|
|
155
|
+
for (const block of record.message.content) {
|
|
156
|
+
if (block === null || typeof block !== "object" || block.type !== "tool_result" || text(block.tool_use_id) === null) continue;
|
|
157
|
+
const receipts = (outputOf(block.content) ?? "").split("\n").map(receiptOf).filter((r) => r !== null);
|
|
158
|
+
if (!receipts.length) continue;
|
|
159
|
+
found.push({
|
|
160
|
+
call: block.tool_use_id,
|
|
161
|
+
receipts,
|
|
162
|
+
branch: text(record.gitBranch),
|
|
163
|
+
cwd: text(record.cwd),
|
|
164
|
+
at: Number.isFinite(at) ? at : null,
|
|
165
|
+
sidechain: record.isSidechain === true,
|
|
166
|
+
agent: text(record.agentId),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
123
172
|
export function readLine(line) {
|
|
124
173
|
const usageLike = line.includes('"usage":');
|
|
125
174
|
const boundaryLike = line.includes('"compact_boundary"');
|
|
126
|
-
|
|
175
|
+
const resultLike = line.includes('"tool_result"') && line.includes("spend=$0.00");
|
|
176
|
+
if (!usageLike && !boundaryLike && !resultLike) return null;
|
|
127
177
|
let record;
|
|
128
178
|
try {
|
|
129
179
|
record = JSON.parse(line);
|
|
@@ -134,7 +184,12 @@ export function readLine(line) {
|
|
|
134
184
|
return { boundary: true, sidechain: record.isSidechain === true, agent: text(record.agentId) };
|
|
135
185
|
}
|
|
136
186
|
const request = requestOf(record);
|
|
137
|
-
|
|
187
|
+
if (request !== null) {
|
|
188
|
+
const calls = bashCallsOf(record);
|
|
189
|
+
return calls.length ? { request, calls } : { request };
|
|
190
|
+
}
|
|
191
|
+
const results = resultLike ? resultsOf(record) : [];
|
|
192
|
+
return results.length ? { results } : null;
|
|
138
193
|
}
|
|
139
194
|
|
|
140
195
|
/** Claude Code 2.1.280, undocumented: one record per content block, so one request per message id. */
|
|
@@ -144,6 +199,8 @@ export function readTranscript(file) {
|
|
|
144
199
|
const requests = [];
|
|
145
200
|
const tally = { records: 0, duplicates: 0, malformed: 0, synthetic: 0, compactions: 0 };
|
|
146
201
|
const figures = sessionFigures();
|
|
202
|
+
const calls = new Set();
|
|
203
|
+
const receipts = [];
|
|
147
204
|
// Compacted contexts awaiting their next request, by chain, so a subagent's compaction is never the session's.
|
|
148
205
|
const awaiting = new Set();
|
|
149
206
|
for (const line of source.split("\n")) {
|
|
@@ -154,6 +211,15 @@ export function readTranscript(file) {
|
|
|
154
211
|
tally.malformed += 1;
|
|
155
212
|
continue;
|
|
156
213
|
}
|
|
214
|
+
if (read.results) {
|
|
215
|
+
// Only a Bash result is read for the format: an agent's report quoting a receipt is no second one.
|
|
216
|
+
for (const result of read.results) {
|
|
217
|
+
if (!calls.has(result.call)) continue;
|
|
218
|
+
const { call, receipts: lines, ...where } = result;
|
|
219
|
+
lines.forEach((receipt, i) => receipts.push({ key: `${call}#${i}`, ...receipt, ...where }));
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
157
223
|
foldFigures(figures, read);
|
|
158
224
|
if (read.boundary) {
|
|
159
225
|
tally.compactions += 1;
|
|
@@ -161,6 +227,7 @@ export function readTranscript(file) {
|
|
|
161
227
|
continue;
|
|
162
228
|
}
|
|
163
229
|
const request = read.request;
|
|
230
|
+
for (const id of read.calls ?? []) calls.add(id);
|
|
164
231
|
tally.records += 1;
|
|
165
232
|
if (request.synthetic) {
|
|
166
233
|
tally.synthetic += 1;
|
|
@@ -177,7 +244,7 @@ export function readTranscript(file) {
|
|
|
177
244
|
request.compacted = awaiting.delete(chainOf(request));
|
|
178
245
|
requests.push(request);
|
|
179
246
|
}
|
|
180
|
-
return { requests, figures, ...tally };
|
|
247
|
+
return { requests, receipts, figures, ...tally };
|
|
181
248
|
}
|
|
182
249
|
|
|
183
250
|
/** The lifetime a request's writes used, or null where it wrote nothing it named. */
|
|
@@ -375,6 +442,8 @@ export function collect({ projects, roots }) {
|
|
|
375
442
|
const contexts = [];
|
|
376
443
|
const tally = { files: 0, records: 0, duplicates: 0, malformed: 0, synthetic: 0 };
|
|
377
444
|
const seen = new Set();
|
|
445
|
+
const receipts = [];
|
|
446
|
+
const seenReceipts = new Set();
|
|
378
447
|
for (const entry of transcriptFiles(projects, roots)) {
|
|
379
448
|
let transcript;
|
|
380
449
|
try {
|
|
@@ -400,8 +469,13 @@ export function collect({ projects, roots }) {
|
|
|
400
469
|
tally.duplicates += inside.length - kept.length;
|
|
401
470
|
if (kept.length) contexts.push({ file: entry.file, session: entry.session, agent, requests: kept, transcript: agent === null ? transcript : null });
|
|
402
471
|
}
|
|
472
|
+
for (const r of transcript.receipts) {
|
|
473
|
+
if (!insideAny(r.cwd, roots) || seenReceipts.has(r.key)) continue;
|
|
474
|
+
seenReceipts.add(r.key);
|
|
475
|
+
receipts.push({ ...r, session: entry.session, agent: entry.agent ?? (r.sidechain ? `inline:${r.agent ?? "unnamed"}` : null) });
|
|
476
|
+
}
|
|
403
477
|
}
|
|
404
|
-
return { contexts, ...tally };
|
|
478
|
+
return { contexts, receipts, ...tally };
|
|
405
479
|
}
|
|
406
480
|
|
|
407
481
|
const zero = () => ({ requests: 0, uncached: 0, written1h: 0, written5m: 0, writtenUnknown: 0, read: 0, output: 0 });
|
|
@@ -451,6 +525,20 @@ export function tally(contexts, branch) {
|
|
|
451
525
|
};
|
|
452
526
|
}
|
|
453
527
|
|
|
528
|
+
/** What the lines in the receipt format on `branch` say, apart from every host figure: each null where none reported it. */
|
|
529
|
+
export function tallyLocal(receipts, branch) {
|
|
530
|
+
const own = receipts.filter((r) => r.branch === branch);
|
|
531
|
+
const figure = (k) => (own.some((r) => r[k] !== null) ? own.reduce((sum, r) => sum + (r[k] ?? 0), 0) : null);
|
|
532
|
+
return {
|
|
533
|
+
receipts: own.length,
|
|
534
|
+
sessions: new Set(own.map((r) => r.session)).size,
|
|
535
|
+
in: figure("in"),
|
|
536
|
+
out: figure("out"),
|
|
537
|
+
time: figure("time"),
|
|
538
|
+
unreported: Object.fromEntries(["in", "out", "time"].map((k) => [k, own.filter((r) => r[k] === null).length])),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
454
542
|
function sessionTotals(contexts) {
|
|
455
543
|
const by = new Map();
|
|
456
544
|
for (const c of contexts) {
|
|
@@ -574,7 +662,8 @@ export function ledger({ projects, config, roots, branch, lines = null, spend =
|
|
|
574
662
|
const latest = latestSession(collected.contexts, branch);
|
|
575
663
|
const priced = { declared: spend.multipliers, horizon: spend.horizon ?? HORIZON };
|
|
576
664
|
const restart = latest === null ? null : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
|
|
577
|
-
|
|
665
|
+
const local = tallyLocal(collected.receipts, branch);
|
|
666
|
+
return { projects, roots, branch, collected, figures, local, host, restart, lines, priced };
|
|
578
667
|
}
|
|
579
668
|
|
|
580
669
|
const signed = (n) => (n > 0 ? `+${grouped(n)}` : n < 0 ? `−${grouped(-n)}` : "0");
|
|
@@ -590,9 +679,21 @@ function unjudged({ declared, horizon } = { declared: null, horizon: HORIZON })
|
|
|
590
679
|
);
|
|
591
680
|
}
|
|
592
681
|
|
|
682
|
+
function localLine({ local: l, branch }) {
|
|
683
|
+
if (l.receipts === 0) return ` local worker: no line in the runner's receipt format on ${branch} is recorded here`;
|
|
684
|
+
const figure = (k, unit) => {
|
|
685
|
+
if (l[k] === null) return `${k} unreported`;
|
|
686
|
+
return `${k} ${grouped(l[k])} ${unit}${l.unreported[k] ? ` (${grouped(l.unreported[k])} receipt(s) did not report it)` : ""}`;
|
|
687
|
+
};
|
|
688
|
+
return (
|
|
689
|
+
` local worker: ${grouped(l.receipts)} line(s) in the runner's receipt format, from Bash results in ${grouped(l.sessions)} session(s), at $0.00, none of it in the host's tokens or the restart threshold: ` +
|
|
690
|
+
`${figure("in", "tokens")}, ${figure("out", "tokens")}, ${figure("time", "ms")}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
593
694
|
export function print(report, say) {
|
|
594
695
|
const { collected, figures: f, host, restart, lines, priced } = report;
|
|
595
|
-
say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read)`);
|
|
696
|
+
say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read but a line in the runner's receipt format in a Bash result)`);
|
|
596
697
|
say(` worktrees: ${report.roots.join(", ")}`);
|
|
597
698
|
say(
|
|
598
699
|
` read: ${grouped(collected.files)} transcript(s) under ${report.projects}, ${grouped(collected.records)} usage record(s): ` +
|
|
@@ -601,6 +702,7 @@ export function print(report, say) {
|
|
|
601
702
|
);
|
|
602
703
|
if (f.main.requests + f.subagents.requests === 0) {
|
|
603
704
|
say(` no request on ${report.branch} is recorded here`);
|
|
705
|
+
say(localLine(report));
|
|
604
706
|
say(unjudged(priced));
|
|
605
707
|
return;
|
|
606
708
|
}
|
|
@@ -613,6 +715,7 @@ export function print(report, say) {
|
|
|
613
715
|
row("written, unstated", "writtenUnknown");
|
|
614
716
|
row("read", "read");
|
|
615
717
|
row("output", "output");
|
|
718
|
+
say(localLine(report));
|
|
616
719
|
say(` contexts opened: ${grouped(f.sessions + f.subagentContexts)} (${grouped(f.sessions)} session(s), ${grouped(f.subagentContexts)} subagent(s)); compactions: ${grouped(f.compactions)}`);
|
|
617
720
|
say(` largest context: ${grouped(f.largest)} tokens`);
|
|
618
721
|
if (f.hitRate === null) say(" hit rate: none — no input was recorded, so none could be read from cache");
|
|
@@ -665,6 +768,7 @@ export function pinned(report) {
|
|
|
665
768
|
rebuilt: f.rebuilt,
|
|
666
769
|
causes: f.causes,
|
|
667
770
|
host: report.host.map((h) => ({ session: h.session, difference: h.difference })),
|
|
771
|
+
local: report.local,
|
|
668
772
|
threshold: report.restart?.threshold ?? null,
|
|
669
773
|
};
|
|
670
774
|
}
|
package/cli/portulan.mjs
CHANGED
|
@@ -23,7 +23,7 @@ export const SUBCOMMANDS = [
|
|
|
23
23
|
{
|
|
24
24
|
name: "doctor",
|
|
25
25
|
module: "doctor.mjs",
|
|
26
|
-
summary: "validate a workspace
|
|
26
|
+
summary: "validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records",
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "compile",
|
package/cli/review-meter.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The review-loop meter: the figures
|
|
2
|
+
// The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews.
|
|
3
3
|
//
|
|
4
4
|
// Fix-rounds are not derivable from the API: whether a push answers a submission is a fact about its contents.
|
|
5
5
|
|
|
@@ -13,7 +13,7 @@ export const SNAPSHOT_VERSION = "1";
|
|
|
13
13
|
// A prefix, not an equality: the reviewer is `copilot-pull-request-reviewer[bot]` on reviews and `Copilot` on comments.
|
|
14
14
|
export const REVIEWER_PREFIX = "copilot";
|
|
15
15
|
|
|
16
|
-
// The review-loop rule
|
|
16
|
+
// The retirement threshold the review-loop rule stated: submissions per pull request below this for a full milestone.
|
|
17
17
|
export const RETIRE_THRESHOLD = 2.0;
|
|
18
18
|
|
|
19
19
|
const isReviewer = (login) =>
|
|
@@ -174,10 +174,14 @@ export function renderRegister(m) {
|
|
|
174
174
|
lines.push("> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.");
|
|
175
175
|
lines.push(">");
|
|
176
176
|
lines.push("> **Every figure here is in SUBMISSION units** — every review the reviewer submits, one");
|
|
177
|
-
lines.push("> per push, including on the branch as opened. It is not the fix-round unit that");
|
|
178
|
-
lines.push(">
|
|
179
|
-
lines.push(">
|
|
180
|
-
lines.push(">
|
|
177
|
+
lines.push("> per push, including on the branch as opened. It is not the fix-round unit that rule 4");
|
|
178
|
+
lines.push("> of the retired `a-review-loop-needs-a-bound.md` bounded, and no figure here may be read");
|
|
179
|
+
lines.push("> as one. See `../../cli/review-meter.mjs` for why fix-rounds are not derivable from the");
|
|
180
|
+
lines.push("> API at all.");
|
|
181
|
+
lines.push(">");
|
|
182
|
+
lines.push("> **This register is historical.** It measures the Copilot review loop, which left this");
|
|
183
|
+
lines.push("> repository's review path on 2026-10-30, by the maintainer's decision; the record that");
|
|
184
|
+
lines.push("> bounded the loop retired with it.");
|
|
181
185
|
lines.push("");
|
|
182
186
|
lines.push(`- **Repository:** \`${m.repository}\``);
|
|
183
187
|
lines.push(`- **Captured:** ${m.captured}`);
|
|
@@ -210,8 +214,9 @@ export function renderRegister(m) {
|
|
|
210
214
|
lines.push("## Against the record's own retirement threshold");
|
|
211
215
|
lines.push("");
|
|
212
216
|
lines.push(
|
|
213
|
-
`\`a-review-loop-needs-a-bound.md\`
|
|
214
|
-
`**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone
|
|
217
|
+
`\`a-review-loop-needs-a-bound.md\` was to retire when submissions per pull request measured below ` +
|
|
218
|
+
`**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone, and retired on 2026-10-30 on its other ` +
|
|
219
|
+
`condition, Copilot review leaving the review path. This window measures ` +
|
|
215
220
|
`**${round2(m.submissionsPerPullRequest)}**, which is ` +
|
|
216
221
|
`${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "**below**" : "**at or above**"} it.`,
|
|
217
222
|
);
|
|
@@ -221,7 +226,7 @@ export function renderRegister(m) {
|
|
|
221
226
|
"field the API carries, so this tool measures a window of merged pull requests and the " +
|
|
222
227
|
"record's *for a full milestone* clause is not evaluated here. Reading this row as the " +
|
|
223
228
|
"retirement condition met would be reading a different measure than the one the record " +
|
|
224
|
-
"
|
|
229
|
+
"stated.",
|
|
225
230
|
);
|
|
226
231
|
lines.push("");
|
|
227
232
|
lines.push("## Per pull request");
|
package/cli/telemetry.mjs
CHANGED
|
@@ -17,16 +17,31 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
|
17
17
|
import { isInside } from "./inside.mjs";
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------------------------
|
|
20
|
-
// The offline audit:
|
|
20
|
+
// The offline audit: recipes reach neither the network nor local usage records
|
|
21
21
|
// ---------------------------------------------------------------------------------------------
|
|
22
22
|
|
|
23
|
-
/** Every mode in cli/
|
|
23
|
+
/** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
|
|
24
24
|
export const NETWORK_MODES = Object.freeze([
|
|
25
25
|
{ module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
|
|
26
26
|
{ module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
|
|
27
27
|
{ module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
|
|
28
|
+
{
|
|
29
|
+
module: "tools/local-llm/runner/local-llm.mjs",
|
|
30
|
+
unless: "--verify-offline",
|
|
31
|
+
alsoUnless: ["eval", "--verify"],
|
|
32
|
+
what: "calls the model endpoint except offline rails and captured-record verification",
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
|
|
37
|
+
export const LOCAL_RECORD_MODES = Object.freeze([
|
|
38
|
+
{ module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
|
|
39
|
+
{ module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
40
|
+
{ module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
28
41
|
]);
|
|
29
42
|
|
|
43
|
+
export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? `${m.module} ${m.flag}` : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
|
|
44
|
+
|
|
30
45
|
/** Whole-line comments only: a trailing `#` may sit inside a quote. */
|
|
31
46
|
export const stripShellComments = (source) =>
|
|
32
47
|
source
|
|
@@ -40,9 +55,35 @@ const tokenNamesModule = (token, module) => token === module || token.endsWith(`
|
|
|
40
55
|
|
|
41
56
|
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
42
57
|
|
|
58
|
+
/** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
|
|
59
|
+
const onlySafeInvocations = (source, m) =>
|
|
60
|
+
stripShellComments(source)
|
|
61
|
+
.replace(/\\\n/g, " ")
|
|
62
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
63
|
+
.every((segment) => {
|
|
64
|
+
const tokens = shellTokens(segment);
|
|
65
|
+
if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
|
|
66
|
+
return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const onlyStaticInvocations = (source, mode) => stripShellComments(source)
|
|
70
|
+
.replace(/\\\n/g, " ")
|
|
71
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
72
|
+
.every((segment) => {
|
|
73
|
+
const words = shellTokens(segment);
|
|
74
|
+
const first = words[0];
|
|
75
|
+
// A path in a file test or dependency list is data, not a CLI invocation.
|
|
76
|
+
if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
|
|
77
|
+
return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
|
|
78
|
+
});
|
|
79
|
+
|
|
43
80
|
export function auditRecipeSource(source) {
|
|
44
81
|
const tokens = shellTokens(stripShellComments(source));
|
|
45
|
-
|
|
82
|
+
const network = NETWORK_MODES.filter((m) =>
|
|
83
|
+
m.flag ? tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
|
|
84
|
+
);
|
|
85
|
+
const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
|
|
86
|
+
return [...network, ...local];
|
|
46
87
|
}
|
|
47
88
|
|
|
48
89
|
export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
@@ -72,9 +113,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
72
113
|
const findings = [];
|
|
73
114
|
const read = [];
|
|
74
115
|
for (const r of recipes) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
116
|
+
const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
|
|
117
|
+
// A run line that is itself a `node` invocation is the whole shell text the recipe runs, so it is graded as read.
|
|
118
|
+
const direct = words[0] === "node" && !words.some((t) => t.endsWith(".sh"));
|
|
119
|
+
const script = direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
|
|
78
120
|
if (!script) {
|
|
79
121
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
|
|
80
122
|
}
|
|
@@ -89,7 +131,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
89
131
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
|
|
90
132
|
}
|
|
91
133
|
read.push(r.id);
|
|
92
|
-
for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
|
|
134
|
+
for (const hit of auditRecipeSource(direct ? r.run : source)) findings.push({ recipe: r.id, script, ...hit });
|
|
93
135
|
}
|
|
94
136
|
return { ok: true, examined: read, findings };
|
|
95
137
|
}
|
|
@@ -157,7 +199,7 @@ export const PRODUCERS = {
|
|
|
157
199
|
return [
|
|
158
200
|
{ name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
|
|
159
201
|
{ name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
|
|
160
|
-
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md
|
|
202
|
+
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
|
|
161
203
|
{ name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
|
|
162
204
|
{ name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
|
|
163
205
|
{ name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
|
|
@@ -336,7 +378,7 @@ const USAGE = [
|
|
|
336
378
|
" first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
|
|
337
379
|
" OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
|
|
338
380
|
" OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
|
|
339
|
-
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
|
|
381
|
+
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
|
|
340
382
|
" takes --workspace and --pack-root, and needs no --config",
|
|
341
383
|
"",
|
|
342
384
|
"exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
|
|
@@ -403,16 +445,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
403
445
|
return 2;
|
|
404
446
|
}
|
|
405
447
|
if (res.findings.length > 0) {
|
|
406
|
-
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
|
|
407
|
-
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f
|
|
408
|
-
io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
|
|
448
|
+
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
|
|
449
|
+
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
|
|
450
|
+
io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
|
|
409
451
|
io.error("both prohibit it, because a rail that moves with the network goes red about the world");
|
|
410
452
|
io.error("rather than about the tree.");
|
|
411
453
|
return 1;
|
|
412
454
|
}
|
|
413
|
-
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
|
|
414
|
-
io.log(` Modes railed: ${NETWORK_MODES.map(
|
|
415
|
-
io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
455
|
+
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
|
|
456
|
+
io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
|
|
457
|
+
io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
416
458
|
return 0;
|
|
417
459
|
}
|
|
418
460
|
|
|
@@ -148,6 +148,70 @@ each section's anchor and size, the same way. It prints from the file as it is,
|
|
|
148
148
|
go stale. Nothing checks that a session outlined first; what the rule saves is measured by rerunning a
|
|
149
149
|
task, never by a recipe, which may not read the host's usage records.
|
|
150
150
|
|
|
151
|
+
## A local model runs without the host floor
|
|
152
|
+
|
|
153
|
+
A model served on the machine itself costs nothing per token, so `0038`'s dollar arithmetic gives it no
|
|
154
|
+
restart threshold. What it does cost is time to prefill, memory for each context it holds, and recall that
|
|
155
|
+
still sags in a long context. And every turn it takes is a turn the parent session does not send again on
|
|
156
|
+
each request after. So a local model is called as a context firewall, and four rules hold it:
|
|
157
|
+
|
|
158
|
+
1. **A local worker runs without the host floor (`omitClaudeMd`) and under a hard input-byte ceiling.** It
|
|
159
|
+
is given one bounded input: a symbol span from [`cli/symbols.mjs`](../../cli/symbols.mjs), a file under
|
|
160
|
+
300 lines, a diff, or a log to compress, and never the parent session's instruction files. A prompt over
|
|
161
|
+
the ceiling is refused before a socket opens. _Why: a host floor of 44k to 85k tokens is prefilled into
|
|
162
|
+
a local model's cache on every cold context, where `0039` measured prompts of about 400 to 470 tokens
|
|
163
|
+
prefilling in 0.85 to 1.88 seconds._
|
|
164
|
+
2. **Thinking effort is set explicitly at invocation**: low or off for a mechanical transform, bounded for
|
|
165
|
+
prose or review, never left to the chat template's default. _Why: one template `0039` read sets
|
|
166
|
+
reasoning effort to `xhigh` unless thinking is switched off, and under a 1,500-token cap that model
|
|
167
|
+
returned no answer on any of three tasks, every token spent thinking; at low effort it passed both
|
|
168
|
+
tasks it was rerun on._
|
|
169
|
+
3. **A local edit is kept only on its verify command's exit `0`.** The runner applies the edit and runs the
|
|
170
|
+
command. On exit `0` one receipt line is all that enters the parent context (`0038` rule 5). On exit `1`
|
|
171
|
+
after its retries, or at once on any other exit, which is no verdict, it restores the file's original
|
|
172
|
+
bytes, and it does the same on SIGINT, SIGTERM and SIGHUP. **This is a rollback, not an atomic write**:
|
|
173
|
+
the verify needs the edited file in place, so any other signal that ends the runner, SIGKILL and SIGQUIT
|
|
174
|
+
among them, or a power loss between the edit and its verdict leaves the edit, and a transform with no
|
|
175
|
+
receipt is unverified. _Why not atomic: a scratch worktree would judge the last commit plus the edit
|
|
176
|
+
rather than the working copy plus the edit, would lack the untracked files a verify reads, and moving
|
|
177
|
+
the edit back after exit `0` would not be atomic either (`0039` ruling 4)._
|
|
178
|
+
4. **Loopback is a rail, and routing is earned.** A local runner refuses an endpoint outside loopback
|
|
179
|
+
(`127.0.0.1`, `localhost`, `[::1]`) unless leaving it is proposed and approved. A screen grades an
|
|
180
|
+
installed model once per task against Portulan's own predicates, from
|
|
181
|
+
[`cli/comments.mjs`](../../cli/comments.mjs), [`cli/doctor.mjs`](../../cli/doctor.mjs) and
|
|
182
|
+
[`cli/skill-goldens.mjs`](../../cli/skill-goldens.mjs), and a model that fails it is not routed. Passing
|
|
183
|
+
it earns nothing. A role is routed to a local model by an A/B record under `0038` rule 4: the local model
|
|
184
|
+
as routed against the host's model on the same tasks, `k` runs per cell, captured once and railed to its
|
|
185
|
+
register in `evals/ab/`. _Why: an endpoint off the machine sends workspace context away under a local
|
|
186
|
+
tool's name, and the screen takes one sample per task with no baseline arm, while a cheaper model for a
|
|
187
|
+
role is earned by an A/B run, never assumed (`0039` ruling 5)._
|
|
188
|
+
|
|
189
|
+
**What the runner holds today.** The [`tools/local-llm`](../../packs/tools/local-llm/README.md) pack's
|
|
190
|
+
runner holds rules 1 to 3 and rule 4's loopback rail for the calls it makes. It sends the model its own
|
|
191
|
+
short instructions and the input it was given, with the end of a red verify's output on a retry, and
|
|
192
|
+
nothing else. It refuses a prompt over its ceiling before a socket opens, names the effort on every chat
|
|
193
|
+
request, low unless set, requires a verify command for an edit and rolls back as rule 3 says, and refuses
|
|
194
|
+
a host off loopback and a redirect away from it. Which inputs it is given is the caller's: only their
|
|
195
|
+
bytes are checked. `--allow-remote` lifts the loopback rail on the caller's word, and the pack's
|
|
196
|
+
`local-llm-off-loopback` gate has no matcher, so approval to leave loopback is the human's. Its `eval`
|
|
197
|
+
subcommand is the screen. Its `runner-rails` recipe holds the loopback and ceiling refusals and the
|
|
198
|
+
rollback on a red or no-verdict verify offline, with no network call; the rollback on a signal is held by
|
|
199
|
+
the runner's tests. This repository composes the pack and registers its skill; the ledger reports local
|
|
200
|
+
receipts beside the host's tokens. `doctor` includes that branch-owned usage report by default,
|
|
201
|
+
with unavailable data reported as unmeasured; `--static` omits usage-record discovery for verification.
|
|
202
|
+
The [captured A/B](../../evals/ab/local-worker.md) supports code
|
|
203
|
+
at low effort (L5/H5), while shared prose is disabled because codify scored L0/H3, despite handoff's
|
|
204
|
+
L5/H5. Operational defaults and explicit roles require the measured model at low effort; explicit
|
|
205
|
+
model-only calls remain candidates. The record and register are regraded and byte-compared in CI,
|
|
206
|
+
with shipped routing rederived from their turns. The `local-worker` persona declares
|
|
207
|
+
`omitClaudeMd: true` and `effort: low`, and `compile` writes both into a workspace's binding against a
|
|
208
|
+
committed golden.
|
|
209
|
+
|
|
210
|
+
_(Provenance: proposal
|
|
211
|
+
[`0039`](../../.portulan/proposals/0039-a-local-model-runs-without-the-host-floor-and-under-a-thinking-cap.md)'s
|
|
212
|
+
measurement of two local models against three of this repository's tasks on 2026-10-27, and its rulings 4
|
|
213
|
+
and 5 of 2026-10-30.)_
|
|
214
|
+
|
|
151
215
|
## What is machinery today, and what is not
|
|
152
216
|
|
|
153
217
|
As of Workspace Definition 2.9 a workspace can declare the budget and the ratio, and both are checked
|
|
@@ -169,10 +233,21 @@ a section is never moved for its size alone. Claude Code 2.1.281 drops a comment
|
|
|
169
233
|
before it loads an instruction file or rule (read in its program text on 2026-09-24), so the mark and the
|
|
170
234
|
marker left behind cost nothing. This repository's own card holds its boot under the 7,500-token line of
|
|
171
235
|
2026-09-23, railed at today's figure plus 2% by the same recipe. A pointer's repository is not measured,
|
|
172
|
-
because a pointer declares no tree. **
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
and
|
|
236
|
+
because a pointer declares no tree. **A persona can declare what its context costs**: `model`, `effort`,
|
|
237
|
+
`omitClaudeMd` and `cacheLifetime`, refused when malformed and never defaulted, which `compile` writes into
|
|
238
|
+
the hand-written binding of a persona a composed pack ships and byte-compares under `--check`, printing
|
|
239
|
+
each field a host cannot express, with the persona and the host, as its degradation report for personas.
|
|
240
|
+
Claude Code 2.1.317 takes all four on an agent, and loads `agents/` only from a plugin root (both read
|
|
241
|
+
in its program text on 2026-11-03), so in a tree that is not a plugin the compiled block is loaded by
|
|
242
|
+
nothing, and the degradation report says so; in one that is, only while the plugin is installed and
|
|
243
|
+
enabled, which `compile` cannot see. That stops short of the row: `compile` writes no
|
|
244
|
+
`.claude/agents/`, where a repository that is not a plugin loads its agents, and no binding where none
|
|
245
|
+
exists; a core persona's binding in this plugin's `agents/` is not compiled; and no host has been shown
|
|
246
|
+
honouring a compiled field. **Still to land**: the
|
|
247
|
+
exact mode that calibrates a ratio, the degradation report's half that says which tiers a host can
|
|
248
|
+
express, the Stop-gate's named demotion and the librarian pass, one change at a time, in `0036`'s order
|
|
249
|
+
of work and under row 12 of
|
|
250
|
+
[`../../docs/plan.md`](../../docs/plan.md).
|
|
176
251
|
Until each lands, the rule it would enforce is held by the human gate, and saying so is the point: a rule
|
|
177
252
|
nothing checks, presented as one that something does, is the failure this page exists to prevent.
|
|
178
253
|
|
package/core/personas/README.md
CHANGED
|
@@ -21,6 +21,39 @@ context firewalls; Cognition — read-parallel / write-isolated.)_
|
|
|
21
21
|
- **Read / write posture** — most personas read in parallel; writes are isolated to one place so two
|
|
22
22
|
agents never clobber the same file.
|
|
23
23
|
|
|
24
|
+
## What a persona may declare about its context
|
|
25
|
+
|
|
26
|
+
Four optional frontmatter fields say what a fresh context for the role should cost, per
|
|
27
|
+
[`../operating/context.md`](../operating/context.md): `model`, one model name or alias as a single
|
|
28
|
+
token; `effort`, one of `low`, `medium`, `high`, `xhigh` or `max`; `omitClaudeMd`, `true` or `false`
|
|
29
|
+
unquoted, whether the role runs without the project's instruction files; and `cacheLifetime`, `5m` or
|
|
30
|
+
`1h`. The effort levels and the name `omitClaudeMd` are adopted from Claude Code's vocabulary, the first
|
|
31
|
+
host to express them. `effort` sets the host subagent's reasoning effort, and is not the thinking cap the
|
|
32
|
+
`tools/local-llm` runner's `--effort` sends with each request it makes. `doctor` fails a
|
|
33
|
+
malformed value and `compile` refuses one, and neither supplies a value the persona did not write. For a
|
|
34
|
+
persona a composed pack ships, `compile` writes the declared fields into the frontmatter of the role's
|
|
35
|
+
hand-written host binding, `agents/<name>.md` in the workspace's tree, and leaves every other line of
|
|
36
|
+
that file as its author wrote it; `--check` byte-compares the result, and a line inside the compiled
|
|
37
|
+
block that `compile` did not write is refused rather than removed. One it cannot catch: a line added inside
|
|
38
|
+
the block in exactly the spelling and order `compile` writes reads as compiled, and is dropped once the
|
|
39
|
+
persona no longer declares that field, since the two cannot be told apart. And once a persona declares any
|
|
40
|
+
field, `compile` owns every top-level key it could write, so a hand-written `experimental:` is refused
|
|
41
|
+
even for a key other than `cacheTtl`. A field a host cannot express is
|
|
42
|
+
named, with the persona and the host, in the degradation report `compile` prints. Claude Code takes all
|
|
43
|
+
four on an agent (`cacheLifetime` as `experimental.cacheTtl`), and a vendored `AGENTS.md` takes none.
|
|
44
|
+
**Claude Code loads `agents/` only from a plugin root**, and a repository that is not a plugin loads its
|
|
45
|
+
agents from `.claude/agents/`, which `compile` does not write. So outside a plugin the compiled block is
|
|
46
|
+
loaded by nothing, and the degradation report says so for each persona it applies to. Where the tree is
|
|
47
|
+
a plugin root the report is silent, which is not proof of loading: a plugin's agents load only while it
|
|
48
|
+
is installed and enabled, and `compile` cannot see that. _(Provenance:
|
|
49
|
+
proposal `0036`'s order of work, item 7, and proposal `0039`'s first two rules. The host's keys, values
|
|
50
|
+
and agent locations were read in Claude Code 2.1.317's program text on 2026-11-03.)_
|
|
51
|
+
|
|
52
|
+
A binding has one persona owner across composed packs, including an unbound persona or one declaring
|
|
53
|
+
no fields; duplicate destinations are refused before any artifact is written. The binding must be a
|
|
54
|
+
regular file whose real path stays in the real declared tree, including its parent directories.
|
|
55
|
+
`omitClaudeMd` accepts only unquoted `true` or `false`, in both declarations and compiled blocks.
|
|
56
|
+
|
|
24
57
|
## Status
|
|
25
58
|
|
|
26
59
|
**Milestone 3 — contract, exemplars, and one host binding.** This README fixes the persona contract; the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clarify
|
|
3
|
-
description: Run a bounded clarification pass before planning a full-lane task
|
|
3
|
+
description: Run a bounded clarification pass before planning a full-lane task. Use when the ask is underspecified, the constraints conflict, or the criteria can't be written as observable checks — surface the smallest set of decision-questions, get human answers, and fold them into EARS-shaped acceptance criteria before any code is planned.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Skill — Clarify
|