@evoclock/pi-agentic-driver 1.1.1 → 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.
- package/.agentic-driver/loop-until-done.defaults.json +5 -0
- package/README.md +315 -33
- package/config/merge-policy.v1.example.json +4 -0
- package/config/relay-authority.v1.example.json +7 -0
- package/config/treehouse.v1.json +4 -0
- package/docs/durable-driver-api.md +23 -0
- package/docs/durable-persistence.md +32 -0
- package/docs/merge-policy.md +45 -0
- package/docs/relay-authority.md +9 -0
- package/extensions/aidr.ts +3 -0
- package/extensions/harvest-lessons.ts +8 -0
- package/extensions/herdr-dispatch.ts +32 -3
- package/extensions/loop-until-done.ts +7 -0
- package/extensions/recovery-evidence.ts +3 -3
- package/extensions/treehouse-recycle.ts +47 -0
- package/janus/README.md +3 -6
- package/janus/janus/config.ts +2 -2
- package/janus/janus/providers.ts +2 -4
- package/janus/tests/providers.test.ts +2 -2
- package/{extensions → lib}/task-store-adapter.ts +1 -5
- package/package.json +161 -7
- package/scripts/enforcement/approval_ledger_pi.js +135 -0
- package/scripts/enforcement/attended_authority_guard.js +113 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
- package/scripts/enforcement/durable_assignment_pi.js +488 -0
- package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
- package/scripts/enforcement/durable_driver_pi.js +254 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
- package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
- package/scripts/enforcement/herdr_communication_pi.js +205 -31
- package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
- package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
- package/scripts/enforcement/loop_until_done_guide.md +13 -0
- package/scripts/enforcement/loop_until_done_pi.js +214 -0
- package/scripts/enforcement/merge_policy.js +181 -0
- package/scripts/enforcement/outward_effect_pi.js +156 -0
- package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
- package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
- package/scripts/enforcement/task_board_core_pi.js +13 -100
- package/scripts/enforcement/task_board_hash_pi.js +86 -0
- package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
- package/scripts/harvest_lessons.js +219 -0
- package/scripts/relay/record_approval.mjs +10 -0
- package/scripts/relay/send_request.mjs +26 -0
- package/skills/architect-pi/SKILL.md +44 -0
- package/skills/arena-pi/SKILL.md +46 -0
- package/skills/automate-me-pi/SKILL.md +37 -0
- package/skills/benchmark-checklist-pi/SKILL.md +35 -0
- package/skills/blast-radius-pi/SKILL.md +24 -0
- package/skills/bro-pi/SKILL.md +17 -0
- package/skills/correct-pi/SKILL.md +36 -0
- package/skills/create-verification-skill-pi/LICENSE +21 -0
- package/skills/create-verification-skill-pi/SKILL.md +24 -0
- package/skills/decision-to-spec/SKILL.md +65 -0
- package/skills/figure-it-out-pi/SKILL.md +37 -0
- package/skills/harvest-lessons/SKILL.md +56 -0
- package/skills/harvest-lessons/harvest.example.json +8 -0
- package/skills/how/LICENSE +24 -0
- package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
- package/skills/how-pi/SKILL.md +33 -0
- package/skills/in-your-own-words-pi/SKILL.md +15 -0
- package/skills/interrogate-pi/SKILL.md +35 -0
- package/skills/ketchup-pi/SKILL.md +21 -0
- package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
- package/skills/make-bot-ui-pi/SKILL.md +33 -0
- package/skills/no-comments-pi/SKILL.md +33 -0
- package/skills/poteto-help-pi/SKILL.md +50 -0
- package/skills/poteto-mode-pi/LICENSE +21 -0
- package/skills/poteto-mode-pi/SKILL.md +41 -0
- package/skills/poteto-mode-pi/playbooks.md +95 -0
- package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
- package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
- package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
- package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
- package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
- package/skills/principle-experience-first-pi/SKILL.md +17 -0
- package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
- package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
- package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
- package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
- package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
- package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
- package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
- package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
- package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
- package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
- package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
- package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
- package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
- package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
- package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
- package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
- package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
- package/skills/public-pr-wording/SKILL.md +69 -0
- package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
- package/skills/public-pr-wording/pr_wording_lint.py +249 -0
- package/skills/public-pr-wording/pr_wording_rules.json +60 -0
- package/skills/pulse/SKILL.md +4 -2
- package/skills/recall-pi/SKILL.md +29 -0
- package/skills/reflect-pi/SKILL.md +27 -0
- package/skills/setup-pstack-pi/SKILL.md +23 -0
- package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
- package/skills/sharpen-pi/SKILL.md +19 -0
- package/skills/show-me-your-work-pi/SKILL.md +27 -0
- package/skills/swarm-pi/SKILL.md +27 -0
- package/skills/tasks/SKILL.md +4 -2
- package/skills/tdd-pi/SKILL.md +21 -0
- package/skills/teach-pi/SKILL.md +21 -0
- package/skills/technical-writing-pi/SKILL.md +27 -0
- package/skills/typescript-best-practices-pi/SKILL.md +21 -0
- package/skills/unslop-pi/SKILL.md +27 -0
- package/skills/why-pi/SKILL.md +37 -0
- package/PROVENANCE.md +0 -83
- package/config/herdr-worker-repositories.v1.json +0 -9
- package/docs/codebase-inventory.md +0 -15
- package/docs/recovery-evidence-store.md +0 -9
|
@@ -27,7 +27,7 @@ const REMOTE_FIXTURE = join(SCRIPT_DIR, "linux_microvm_remote_fixture.sh");
|
|
|
27
27
|
const TARGET_EXAMPLE = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.example.json");
|
|
28
28
|
const TARGET_PACKAGE_CONFIG = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.json");
|
|
29
29
|
// The saved target config lives in the Pi coding-agent config directory
|
|
30
|
-
// (PI_CODING_AGENT_DIR when set, otherwise
|
|
30
|
+
// (PI_CODING_AGENT_DIR when set, otherwise the installed agent profile), matching how Pi
|
|
31
31
|
// resolves its own config.
|
|
32
32
|
function resolveTargetUserConfigPath(env = process.env) {
|
|
33
33
|
const agentDir = env.PI_CODING_AGENT_DIR?.trim()
|
|
@@ -105,7 +105,7 @@ function fixtureDomainForId(fixtureId) {
|
|
|
105
105
|
// user-supplied: arch, libvirt URI, and kernel are auto-discovered from the
|
|
106
106
|
// target at probe time (informational, not configured). The model cannot
|
|
107
107
|
// choose or change the target. Read order: the user's own config
|
|
108
|
-
// (
|
|
108
|
+
// (the installed Pi configuration directory) first, then the package-local
|
|
109
109
|
// config/microvm-target.v1.json (shipped as a REPLACE-WITH template).
|
|
110
110
|
const TARGET_FIELDS = new Set(["schema", "sshTarget", "local", "vcpu", "memoryMiB", "jobPayload", "_comment"]);
|
|
111
111
|
// Elastic resource allocation bounds (design section 6.1). User-configured
|
|
@@ -197,7 +197,7 @@ export function targetArgumentError(value) {
|
|
|
197
197
|
|
|
198
198
|
function targetNotConfigured() {
|
|
199
199
|
return denied("blocked", reason("policy", "target-not-configured",
|
|
200
|
-
"No trusted microVM target is configured. Copy config/microvm-target.v1.example.json to
|
|
200
|
+
"No trusted microVM target is configured. Copy config/microvm-target.v1.example.json to the installed Pi config directory as microvm-target.v1.json and set either sshTarget (user@host or ip of the machine that runs the microVM) or local:true (this session runs on a Linux machine). The model cannot choose the target."));
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
function parseFacts(stdout, fixtureId, target) {
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
4
|
# --- guest containment core (taxonomy, structured log, killswitch decision) ---
|
|
5
|
-
# Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
|
|
6
|
-
# 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
|
|
7
5
|
# Watchers and shims arrive in a later step; this core is callable and testable
|
|
8
6
|
# through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
|
|
9
7
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
@@ -666,8 +664,6 @@ if [ "${1:-}" = "--gc-core-embed" ]; then
|
|
|
666
664
|
# is "bash" and there is no script file to read back.
|
|
667
665
|
cat >"$1" <<'GC_CORE_EOF'
|
|
668
666
|
# --- guest containment core (taxonomy, structured log, killswitch decision) ---
|
|
669
|
-
# Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
|
|
670
|
-
# 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
|
|
671
667
|
# Watchers and shims arrive in a later step; this core is callable and testable
|
|
672
668
|
# through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
|
|
673
669
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
@@ -1715,8 +1711,6 @@ if ! mkdir -p "$root/gc" "$root/shims" "$root/tmp" "$root/var/cache" "$root/root
|
|
|
1715
1711
|
# two can never drift. The initramfsSha256 covers the embedded result.
|
|
1716
1712
|
if ! cat >"$root/gc/core.sh" <<'GC_CORE_EOF'
|
|
1717
1713
|
# --- guest containment core (taxonomy, structured log, killswitch decision) ---
|
|
1718
|
-
# Design: /Users/julen/pi-dev-env/evidence/GUEST_CONTAINMENT_DESIGN.md sections
|
|
1719
|
-
# 2 (taxonomy), 2.0 (severity tiers), 4 (log format), 5 (killswitch semantics).
|
|
1720
1714
|
# Watchers and shims arrive in a later step; this core is callable and testable
|
|
1721
1715
|
# through the --gc-decide / --gc-log / --gc-taxonomy-sha hooks below.
|
|
1722
1716
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Bounded loop guidance
|
|
2
|
+
|
|
3
|
+
Only the Pi extension command may load this guide; it is not a standalone slash-command template. The validated request and step budget are: $ARGUMENTS
|
|
4
|
+
|
|
5
|
+
This guide is a request to plan and check a limited journey, not permission to execute new effects. Read `skills/how/WORKFLOW_BOUNDARIES.md`. Before any work, identify the direct owner-approved session task, repository, allowed effects, an observable exit predicate, its real verification seam, and a positive integer step limit. The command validates the goal, exit check and finite step budget. The extension reads `.agentic-driver/loop-until-done.defaults.json` when registering. The default is `maxSteps: 200`, `maxDurationMs: 1800000` (30 minutes), and `wake: { "mode": "until-done" }`. To wake periodically instead, set `wake: { "mode": "interval", "periodMs": 60000 }` (this example wakes after 60 seconds). Limits and period must be positive safe integers; the period must also fit a JavaScript timer. Unknown keys, missing keys and invalid JSON prevent registration. A changed configuration cannot reopen a recorded run on reload. Neither limit can be extended during a run. If task authority or a real verification seam is missing, ask for that specific missing term and stop. Do not derive authority from the template text, a worker report, a task priority, or a model role.
|
|
6
|
+
|
|
7
|
+
Read the project role map created by `/skill:setup-pstack-pi`. Resolve `development` for the developer and `independent-review` for the reviewer. If the map or either needed role is missing or invalid, name the role and stop with `pstack role <role> is not configured at .pi/pstack-model-roles.v1.json; run /skill:setup-pstack-pi`. Confirm the actual author and reviewer are distinct and both routes are installed, approved and available. Do not invent a model, switch the session's model or fall back to another provider.
|
|
8
|
+
|
|
9
|
+
For coordinator **session** tasks only, use the existing `agentic_worker_dispatch` journey when its worker role, cast, model route and scope are already authorized. Never route a `TASKS.md` board card through this tool: Board Pulse and the board dispatcher own those claims. This template does not create a worker, grant autonomy, authorize a reviewer, or change the assignment. The active extension blocks dispatch after the 30-minute deadline, blocks an uncounted dispatch and consumes one step **before** each permitted attempt. For each iteration request exactly **one** exchange (`maxSteps: 1`, `mode: "turn-by-turn"`, `autonomy: "confirmed-default"`); observe the result before considering the next. One Pi session gets only one allowance, retained across turns, extension reloads and tree navigation. `/loop-until-done stop` closes it; it cannot be restarted in that session. A new session needs a new approved task request. Treat a journey's `completed` status at its step limit as a bounded tool result, **not** proof the user's exit predicate passed.
|
|
10
|
+
|
|
11
|
+
After an admitted dispatch attempt settles, `until-done` schedules the next check immediately; `interval` schedules it after the configured period. A wake asks for verification first, not permission for another dispatch. Stop, session navigation and shutdown cancel pending wakes. A wake does not bypass the deadline or replenish steps.\n\nAfter each exchange, check the original predicate against the real approved entry point and independently observable evidence. If it passed, report that evidence and stop. If it did not pass, record what changed, what failed and the remaining step budget before another permitted exchange. Do not silently retry, resend, reset, discard work, run a new harness, install packages, publish, create a PR or merge. A request for independent review goes to the configured reviewer only if that handoff is separately authorized; it is not automatic completion.
|
|
12
|
+
|
|
13
|
+
Stop and report `passed`, `failed`, `blocked` or `limit reached` with the exact observed result. Stop immediately on a failed or unattributed exchange, denied confirmation, unresolved effect, missing authority, unavailable reviewer, or exhausted budget. No completion claim follows from source inspection, a worker's self-report, the tool's terminal state or reaching the iteration limit. The owner alone decides canonical task completion and merges.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
export const LOOP_COMMAND = "loop-until-done";
|
|
7
|
+
const CONFIG = new URL("../../.agentic-driver/loop-until-done.defaults.json", import.meta.url);
|
|
8
|
+
|
|
9
|
+
export function loadLoopConfig(read = (url) => readFileSync(url, "utf8")) {
|
|
10
|
+
let config;
|
|
11
|
+
try { config = JSON.parse(read(CONFIG)); } catch { throw new Error("Invalid loop configuration: expected readable JSON."); }
|
|
12
|
+
if (!config || Array.isArray(config) || typeof config !== "object"
|
|
13
|
+
|| Object.keys(config).sort().join(",") !== "maxDurationMs,maxSteps,wake"
|
|
14
|
+
|| !Number.isSafeInteger(config.maxSteps) || config.maxSteps < 1
|
|
15
|
+
|| !Number.isSafeInteger(config.maxDurationMs) || config.maxDurationMs < 1
|
|
16
|
+
|| !config.wake || Array.isArray(config.wake) || typeof config.wake !== "object"
|
|
17
|
+
|| !["interval", "until-done"].includes(config.wake.mode)
|
|
18
|
+
|| (config.wake.mode === "interval"
|
|
19
|
+
? Object.keys(config.wake).sort().join(",") !== "mode,periodMs"
|
|
20
|
+
|| !Number.isSafeInteger(config.wake.periodMs) || config.wake.periodMs < 1 || config.wake.periodMs > 2147483647
|
|
21
|
+
: Object.keys(config.wake).join(",") !== "mode")) {
|
|
22
|
+
throw new Error("Invalid loop configuration: positive safe integer limits and a valid wake mode are required.");
|
|
23
|
+
}
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
26
|
+
const ENTRY_TYPE = "loop-until-done-budget.v1";
|
|
27
|
+
const GUIDE = new URL("./loop_until_done_guide.md", import.meta.url);
|
|
28
|
+
const WORKER_ROUTES = new Set([
|
|
29
|
+
"agentic_worker_dispatch_async",
|
|
30
|
+
"agentic_herdr_communication",
|
|
31
|
+
"agentic_herdr_spawn_worker",
|
|
32
|
+
"agentic_kanban_board_dispatch",
|
|
33
|
+
"agentic_kanban_pulse",
|
|
34
|
+
]);
|
|
35
|
+
const REGISTERED = new WeakSet();
|
|
36
|
+
|
|
37
|
+
export function parseLoopRequest(args, config = loadLoopConfig()) {
|
|
38
|
+
const text = typeof args === "string" ? args.trim() : "";
|
|
39
|
+
const separator = text.indexOf("::");
|
|
40
|
+
const prefix = separator < 0 ? "" : text.slice(0, separator).trim();
|
|
41
|
+
const exit = separator < 0 ? "" : text.slice(separator + 2).trim();
|
|
42
|
+
const match = /^(\d+)\s+(.+)$/s.exec(prefix);
|
|
43
|
+
const steps = match ? Number(match[1]) : NaN;
|
|
44
|
+
const goal = match?.[2]?.trim() ?? "";
|
|
45
|
+
if (!Number.isSafeInteger(steps) || steps < 1 || steps > config.maxSteps || !goal || !exit
|
|
46
|
+
|| goal.length > 2000 || exit.length > 1000) {
|
|
47
|
+
throw new Error(`Use /${LOOP_COMMAND} <1-${config.maxSteps} steps> <approved goal> :: <observable exit check>`);
|
|
48
|
+
}
|
|
49
|
+
return { steps, goal, exit };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One admitted dispatch attempt consumes one step, even if the worker fails or
|
|
53
|
+
// native confirmation is refused. Consuming before execution prevents parallel
|
|
54
|
+
// tool calls and retries from overspending the session's allowance.
|
|
55
|
+
export function createLoopBudget(record, now = () => Date.now(), config = loadLoopConfig()) {
|
|
56
|
+
if (typeof record !== "function" || typeof now !== "function") throw new Error("A Pi session entry writer and clock are required for a durable loop budget.");
|
|
57
|
+
let active = null;
|
|
58
|
+
let used = false;
|
|
59
|
+
return {
|
|
60
|
+
restore(entries) {
|
|
61
|
+
// Count the entire append-only session, not just the current branch: tree
|
|
62
|
+
// navigation must not restore spent steps or reopen a finished run.
|
|
63
|
+
const records = entries.filter((entry) => entry.type === "custom" && entry.customType === ENTRY_TYPE)
|
|
64
|
+
.map((entry) => entry.data);
|
|
65
|
+
const starts = records.filter((data) => data?.event === "start");
|
|
66
|
+
used = starts.length > 0;
|
|
67
|
+
const steps = starts[0]?.steps;
|
|
68
|
+
const startedAt = starts[0]?.startedAt;
|
|
69
|
+
const valid = starts.length === 1 && starts[0]?.maxSteps === config.maxSteps && starts[0]?.maxDurationMs === config.maxDurationMs
|
|
70
|
+
&& JSON.stringify(starts[0]?.wake) === JSON.stringify(config.wake)
|
|
71
|
+
&& Number.isSafeInteger(steps) && steps >= 1 && steps <= config.maxSteps
|
|
72
|
+
&& Number.isSafeInteger(startedAt) && startedAt >= 0
|
|
73
|
+
&& records.every((data) => ["start", "attempt", "expire", "stop"].includes(data?.event));
|
|
74
|
+
if (!used) active = records.length ? { steps: 0, remaining: 0 } : null;
|
|
75
|
+
else if (!valid) active = { steps: 0, remaining: 0 };
|
|
76
|
+
else if (records.some((data) => data.event === "stop")) active = null;
|
|
77
|
+
else {
|
|
78
|
+
const recordedExpiry = records.some((data) => data.event === "expire");
|
|
79
|
+
const sameRun = active?.startedAt === startedAt;
|
|
80
|
+
active = { steps, startedAt, expired: recordedExpiry || (sameRun && active.expired),
|
|
81
|
+
expiryRecorded: recordedExpiry || (sameRun && active.expiryRecorded),
|
|
82
|
+
remaining: Math.max(0, steps - records.filter((data) => data.event === "attempt").length) };
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
start(request) {
|
|
86
|
+
if (used || active) throw new Error("A loop has already started in this session; it cannot be restarted or reset.");
|
|
87
|
+
const startedAt = now();
|
|
88
|
+
if (!Number.isSafeInteger(startedAt) || startedAt < 0) throw new Error("A valid clock is required to open a bounded loop.");
|
|
89
|
+
record({ event: "start", steps: request.steps, startedAt, maxSteps: config.maxSteps, maxDurationMs: config.maxDurationMs, wake: config.wake });
|
|
90
|
+
used = true;
|
|
91
|
+
active = { ...request, startedAt, remaining: request.steps };
|
|
92
|
+
},
|
|
93
|
+
stop() {
|
|
94
|
+
if (!active) return;
|
|
95
|
+
record({ event: "stop" });
|
|
96
|
+
active = null;
|
|
97
|
+
},
|
|
98
|
+
snapshot() { return active && { ...active }; },
|
|
99
|
+
hasStarted() { return used; },
|
|
100
|
+
guard(event) {
|
|
101
|
+
if (!active) return undefined;
|
|
102
|
+
const tool = event?.toolName;
|
|
103
|
+
const input = event?.input ?? {};
|
|
104
|
+
if (tool === "agentic_worker_dispatch") {
|
|
105
|
+
if (input.action === "pulse") return undefined;
|
|
106
|
+
if (input.action !== "dispatch" || input.maxSteps !== 1
|
|
107
|
+
|| input.mode !== "turn-by-turn" || input.autonomy !== "confirmed-default"
|
|
108
|
+
|| !["developer", "reviewer"].includes(input.role)
|
|
109
|
+
|| input.model !== undefined || input.cast !== undefined) {
|
|
110
|
+
return { block: true, reason: "Loop dispatch requires one confirmed step for an authorized developer or reviewer, without a model override." };
|
|
111
|
+
}
|
|
112
|
+
const current = now();
|
|
113
|
+
if (active.expired || !Number.isSafeInteger(current) || current < active.startedAt
|
|
114
|
+
|| current - active.startedAt >= config.maxDurationMs) {
|
|
115
|
+
active.expired = true;
|
|
116
|
+
// Persist expiry before refusing so extension reload or clock rollback
|
|
117
|
+
// cannot reopen the run. A failed write throws and blocks this call;
|
|
118
|
+
// the next call retries persistence before it can return a refusal.
|
|
119
|
+
if (!active.expiryRecorded) {
|
|
120
|
+
record({ event: "expire" });
|
|
121
|
+
active.expiryRecorded = true;
|
|
122
|
+
}
|
|
123
|
+
return { block: true, reason: "Loop time limit reached; no further worker dispatch is permitted in this session run." };
|
|
124
|
+
}
|
|
125
|
+
if (active.remaining === 0) {
|
|
126
|
+
return { block: true, reason: "Loop step limit reached; no further worker dispatch is permitted in this session run." };
|
|
127
|
+
}
|
|
128
|
+
// If persistence fails, Pi blocks the tool_call. Never admit an
|
|
129
|
+
// exchange whose attempt was not recorded first.
|
|
130
|
+
record({ event: "attempt" });
|
|
131
|
+
active.remaining -= 1;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (WORKER_ROUTES.has(tool)) {
|
|
135
|
+
const readOnly = (tool === "agentic_worker_dispatch_async" && ["poll", "observe", "read"].includes(input.action))
|
|
136
|
+
|| (tool === "agentic_herdr_communication" && ["list", "get", "delivery", "wait", "read"].includes(input.action))
|
|
137
|
+
|| (tool === "agentic_kanban_pulse" && input.action === "check");
|
|
138
|
+
if (!readOnly) return { block: true, reason: "This worker route bypasses the active loop's step counter." };
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function registerLoopUntilDone(pi, now = () => Date.now(), config = loadLoopConfig()) {
|
|
146
|
+
if (REGISTERED.has(pi)) return undefined;
|
|
147
|
+
if (typeof pi?.registerCommand !== "function" || typeof pi?.on !== "function" || typeof pi?.appendEntry !== "function") {
|
|
148
|
+
throw new Error("Pi session entry, command and tool-call APIs are required for /loop-until-done.");
|
|
149
|
+
}
|
|
150
|
+
// Validate injected configuration as well as the on-disk default before registration.
|
|
151
|
+
config = loadLoopConfig(() => JSON.stringify(config));
|
|
152
|
+
REGISTERED.add(pi);
|
|
153
|
+
const budget = createLoopBudget((data) => pi.appendEntry(ENTRY_TYPE, data), now, config);
|
|
154
|
+
let pendingWake = null;
|
|
155
|
+
let admitted = false;
|
|
156
|
+
let successful = false;
|
|
157
|
+
const cancelWake = () => { if (pendingWake !== null) clearTimeout(pendingWake); pendingWake = null; };
|
|
158
|
+
pi.on("tool_call", (event) => {
|
|
159
|
+
const result = budget.guard(event);
|
|
160
|
+
if (event?.toolName === "agentic_worker_dispatch" && event?.input?.action === "dispatch" && !result && budget.snapshot()) admitted = true;
|
|
161
|
+
return result;
|
|
162
|
+
});
|
|
163
|
+
pi.on("tool_result", (event) => {
|
|
164
|
+
if (admitted && event?.toolName === "agentic_worker_dispatch" && !event?.isError) successful = true;
|
|
165
|
+
});
|
|
166
|
+
pi.on("agent_settled", () => {
|
|
167
|
+
if (!admitted) return;
|
|
168
|
+
admitted = false;
|
|
169
|
+
if (!successful) return;
|
|
170
|
+
successful = false;
|
|
171
|
+
const state = budget.snapshot();
|
|
172
|
+
if (!state || state.remaining < 1 || state.expired || pendingWake !== null) return;
|
|
173
|
+
const delay = config.wake.mode === "interval" ? config.wake.periodMs : 0;
|
|
174
|
+
pendingWake = setTimeout(() => {
|
|
175
|
+
pendingWake = null;
|
|
176
|
+
const current = budget.snapshot();
|
|
177
|
+
if (!current || current.remaining < 1 || current.expired) return;
|
|
178
|
+
if (now() < current.startedAt || now() - current.startedAt >= config.maxDurationMs) return;
|
|
179
|
+
pi.sendUserMessage(`Continue the approved loop goal. Verify the exit check first; stop if passed, blocked, or failed. Remaining dispatch attempts: ${current.remaining}.`);
|
|
180
|
+
}, delay);
|
|
181
|
+
});
|
|
182
|
+
for (const event of ["session_start", "session_tree"]) {
|
|
183
|
+
pi.on(event, (_event, ctx) => { cancelWake(); admitted = false; successful = false; budget.restore(ctx.sessionManager.getEntries()); });
|
|
184
|
+
}
|
|
185
|
+
pi.on("session_shutdown", () => { cancelWake(); admitted = false; successful = false; budget.restore([]); });
|
|
186
|
+
pi.registerCommand(LOOP_COMMAND, {
|
|
187
|
+
description: `Work toward a checkable goal with up to ${config.maxSteps} dispatch attempts and a ${config.maxDurationMs} ms time ceiling`,
|
|
188
|
+
argumentHint: `<1-${config.maxSteps} steps> <approved goal> :: <observable exit check> | stop`,
|
|
189
|
+
handler: async (args, ctx) => {
|
|
190
|
+
if (args?.trim() === "stop") {
|
|
191
|
+
cancelWake();
|
|
192
|
+
budget.stop();
|
|
193
|
+
ctx?.ui?.notify?.("Loop stopped. No remaining step allowance.", "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// A session gets one allowance, even after stop, settle or branch navigation.
|
|
197
|
+
if (budget.hasStarted()) throw new Error("A loop has already started in this session; it cannot be restarted or reset.");
|
|
198
|
+
const request = parseLoopRequest(args, config);
|
|
199
|
+
if (typeof pi.sendUserMessage !== "function") throw new Error("Pi cannot send the loop request; no budget was opened.");
|
|
200
|
+
const guide = readFileSync(GUIDE, "utf8").replace("$ARGUMENTS", () => JSON.stringify(request));
|
|
201
|
+
budget.start(request);
|
|
202
|
+
try {
|
|
203
|
+
pi.sendUserMessage(`${guide}\n\nEnforced worker-dispatch allowance: ${request.steps} attempts and a ${config.maxDurationMs} ms time ceiling. A refusal or failure consumes an attempt. To close the run, use /${LOOP_COMMAND} stop; it cannot restart in this session. No tool or model can increase this allowance.`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
cancelWake();
|
|
206
|
+
budget.stop();
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
return budget;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default registerLoopUntilDone;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
|
|
3
|
+
// Merge policy for the driver. One owner-set value in the driver's own
|
|
4
|
+
// profile config decides whether an agent may ever run a pull-request merge:
|
|
5
|
+
//
|
|
6
|
+
// human-merges-only (default) The owner merges on GitHub. Agent merge
|
|
7
|
+
// commands are always refused.
|
|
8
|
+
// agents-after-checks-and-review
|
|
9
|
+
// An agent may run `gh pr merge <ref>` only when
|
|
10
|
+
// every check on the PR head has passed and a
|
|
11
|
+
// Standards PASS and a Spec PASS are recorded
|
|
12
|
+
// for that exact head.
|
|
13
|
+
//
|
|
14
|
+
// The file lives at {PI_CODING_AGENT_DIR}/config/merge-policy.v1.json
|
|
15
|
+
// (fallback to the installed agent config directory). It is never read from the working tree, so
|
|
16
|
+
// a repository or a model cannot widen the policy. A missing file means the
|
|
17
|
+
// default; an unreadable or unknown value fails closed to the default.
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const MERGE_POLICY_SCHEMA = "pi-agentic-driver.merge-policy.v1";
|
|
25
|
+
export const HUMAN_MERGES_ONLY = "human-merges-only";
|
|
26
|
+
export const AGENTS_AFTER_CHECKS_AND_REVIEW = "agents-after-checks-and-review";
|
|
27
|
+
export const DEFAULT_MERGE_POLICY = HUMAN_MERGES_ONLY;
|
|
28
|
+
const POLICIES = new Set([HUMAN_MERGES_ONLY, AGENTS_AFTER_CHECKS_AND_REVIEW]);
|
|
29
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
30
|
+
|
|
31
|
+
export function profileDir(env = process.env) {
|
|
32
|
+
const dir = typeof env.PI_CODING_AGENT_DIR === "string" ? env.PI_CODING_AGENT_DIR.trim() : "";
|
|
33
|
+
return dir ? resolve(dir) : resolve(homedir(), ".pi", "agent");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mergePolicyPath(env = process.env) {
|
|
37
|
+
return join(profileDir(env), "config", "merge-policy.v1.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sessionIdOf(ctx) {
|
|
41
|
+
try { return String(ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? "").trim(); } catch { return ""; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function reviewRecordPath(headSha, env = process.env) {
|
|
45
|
+
if (!SHA.test(String(headSha))) throw new Error("review record needs a full commit SHA");
|
|
46
|
+
return join(profileDir(env), "merge-reviews", `${headSha}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Returns { policy, source }. source is "default", "config" or "invalid".
|
|
50
|
+
export function loadMergePolicy({ env = process.env, read = (p) => readFileSync(p, "utf8") } = {}) {
|
|
51
|
+
let text;
|
|
52
|
+
try { text = read(mergePolicyPath(env)); } catch (error) {
|
|
53
|
+
return { policy: DEFAULT_MERGE_POLICY, source: error?.code === "ENOENT" ? "default" : "invalid" };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const data = JSON.parse(text);
|
|
57
|
+
if (data?.schema === MERGE_POLICY_SCHEMA && POLICIES.has(data.policy)) return { policy: data.policy, source: "config" };
|
|
58
|
+
} catch { /* fall through */ }
|
|
59
|
+
return { policy: DEFAULT_MERGE_POLICY, source: "invalid" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MERGE_FORMS = [
|
|
63
|
+
{ pattern: /\bgh\s+pr\s+merge\b/, form: "gh" },
|
|
64
|
+
{ pattern: /\/pulls\/[^\s/]+\/merge\b/, form: "api" },
|
|
65
|
+
{ pattern: /\bmergePullRequest\b/, form: "graphql" },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function normalize(text) {
|
|
69
|
+
return String(text ?? "").split(/\s+/)
|
|
70
|
+
.map((t) => t.replace(/["'\\]/g, "").replace(/^.*\/gh$/, "gh"))
|
|
71
|
+
.join(" ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Detects a pull-request merge in a shell command. ref is the PR argument of
|
|
75
|
+
// a simple `gh pr merge <ref>` call, or "" when none can be read safely.
|
|
76
|
+
// Forms that skip checks or branch protection. Refused under every policy.
|
|
77
|
+
const BYPASS_FORMS = [
|
|
78
|
+
{ pattern: /\bgh pr merge\b[^;&|\n]*\s--admin\b/, why: "--admin skips branch protection" },
|
|
79
|
+
{ pattern: /\bgh pr merge\b[^;&|\n]*\s--auto\b/, why: "--auto merges later, after the evidence was checked" },
|
|
80
|
+
{ pattern: /\/branches\/[^\s]+\/protection\b/, why: "changes branch protection", write: true },
|
|
81
|
+
{ pattern: /\/rulesets\b/, why: "changes repository rulesets", write: true },
|
|
82
|
+
{ pattern: /\b(?:enablePullRequestAutoMerge|mergeBranch|updateBranchProtectionRule|deleteBranchProtectionRule)\b/, why: "GraphQL merge or protection change" },
|
|
83
|
+
{ pattern: /\bbypass[_-]?(?:pull[_-]?request[_-]?)?(?:allowances|actors)\b/i, why: "edits protection bypass lists" },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export function detectBypass(command) {
|
|
87
|
+
const text = normalize(command);
|
|
88
|
+
// Reading protection or rulesets is fine; only writes are a bypass.
|
|
89
|
+
const writes = /(?:^|\s)(?:-X|--method)(?:\s+|=)?(?:PUT|POST|PATCH|DELETE)\b|(?:^|\s)(?:-f|-F|--field|--raw-field|--input)\b/i.test(text);
|
|
90
|
+
const hit = BYPASS_FORMS.find(({ pattern, write }) => pattern.test(text) && (!write || writes));
|
|
91
|
+
return hit ? { bypass: true, why: hit.why } : { bypass: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function detectMerge(command) {
|
|
95
|
+
const raw = String(command ?? "");
|
|
96
|
+
const text = normalize(raw);
|
|
97
|
+
const bypass = detectBypass(raw);
|
|
98
|
+
if (bypass.bypass) return { merge: true, form: "bypass", ref: "", why: bypass.why };
|
|
99
|
+
const hit = MERGE_FORMS.find(({ pattern }) => pattern.test(text));
|
|
100
|
+
if (!hit) return { merge: false };
|
|
101
|
+
let ref = "";
|
|
102
|
+
const compound = /[;&|>`\n]|\$\(/.test(raw);
|
|
103
|
+
if (hit.form === "gh" && !compound) {
|
|
104
|
+
const args = text.split(/\bgh pr merge\b/)[1].trim().split(" ").filter(Boolean);
|
|
105
|
+
// A repository flag could point the merge at a different repository from
|
|
106
|
+
// the one the evidence is read from, so it is never checkable.
|
|
107
|
+
const repoFlag = args.some((a) => a === "-R" || a === "--repo" || a.startsWith("--repo=") || /^-R./.test(a));
|
|
108
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
109
|
+
if (!repoFlag && positional.length === 1 && /^(?:\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/.test(positional[0])) ref = positional[0];
|
|
110
|
+
}
|
|
111
|
+
return { merge: true, form: hit.form, ref };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const PASSING = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
115
|
+
|
|
116
|
+
// Pure decision. evidence: { headSha, author, mergerSession,
|
|
117
|
+
// checks: [{ state }],
|
|
118
|
+
// review: { headSha, standards, spec, standardsReviewer, specReviewer, recordedBySession } }.
|
|
119
|
+
// A PASS counts only when its reviewer differs from the PR author and from the
|
|
120
|
+
// merging session, and the record was not written by the merging session.
|
|
121
|
+
export function evaluateMerge(policy, detection, evidence) {
|
|
122
|
+
if (detection.form === "bypass") return { allow: false, reason: `refused under every merge policy: ${detection.why}` };
|
|
123
|
+
if (policy !== AGENTS_AFTER_CHECKS_AND_REVIEW) {
|
|
124
|
+
return { allow: false, reason: "merge policy is human-merges-only: the owner merges on GitHub" };
|
|
125
|
+
}
|
|
126
|
+
if (detection.form !== "gh" || !detection.ref) {
|
|
127
|
+
return { allow: false, reason: "only a simple `gh pr merge <number|url>` can be checked against the merge policy" };
|
|
128
|
+
}
|
|
129
|
+
if (!evidence || evidence.error) return { allow: false, reason: `merge evidence unavailable: ${evidence?.error ?? "none"}` };
|
|
130
|
+
const checks = Array.isArray(evidence.checks) ? evidence.checks : [];
|
|
131
|
+
if (!checks.length) return { allow: false, reason: "no checks reported on the PR head; a required check (for example PR wording) must pass first" };
|
|
132
|
+
const failing = checks.filter((c) => !PASSING.has(String(c.state ?? "").toUpperCase()));
|
|
133
|
+
if (failing.length) return { allow: false, reason: `${failing.length} check(s) on the PR head have not passed` };
|
|
134
|
+
const review = evidence.review;
|
|
135
|
+
if (!review || review.headSha !== evidence.headSha) return { allow: false, reason: "no independent review recorded for the current PR head" };
|
|
136
|
+
if (review.standards !== "PASS" || review.spec !== "PASS") return { allow: false, reason: "the recorded review for this head is not Standards PASS and Spec PASS" };
|
|
137
|
+
const merger = String(evidence.mergerSession ?? "").trim();
|
|
138
|
+
if (!merger) return { allow: false, reason: "the merging session has no id, so reviewer independence cannot be checked" };
|
|
139
|
+
const author = String(evidence.author ?? "").trim().toLowerCase();
|
|
140
|
+
if (!author) return { allow: false, reason: "the PR author is unknown, so reviewer independence cannot be checked" };
|
|
141
|
+
if (String(review.recordedBySession ?? "") === merger) return { allow: false, reason: "the review was recorded by the merging session itself" };
|
|
142
|
+
for (const axis of ["standards", "spec"]) {
|
|
143
|
+
const who = String(review[`${axis}Reviewer`] ?? "").trim();
|
|
144
|
+
if (!who) return { allow: false, reason: `no ${axis} reviewer identity recorded` };
|
|
145
|
+
if (who === merger) return { allow: false, reason: `the ${axis} reviewer is the merging session` };
|
|
146
|
+
if (who.toLowerCase() === author) return { allow: false, reason: `the ${axis} reviewer is the PR author` };
|
|
147
|
+
}
|
|
148
|
+
return { allow: true, reason: "checks passed and Standards and Spec PASS recorded for this head" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Default evidence source: GitHub through the existing gh login, plus the
|
|
152
|
+
// review record kept in the profile directory.
|
|
153
|
+
export function gatherMergeEvidence(ref, { run = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", timeout: 15000 }), env = process.env, read = (p) => readFileSync(p, "utf8") } = {}) {
|
|
154
|
+
try {
|
|
155
|
+
const view = JSON.parse(run("gh", ["pr", "view", ref, "--json", "headRefOid,statusCheckRollup,author"]));
|
|
156
|
+
const headSha = String(view.headRefOid ?? "");
|
|
157
|
+
const author = String(view.author?.login ?? "");
|
|
158
|
+
const checks = (view.statusCheckRollup ?? []).map((c) => ({ state: c.conclusion || c.state || "PENDING" }));
|
|
159
|
+
let review;
|
|
160
|
+
try { review = JSON.parse(read(reviewRecordPath(headSha, env))); } catch { review = undefined; }
|
|
161
|
+
return { headSha, author, checks, review };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { error: String(error?.message ?? error).split("\n")[0] };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function reviewRecord({ headSha, standards, spec, standardsReviewer, specReviewer, recordedBySession }) {
|
|
168
|
+
if (!SHA.test(String(headSha))) return { ok: false, reason: "headSha must be a full 40-character commit SHA" };
|
|
169
|
+
const verdict = (v) => (v === "PASS" || v === "FAIL" ? v : null);
|
|
170
|
+
if (!verdict(standards) || !verdict(spec)) return { ok: false, reason: "standards and spec must each be PASS or FAIL" };
|
|
171
|
+
const id = (v) => (typeof v === "string" ? v.trim() : "");
|
|
172
|
+
if (!id(standardsReviewer) || !id(specReviewer)) return { ok: false, reason: "each verdict needs its reviewer identity or session id" };
|
|
173
|
+
if (!id(recordedBySession)) return { ok: false, reason: "the recording session has no id" };
|
|
174
|
+
if (id(standardsReviewer) === id(recordedBySession) || id(specReviewer) === id(recordedBySession)) {
|
|
175
|
+
return { ok: false, reason: "a session cannot record its own review verdict" };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, record: {
|
|
178
|
+
schema: "pi-agentic-driver.merge-review.v1", headSha, standards, spec,
|
|
179
|
+
standardsReviewer: id(standardsReviewer), specReviewer: id(specReviewer), recordedBySession: id(recordedBySession),
|
|
180
|
+
} };
|
|
181
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The only writable outward route for a driver-owned journey. A worker's
|
|
2
|
+
// independent shell actions are outside this boundary.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
7
|
+
|
|
8
|
+
export const OUTWARD_TOOL = "agentic_journey_outward_effect";
|
|
9
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
10
|
+
const BRANCH = /^(?!main$|master$)[a-zA-Z0-9][a-zA-Z0-9._/-]{0,100}$/;
|
|
11
|
+
const BASE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,100}$/;
|
|
12
|
+
const REPO = /^[a-zA-Z0-9-]+\/[a-zA-Z0-9._-]+$/;
|
|
13
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
14
|
+
const run = (bin, args, cwd, input) => execFileSync(bin, args, {
|
|
15
|
+
cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024,
|
|
16
|
+
...(input === undefined ? {} : { input: JSON.stringify(input) }),
|
|
17
|
+
}).trim();
|
|
18
|
+
const git = (args, cwd) => run("git", args, cwd);
|
|
19
|
+
const remoteHead = (cwd, branch) => {
|
|
20
|
+
const line = git(["ls-remote", "origin", `refs/heads/${branch}`], cwd);
|
|
21
|
+
if (!line) return null;
|
|
22
|
+
const match = /^([a-f0-9]{40})\trefs\/heads\/(.+)$/.exec(line);
|
|
23
|
+
if (!match || match[2] !== branch) throw new Error("remote branch observation ambiguous");
|
|
24
|
+
return match[1];
|
|
25
|
+
};
|
|
26
|
+
const gh = (args, cwd, options, input) => run(options.ghExecutable ?? "gh", args, cwd, input);
|
|
27
|
+
const pr = (repository, number, cwd, options) => JSON.parse(gh(["api", `repos/${repository}/pulls/${number}`], cwd, options));
|
|
28
|
+
const prs = (repository, branch, cwd, options) => {
|
|
29
|
+
const owner = repository.split("/")[0];
|
|
30
|
+
const list = JSON.parse(gh(["api", `repos/${repository}/pulls?state=all&head=${owner}:${branch}&per_page=100`], cwd, options));
|
|
31
|
+
if (!Array.isArray(list) || list.length > 1 || list.length === 100) throw new Error("PR observation ambiguous");
|
|
32
|
+
return list[0] ?? null;
|
|
33
|
+
};
|
|
34
|
+
function matchingPr(item, request) {
|
|
35
|
+
return item?.state === "open" && item?.draft === true && item?.head?.ref === request.branch &&
|
|
36
|
+
item.head?.sha === request.targetHead && item.head?.repo?.full_name === request.repository &&
|
|
37
|
+
item.base?.ref === request.base && item.base?.repo?.full_name === request.repository &&
|
|
38
|
+
item.title === request.title && digest(item.body ?? "") === digest(request.body);
|
|
39
|
+
}
|
|
40
|
+
function validate(request) {
|
|
41
|
+
if (!request || !["push", "pr-create", "pr-update"].includes(request.action) ||
|
|
42
|
+
!/^[a-zA-Z0-9_-]{1,100}$/.test(request.journeyId ?? "") ||
|
|
43
|
+
!/^[a-zA-Z0-9_-]{1,100}$/.test(request.effectId ?? "") ||
|
|
44
|
+
!REPO.test(request.repository ?? "") || !BRANCH.test(request.branch ?? "") ||
|
|
45
|
+
!SHA.test(request.targetHead ?? "")) throw new Error("invalid outward effect identity");
|
|
46
|
+
if (request.action === "push") {
|
|
47
|
+
if (request.expectedHead !== null && !SHA.test(request.expectedHead ?? "")) throw new Error("expected remote head required (or null for a new branch)");
|
|
48
|
+
} else if (!BASE.test(request.base ?? "") || typeof request.title !== "string" ||
|
|
49
|
+
!request.title.trim() || request.title.length > 200 || typeof request.body !== "string" ||
|
|
50
|
+
Buffer.byteLength(request.body, "utf8") > 16000 ||
|
|
51
|
+
(request.action === "pr-create" && request.number !== undefined) ||
|
|
52
|
+
(request.action === "pr-update" && (!Number.isSafeInteger(request.number) || request.number < 1 ||
|
|
53
|
+
!/^[a-f0-9]{64}$/.test(request.previousBodyDigest ?? "")))) {
|
|
54
|
+
throw new Error("invalid private PR request");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function privateRepository(request, cwd, options) {
|
|
58
|
+
if (options.repositoryProof) return options.repositoryProof(request, cwd);
|
|
59
|
+
const url = git(["remote", "get-url", "origin"], cwd);
|
|
60
|
+
const https = `https://github.com/${request.repository}.git`;
|
|
61
|
+
const ssh = `git@github.com:${request.repository}.git`;
|
|
62
|
+
if (![https, https.slice(0, -4), ssh, ssh.slice(0, -4)].includes(url)) throw new Error("origin does not match requested repository");
|
|
63
|
+
const observed = JSON.parse(gh(["repo", "view", request.repository, "--json", "nameWithOwner,isPrivate"], cwd, options));
|
|
64
|
+
if (observed.nameWithOwner !== request.repository || observed.isPrivate !== true) throw new Error("verified private repository required");
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function executeOutwardEffect(request, context, options = {}) {
|
|
69
|
+
try {
|
|
70
|
+
validate(request);
|
|
71
|
+
const cwd = realpathSync(context.cwd);
|
|
72
|
+
git(["check-ref-format", "--branch", request.branch], cwd);
|
|
73
|
+
if (request.action !== "push") git(["check-ref-format", "--branch", request.base], cwd);
|
|
74
|
+
if (await privateRepository(request, cwd, options) !== true) throw new Error("verified private repository required");
|
|
75
|
+
if (!isNativeTuiContext(context)) throw new Error("native TUI confirmation required");
|
|
76
|
+
const store = await options.getJourneyStore(context);
|
|
77
|
+
const journeys = await store.listJourneys();
|
|
78
|
+
if (!journeys.some((entry) => entry.journeyId === request.journeyId &&
|
|
79
|
+
realpathSync(entry.repository) === cwd)) {
|
|
80
|
+
throw new Error("existing durable journey required");
|
|
81
|
+
}
|
|
82
|
+
const confirmed = await context.ui.confirm("Confirm outward journey effect",
|
|
83
|
+
`${request.action} for ${request.repository}, branch ${request.branch}, journey ${request.journeyId}?`);
|
|
84
|
+
if (confirmed !== true) throw new Error("native confirmation not granted");
|
|
85
|
+
const spec = request.action === "push"
|
|
86
|
+
? { expectedHead: request.expectedHead, targetHead: request.targetHead }
|
|
87
|
+
: { targetHead: request.targetHead, base: request.base, titleDigest: digest(request.title),
|
|
88
|
+
bodyDigest: digest(request.body), ...(request.action === "pr-update" ? { number: request.number } : {}),
|
|
89
|
+
...(request.previousBodyDigest ? { previousBodyDigest: request.previousBodyDigest } : {}) };
|
|
90
|
+
const record = { id: `out-${digest(`${request.journeyId}:${request.effectId}`).slice(0, 32)}`,
|
|
91
|
+
digest: digest(JSON.stringify({ action: request.action, repository: request.repository, branch: request.branch, spec })),
|
|
92
|
+
repository: cwd, createdAt: Date.now(), kind: request.action, journeyId: request.journeyId };
|
|
93
|
+
// createdAt must remain stable across reattachment; the store checks every
|
|
94
|
+
// immutable field except its timestamp, which is not part of the identity.
|
|
95
|
+
const observe = () => {
|
|
96
|
+
if (request.action === "push") {
|
|
97
|
+
const head = remoteHead(cwd, request.branch);
|
|
98
|
+
return head === request.targetHead ? { verified: true, reference: head } : { verified: false };
|
|
99
|
+
}
|
|
100
|
+
const item = request.action === "pr-create"
|
|
101
|
+
? prs(request.repository, request.branch, cwd, options)
|
|
102
|
+
: pr(request.repository, request.number, cwd, options);
|
|
103
|
+
return matchingPr(item, request) && Number.isSafeInteger(item.number)
|
|
104
|
+
? { verified: true, reference: String(item.number) } : { verified: false };
|
|
105
|
+
};
|
|
106
|
+
const apply = () => {
|
|
107
|
+
if (request.action === "push") {
|
|
108
|
+
const local = git(["rev-parse", "HEAD"], cwd);
|
|
109
|
+
if (local !== request.targetHead || git(["branch", "--show-current"], cwd) !== request.branch ||
|
|
110
|
+
remoteHead(cwd, request.branch) !== request.expectedHead) throw new Error("branch or remote head drift");
|
|
111
|
+
git(["push", "origin", `${request.targetHead}:refs/heads/${request.branch}`], cwd);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (remoteHead(cwd, request.branch) !== request.targetHead) throw new Error("PR head is not on the remote");
|
|
115
|
+
const existing = request.action === "pr-create"
|
|
116
|
+
? prs(request.repository, request.branch, cwd, options)
|
|
117
|
+
: pr(request.repository, request.number, cwd, options);
|
|
118
|
+
if (request.action === "pr-create") {
|
|
119
|
+
if (existing) throw new Error("PR already exists; observe only");
|
|
120
|
+
gh(["api", "--method", "POST", `repos/${request.repository}/pulls`, "--input", "-"], cwd, options,
|
|
121
|
+
{ title: request.title, body: request.body, head: request.branch, base: request.base, draft: true });
|
|
122
|
+
} else {
|
|
123
|
+
if (existing?.state !== "open" || existing?.draft !== true || existing?.head?.ref !== request.branch ||
|
|
124
|
+
existing?.head?.sha !== request.targetHead || existing?.head?.repo?.full_name !== request.repository ||
|
|
125
|
+
existing?.base?.ref !== request.base || digest(existing?.body ?? "") !== request.previousBodyDigest) {
|
|
126
|
+
throw new Error("PR baseline changed; refusing update");
|
|
127
|
+
}
|
|
128
|
+
gh(["api", "--method", "PATCH", `repos/${request.repository}/pulls/${request.number}`, "--input", "-"],
|
|
129
|
+
cwd, options, { title: request.title, body: request.body, base: request.base });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const result = await store.runOutwardEffect(record, { apply, observe, boundary: options.boundary });
|
|
133
|
+
return { schema: "agentic-driver.outward-effect.v1", ok: result.state === "verified", status: result.state,
|
|
134
|
+
reference: result.report ?? null, nonAuthorizing: true };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { schema: "agentic-driver.outward-effect.v1", ok: false, status: "blocked",
|
|
137
|
+
reason: String(error?.message ?? error).slice(0, 256), nonAuthorizing: true };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function registerOutwardEffectInterface(pi, options = {}) {
|
|
142
|
+
pi.registerTool({ name: OUTWARD_TOOL,
|
|
143
|
+
description: "Confirm one durable Git push or draft private PR create/update for an existing journey; uncertain effects are never replayed.",
|
|
144
|
+
parameters: {
|
|
145
|
+
type: "object", additionalProperties: false,
|
|
146
|
+
required: ["action", "journeyId", "effectId", "repository", "branch", "targetHead"],
|
|
147
|
+
properties: Object.fromEntries(["action", "journeyId", "effectId", "repository", "branch", "targetHead",
|
|
148
|
+
"expectedHead", "base", "title", "body", "number", "previousBodyDigest"].map((key) =>
|
|
149
|
+
[key, { type: key === "number" ? "integer" : key === "expectedHead" ? ["string", "null"] : "string" }])),
|
|
150
|
+
},
|
|
151
|
+
async execute(_id, params, _signal, _update, context) {
|
|
152
|
+
const result = await executeOutwardEffect(params, context, options);
|
|
153
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|