@gtrabanco/pi-agentic-workflow 0.18.3 → 0.18.5
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/dist/extension/factory.d.ts +6 -3
- package/dist/extension/factory.js +2 -1
- package/dist/extension/index.js +8 -0
- package/dist/extension/merge-guard.d.ts +38 -0
- package/dist/extension/merge-guard.js +53 -0
- package/package.json +1 -1
- package/skills/phase-contract/SKILL.md +68 -5
- package/skills/unit-lane/SKILL.md +1 -1
- package/skills/unit-lane/references/PLAN.md +13 -3
|
@@ -2,6 +2,7 @@ import type { LoadedConfig } from "../config/load.js";
|
|
|
2
2
|
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
|
+
import { mergeGuard } from "./merge-guard.js";
|
|
5
6
|
import type { ExtensionSurface, InvocationContext, ModelRef, RoutingControls } from "../routing/types.js";
|
|
6
7
|
import type { HintStore } from "../routing/state.js";
|
|
7
8
|
/**
|
|
@@ -48,12 +49,14 @@ export interface ExtensionHandle<M extends ModelRef = ModelRef> {
|
|
|
48
49
|
catalogue: Catalogue;
|
|
49
50
|
/**
|
|
50
51
|
* The Pi-free predicates the adapter (`index.ts`) binds to Pi's lifecycle
|
|
51
|
-
* events: the inline-receipt block guard
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* events: the inline-receipt block guard, the merge guard (owner-only
|
|
53
|
+
* merges, issue #278), and the dirty-worktree notice. They travel with the
|
|
54
|
+
* handle so the entry stays a thin translation layer and the decisions are
|
|
55
|
+
* unit-tested with no session (issues #182, #278).
|
|
54
56
|
*/
|
|
55
57
|
guards: {
|
|
56
58
|
receiptGuard: typeof receiptGuard;
|
|
59
|
+
mergeGuard: typeof mergeGuard;
|
|
57
60
|
dirtyWorktreeWarning: typeof dirtyWorktreeWarning;
|
|
58
61
|
};
|
|
59
62
|
}
|
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../config/load.js";
|
|
|
2
2
|
import { readCatalogue } from "../routing/catalogue.js";
|
|
3
3
|
import { createRouter } from "../routing/dispatch.js";
|
|
4
4
|
import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
|
|
5
|
+
import { mergeGuard } from "./merge-guard.js";
|
|
5
6
|
import { ADVANCE_COMMAND, SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
|
|
6
7
|
import { registerAdvanceCommand } from "./conductor-command.js";
|
|
7
8
|
import { createProfileStateStore, stateFilePath } from "../routing/state.js";
|
|
@@ -69,5 +70,5 @@ export function createExtension(deps) {
|
|
|
69
70
|
description: "Show and configure per-command model routing",
|
|
70
71
|
handler: settingsHandler,
|
|
71
72
|
});
|
|
72
|
-
return { router, catalogue, guards: { receiptGuard, dirtyWorktreeWarning } };
|
|
73
|
+
return { router, catalogue, guards: { receiptGuard, mergeGuard, dirtyWorktreeWarning } };
|
|
73
74
|
}
|
package/dist/extension/index.js
CHANGED
|
@@ -211,8 +211,16 @@ export default function extension(pi) {
|
|
|
211
211
|
// names the script that proves the receipt landed (issue #182). The guard
|
|
212
212
|
// itself filters to `bash`, so every tool call is checked — nesting it under
|
|
213
213
|
// one tool name would make the block unreachable for the calls it exists for.
|
|
214
|
+
//
|
|
215
|
+
// Merges are owner-only (issue #278): `gh pr merge`, the `gh api` merge
|
|
216
|
+
// endpoints, and `git merge` are blocked before anything else runs — a
|
|
217
|
+
// review waiver is not merge authorization. Read-only git (`merge-tree`,
|
|
218
|
+
// `merge-base`) and rebases pass; the reason hands the PR URL to the owner.
|
|
214
219
|
pi.on("tool_call", (event, ctx) => {
|
|
215
220
|
const command = "command" in event.input && typeof event.input.command === "string" ? event.input.command : undefined;
|
|
221
|
+
const merge = guards.mergeGuard({ toolName: event.toolName, command });
|
|
222
|
+
if (merge.block)
|
|
223
|
+
return merge;
|
|
216
224
|
const receipt = guards.receiptGuard({ toolName: event.toolName, command });
|
|
217
225
|
if (receipt.block)
|
|
218
226
|
return receipt;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge guard — owner-only merges, enforced at the `tool_call` gate
|
|
3
|
+
* (issue #278).
|
|
4
|
+
*
|
|
5
|
+
* On 2026-09-29 an agent session ran `gh pr merge` after an owner message that
|
|
6
|
+
* waived only the *review* ("No hace falta una review para esto"), not the
|
|
7
|
+
* merge. Merging is owner-only: prose already says so (`audit-pr` "never
|
|
8
|
+
* merges") and prose demonstrably failed once, so the block lives here, in the
|
|
9
|
+
* same Pi-free layer as the inline-receipt guard (issue #182) — a predicate the
|
|
10
|
+
* shipped adapter binds to `tool_call`, unit-tested with no session.
|
|
11
|
+
*
|
|
12
|
+
* Same philosophy as `receipt-guard`: deterministic over clever. A bash command
|
|
13
|
+
* that merely *mentions* `gh pr merge` (a search for the guard's own text) is
|
|
14
|
+
* blocked like an invocation; the reason tells the agent to search `pr merge`
|
|
15
|
+
* instead. This is a guard, not a wall — it covers what routes through Pi's
|
|
16
|
+
* tool pipeline, exactly like the path-protection guard beside it.
|
|
17
|
+
*
|
|
18
|
+
* Pure on purpose: `index.ts` is the only file that imports Pi values, so this
|
|
19
|
+
* predicate compiles and is tested with no session.
|
|
20
|
+
*/
|
|
21
|
+
/** The narrow slice of a Pi `tool_call` the guard reads. */
|
|
22
|
+
export interface ToolCallLike {
|
|
23
|
+
toolName: string;
|
|
24
|
+
command?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The Pi `tool_call` result shape: `block` plus an actionable `reason`. */
|
|
27
|
+
export interface MergeGuardDecision {
|
|
28
|
+
block: boolean;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The `tool_call` gate. Blocks merge shapes on the `bash` tool only — every
|
|
33
|
+
* other tool passes, and every non-merge command passes. Read-only git
|
|
34
|
+
* (`merge-tree`, `merge-base`), rebases, and PR reads (`gh pr view`, checks,
|
|
35
|
+
* comments) are deliberately out of scope; `gh pr comment` receipts remain the
|
|
36
|
+
* receipt guard's domain.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mergeGuard({ toolName, command }: ToolCallLike): MergeGuardDecision;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge guard — owner-only merges, enforced at the `tool_call` gate
|
|
3
|
+
* (issue #278).
|
|
4
|
+
*
|
|
5
|
+
* On 2026-09-29 an agent session ran `gh pr merge` after an owner message that
|
|
6
|
+
* waived only the *review* ("No hace falta una review para esto"), not the
|
|
7
|
+
* merge. Merging is owner-only: prose already says so (`audit-pr` "never
|
|
8
|
+
* merges") and prose demonstrably failed once, so the block lives here, in the
|
|
9
|
+
* same Pi-free layer as the inline-receipt guard (issue #182) — a predicate the
|
|
10
|
+
* shipped adapter binds to `tool_call`, unit-tested with no session.
|
|
11
|
+
*
|
|
12
|
+
* Same philosophy as `receipt-guard`: deterministic over clever. A bash command
|
|
13
|
+
* that merely *mentions* `gh pr merge` (a search for the guard's own text) is
|
|
14
|
+
* blocked like an invocation; the reason tells the agent to search `pr merge`
|
|
15
|
+
* instead. This is a guard, not a wall — it covers what routes through Pi's
|
|
16
|
+
* tool pipeline, exactly like the path-protection guard beside it.
|
|
17
|
+
*
|
|
18
|
+
* Pure on purpose: `index.ts` is the only file that imports Pi values, so this
|
|
19
|
+
* predicate compiles and is tested with no session.
|
|
20
|
+
*/
|
|
21
|
+
/** `gh pr merge` as an invoked command, not as a quoted string elsewhere in it. */
|
|
22
|
+
const GH_PR_MERGE_RE = /\bgh\s+pr\s+merge\b/;
|
|
23
|
+
/** The REST merge endpoint and the GraphQL mutation, both under `gh api`. */
|
|
24
|
+
const GH_API_MERGE_RE = /\bgh\s+api\b[\s\S]*(?:pulls\/\d+\/merge\b|mergePullRequest\b)/;
|
|
25
|
+
/**
|
|
26
|
+
* `git merge <args>` (optionally after `-C <path>` / `-c <name>`), at a word
|
|
27
|
+
* boundary that is deliberately NOT `-`: `git merge-tree` and `git merge-base`
|
|
28
|
+
* are read-only and the reconciliation flow depends on them (issue #275).
|
|
29
|
+
*/
|
|
30
|
+
const GIT_MERGE_RE = /(^|[\s;|&()])git\s+(?:-[Cc]\s+\S+\s+)*merge(?![\w-])(?=\s|$)/;
|
|
31
|
+
const GH_MERGE_REASON = "Merging is owner-only (merge guard, issue #278): a merge command from an agent session is blocked — a review waiver is not merge authorization. " +
|
|
32
|
+
"Open or refresh the PR and hand its URL to the owner (`gh pr view <N> --json url -q .url`); if you were searching text, search `pr merge` instead of `gh pr merge`.";
|
|
33
|
+
const GIT_MERGE_REASON = "Merging is owner-only (merge guard, issue #278): `git merge` from an agent session is blocked. " +
|
|
34
|
+
"Hand the branch to the owner for integration, or use the read-only forms (`git merge-tree`, `git merge-base`) and rebases, which stay allowed.";
|
|
35
|
+
/**
|
|
36
|
+
* The `tool_call` gate. Blocks merge shapes on the `bash` tool only — every
|
|
37
|
+
* other tool passes, and every non-merge command passes. Read-only git
|
|
38
|
+
* (`merge-tree`, `merge-base`), rebases, and PR reads (`gh pr view`, checks,
|
|
39
|
+
* comments) are deliberately out of scope; `gh pr comment` receipts remain the
|
|
40
|
+
* receipt guard's domain.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeGuard({ toolName, command }) {
|
|
43
|
+
if (toolName !== "bash")
|
|
44
|
+
return { block: false };
|
|
45
|
+
const text = command ?? "";
|
|
46
|
+
if (GH_PR_MERGE_RE.test(text) || GH_API_MERGE_RE.test(text)) {
|
|
47
|
+
return { block: true, reason: GH_MERGE_REASON };
|
|
48
|
+
}
|
|
49
|
+
if (GIT_MERGE_RE.test(text)) {
|
|
50
|
+
return { block: true, reason: GIT_MERGE_REASON };
|
|
51
|
+
}
|
|
52
|
+
return { block: false };
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: phase-contract
|
|
3
3
|
user-invocable: false
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
author: "Gabriel Trabanco <1969593+gtrabanco@users.noreply.github.com>"
|
|
6
6
|
license: MIT
|
|
7
7
|
description: >
|
|
8
|
-
Internal contract: the single owner of the eight phase-lint rules, the
|
|
9
|
-
PASS/BLOCKED result, and the normalized
|
|
10
|
-
`execute-phase` and the lane's `plan` step.
|
|
8
|
+
Internal contract: the single owner of the eight phase-lint rules, the two
|
|
9
|
+
accepted phase grammars, the fixed PASS/BLOCKED result, and the normalized
|
|
10
|
+
phase fingerprint. Consumed by `execute-phase` and the lane's `plan` step.
|
|
11
|
+
Not a menu entry.
|
|
11
12
|
---
|
|
12
13
|
|
|
13
14
|
# Phase Contract (internal)
|
|
14
15
|
|
|
15
16
|
The one authoritative owner of phase shape for this workflow. Planners lint
|
|
16
17
|
every phase they emit against the eight rules below; `execute-phase` re-checks
|
|
17
|
-
the same rules before any edit. Nothing else may define what a valid phase is
|
|
18
|
+
the same rules before any edit. Nothing else may define what a valid phase is —
|
|
19
|
+
including which grammar a phase is written in.
|
|
18
20
|
|
|
19
21
|
## When to use
|
|
20
22
|
|
|
@@ -62,18 +64,75 @@ The exemption is scoped to those two boxes: boxes 1, 2, 4, 5, 6 and 8 stay armed
|
|
|
62
64
|
executed phase, and for an unemitted phase every box stays armed, so pre-ticking
|
|
63
65
|
a phase to dodge a check is a defect, never a pass.
|
|
64
66
|
|
|
67
|
+
## The two grammars (fix #272)
|
|
68
|
+
|
|
69
|
+
One contract, two shapes. `scripts/phase-lint.mjs` mechanizes exactly this
|
|
70
|
+
split and never carries a second copy of the semantics:
|
|
71
|
+
|
|
72
|
+
| grammar | where | shape |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| **plan** | a plan file, or any `##/###/#### P<n> — <Title>` heading | heading + `Layer: <enum>` + `- [ ]` tasks + `Done-when:` |
|
|
75
|
+
| **unit doc** | the unit doc's `## Tasks` section | one `P<n> — <task> (validator: <command> <outcome>)` bullet per phase, wrapped lines joining the bullet, plus an optional nested `- Relevant files: <path, path…>` line |
|
|
76
|
+
|
|
77
|
+
Precedence is fail-closed and one-way: **if a document carries any plan-style
|
|
78
|
+
phase heading, the plan grammar governs it**; only when it carries none does
|
|
79
|
+
the unit-doc grammar apply; a document with no phase in either answers
|
|
80
|
+
`BLOCKED: no-phases`. A `P<n>` bullet outside `## Tasks` (a progress-log line,
|
|
81
|
+
for instance) is prose, never a phase.
|
|
82
|
+
|
|
83
|
+
### Mapping the eight boxes to a bullet phase
|
|
84
|
+
|
|
85
|
+
A bullet has no separate title line and declares no layer, so two boxes have no
|
|
86
|
+
input and are **skipped** (not "passed"); the rest run unchanged over the
|
|
87
|
+
bullet's joined text:
|
|
88
|
+
|
|
89
|
+
| box | unit-doc status |
|
|
90
|
+
|---|---|
|
|
91
|
+
| 1 title names one deliverable | **n/a** — there is no title line; the bullet is the task, and box 4 carries atomicity |
|
|
92
|
+
| 2 one declared layer | **n/a** — a unit doc declares no layer (layers belong to plans) |
|
|
93
|
+
| 3 ≥ 1 task, ≤ 8 tasks | applies — a bullet is exactly one task; a repeated `P<n>` number FAILs as `duplicate unit-doc phase P<n>` |
|
|
94
|
+
| 4 one deliverable | applies — same arrow-chain, >3-case and created-file checks |
|
|
95
|
+
| 5 zero decision words | applies, unchanged |
|
|
96
|
+
| 6 no conditional scope mutation | applies, unchanged |
|
|
97
|
+
| 7 no external/manual gates | applies with the **final-bullet exemption**: the last bullet is the unit doc's verification/close-out line, so `manual` / `ask the user` / `gh pr` are allowed there only |
|
|
98
|
+
| 8 machine-checkable done-when | applies — the bullet's `(validator: …)` span **is** the done-when: it must carry a backticked command *and* an expected outcome; a bullet without the span FAILs as `bullet carries no \`(validator: …)\`` |
|
|
99
|
+
|
|
100
|
+
### The `Relevant files:` sub-section is metadata
|
|
101
|
+
|
|
102
|
+
A nested `- Relevant files: <path, path…>` line under a bullet declares that
|
|
103
|
+
phase's read set (the lane's plan step writes it from its own discovery). It is
|
|
104
|
+
**never** a task: it does not count toward box 3, never feeds a box-2 target and
|
|
105
|
+
is scanned by no rule. Every entry must be a path or a glob; an empty list, an
|
|
106
|
+
empty entry or a token that is neither fails the phase closed under its own
|
|
107
|
+
`relevant-files:` finding id — a malformed read set is reported, never dropped.
|
|
108
|
+
**Never-authority (same rule as every selector):** a file that is *not* listed
|
|
109
|
+
is never out of scope — an executor may always add one, logging it in the unit
|
|
110
|
+
doc's Progress log.
|
|
111
|
+
|
|
65
112
|
## Result — fixed PASS/BLOCKED output
|
|
66
113
|
|
|
67
114
|
```text
|
|
68
115
|
Phase-lint: PASS (8/8)
|
|
69
116
|
```
|
|
70
117
|
|
|
118
|
+
A unit-doc bullet phase reports the boxes it actually runs:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
P<n> Phase-lint: PASS (6/6) · fingerprint P<n>:unit-doc:<n-tasks>:<title-deliverable>
|
|
122
|
+
```
|
|
123
|
+
|
|
71
124
|
or, on the first failing box:
|
|
72
125
|
|
|
73
126
|
```text
|
|
74
127
|
Phase-lint: BLOCKED — box <n>: <one-line reason>
|
|
75
128
|
```
|
|
76
129
|
|
|
130
|
+
A malformed `Relevant files:` sub-section reports under its own id:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
P<n> relevant-files: <one-line reason>
|
|
134
|
+
```
|
|
135
|
+
|
|
77
136
|
A BLOCKED phase is re-cut or split; it is never emitted, committed, or executed
|
|
78
137
|
as-is (no `--force` bypass exists for a non-atomic phase — that decision belongs
|
|
79
138
|
to the user).
|
|
@@ -85,6 +144,10 @@ Each phase's fingerprint is a deterministic string over the phase's shape:
|
|
|
85
144
|
exact phase version a plan committed to — templates and plans record
|
|
86
145
|
`Phase-lint: PASS (8/8) · fingerprint <fingerprint>` so later re-lints and
|
|
87
146
|
`execute-phase` can confirm nothing in the phase shape drifted since planning.
|
|
147
|
+
A unit-doc bullet phase has no layer, so its slot is the literal `unit-doc`
|
|
148
|
+
(`P<n>:unit-doc:<n-tasks>:<title-deliverable>`, title = the bullet text up to
|
|
149
|
+
its first `:`, with the `(validator: …)` span removed); both grammars hash the
|
|
150
|
+
same way, so a re-lint of either is comparable with its own earlier run.
|
|
88
151
|
|
|
89
152
|
## Guardrails
|
|
90
153
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: unit-lane
|
|
3
3
|
user-invocable: true
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
argument-hint: <NN-slug | "<idea>"> [--retriage] | --fix <issue-number> | --from-issue <issue-number>
|
|
6
6
|
description: >
|
|
7
7
|
One-shot lane conductor for a delivery unit: triage, implement, evidence, and
|
|
@@ -15,17 +15,27 @@ first. No future-phase work.
|
|
|
15
15
|
Fill the unit doc's **Tasks** section:
|
|
16
16
|
|
|
17
17
|
```
|
|
18
|
-
P1 — <task description> (validator: <
|
|
19
|
-
|
|
18
|
+
P1 — <task description> (validator: <backticked command> <expected outcome>)
|
|
19
|
+
- Relevant files: <path, path…>
|
|
20
|
+
P2 — <task description> (validator: <backticked command> <expected outcome>)
|
|
20
21
|
…
|
|
21
22
|
Pn — last task is verification when the unit has behavior
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
Both phase grammars are accepted (owner: `phase-contract`): these bullets, or
|
|
26
|
+
full plan-style `## P<n> —` blocks when the unit doc already carries them —
|
|
27
|
+
`phase-lint` parses either and answers `no-phases` only when it finds neither.
|
|
28
|
+
A bullet's `(validator: …)` span is its machine-checkable done-when (a
|
|
29
|
+
backticked command **and** an expected outcome, e.g. `exits 0`), and the nested
|
|
30
|
+
`Relevant files:` line is **metadata, never a task** — it is that phase's read
|
|
31
|
+
set, written from the planner's own discovery, and a file that is missing from
|
|
32
|
+
it is never out of scope for the executor.
|
|
33
|
+
|
|
24
34
|
## Checklist (pass only if)
|
|
25
35
|
|
|
26
36
|
- [ ] Every AC maps to at least one task
|
|
27
37
|
- [ ] Tasks are ordered smallest first
|
|
28
|
-
- [ ] Each task names its validator (command
|
|
38
|
+
- [ ] Each task names its validator (a backticked command plus its expected outcome)
|
|
29
39
|
- [ ] Final task is verification (proves the whole unit works)
|
|
30
40
|
- [ ] No task reaches into a future-phase concern (docs, release, migrations)
|
|
31
41
|
- [ ] Phases labeled P1, P2, … (never S1, Step 1)
|