@gtrabanco/pi-agentic-workflow 0.36.0 → 4.10.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 (52) hide show
  1. package/dist/config/defaults.js +8 -0
  2. package/dist/config/merge.js +20 -0
  3. package/dist/config/schema.js +103 -1
  4. package/dist/config/types.d.ts +60 -0
  5. package/dist/extension/ci-guard.d.ts +29 -0
  6. package/dist/extension/ci-guard.js +39 -0
  7. package/dist/extension/cost-guard.d.ts +30 -0
  8. package/dist/extension/cost-guard.js +50 -0
  9. package/dist/extension/factory.d.ts +11 -4
  10. package/dist/extension/factory.js +4 -1
  11. package/dist/extension/guard-util.d.ts +26 -0
  12. package/dist/extension/guard-util.js +35 -0
  13. package/dist/extension/history-guard.d.ts +59 -0
  14. package/dist/extension/history-guard.js +132 -0
  15. package/dist/extension/index.js +21 -5
  16. package/dist/extension/merge-guard.d.ts +26 -28
  17. package/dist/extension/merge-guard.js +64 -38
  18. package/package.json +7 -6
  19. package/skills/audit-docs/SKILL.md +1 -1
  20. package/skills/audit-pr/SKILL.md +12 -1
  21. package/skills/discover-repository-state/SKILL.md +1 -1
  22. package/skills/execute-phase/SKILL.md +61 -6
  23. package/skills/fold-findings/SKILL.md +126 -8
  24. package/skills/fold-findings/references/FOLD_PROCESS.md +7 -1
  25. package/skills/init-workspace/SKILL.md +1 -1
  26. package/skills/log-session/SKILL.md +1 -1
  27. package/skills/orchestration-envelope/SKILL.md +1 -1
  28. package/skills/phase-contract/SKILL.md +1 -1
  29. package/skills/pre-execution-review/SKILL.md +1 -1
  30. package/skills/product-audit/SKILL.md +4 -1
  31. package/skills/product-audit/references/AUDIT_DIMENSIONS.md +34 -0
  32. package/skills/product-audit/references/AUDIT_PROCESS.md +5 -1
  33. package/skills/resolve-repository-state/SKILL.md +1 -1
  34. package/skills/review-a11y/SKILL.md +1 -1
  35. package/skills/review-brand/SKILL.md +1 -1
  36. package/skills/review-change/SKILL.md +18 -3
  37. package/skills/review-code/SKILL.md +28 -2
  38. package/skills/review-debt/SKILL.md +8 -2
  39. package/skills/review-design/SKILL.md +1 -1
  40. package/skills/review-implementation/SKILL.md +1 -1
  41. package/skills/review-implementation/references/CLASSIFY.md +25 -0
  42. package/skills/review-perf/SKILL.md +1 -1
  43. package/skills/review-security/SKILL.md +1 -1
  44. package/skills/review-seo/SKILL.md +1 -1
  45. package/skills/review-verify/SKILL.md +19 -2
  46. package/skills/triage-issue/SKILL.md +9 -1
  47. package/skills/triage-issue/references/LABELS.md +22 -0
  48. package/skills/unit-lane/SKILL.md +26 -7
  49. package/skills/unit-lane/references/DESIGN.md +55 -1
  50. package/skills/unit-lane/references/PLAN.md +29 -0
  51. package/skills/verification-contract/SKILL.md +9 -1
  52. package/skills/workflow-status/SKILL.md +1 -1
@@ -20,6 +20,14 @@ export const DEFAULT_CONFIG = {
20
20
  // Feature 66 — the memory substrate is ask-first: nothing is provisioned
21
21
  // until the operator opts in.
22
22
  memory: { engram: false },
23
+ // Tool-call guards ship all-off: they are this repository's working rules,
24
+ // not a package opinion — a project opts in per guard in its config file.
25
+ guards: {
26
+ merge: false,
27
+ history: false,
28
+ ci: { create: false, run: false },
29
+ expensiveTests: { maxTokens: 0, patterns: [] },
30
+ },
23
31
  profiles: {},
24
32
  profileOrder: [],
25
33
  profileFallback: { ...DEFAULT_PROFILE_FALLBACK },
@@ -132,6 +132,9 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
132
132
  memory: {
133
133
  engram: pick(false, projectFile.memory?.engram, globalFile.memory?.engram) === true,
134
134
  },
135
+ // Tool-call guards resolve like every other knob: project wins, then
136
+ // global, then the shipped OFF. A guard no scope mentions stays disabled.
137
+ guards: mergeGuards(globalFile.guards, projectFile.guards),
135
138
  profiles: mergeProfiles(globalFile.profiles, projectFile.profiles),
136
139
  profileOrder: pick([], projectFile.profileOrder, globalFile.profileOrder),
137
140
  profileFallback: mergeProfileFallback(globalFile.profileFallback, projectFile.profileFallback),
@@ -144,6 +147,23 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
144
147
  merged.advance = advance;
145
148
  return merged;
146
149
  }
150
+ /** Guards merge whole-knob: a boolean takes project-then-global, and the
151
+ * expensive-tests block resolves field-by-field (patterns are replaced, not
152
+ * concatenated, so a project override is predictable — no hidden union). */
153
+ function mergeGuards(globalGuards, projectGuards) {
154
+ return {
155
+ merge: pick(DEFAULT_CONFIG.guards.merge, projectGuards?.merge, globalGuards?.merge),
156
+ history: pick(DEFAULT_CONFIG.guards.history, projectGuards?.history, globalGuards?.history),
157
+ ci: {
158
+ create: pick(DEFAULT_CONFIG.guards.ci.create, projectGuards?.ci?.create, globalGuards?.ci?.create),
159
+ run: pick(DEFAULT_CONFIG.guards.ci.run, projectGuards?.ci?.run, globalGuards?.ci?.run),
160
+ },
161
+ expensiveTests: {
162
+ maxTokens: pick(DEFAULT_CONFIG.guards.expensiveTests.maxTokens, projectGuards?.expensiveTests?.maxTokens, globalGuards?.expensiveTests?.maxTokens),
163
+ patterns: pick(DEFAULT_CONFIG.guards.expensiveTests.patterns, projectGuards?.expensiveTests?.patterns, globalGuards?.expensiveTests?.patterns),
164
+ },
165
+ };
166
+ }
147
167
  /** Project-over-global merge of the advance (conductor) knobs (feature 62).
148
168
  * Absent from the result when neither scope declares any — the resolved
149
169
  * shape of pre-62 configs is byte-stable. */
@@ -21,6 +21,7 @@ const ROOT_KEYS = new Set([
21
21
  "profileOrder",
22
22
  "profileFallback",
23
23
  "memory",
24
+ "guards",
24
25
  ]);
25
26
  const ROUTE_KEYS = new Set(["model", "thinking"]);
26
27
  const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
@@ -332,7 +333,7 @@ function validateConfig(value) {
332
333
  if (!ROOT_KEYS.has(key)) {
333
334
  issues.push({
334
335
  path: `$.${displayKey(key)}`,
335
- message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory)`,
336
+ message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory, guards)`,
336
337
  });
337
338
  }
338
339
  }
@@ -407,6 +408,11 @@ function validateConfig(value) {
407
408
  if (memory)
408
409
  config.memory = memory;
409
410
  }
411
+ if (value.guards !== undefined) {
412
+ const guards = checkGuards(value.guards, "$.guards", issues);
413
+ if (guards)
414
+ config.guards = guards;
415
+ }
410
416
  if (value.profiles !== undefined) {
411
417
  if (!isRecord(value.profiles)) {
412
418
  issues.push({ path: "$.profiles", message: "must be an object mapping profile names to profile definitions" });
@@ -466,6 +472,102 @@ function checkMemory(value, path, issues) {
466
472
  }
467
473
  return unknown ? undefined : out;
468
474
  }
475
+ /** Strict validator for the `guards` block. Everything absent stays off. */
476
+ function checkGuards(value, path, issues) {
477
+ if (!isRecord(value)) {
478
+ issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
479
+ return undefined;
480
+ }
481
+ const out = {};
482
+ for (const key of ["merge", "history"]) {
483
+ if (value[key] !== undefined) {
484
+ if (typeof value[key] !== "boolean") {
485
+ issues.push({ path: `${path}.${key}`, message: `must be a boolean, got ${describe(value[key])}` });
486
+ }
487
+ else {
488
+ out[key] = value[key];
489
+ }
490
+ }
491
+ }
492
+ if (value.ci !== undefined) {
493
+ const ciPath = `${path}.ci`;
494
+ if (!isRecord(value.ci)) {
495
+ issues.push({ path: ciPath, message: `must be a JSON object, got ${describe(value.ci)}` });
496
+ }
497
+ else {
498
+ const ci = {};
499
+ for (const key of ["create", "run"]) {
500
+ if (value.ci[key] !== undefined) {
501
+ if (typeof value.ci[key] !== "boolean") {
502
+ issues.push({ path: `${ciPath}.${key}`, message: `must be a boolean, got ${describe(value.ci[key])}` });
503
+ }
504
+ else {
505
+ ci[key] = value.ci[key];
506
+ }
507
+ }
508
+ }
509
+ for (const key of Object.keys(value.ci)) {
510
+ if (key !== "create" && key !== "run") {
511
+ issues.push({ path: `${ciPath}.${displayKey(key)}`, message: `unknown ci key "${key}" (allowed: create, run)` });
512
+ }
513
+ }
514
+ out.ci = ci;
515
+ }
516
+ }
517
+ if (value.expensiveTests !== undefined) {
518
+ out.expensiveTests = checkExpensiveTests(value.expensiveTests, `${path}.expensiveTests`, issues);
519
+ }
520
+ for (const key of Object.keys(value)) {
521
+ if (key !== "merge" && key !== "history" && key !== "ci" && key !== "expensiveTests") {
522
+ issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown guards key "${key}" (allowed: merge, history, ci, expensiveTests)` });
523
+ }
524
+ }
525
+ return out;
526
+ }
527
+ function checkExpensiveTests(value, path, issues) {
528
+ if (!isRecord(value)) {
529
+ issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
530
+ return undefined;
531
+ }
532
+ const out = {};
533
+ if (value.maxTokens !== undefined) {
534
+ if (typeof value.maxTokens !== "number" || !Number.isInteger(value.maxTokens) || value.maxTokens < 0) {
535
+ issues.push({ path: `${path}.maxTokens`, message: `must be a non-negative integer, got ${describe(value.maxTokens)}` });
536
+ }
537
+ else {
538
+ out.maxTokens = value.maxTokens;
539
+ }
540
+ }
541
+ if (value.patterns !== undefined) {
542
+ if (!Array.isArray(value.patterns)) {
543
+ issues.push({ path: `${path}.patterns`, message: `must be an array, got ${describe(value.patterns)}` });
544
+ }
545
+ else {
546
+ const patterns = [];
547
+ value.patterns.forEach((entry, index) => {
548
+ const entryPath = `${path}.patterns[${index}]`;
549
+ if (!isRecord(entry) || typeof entry.match !== "string" || entry.match === "") {
550
+ issues.push({ path: entryPath, message: `must be an object with a non-empty string "match", got ${describe(entry)}` });
551
+ return;
552
+ }
553
+ if (typeof entry.tokens !== "number" || !Number.isInteger(entry.tokens) || entry.tokens < 0) {
554
+ issues.push({ path: `${entryPath}.tokens`, message: `must be a non-negative integer, got ${describe(entry.tokens)}` });
555
+ return;
556
+ }
557
+ try {
558
+ new RegExp(entry.match, "u");
559
+ }
560
+ catch {
561
+ issues.push({ path: `${entryPath}.match`, message: `not a valid regular expression: ${entry.match}` });
562
+ return;
563
+ }
564
+ patterns.push({ match: entry.match, tokens: entry.tokens });
565
+ });
566
+ out.patterns = patterns;
567
+ }
568
+ }
569
+ return out;
570
+ }
469
571
  export function parseConfigFile(text) {
470
572
  if (text.trim() === "")
471
573
  return { ok: true, config: {} };
@@ -78,6 +78,9 @@ export interface ConfigFile {
78
78
  /** Feature 66 (AC3/D3): the memory-substrate consent surface — Engram's MCP
79
79
  * registration is ask-first, so nothing is provisioned unless this says so. */
80
80
  memory?: MemoryConfigFile;
81
+ /** Tool-call guards (owner-gated merges, history, CI, expensive tests).
82
+ * Everything absent stays off. */
83
+ guards?: GuardsConfigFile;
81
84
  /** Named user profiles keyed by profile name (feature 63, AC5). */
82
85
  profiles?: Record<string, ProfileFile>;
83
86
  /** Ordered list of profile names to probe; empty means ["default"] only
@@ -105,6 +108,61 @@ export interface MemoryConfigFile {
105
108
  export interface MemoryConfig {
106
109
  engram: boolean;
107
110
  }
111
+ /**
112
+ * One tool-call guard, as an operator WRITES it (this repository's working
113
+ * rules, made mechanical; every other project gets none of it unless it
114
+ * configures it). Each guard ships disabled — an absent key is OFF.
115
+ *
116
+ * merge every direct merge route is owner-only: `git merge`,
117
+ * `gh pr merge`, and `git pull`'s merge form.
118
+ * history pushes are decided by asking git (`git push --dry-run`):
119
+ * a forced update, a ref delete or a push to `main` is
120
+ * declined — one mechanism covering every flag spelling and
121
+ * git alias.
122
+ * ci CI is owner-gated: creating workflow files and triggering
123
+ * workflow runs are blocked.
124
+ * expensiveTests commands matching a configured pattern are estimated in
125
+ * tokens; above `maxTokens` they are blocked. `tokens: 0`
126
+ * blocks every match regardless of estimate.
127
+ *
128
+ * The only way through a guard is the explicit per-command token — the
129
+ * command's first word — after an owner authorization in the same turn:
130
+ * `AW_ALLOW=merge`, `AW_ALLOW=force`, `AW_ALLOW=ci`, `AW_ALLOW=cost`.
131
+ */
132
+ export interface GuardsConfigFile {
133
+ merge?: boolean;
134
+ history?: boolean;
135
+ ci?: GuardsCiFile;
136
+ expensiveTests?: GuardsExpensiveTestsFile;
137
+ }
138
+ export interface GuardsCiFile {
139
+ create?: boolean;
140
+ run?: boolean;
141
+ }
142
+ export interface GuardsExpensiveTestsFile {
143
+ /** Estimated-token ceiling; a match above it is blocked. `0` (default)
144
+ * blocks every match regardless of estimate. */
145
+ maxTokens?: number;
146
+ /** Command patterns (regex source, `u` flag) and their estimated cost. */
147
+ patterns?: GuardsCostPattern[];
148
+ }
149
+ export interface GuardsCostPattern {
150
+ match: string;
151
+ tokens: number;
152
+ }
153
+ /** What the extension READS: every guard knob resolved. */
154
+ export interface GuardsConfig {
155
+ merge: boolean;
156
+ history: boolean;
157
+ ci: {
158
+ create: boolean;
159
+ run: boolean;
160
+ };
161
+ expensiveTests: {
162
+ maxTokens: number;
163
+ patterns: GuardsCostPattern[];
164
+ };
165
+ }
108
166
  export interface ConfigIssue {
109
167
  path: string;
110
168
  message: string;
@@ -133,6 +191,8 @@ export interface EffectiveConfig {
133
191
  profileFallback: ProfileFallbackConfig;
134
192
  /** Feature 66: the memory-substrate consent surface. */
135
193
  memory: MemoryConfig;
194
+ /** The effective tool-call guards (all off unless a config file enables them). */
195
+ guards: GuardsConfig;
136
196
  /** Feature 63: declaredness of the implicit `default` profile. */
137
197
  declared: {
138
198
  default: RouteDeclaration;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The CI guard — creating and triggering CI is owner-gated.
3
+ *
4
+ * Two routes, both config-gated (`guards.ci.create` / `guards.ci.run`; off by
5
+ * default for every project):
6
+ *
7
+ * create a write/edit that adds or changes a workflow definition — under
8
+ * `.github/workflows/`, `.gitea/workflows/`, `.forgejo/workflows/`,
9
+ * or a `.gitlab-ci.yml` — is blocked. A workflow file is code the
10
+ * forge runs on the next push; writing one is arming it.
11
+ * run `gh workflow run`, `gh run rerun`, and the `dispatches` REST
12
+ * endpoint are blocked: triggering CI consumes the repo's minutes
13
+ * and runs its automation without the owner watching.
14
+ *
15
+ * The only way through is an explicit owner instruction in the same turn,
16
+ * carried as the command's first word: `AW_ALLOW=ci`. Reads (`gh workflow
17
+ * list`, `gh run list`, `gh run view`) are not gated.
18
+ */
19
+ import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
20
+ export declare const CI_CREATE_REASON: string;
21
+ export declare const CI_RUN_REASON: string;
22
+ /**
23
+ * The `tool_call` gate. `create` judges write/edit targets, `run` judges bash
24
+ * commands; both are no-ops while disabled. Reads pass.
25
+ */
26
+ export declare function ciGuard({ toolName, command, path }: ToolCallLike, policy?: {
27
+ create: boolean;
28
+ run: boolean;
29
+ }): GuardDecision;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The CI guard — creating and triggering CI is owner-gated.
3
+ *
4
+ * Two routes, both config-gated (`guards.ci.create` / `guards.ci.run`; off by
5
+ * default for every project):
6
+ *
7
+ * create a write/edit that adds or changes a workflow definition — under
8
+ * `.github/workflows/`, `.gitea/workflows/`, `.forgejo/workflows/`,
9
+ * or a `.gitlab-ci.yml` — is blocked. A workflow file is code the
10
+ * forge runs on the next push; writing one is arming it.
11
+ * run `gh workflow run`, `gh run rerun`, and the `dispatches` REST
12
+ * endpoint are blocked: triggering CI consumes the repo's minutes
13
+ * and runs its automation without the owner watching.
14
+ *
15
+ * The only way through is an explicit owner instruction in the same turn,
16
+ * carried as the command's first word: `AW_ALLOW=ci`. Reads (`gh workflow
17
+ * list`, `gh run list`, `gh run view`) are not gated.
18
+ */
19
+ import { explicitAllow } from "./guard-util.js";
20
+ const WORKFLOW_PATH_RE = /(?:^|\/)\.(?:github|gitea|forgejo)\/workflows\/|(?:^|\/)\.gitlab-ci\.yml$/u;
21
+ const CI_RUN_RE = /\bgh\s+(?:workflow\s+run|run\s+rerun)\b|actions\/workflows\/[^/\s"']+\/dispatches\b/u;
22
+ export const CI_CREATE_REASON = "Creating CI is owner-gated (ci guard): a workflow definition is code the forge runs on the next push, so an agent session does not write one unannounced. " +
23
+ "Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=ci`.";
24
+ export const CI_RUN_REASON = "Running CI is owner-gated (ci guard): a workflow run consumes the repository's minutes and fires its automation, so an agent session does not trigger one unannounced. " +
25
+ "Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=ci`.";
26
+ /**
27
+ * The `tool_call` gate. `create` judges write/edit targets, `run` judges bash
28
+ * commands; both are no-ops while disabled. Reads pass.
29
+ */
30
+ export function ciGuard({ toolName, command, path }, policy = { create: false, run: false }) {
31
+ if (policy.create && (toolName === "write" || toolName === "edit") && path !== undefined && WORKFLOW_PATH_RE.test(path)) {
32
+ if (!explicitAllow(command ?? "").has("ci"))
33
+ return { block: true, reason: CI_CREATE_REASON };
34
+ }
35
+ if (policy.run && toolName === "bash" && command !== undefined && !explicitAllow(command).has("ci") && CI_RUN_RE.test(command)) {
36
+ return { block: true, reason: CI_RUN_REASON };
37
+ }
38
+ return { block: false };
39
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The cost guard — tests estimated above the token budget are owner-gated.
3
+ *
4
+ * Operators declare command patterns and their estimated cost in tokens
5
+ * (`guards.expensiveTests.patterns`), plus a ceiling (`maxTokens`, `0` = every
6
+ * match is gated). A bash command matching a pattern whose estimate exceeds the
7
+ * ceiling is blocked — an LLM-driven suite or a mutation run can burn a budget
8
+ * silently, and "it was just a test" is not authorization to spend it.
9
+ *
10
+ * The estimate is exactly what the word says: a number the operator wrote next
11
+ * to the pattern, not a measurement. It answers "may this run unattended?",
12
+ * never "how much did it cost?".
13
+ *
14
+ * The only way through is an explicit owner instruction in the same turn,
15
+ * carried as the command's first word: `AW_ALLOW=cost`. Listing or reading
16
+ * tests is never gated.
17
+ */
18
+ import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
19
+ import type { GuardsCostPattern } from "../config/types.js";
20
+ export declare const COST_REASON: string;
21
+ /** The estimated cost of one command, or null when it matches nothing. */
22
+ export declare function estimatedCost(command: string, patterns: GuardsCostPattern[]): number | null;
23
+ /**
24
+ * The `tool_call` gate, bash only. `policy.maxTokens` is the ceiling; `0`
25
+ * gates every match. The guard is a no-op while no patterns are configured.
26
+ */
27
+ export declare function costGuard({ toolName, command }: ToolCallLike, policy?: {
28
+ maxTokens: number;
29
+ patterns: GuardsCostPattern[];
30
+ }): GuardDecision;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The cost guard — tests estimated above the token budget are owner-gated.
3
+ *
4
+ * Operators declare command patterns and their estimated cost in tokens
5
+ * (`guards.expensiveTests.patterns`), plus a ceiling (`maxTokens`, `0` = every
6
+ * match is gated). A bash command matching a pattern whose estimate exceeds the
7
+ * ceiling is blocked — an LLM-driven suite or a mutation run can burn a budget
8
+ * silently, and "it was just a test" is not authorization to spend it.
9
+ *
10
+ * The estimate is exactly what the word says: a number the operator wrote next
11
+ * to the pattern, not a measurement. It answers "may this run unattended?",
12
+ * never "how much did it cost?".
13
+ *
14
+ * The only way through is an explicit owner instruction in the same turn,
15
+ * carried as the command's first word: `AW_ALLOW=cost`. Listing or reading
16
+ * tests is never gated.
17
+ */
18
+ import { explicitAllow } from "./guard-util.js";
19
+ export const COST_REASON = "Running this is owner-gated (cost guard): its estimated token cost exceeds the configured budget, and a test is not authorization to spend it. " +
20
+ "Only an explicit owner instruction in this turn enables it, carried as the command's first word: `AW_ALLOW=cost`.";
21
+ /** The estimated cost of one command, or null when it matches nothing. */
22
+ export function estimatedCost(command, patterns) {
23
+ let cost = null;
24
+ for (const pattern of patterns) {
25
+ let re;
26
+ try {
27
+ re = new RegExp(pattern.match, "u");
28
+ }
29
+ catch {
30
+ continue; // Invalid patterns are rejected at config load; never trust twice.
31
+ }
32
+ if (re.test(command) && (cost === null || pattern.tokens > cost))
33
+ cost = pattern.tokens;
34
+ }
35
+ return cost;
36
+ }
37
+ /**
38
+ * The `tool_call` gate, bash only. `policy.maxTokens` is the ceiling; `0`
39
+ * gates every match. The guard is a no-op while no patterns are configured.
40
+ */
41
+ export function costGuard({ toolName, command }, policy = { maxTokens: 0, patterns: [] }) {
42
+ if (toolName !== "bash" || command === undefined || policy.patterns.length === 0)
43
+ return { block: false };
44
+ if (explicitAllow(command).has("cost"))
45
+ return { block: false };
46
+ const cost = estimatedCost(command, policy.patterns);
47
+ if (cost === null || cost <= policy.maxTokens)
48
+ return { block: false };
49
+ return { block: true, reason: `${COST_REASON} (estimated: ${cost} tokens, budget: ${policy.maxTokens})` };
50
+ }
@@ -3,6 +3,9 @@ import type { Catalogue } from "../routing/catalogue.js";
3
3
  import type { Router } from "../routing/dispatch.js";
4
4
  import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
5
5
  import { mergeGuard } from "./merge-guard.js";
6
+ import { historyGuard } from "./history-guard.js";
7
+ import { ciGuard } from "./ci-guard.js";
8
+ import { costGuard } from "./cost-guard.js";
6
9
  import type { ExtensionSurface, InvocationContext, ModelRef, RoutingControls } from "../routing/types.js";
7
10
  import type { HintStore } from "../routing/state.js";
8
11
  /**
@@ -55,14 +58,18 @@ export interface ExtensionHandle<M extends ModelRef = ModelRef> {
55
58
  catalogue: Catalogue;
56
59
  /**
57
60
  * The Pi-free predicates the adapter (`index.ts`) binds to Pi's lifecycle
58
- * events: the inline-receipt block guard, the merge guard (owner-only
59
- * merges, issue #278), and the dirty-worktree notice. They travel with the
60
- * handle so the entry stays a thin translation layer and the decisions are
61
- * unit-tested with no session (issues #182, #278).
61
+ * events: the inline-receipt block guard, the owner-gated tool guards (merge,
62
+ * history, CI, cost — all config-gated, all off unless a project enables
63
+ * them), and the dirty-worktree notice. They travel with the handle so the
64
+ * entry stays a thin translation layer and the decisions are unit-tested with
65
+ * no session (issues #182, #278).
62
66
  */
63
67
  guards: {
64
68
  receiptGuard: typeof receiptGuard;
65
69
  mergeGuard: typeof mergeGuard;
70
+ historyGuard: typeof historyGuard;
71
+ ciGuard: typeof ciGuard;
72
+ costGuard: typeof costGuard;
66
73
  dirtyWorktreeWarning: typeof dirtyWorktreeWarning;
67
74
  };
68
75
  }
@@ -3,6 +3,9 @@ import { readCatalogue } from "../routing/catalogue.js";
3
3
  import { createRouter } from "../routing/dispatch.js";
4
4
  import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
5
5
  import { mergeGuard } from "./merge-guard.js";
6
+ import { historyGuard } from "./history-guard.js";
7
+ import { ciGuard } from "./ci-guard.js";
8
+ import { costGuard } from "./cost-guard.js";
6
9
  import { ADVANCE_COMMAND, SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
7
10
  import { registerAdvanceCommand } from "./conductor-command.js";
8
11
  import { createProfileStateStore, stateFilePath } from "../routing/state.js";
@@ -79,5 +82,5 @@ export function createExtension(deps) {
79
82
  description: "Show and configure per-command model routing",
80
83
  handler: settingsHandler,
81
84
  });
82
- return { router, catalogue, guards: { receiptGuard, mergeGuard, dirtyWorktreeWarning } };
85
+ return { router, catalogue, guards: { receiptGuard, mergeGuard, historyGuard, ciGuard, costGuard, dirtyWorktreeWarning } };
83
86
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared plumbing for the pure `tool_call` guards (`merge-guard`,
3
+ * `history-guard`, `ci-guard`, `cost-guard`): the input shape, the
4
+ * explicit-authorization token, and command segmentation.
5
+ *
6
+ * Pure on purpose: no session, no fs, no Pi — unit-tested with no session,
7
+ * exactly like the guards themselves.
8
+ */
9
+ /** The narrow slice of a Pi `tool_call` the guards read. */
10
+ export interface ToolCallLike {
11
+ toolName: string;
12
+ command?: string | undefined;
13
+ path?: string | undefined;
14
+ }
15
+ /** The Pi `tool_call` result shape: `block` plus an actionable `reason`. */
16
+ export interface GuardDecision {
17
+ block: boolean;
18
+ reason?: string;
19
+ }
20
+ export declare function explicitAllow(text: string): Set<string>;
21
+ /**
22
+ * Split a command into shell segments so a flag is never attributed across
23
+ * commands: `docker build -f Dockerfile . && git push origin feat/x` must read
24
+ * as a docker flag plus a clean push, not as a force push.
25
+ */
26
+ export declare function splitSegments(text: string): string[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared plumbing for the pure `tool_call` guards (`merge-guard`,
3
+ * `history-guard`, `ci-guard`, `cost-guard`): the input shape, the
4
+ * explicit-authorization token, and command segmentation.
5
+ *
6
+ * Pure on purpose: no session, no fs, no Pi — unit-tested with no session,
7
+ * exactly like the guards themselves.
8
+ */
9
+ /**
10
+ * The explicit-authorization tokens, one per guard capability:
11
+ *
12
+ * AW_ALLOW=merge owner-only merges ("Sí, mergea la PR", "Mergea rama B en rama A")
13
+ * AW_ALLOW=force a forced/deleting push ("Sí, usa force") — never a push to `main`
14
+ * AW_ALLOW=ci creating or triggering CI workflows
15
+ * AW_ALLOW=cost running a test estimated above the token budget
16
+ *
17
+ * Only an owner instruction in the same turn lets a guard through, and the
18
+ * authorization must travel with the command it enables: the token is the
19
+ * command's first word (`AW_ALLOW=merge git merge ...`) or follows `env `
20
+ * (`env AW_ALLOW=ci gh workflow run x`). Anywhere else — mid-command, in a
21
+ * search string, in a chained command, in an export — it authorizes nothing,
22
+ * so a command cannot smuggle the token for a later or different action.
23
+ */
24
+ const AW_ALLOW_RE = /(?:^|env\s+)AW_ALLOW=([a-z]+(?:,[a-z]+)*)/u;
25
+ export function explicitAllow(text) {
26
+ return new Set(AW_ALLOW_RE.exec(text ?? "")?.[1]?.split(",") ?? []);
27
+ }
28
+ /**
29
+ * Split a command into shell segments so a flag is never attributed across
30
+ * commands: `docker build -f Dockerfile . && git push origin feat/x` must read
31
+ * as a docker flag plus a clean push, not as a force push.
32
+ */
33
+ export function splitSegments(text) {
34
+ return String(text ?? "").split(/[;|&\n]/u);
35
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The history guard — published history is owner-written, decided by git
3
+ * itself (`git push --dry-run`), not by enumerating spellings.
4
+ *
5
+ * AGENTS.md → Working rules states the way this repository integrates work:
6
+ * short branches off `main`, `main` only advances through a PR, and when `main`
7
+ * moves the conflicted branch is handed back for the owner to integrate. A
8
+ * local `git rebase` is a private rewrite; the moment it is pushed it rewrites
9
+ * commits a reviewer, another session or a CI artifact may already be building
10
+ * on, so the block sits where the rewrite becomes public — on `git push`.
11
+ *
12
+ * The outcome, not the spelling, is what is judged. Every direct `git push`
13
+ * runs first as `git push --dry-run` (the same arguments, plus `--dry-run` —
14
+ * git resolves flags and refspecs itself), and the printed outcome decides: a
15
+ * `(forced update)`, a `[deleted]` ref, or a destination of `main` declines
16
+ * the push. One mechanism covers every force flag spelling and `+`/`:ref`
17
+ * refspec, because the answer comes from git, not from this file. When the
18
+ * dry-run cannot run (offline — where a real push would fail anyway), a simple
19
+ * text check on the push's own segment is the fallback.
20
+ *
21
+ * The guard is config-gated (`guards.history`): off by default for every
22
+ * project, enabled where an operator asks for it. The only way through is an
23
+ * explicit owner instruction in the same turn, carried as the command's first
24
+ * word: `AW_ALLOW=force` — which never enables a push to `main`, because main
25
+ * advances only through a PR.
26
+ *
27
+ * What is deliberately out of scope: payloads hidden inside scripts or
28
+ * interpreter one-liners that never spell `git push` in the command text, and
29
+ * a git alias invoked as `git fp` — the dry-run sees what git is told to push,
30
+ * not what an alias named `fp` expands to. The dry-run sees what git sees; it
31
+ * cannot see what a script builds at runtime, and no text predicate honestly
32
+ * can. The residual risk is documented, not chased with a regex zoo.
33
+ */
34
+ import { type GuardDecision, type ToolCallLike } from "./guard-util.js";
35
+ /** The outcome a dry-run can report for one push. */
36
+ export type PushOutcome = "clean" | "forced" | "deleted" | "main" | "unknown";
37
+ export interface PushRunnerResult {
38
+ status: number;
39
+ stdout: string;
40
+ stderr: string;
41
+ }
42
+ export type PushRunner = (args: string[]) => PushRunnerResult;
43
+ /** The default runner: git in the session's working directory. */
44
+ export declare function spawnPushRunner(cwd: string): PushRunner;
45
+ /** Shell-tokenize the arguments of one push segment (quotes stripped). */
46
+ export declare function pushArgs(segment: string): string[];
47
+ /** Read the outcome git printed for one dry-run. */
48
+ export declare function readPushOutcome(result: PushRunnerResult): PushOutcome;
49
+ /**
50
+ * The `tool_call` gate. `enabled` comes from the config (`guards.history`);
51
+ * the guard is a no-op while disabled. `runner`/`cwd` are injectable so the
52
+ * suite never touches the network.
53
+ */
54
+ export declare function historyGuard({ toolName, command }: ToolCallLike, enabled?: boolean, { runner, cwd, offlineFallback }?: {
55
+ runner?: PushRunner;
56
+ cwd?: string;
57
+ offlineFallback?: (segment: string) => PushOutcome;
58
+ }): GuardDecision;
59
+ export declare const HISTORY_REASON: string;