pi-anchorloop 0.0.0-stage → 2.0.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -2
  3. package/README.zh-TW.md +64 -0
  4. package/hooks/validate_tool_use.py +88 -0
  5. package/hooks/validators/__init__.py +1 -0
  6. package/hooks/validators/constants.py +47 -0
  7. package/hooks/validators/durable_validator.py +89 -0
  8. package/hooks/validators/rule_validator.py +42 -0
  9. package/hooks/validators/skill_validator.py +144 -0
  10. package/hooks/validators/utils.py +71 -0
  11. package/package.json +44 -4
  12. package/pi/anchor-gate.ts +84 -0
  13. package/pi/decision-tool.ts +88 -0
  14. package/pi/second-thought.ts +242 -0
  15. package/pi/support/validators.ts +403 -0
  16. package/pi/ttsr.ts +516 -0
  17. package/pi/validate-tool-use.ts +27 -0
  18. package/skills/agent-terse/SKILL.md +46 -0
  19. package/skills/anchorloop/PRINCIPLES.md +34 -0
  20. package/skills/anchorloop/SKILL.md +76 -0
  21. package/skills/anchorloop/playbooks/autonomous-run.md +12 -0
  22. package/skills/anchorloop/playbooks/pause.md +10 -0
  23. package/skills/anchorloop/playbooks/pickup.md +11 -0
  24. package/skills/answering/SKILL.md +19 -0
  25. package/skills/answering/playbooks/compare.md +9 -0
  26. package/skills/answering/playbooks/explain.md +9 -0
  27. package/skills/coding/MINI-SDD.md +61 -0
  28. package/skills/coding/SKILL.md +27 -0
  29. package/skills/coding/playbooks/bug-fix.md +11 -0
  30. package/skills/coding/playbooks/feature.md +15 -0
  31. package/skills/coding/playbooks/perf.md +10 -0
  32. package/skills/coding/playbooks/prototype.md +14 -0
  33. package/skills/coding/playbooks/refactoring.md +12 -0
  34. package/skills/domain-modeling/ADR-FORMAT.md +47 -0
  35. package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  36. package/skills/domain-modeling/SKILL.md +48 -0
  37. package/skills/grilling/SKILL.md +30 -0
  38. package/skills/human-feedback/SKILL.md +83 -0
  39. package/skills/inspecting/SKILL.md +49 -0
  40. package/skills/inspecting/playbooks/diff-review.md +57 -0
  41. package/skills/inspecting/playbooks/qa-release.md +9 -0
  42. package/skills/investigating/SKILL.md +22 -0
  43. package/skills/investigating/playbooks/diagnose.md +9 -0
  44. package/skills/investigating/playbooks/research.md +9 -0
  45. package/skills/solid-loop/SKILL.md +50 -0
  46. package/skills/solid-loop/playbooks/apply-friction.md +15 -0
  47. package/skills/solid-loop/playbooks/author-skill.md +11 -0
  48. package/skills/solid-loop/playbooks/bootstrap.md +10 -0
  49. package/skills/solid-loop/references/template-skill.md +26 -0
  50. package/skills/writing/SKILL.md +16 -0
  51. package/skills/writing/playbooks/comments.md +10 -0
  52. package/skills/writing/playbooks/draft.md +10 -0
  53. package/skills/writing/playbooks/tighten.md +9 -0
package/pi/ttsr.ts ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * pi extension: Time Traveling Stream Rules (TTSR), ported from oh-my-pi.
3
+ *
4
+ * A rule is a Markdown file whose frontmatter carries a `condition` regex. Rules load from
5
+ * `<agentDir>/rules/*.md` and `<cwd>/.pi/rules/*.md`; a project rule shadows a user rule of
6
+ * the same name. Rules without `condition` are ordinary prose rules and are left alone.
7
+ *
8
+ * - A match in streamed assistant text (or thinking, when scoped) aborts the response, drops
9
+ * the partial output from context, and retries with the rule injected.
10
+ * - A match in tool arguments blocks the call before it runs, with the rule as the reason.
11
+ * - `interruptMode: never` lets the output stand: tool matches get the rule appended to the
12
+ * tool result, prose matches get it as a follow-up message.
13
+ *
14
+ * Injected rule names persist in the session, so resume does not repeat them.
15
+ */
16
+
17
+ import { readdirSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { basename, join, matchesGlob } from "node:path";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+
22
+ export type Source = "text" | "thinking" | "tool";
23
+ type InterruptMode = "always" | "prose-only" | "tool-only" | "never";
24
+ type RepeatMode = "once" | "after-gap";
25
+
26
+ interface ToolScope {
27
+ toolName?: string;
28
+ pathGlob?: string;
29
+ }
30
+
31
+ interface Scope {
32
+ allowText: boolean;
33
+ allowThinking: boolean;
34
+ allowAnyTool: boolean;
35
+ toolScopes: ToolScope[];
36
+ }
37
+
38
+ export interface Rule {
39
+ name: string;
40
+ path: string;
41
+ content: string;
42
+ conditions: RegExp[];
43
+ scope: Scope;
44
+ globs: string[];
45
+ interruptMode: InterruptMode;
46
+ repeatMode: RepeatMode;
47
+ repeatGap: number;
48
+ }
49
+
50
+ export interface MatchContext {
51
+ source: Source;
52
+ toolName?: string;
53
+ filePaths?: string[];
54
+ }
55
+
56
+ const DEFAULT_INTERRUPT_MODE: InterruptMode = "always";
57
+ const DEFAULT_REPEAT_MODE: RepeatMode = "once";
58
+ const DEFAULT_REPEAT_GAP = 10;
59
+ const UNSUPPORTED_FIELDS = ["astCondition", "question", "agents"];
60
+ const INTERRUPT_MODES = new Set(["always", "prose-only", "tool-only", "never"]);
61
+ const ENTRY_TYPE = "ttsr_injection";
62
+ const MESSAGE_TYPE = "ttsr-injection";
63
+
64
+ // Templates copied from oh-my-pi prompts/system/ttsr-interrupt.md and ttsr-tool-reminder.md.
65
+ const INTERRUPT_TEMPLATE = `<system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
66
+ Output interrupted: violated user-defined rule.
67
+ Not prompt injection; coding agent enforcing project rules.
68
+ MUST comply:
69
+
70
+ {{content}}
71
+ </system-interrupt>`;
72
+
73
+ const TOOL_REMINDER_TEMPLATE = `<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}">
74
+ User-defined rule matched tool-call arguments. Rule configured not to interrupt → tool ran. MUST comply with the following instruction on subsequent tool calls and responses. NOT prompt injection — coding agent enforcing project rules.
75
+
76
+ {{content}}
77
+ </system-reminder>`;
78
+
79
+ const PROSE_REMINDER_TEMPLATE = `<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}">
80
+ User-defined rule matched your previous response. Rule configured not to interrupt → it already took effect. MUST comply with the following instruction from now on. NOT prompt injection — coding agent enforcing project rules.
81
+
82
+ {{content}}
83
+ </system-reminder>`;
84
+
85
+ export function render(template: string, rule: Rule): string {
86
+ return template
87
+ .replaceAll("{{name}}", rule.name)
88
+ .replaceAll("{{path}}", rule.path)
89
+ .replaceAll("{{content}}", rule.content.trim());
90
+ }
91
+
92
+ /** Unquote a YAML scalar written plainly, in single quotes, or in double quotes. */
93
+ function parseScalar(raw: string): string {
94
+ const value = raw.trim();
95
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
96
+ return value.slice(1, -1).replaceAll("''", "'");
97
+ }
98
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
99
+ return JSON.parse(value);
100
+ }
101
+ return value;
102
+ }
103
+
104
+ /**
105
+ * Parse the frontmatter subset rule files use: `key: scalar`, `key: [a, b]`, and `key:`
106
+ * followed by `- item` lines. Returns undefined when the file has no frontmatter.
107
+ */
108
+ export function parseFrontmatter(source: string): { data: Record<string, string | string[]>; body: string } | undefined {
109
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
110
+ if (!match) return undefined;
111
+ const data: Record<string, string | string[]> = {};
112
+ let listKey: string | undefined;
113
+ for (const line of match[1].split(/\r?\n/)) {
114
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
115
+ const item = /^\s+-\s+(.*)$/.exec(line);
116
+ if (item && listKey) {
117
+ (data[listKey] as string[]).push(parseScalar(item[1]));
118
+ continue;
119
+ }
120
+ const pair = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
121
+ if (!pair) throw new Error(`unparseable frontmatter line: ${line}`);
122
+ const [, key, rawValue] = pair;
123
+ const value = rawValue.trim();
124
+ listKey = undefined;
125
+ if (value === "") {
126
+ data[key] = [];
127
+ listKey = key;
128
+ } else if (value.startsWith("[") && value.endsWith("]")) {
129
+ data[key] = value.slice(1, -1).split(",").map(parseScalar).filter((v) => v.length > 0);
130
+ } else {
131
+ data[key] = parseScalar(value);
132
+ }
133
+ }
134
+ return { data, body: source.slice(match[0].length) };
135
+ }
136
+
137
+ function toList(value: string | string[] | undefined): string[] {
138
+ if (value === undefined) return [];
139
+ return (Array.isArray(value) ? value : [value]).map((v) => v.trim()).filter((v) => v.length > 0);
140
+ }
141
+
142
+ /** Split comma-separated scope tokens, keeping commas inside (), [] and {} intact. */
143
+ function splitScopeTokens(value: string): string[] {
144
+ const tokens: string[] = [];
145
+ let depth = 0;
146
+ let current = "";
147
+ for (const char of value) {
148
+ if ("([{".includes(char)) depth++;
149
+ if (")]}".includes(char)) depth = Math.max(0, depth - 1);
150
+ if (char === "," && depth === 0) {
151
+ if (current.trim()) tokens.push(current.trim());
152
+ current = "";
153
+ continue;
154
+ }
155
+ current += char;
156
+ }
157
+ if (current.trim()) tokens.push(current.trim());
158
+ return tokens.map((token) => parseScalar(token));
159
+ }
160
+
161
+ /** Compile a condition, translating a leading PCRE inline flag group such as `(?i)`. */
162
+ export function compileCondition(pattern: string): RegExp {
163
+ const inline = /^\(\?([ims]+)\)/.exec(pattern);
164
+ if (inline) return new RegExp(pattern.slice(inline[0].length), Array.from(new Set(inline[1])).join(""));
165
+ return new RegExp(pattern);
166
+ }
167
+
168
+ function buildScope(tokens: string[], warnings: string[], name: string): Scope {
169
+ if (tokens.length === 0) return { allowText: true, allowThinking: false, allowAnyTool: true, toolScopes: [] };
170
+ const scope: Scope = { allowText: false, allowThinking: false, allowAnyTool: false, toolScopes: [] };
171
+ for (const token of tokens) {
172
+ const normalized = token.toLowerCase();
173
+ if (normalized === "text") scope.allowText = true;
174
+ else if (normalized === "thinking") scope.allowThinking = true;
175
+ else if (normalized === "tool" || normalized === "toolcall") scope.allowAnyTool = true;
176
+ else {
177
+ const match = /^(?:(?<prefix>tool)(?::(?<tool>[a-z0-9_-]+))?|(?<bare>[a-z0-9_-]+))(?:\((?<path>[^)]+)\))?$/i.exec(token);
178
+ if (!match?.groups) {
179
+ warnings.push(`${name}: invalid scope token "${token}" skipped`);
180
+ continue;
181
+ }
182
+ const { prefix, tool, bare, path } = match.groups;
183
+ const toolName = (tool ?? (prefix ? undefined : bare))?.toLowerCase();
184
+ const pathGlob = path?.trim();
185
+ if (!toolName && !pathGlob) scope.allowAnyTool = true;
186
+ else scope.toolScopes.push({ toolName, pathGlob });
187
+ }
188
+ }
189
+ return scope;
190
+ }
191
+
192
+ /** Build a TTSR rule from a Markdown file, or return undefined for a non-TTSR file. */
193
+ export function buildRule(path: string, source: string, warnings: string[]): Rule | undefined {
194
+ const name = basename(path).replace(/\.mdc?$/, "");
195
+ let parsed: ReturnType<typeof parseFrontmatter>;
196
+ try {
197
+ parsed = parseFrontmatter(source);
198
+ } catch (error) {
199
+ warnings.push(`${name}: ${(error as Error).message}`);
200
+ return undefined;
201
+ }
202
+ if (!parsed) return undefined;
203
+ const { data, body } = parsed;
204
+ if (data.enabled === "false") return undefined;
205
+ const rawConditions = toList(data.condition ?? data.ttsr_trigger ?? data.ttsrTrigger);
206
+ const unsupported = UNSUPPORTED_FIELDS.filter((field) => data[field] !== undefined);
207
+ if (rawConditions.length === 0) {
208
+ if (unsupported.length > 0) warnings.push(`${name}: ${unsupported.join(", ")} not supported; rule skipped`);
209
+ return undefined;
210
+ }
211
+ if (unsupported.length > 0) {
212
+ warnings.push(`${name}: ${unsupported.join(", ")} not supported; rule skipped`);
213
+ return undefined;
214
+ }
215
+ const conditions: RegExp[] = [];
216
+ for (const pattern of rawConditions) {
217
+ try {
218
+ conditions.push(compileCondition(pattern));
219
+ } catch (error) {
220
+ warnings.push(`${name}: invalid condition /${pattern}/ skipped (${(error as Error).message})`);
221
+ }
222
+ }
223
+ if (conditions.length === 0) return undefined;
224
+ const scope = buildScope(toList(data.scope).flatMap(splitScopeTokens), warnings, name);
225
+ if (!scope.allowText && !scope.allowThinking && !scope.allowAnyTool && scope.toolScopes.length === 0) {
226
+ warnings.push(`${name}: scope excludes every stream; rule skipped`);
227
+ return undefined;
228
+ }
229
+ const interruptMode = typeof data.interruptMode === "string" ? data.interruptMode : DEFAULT_INTERRUPT_MODE;
230
+ if (!INTERRUPT_MODES.has(interruptMode)) {
231
+ warnings.push(`${name}: invalid interruptMode "${interruptMode}"; rule skipped`);
232
+ return undefined;
233
+ }
234
+ const repeatMode = data.repeatMode === "after-gap" ? "after-gap" : DEFAULT_REPEAT_MODE;
235
+ const repeatGap = typeof data.repeatGap === "string" ? Number.parseInt(data.repeatGap, 10) : DEFAULT_REPEAT_GAP;
236
+ return {
237
+ name,
238
+ path,
239
+ content: body,
240
+ conditions,
241
+ scope,
242
+ globs: toList(data.globs).flatMap(splitScopeTokens),
243
+ interruptMode: interruptMode as InterruptMode,
244
+ repeatMode,
245
+ repeatGap: Number.isFinite(repeatGap) && repeatGap > 0 ? repeatGap : DEFAULT_REPEAT_GAP,
246
+ };
247
+ }
248
+
249
+ function listRuleFiles(dir: string): string[] {
250
+ try {
251
+ return readdirSync(dir)
252
+ .filter((file) => /\.mdc?$/.test(file))
253
+ .sort()
254
+ .map((file) => join(dir, file));
255
+ } catch {
256
+ return [];
257
+ }
258
+ }
259
+
260
+ export function agentDir(): string {
261
+ const envDir = process.env.PI_CODING_AGENT_DIR;
262
+ if (envDir) return envDir.replace(/^~(?=$|\/)/, homedir());
263
+ return join(homedir(), ".pi", "agent");
264
+ }
265
+
266
+ /** Discover rules: project rules first so they shadow user rules of the same name. */
267
+ export function discoverRules(cwd: string, userDir: string): { rules: Rule[]; warnings: string[] } {
268
+ const warnings: string[] = [];
269
+ const rules: Rule[] = [];
270
+ const seen = new Set<string>();
271
+ for (const file of [...listRuleFiles(join(cwd, ".pi", "rules")), ...listRuleFiles(join(userDir, "rules"))]) {
272
+ const rule = buildRule(file, readFileSync(file, "utf-8"), warnings);
273
+ if (!rule || seen.has(rule.name)) continue;
274
+ seen.add(rule.name);
275
+ rules.push(rule);
276
+ }
277
+ return { rules, warnings };
278
+ }
279
+
280
+ function matchesPathGlob(glob: string, filePaths: string[] | undefined): boolean {
281
+ for (const filePath of filePaths ?? []) {
282
+ const normalized = filePath.replaceAll("\\", "/");
283
+ if (matchesGlob(normalized, glob) || matchesGlob(basename(normalized), glob)) return true;
284
+ }
285
+ return false;
286
+ }
287
+
288
+ /** Tracks rules, scope matching, and the repeat policy for one session. */
289
+ export class TtsrManager {
290
+ readonly rules: Rule[];
291
+ #turnCount = 0;
292
+ #injectedAt = new Map<string, number>();
293
+
294
+ constructor(rules: Rule[]) {
295
+ this.rules = rules;
296
+ }
297
+
298
+ turnEnded(): void {
299
+ this.#turnCount++;
300
+ }
301
+
302
+ restore(names: string[]): void {
303
+ for (const name of names) this.#injectedAt.set(name, 0);
304
+ }
305
+
306
+ markInjected(names: string[]): void {
307
+ for (const name of names) this.#injectedAt.set(name, this.#turnCount);
308
+ }
309
+
310
+ #eligible(rule: Rule): boolean {
311
+ const last = this.#injectedAt.get(rule.name);
312
+ if (last === undefined) return true;
313
+ return rule.repeatMode === "after-gap" && this.#turnCount - last >= rule.repeatGap;
314
+ }
315
+
316
+ #inScope(rule: Rule, context: MatchContext): boolean {
317
+ if (rule.globs.length > 0 && !rule.globs.some((glob) => matchesPathGlob(glob, context.filePaths))) return false;
318
+ if (context.source === "text") return rule.scope.allowText;
319
+ if (context.source === "thinking") return rule.scope.allowThinking;
320
+ if (rule.scope.allowAnyTool) return true;
321
+ const toolName = context.toolName?.toLowerCase();
322
+ return rule.scope.toolScopes.some(
323
+ (scope) =>
324
+ (!scope.toolName || scope.toolName === toolName) &&
325
+ (!scope.pathGlob || matchesPathGlob(scope.pathGlob, context.filePaths)),
326
+ );
327
+ }
328
+
329
+ /** Rules whose scope, path globs, condition, and repeat policy all admit `content`. */
330
+ check(content: string, context: MatchContext): Rule[] {
331
+ return this.rules.filter(
332
+ (rule) =>
333
+ this.#eligible(rule) &&
334
+ this.#inScope(rule, context) &&
335
+ rule.conditions.some((condition) => {
336
+ condition.lastIndex = 0;
337
+ return condition.test(content);
338
+ }),
339
+ );
340
+ }
341
+ }
342
+
343
+ export function interrupts(rule: Rule, source: Source): boolean {
344
+ if (rule.interruptMode === "always") return true;
345
+ if (rule.interruptMode === "prose-only") return source !== "tool";
346
+ if (rule.interruptMode === "tool-only") return source === "tool";
347
+ return false;
348
+ }
349
+
350
+ /**
351
+ * The text a tool call contributes to matching: the new source for write/edit, the command
352
+ * for bash, and the raw JSON arguments for every other tool.
353
+ */
354
+ export function toolDigest(toolName: string, input: Record<string, unknown>): { digest: string; filePaths: string[] } {
355
+ const filePaths = [input.path, input.file_path, ...(Array.isArray(input.paths) ? input.paths : [])].filter(
356
+ (value): value is string => typeof value === "string",
357
+ );
358
+ if (toolName === "write" && typeof input.content === "string") return { digest: input.content, filePaths };
359
+ if (toolName === "edit" && Array.isArray(input.edits)) {
360
+ const added = input.edits.map((edit) => (edit as { newText?: unknown }).newText).filter((t) => typeof t === "string");
361
+ return { digest: added.join("\n"), filePaths };
362
+ }
363
+ if (toolName === "bash" && typeof input.command === "string") return { digest: input.command, filePaths };
364
+ return { digest: JSON.stringify(input), filePaths };
365
+ }
366
+
367
+ export default function (pi: ExtensionAPI) {
368
+ let manager = new TtsrManager([]);
369
+ let warnings: string[] = [];
370
+ const buffers = new Map<string, string>();
371
+ const pendingInterrupt = new Map<string, Rule>();
372
+ const pendingProse = new Map<string, Rule>();
373
+ const toolReminders = new Map<string, Rule[]>();
374
+ const discardedTimestamps = new Set<number>();
375
+
376
+ function load(ctx: { cwd: string; ui: { notify(message: string, level?: "info" | "warning" | "error"): void } }) {
377
+ const discovered = discoverRules(ctx.cwd, agentDir());
378
+ manager = new TtsrManager(discovered.rules);
379
+ warnings = discovered.warnings;
380
+ if (warnings.length > 0) ctx.ui.notify(`TTSR rule problems:\n${warnings.join("\n")}`, "warning");
381
+ }
382
+
383
+ function injectedNames(entries: Array<{ type: string; customType?: string; data?: unknown }>): string[] {
384
+ return entries.flatMap((entry) =>
385
+ entry.type === "custom" && entry.customType === ENTRY_TYPE ? ((entry.data as { rules?: string[] })?.rules ?? []) : [],
386
+ );
387
+ }
388
+
389
+ pi.on("session_start", (_event, ctx) => {
390
+ load(ctx);
391
+ manager.restore(injectedNames(ctx.sessionManager.getBranch() as never));
392
+ });
393
+
394
+ pi.on("turn_start", () => {
395
+ buffers.clear();
396
+ });
397
+
398
+ pi.on("turn_end", () => {
399
+ manager.turnEnded();
400
+ });
401
+
402
+ pi.on("message_start", () => {
403
+ buffers.clear();
404
+ });
405
+
406
+ pi.on("message_update", (event, ctx) => {
407
+ if (manager.rules.length === 0 || pendingInterrupt.size > 0) return;
408
+ const update = event.assistantMessageEvent;
409
+ if (update.type !== "text_delta" && update.type !== "thinking_delta") return;
410
+ const source: Source = update.type === "text_delta" ? "text" : "thinking";
411
+ const key = `${source}:${update.contentIndex}`;
412
+ const buffer = (buffers.get(key) ?? "") + update.delta;
413
+ buffers.set(key, buffer);
414
+ const matched = manager.check(buffer, { source });
415
+ if (matched.length === 0) return;
416
+ manager.markInjected(matched.map((rule) => rule.name));
417
+ const interrupting = matched.some((rule) => interrupts(rule, source));
418
+ for (const rule of matched) (interrupting ? pendingInterrupt : pendingProse).set(rule.name, rule);
419
+ if (interrupting) {
420
+ ctx.ui.notify(`TTSR: injecting rule ${[...pendingInterrupt.keys()].join(", ")}`, "warning");
421
+ ctx.abort();
422
+ }
423
+ });
424
+
425
+ pi.on("message_end", (event) => {
426
+ const message = event.message as { role?: string; stopReason?: string; timestamp?: number };
427
+ if (message.role !== "assistant") return;
428
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
429
+ toolReminders.clear();
430
+ if (pendingInterrupt.size > 0 && message.timestamp !== undefined) discardedTimestamps.add(message.timestamp);
431
+ pendingProse.clear();
432
+ return;
433
+ }
434
+ if (pendingProse.size === 0) return;
435
+ const rules = [...pendingProse.values()];
436
+ pendingProse.clear();
437
+ pi.sendMessage(
438
+ {
439
+ customType: MESSAGE_TYPE,
440
+ content: rules.map((rule) => render(PROSE_REMINDER_TEMPLATE, rule)).join("\n\n"),
441
+ display: true,
442
+ details: { rules: rules.map((rule) => rule.name) },
443
+ },
444
+ { deliverAs: "followUp", triggerTurn: true },
445
+ );
446
+ pi.appendEntry(ENTRY_TYPE, { rules: rules.map((rule) => rule.name) });
447
+ });
448
+
449
+ pi.on("agent_settled", () => {
450
+ if (pendingInterrupt.size === 0) return;
451
+ const rules = [...pendingInterrupt.values()];
452
+ pendingInterrupt.clear();
453
+ pi.appendEntry(ENTRY_TYPE, { rules: rules.map((rule) => rule.name) });
454
+ pi.sendMessage(
455
+ {
456
+ customType: MESSAGE_TYPE,
457
+ content: rules.map((rule) => render(INTERRUPT_TEMPLATE, rule)).join("\n\n"),
458
+ display: true,
459
+ details: { rules: rules.map((rule) => rule.name) },
460
+ },
461
+ { triggerTurn: true },
462
+ );
463
+ });
464
+
465
+ // contextMode "discard": the interrupted partial response never reaches the model again.
466
+ pi.on("context", (event) => {
467
+ if (discardedTimestamps.size === 0) return;
468
+ const messages = event.messages.filter(
469
+ (message) =>
470
+ !(message.role === "assistant" && discardedTimestamps.has((message as { timestamp?: number }).timestamp ?? -1)),
471
+ );
472
+ return { messages };
473
+ });
474
+
475
+ pi.on("tool_call", (event, ctx) => {
476
+ if (manager.rules.length === 0) return;
477
+ const { digest, filePaths } = toolDigest(event.toolName, event.input as Record<string, unknown>);
478
+ const matched = manager.check(digest, { source: "tool", toolName: event.toolName, filePaths });
479
+ if (matched.length === 0) return;
480
+ manager.markInjected(matched.map((rule) => rule.name));
481
+ const blocking = matched.filter((rule) => interrupts(rule, "tool"));
482
+ const passive = matched.filter((rule) => !interrupts(rule, "tool"));
483
+ if (blocking.length > 0) {
484
+ pi.appendEntry(ENTRY_TYPE, { rules: matched.map((rule) => rule.name) });
485
+ ctx.ui.notify(`TTSR: blocked ${event.toolName} by rule ${blocking.map((rule) => rule.name).join(", ")}`, "warning");
486
+ return {
487
+ block: true,
488
+ reason: [...blocking.map((rule) => render(INTERRUPT_TEMPLATE, rule)), ...passive.map((rule) => render(TOOL_REMINDER_TEMPLATE, rule))].join("\n\n"),
489
+ };
490
+ }
491
+ toolReminders.set(event.toolCallId, passive);
492
+ });
493
+
494
+ pi.on("tool_result", (event) => {
495
+ const rules = toolReminders.get(event.toolCallId);
496
+ if (!rules) return;
497
+ toolReminders.delete(event.toolCallId);
498
+ pi.appendEntry(ENTRY_TYPE, { rules: rules.map((rule) => rule.name) });
499
+ return {
500
+ content: [...event.content, { type: "text", text: rules.map((rule) => render(TOOL_REMINDER_TEMPLATE, rule)).join("\n\n") }],
501
+ };
502
+ });
503
+
504
+ pi.registerCommand("ttsr", {
505
+ description: "List loaded TTSR rules and rule-file problems",
506
+ handler: async (_args, ctx) => {
507
+ const lines = manager.rules.map(
508
+ (rule) => `${rule.name} [${rule.interruptMode}, ${rule.repeatMode}] ${rule.conditions.map(String).join(" | ")} ${rule.path}`,
509
+ );
510
+ ctx.ui.notify(
511
+ [`TTSR rules: ${manager.rules.length}`, ...lines, ...(warnings.length > 0 ? ["Problems:", ...warnings] : [])].join("\n"),
512
+ "info",
513
+ );
514
+ },
515
+ });
516
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * pi extension: validates the file after each write and edit tool result and
3
+ * appends the suggestions to the model-visible result content. The checks run
4
+ * in-process (pi/support/validators.ts), so no Python is needed on any platform.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { validateFile } from "./support/validators.ts";
9
+
10
+ function validate(filePath: string, cwd: string): string | undefined {
11
+ try {
12
+ return validateFile(filePath, cwd);
13
+ } catch (error) {
14
+ return `⚠ [anchorloop] Validator did not run: ${error instanceof Error ? error.message : String(error)}`;
15
+ }
16
+ }
17
+
18
+ export default function (pi: ExtensionAPI) {
19
+ pi.on("tool_result", async (event, ctx) => {
20
+ if (event.isError || (event.toolName !== "write" && event.toolName !== "edit")) return;
21
+ const filePath = event.input.path;
22
+ if (typeof filePath !== "string") return;
23
+ const advice = validate(filePath, ctx.cwd);
24
+ if (!advice) return;
25
+ return { content: [...event.content, { type: "text", text: advice }] };
26
+ });
27
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: agent-terse
3
+ description: Use when writing text another agent reads rather than the user, such as a subagent brief or follow-up, a subagent's final report, an intercom, tower, broadcast, or ping message, or a scratch handoff, plan, or note only agents read.
4
+ ---
5
+
6
+ # Agent Terse
7
+
8
+ An agent reader pays for every token it holds and needs no courtesy. Write for it terse like a smart caveman: cut words, never facts. Text the user reads (chat replies, questions, commits, tickets, repository docs) keeps full prose.
9
+
10
+ ## Rules
11
+
12
+ 1. **Point first, no ceremony.** Verdict or ask, evidence, next step. No greeting, recap, hedge, or sign-off.
13
+ 2. **Structure over transitions.** `if <condition>: <action>` for branches, numbered steps for order, `key: value` for state, bullets for sets. Fragments are fine while a line reads in one pass; no invented abbreviations or arrows.
14
+ 3. **Intent and acceptance, not method.** Say what must hold and how it is checked; spell out a method only where it is a constraint. A strong reader plans better than step-by-step pseudocode does.
15
+ 4. **Abstract the routine, list the targets.** Name routine work as a class (`fix lint in touched files`). Give exactly each thing the reader acts on or checks: file:line, command, commit, branch, ID, port, value.
16
+ 5. **Keep what flips meaning.** Every not, never, no, only, except, unless; numbers with units; code, paths, and errors verbatim; the user's authorizations and decisions quoted.
17
+ 6. **Motive and reason stay whole.** Write them short but keep the cause, never a bare label like `for safety`; a reader that knows why makes the call you would where the brief is silent. The user's reasons may be restated in project terms when the cause and meaning stay the same.
18
+
19
+ ## A brief stays complete
20
+
21
+ Terse cuts words inside a brief, never its fields, because a thin brief sends a subagent to redo a sibling's work or to miss part of the scope. Every brief carries:
22
+
23
+ - `goal`: the outcome and why it matters.
24
+ - `context`: the situation and the facts that change judgment (who is waiting, which environment, what an earlier round found).
25
+ - `scope`: what it owns, and what is out of scope.
26
+ - `others`: what sibling agents own or already found, so it neither repeats nor overlaps them.
27
+ - `constraints`: authorizations and decisions quoted verbatim, plus limits on method.
28
+ - `acceptance`: the check that proves done, and the report shape to return.
29
+
30
+ A reader that finds the goal, context, or reason unclear restates its reading in one or two lines, or asks the sender, before it acts.
31
+
32
+ ## Full sentences instead
33
+
34
+ Write full sentences for a security warning, an irreversible or outward-facing action, a step order a fragment could scramble, and a decision the reader must make, because a misread there costs more than every token saved.
35
+
36
+ ## Before sending
37
+
38
+ 1. An announcing opening or a recapping ending? Delete it.
39
+ 2. Every negation, number, path, and quoted authorization still there?
40
+ 3. A brief missing goal, context, scope, others, constraints, or acceptance? Add it.
41
+ 4. Without the stated reason, would the reader make the same call at an edge case? If not, the reason is too thin.
42
+ 5. A line with two readings? Make it a full sentence.
43
+
44
+ ---
45
+
46
+ Adapted, with changes, from [JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman) (Apache-2.0, Copyright 2026 Julius Brussee), revision `bad48a0`, `plugins/caveman/skills/caveman/SKILL.md`.
@@ -0,0 +1,34 @@
1
+ # Principles
2
+
3
+ Each entry: the name, when it applies, and the rule. Playbooks cite them by name, and the user steers with the same names.
4
+
5
+ ## Core
6
+
7
+ - **Subtract before you add.** Sizing any change. Delete dead code, one-caller wrappers, redundant checks, and stale prose first; the smallest change that solves the problem ships.
8
+ - **Foundational thinking.** Before writing logic or integrating a new requirement. Choose the data shape and its organizing structure first, as if the requirement had been there from day one, so the code that follows is obvious.
9
+ - **Attack the premise.** Two fixes resting on one premise failed the same check. Write the premise as one sentence and test it directly before another fix.
10
+ - **Minimize reader load.** Shaping code or prose that is hard to trace. Count the layers between question and answer and the state a reader must hold; collapse what does not earn its place.
11
+ - **Build the lever.** Work you would repeat by hand. Write the script, codemod, or check that does or proves it; the tool is what a reviewer reruns.
12
+
13
+ ## Architecture
14
+
15
+ - **Model the domain.** Logic that branches a lot or repeats a shape across files. Put the rule in one structure (state machine, table, typed model) instead of scattered conditionals.
16
+ - **Boundary discipline.** Wiring validation and errors. Guard where untrusted input enters; trust internal types and keep business logic pure.
17
+ - **Migrate callers, then delete.** Replacing an internal API. Move every caller and delete the old path in the same change; no compatibility layer outlives the move.
18
+
19
+ ## Verification
20
+
21
+ - **Prove it works.** Before declaring done. Check the real artifact (run it, read the stored value), never a proxy, a self-report, or "it compiles".
22
+ - **Fix root causes.** Debugging. Fix where the system first went wrong; a guard counts as a fix only when the state it handles is legitimate.
23
+ - **Sequence verifiable units.** Multi-step work and commit order. Each unit ends in a check that passes before the next starts; the history reads as an argument, failing test before fix.
24
+ - **Test behavior, not implementation.** Writing or keeping a test. Assert what users of the code observe, so the test fails when behavior breaks and survives internal change.
25
+ - **Observe, don't ask.** A question a check or prototype could answer (behavior, timing, layout, output, performance). Run it; only product and preference calls go to the user.
26
+ - **Explain the number.** Trusting a measurement. Name what limits it and rule out that it measured something else before reporting it.
27
+
28
+ ## Delegation
29
+
30
+ - **Never block on the human.** Reversible execution. Proceed and present the result; bring only irreversible actions and user-owned decisions to the user.
31
+
32
+ ---
33
+
34
+ Adapted from the pstack principle skills by Lauren Tan (MIT License), revision `e44668c`.