@aeondave/pi-persona 1.15.2
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/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DelegationNudge — a pure state machine that watches the supervisor's OWN tool-result stream
|
|
3
|
+
* and, when hands-on work burns too much context without a hand-off, returns a short reminder to
|
|
4
|
+
* append to the offending tool's result. This is the runtime reinforcement the static persona
|
|
5
|
+
* prompt cannot give: the persona directive sits at the TOP of the context and its behavioral pull
|
|
6
|
+
* decays as recent tool output balloons ("delegate the breadth" loses the tug-of-war against 50
|
|
7
|
+
* commands of momentum). The nudge lands in RECENT context — on the very command that triggered
|
|
8
|
+
* it — where a decaying system-prompt line has no force.
|
|
9
|
+
*
|
|
10
|
+
* The trigger is REPETITION, not raw volume. The antipattern the persona warns against is "chaining
|
|
11
|
+
* sweeps by hand" — a RUN of substantive hands-on commands with no hand-off (a dirbust/spray/enum
|
|
12
|
+
* loop). Output volume alone is a poor proxy: a single big file read is high-volume but a legitimate
|
|
13
|
+
* one-shot that belongs to reading-budget-discipline (grep-first), not delegation. So the primary
|
|
14
|
+
* trigger counts consecutive SUBSTANTIVE hands-on tool calls since the last hand-off — a tiny call
|
|
15
|
+
* (echo/cd/one-line check) is orchestration glue and does not advance the run; a run reaching the
|
|
16
|
+
* threshold is a by-hand sweep. Tool identity is not required (a real sweep mixes read/grep/curl). A
|
|
17
|
+
* high single-command threshold stays as a SECONDARY trigger for a genuinely enormous one-shot dump
|
|
18
|
+
* (a sign it should have been detached/quarantined). A hand-off (delegate/council) resets the run;
|
|
19
|
+
* nothing else does.
|
|
20
|
+
*
|
|
21
|
+
* Backoff: repeating the identical reminder is noise once the operator has seen it and kept going —
|
|
22
|
+
* often because the work is session-bound and cannot be delegated at all (a live shell / tunnel a
|
|
23
|
+
* sub-agent can't inherit; from the supervisor side that loop is indistinguishable from a delegable
|
|
24
|
+
* sweep, so no signal can separate them). Each un-actioned nudge WIDENS the next run window (n× the
|
|
25
|
+
* base): the reminder lands early, then de-escalates. Pure — no I/O, fully unit-testable.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Tools that mean "the operator handed work off" — they end (reset) a by-hand run. */
|
|
29
|
+
const HANDOFF_TOOLS = new Set(["delegate", "council"]);
|
|
30
|
+
/** Supervision glue: collecting a delegated result (`intercom wait`/`result`) or arming a
|
|
31
|
+
* `timer` is neither hands-on work nor a hand-off. Their payloads are large by nature, so
|
|
32
|
+
* counting them would punish the exact behavior the nudge exists to encourage. They neither
|
|
33
|
+
* advance nor reset the run, and never nudge. */
|
|
34
|
+
const GLUE_TOOLS = new Set(["intercom", "timer"]);
|
|
35
|
+
/** Mutation tools often return only a terse acknowledgement. Their output size is not a useful
|
|
36
|
+
* proxy for the amount of direct coding work, so every call counts as substantive. */
|
|
37
|
+
const MUTATING_TOOLS = new Set(["edit", "write", "apply_patch"]);
|
|
38
|
+
|
|
39
|
+
/** Stable, fixed-size identity for an untrusted failed tool result. The input is streamed through
|
|
40
|
+
* the hash and never retained in the nudge state; the stored key is only the length + 32-bit digest. */
|
|
41
|
+
function fingerprintFailure(text: string): string {
|
|
42
|
+
let hash = 0x811c9dc5;
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
hash ^= text.charCodeAt(i);
|
|
45
|
+
hash = Math.imul(hash, 0x01000193);
|
|
46
|
+
}
|
|
47
|
+
return `${text.length}:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NudgeThresholds {
|
|
51
|
+
/** A single result at/above this many chars is a fat one-shot dump — nudge on its own. Kept
|
|
52
|
+
* high so a normal large file read doesn't trip it; only a linpeas/ffuf-scale dump does. */
|
|
53
|
+
singleHeavyChars: number;
|
|
54
|
+
/** A run of this many consecutive SUBSTANTIVE hands-on commands (no hand-off) is a by-hand
|
|
55
|
+
* sweep — the primary trigger. Counts steps, not bytes, so one big read never trips it. */
|
|
56
|
+
runLength: number;
|
|
57
|
+
/** A command whose output is below this many chars is orchestration glue (echo/cd/one-line
|
|
58
|
+
* check), not a sweep step: it does not advance the run counter. */
|
|
59
|
+
minStepChars: number;
|
|
60
|
+
/** Minimum cumulative burn (chars since last hand-off) before a sweep run can fire. Without
|
|
61
|
+
* this, 5 tiny git/grep commands (200 chars each = 1000 total ≈ 0k tokens) trip the nudge
|
|
62
|
+
* and renderNudge prints "~0k tokens" — advice to "delegate what burns context" when nothing
|
|
63
|
+
* burned. This floor gates the sweep so it only fires when the burn is substantive.
|
|
64
|
+
* Defaults to `DEFAULT_NUDGE_THRESHOLDS.minSweepBurnChars` when omitted. */
|
|
65
|
+
minSweepBurnChars?: number;
|
|
66
|
+
/** Number of identical consecutive failed hand-off reminders to suppress before repeating one. */
|
|
67
|
+
failedHandoffBackoff?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_NUDGE_THRESHOLDS: NudgeThresholds = {
|
|
71
|
+
singleHeavyChars: 40_000, // ~10k tokens in one command — a fat one-shot dump
|
|
72
|
+
runLength: 5, // 5 substantive hands-on commands in a row without a hand-off = a by-hand sweep.
|
|
73
|
+
// The standing delegation brief (core/brief.ts) carries the default every turn; this is the
|
|
74
|
+
// reactive backstop for the supervisor who grinds a sweep through it anyway.
|
|
75
|
+
minStepChars: 200, // below this a command is glue, not a sweep step
|
|
76
|
+
minSweepBurnChars: 8_000, // ~2k tokens cumulative — below this the sweep is cheap triage, not a grind
|
|
77
|
+
failedHandoffBackoff: 1, // suppress one identical immediate repair reminder; the failure remains visible
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** chars → ~thousands of tokens (≈4 chars/token), for the human-facing nudge text. */
|
|
81
|
+
const toK = (chars: number): number => Math.round(chars / 4 / 1000);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Keep runtime reinforcement structured for both its semantic tool-result copy and the dedicated
|
|
85
|
+
* TUI card. It is never moved to a hidden-only context channel; expanding either surface reveals
|
|
86
|
+
* the same labelled checkpoint.
|
|
87
|
+
*/
|
|
88
|
+
function renderVisibleCheckpoint(title: string, lines: readonly string[]): string {
|
|
89
|
+
return [`⟢ pi-persona · ${title}`, ...lines.map((line) => ` ${line}`)].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Honest lead: name what actually tripped the nudge. A single fat dump blames THAT command; a
|
|
94
|
+
* cumulative streak names the accumulated burn (never the tiny command that merely crossed the
|
|
95
|
+
* line — printing its ~0k size looked like the nudge fired on nothing). The tail acknowledges that
|
|
96
|
+
* not all burn is delegable: work bound to one interactive session a sub-agent can't inherit
|
|
97
|
+
* (a live shell, a specific tunnel) stays with you — then the fix is to keep it lean, not to hand off.
|
|
98
|
+
*/
|
|
99
|
+
function renderNudge(reason: "dump" | "sweep", run: number, burn: number, size: number): string {
|
|
100
|
+
const signal =
|
|
101
|
+
reason === "dump"
|
|
102
|
+
? [`Trigger: one direct tool result · ~${toK(size)}k estimated output tokens.`, `Scope: ~${toK(burn)}k in the current direct-work streak.`]
|
|
103
|
+
: [`Trigger: ${run} substantive direct tool calls · ~${toK(burn)}k estimated output tokens.`, "Scope: since the last successful hand-off."];
|
|
104
|
+
return renderVisibleCheckpoint("delegation checkpoint", [
|
|
105
|
+
...signal,
|
|
106
|
+
"Action: delegate independent work; repair weak hand-offs, then re-dispatch.",
|
|
107
|
+
"Keep local: session-bound work; keep it lean and grep-first.",
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class DelegationNudge {
|
|
112
|
+
private run = 0; // consecutive SUBSTANTIVE hands-on calls since the last hand-off
|
|
113
|
+
private burn = 0; // cumulative output chars since the last hand-off (for the message only)
|
|
114
|
+
private lastNudgeRun = 0; // run length at which we last nudged (the re-arm baseline)
|
|
115
|
+
private nudges = 0; // cumulative nudges fired since the last hand-off (drives backoff)
|
|
116
|
+
private failedHandoffKey: string | undefined;
|
|
117
|
+
private failedHandoffRepeats = 0;
|
|
118
|
+
private readonly t: NudgeThresholds;
|
|
119
|
+
|
|
120
|
+
constructor(thresholds: NudgeThresholds = DEFAULT_NUDGE_THRESHOLDS) {
|
|
121
|
+
this.t = thresholds;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clear the run — call on a new session or persona switch. */
|
|
125
|
+
reset(): void {
|
|
126
|
+
this.run = 0;
|
|
127
|
+
this.burn = 0;
|
|
128
|
+
this.lastNudgeRun = 0;
|
|
129
|
+
this.nudges = 0;
|
|
130
|
+
this.failedHandoffKey = undefined;
|
|
131
|
+
this.failedHandoffRepeats = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Feed one supervisor tool result: its tool name, output length, and optionally its text identity.
|
|
136
|
+
* The text is fingerprinted immediately and never retained. Returns the reminder text to append to
|
|
137
|
+
* that result, or undefined to leave the result untouched.
|
|
138
|
+
*/
|
|
139
|
+
observe(toolName: string, size: number, handoffSucceeded = true, failureText?: string): string | undefined {
|
|
140
|
+
if (GLUE_TOOLS.has(toolName)) return undefined;
|
|
141
|
+
if (HANDOFF_TOOLS.has(toolName)) {
|
|
142
|
+
if (!handoffSucceeded) {
|
|
143
|
+
const normalizedSize = Math.max(0, Math.floor(size));
|
|
144
|
+
const identity = failureText === undefined ? `size:${normalizedSize}` : fingerprintFailure(failureText);
|
|
145
|
+
const key = `${toolName}:${identity}`;
|
|
146
|
+
if (key === this.failedHandoffKey) {
|
|
147
|
+
this.failedHandoffRepeats += 1;
|
|
148
|
+
const suppress = Math.max(0, Math.floor(this.t.failedHandoffBackoff ?? DEFAULT_NUDGE_THRESHOLDS.failedHandoffBackoff ?? 0));
|
|
149
|
+
if (this.failedHandoffRepeats <= suppress) return undefined;
|
|
150
|
+
this.failedHandoffRepeats = 0;
|
|
151
|
+
} else {
|
|
152
|
+
this.failedHandoffKey = key;
|
|
153
|
+
this.failedHandoffRepeats = 0;
|
|
154
|
+
}
|
|
155
|
+
return renderVisibleCheckpoint("hand-off repair", [
|
|
156
|
+
"Trigger: hand-off failed before useful work landed; direct-work streak remains active.",
|
|
157
|
+
"Action: fix agent/model/brief/tool grant, then re-dispatch.",
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
// The operator delegated successfully — the by-hand run is over; the hand-off itself never nudges.
|
|
161
|
+
this.reset();
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
// A failed hand-off is only deduplicated when the next event is another failed hand-off;
|
|
165
|
+
// direct work starts a fresh repair sequence.
|
|
166
|
+
this.failedHandoffKey = undefined;
|
|
167
|
+
this.failedHandoffRepeats = 0;
|
|
168
|
+
const step = Math.max(0, size);
|
|
169
|
+
this.burn += step;
|
|
170
|
+
// Only a substantive step advances the run; a trivial call is orchestration glue, not a sweep
|
|
171
|
+
// step (it neither advances nor resets the run — a stray echo mid-sweep doesn't break it).
|
|
172
|
+
if (MUTATING_TOOLS.has(toolName) || step >= this.t.minStepChars) this.run += 1;
|
|
173
|
+
// Backoff: each un-actioned nudge widens the next run window, so a long non-delegable
|
|
174
|
+
// session-bound loop is reminded ONCE then left alone; a real runaway grind still trips the
|
|
175
|
+
// widened window; a hand-off resets it. The dump trigger gets the same de-escalation —
|
|
176
|
+
// repeated fat one-shots (a legit review loop) must not re-fire the identical reminder on
|
|
177
|
+
// every result: after the first dump nudge, a full base window of further work must pass.
|
|
178
|
+
const window = this.t.runLength * (this.nudges + 1);
|
|
179
|
+
const dumpReady = this.nudges === 0 || this.run - this.lastNudgeRun >= this.t.runLength * this.nudges;
|
|
180
|
+
const single = size >= this.t.singleHeavyChars && dumpReady;
|
|
181
|
+
const burnFloor = this.t.minSweepBurnChars ?? DEFAULT_NUDGE_THRESHOLDS.minSweepBurnChars ?? 0;
|
|
182
|
+
const sweep = this.run - this.lastNudgeRun >= window && this.burn >= burnFloor;
|
|
183
|
+
if (!single && !sweep) return undefined;
|
|
184
|
+
this.lastNudgeRun = this.run;
|
|
185
|
+
this.nudges += 1;
|
|
186
|
+
return renderNudge(sweep ? "sweep" : "dump", this.run, this.burn, size);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* PersistenceNudge — the counterweight to premature surrender. Where {@link DelegationNudge} fires
|
|
192
|
+
* when the supervisor grinds work BY HAND, this fires when a delegated leg comes back having GIVEN
|
|
193
|
+
* UP: the child's report carries an explicit surrender/blocked marker. It returns a reminder to
|
|
194
|
+
* append to that very result — in RECENT context, on the leg that just landed — that a delegating
|
|
195
|
+
* supervisor must not bank a premature block: confirm it names a genuine missing capability, else
|
|
196
|
+
* steer it back with the recovery pass or re-dispatch. This is the runtime half of the operator
|
|
197
|
+
* protocol's "reject premature surrender" rule; the top-of-prompt persona directive has decayed by
|
|
198
|
+
* the time a blocked leg returns.
|
|
199
|
+
*
|
|
200
|
+
* Keyed on the ecosystem's OWN explicit protocol tokens (an operator emits `[BLOCKED: need X]`, a CTF
|
|
201
|
+
* leg emits `FLAG: UNKNOWN`) — never a fuzzy "sounds stuck" guess, so it does not misfire on ordinary
|
|
202
|
+
* prose. It inspects only `delegate`/`council` results (a child's report); the supervisor's own tools
|
|
203
|
+
* are DelegationNudge's job, so the two never fire on the same event. Pure — no I/O, unit-testable.
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
/** Tools whose result is a delegated child's report (where a surrender can surface). */
|
|
207
|
+
const REPORT_TOOLS = new Set(["delegate", "council"]);
|
|
208
|
+
|
|
209
|
+
/** Explicit surrender/blocked markers the operator + CTF protocols emit (operator.md). */
|
|
210
|
+
const SURRENDER_MARKERS: readonly RegExp[] = [/\[BLOCKED\b/i, /\bFLAG:\s*UNKNOWN\b/i];
|
|
211
|
+
|
|
212
|
+
const PERSISTENCE_NOTE = renderVisibleCheckpoint("delegated result needs verification", [
|
|
213
|
+
"Trigger: a delegated leg returned BLOCKED/UNKNOWN.",
|
|
214
|
+
"Verify: accept only concrete external blockers (access, credentials, or tools).",
|
|
215
|
+
"Otherwise: run the recovery pass or re-dispatch with a sharper brief.",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
export class PersistenceNudge {
|
|
219
|
+
/**
|
|
220
|
+
* Feed one supervisor tool result: its tool name and the full text of the result. Returns the
|
|
221
|
+
* reminder to append when a delegated leg's report signals premature surrender, else undefined.
|
|
222
|
+
* The SYNC path (a `delegate`/`council` tool_result the supervisor sees inline).
|
|
223
|
+
*/
|
|
224
|
+
observe(toolName: string, text: string): string | undefined {
|
|
225
|
+
if (!REPORT_TOOLS.has(toolName)) return undefined;
|
|
226
|
+
return this.scan(text);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Tool-agnostic surrender check on already-known-to-be-a-leg-report text. The ASYNC path uses
|
|
231
|
+
* this: a background run (the interactive default) delivers its report through the completion
|
|
232
|
+
* notifier as a fresh follow-up, NOT a `delegate` tool_result, so {@link observe}'s tool gate
|
|
233
|
+
* would never see it — the caller has already established the text is a settled leg's output.
|
|
234
|
+
*/
|
|
235
|
+
scan(text: string): string | undefined {
|
|
236
|
+
return SURRENDER_MARKERS.some((re) => re.test(text)) ? PERSISTENCE_NOTE : undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Shared write-set overlap — used by delegate and the exocom work ledger. Pure. */
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface WriteSetOwner {
|
|
5
|
+
agent?: string;
|
|
6
|
+
writeSet?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NormalizedWritePath {
|
|
10
|
+
value: string;
|
|
11
|
+
windows: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeWritePath(raw: string): NormalizedWritePath {
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
// Case-folding is decided PER PATH by its syntax, never by the host platform: a plan is
|
|
17
|
+
// authored on one OS and may execute on another, so the overlap answer must not change
|
|
18
|
+
// with the machine. Backslashes, a drive letter, or a UNC prefix mark a Windows path
|
|
19
|
+
// (case-insensitive); plain slash-separated ownership names stay case-sensitive.
|
|
20
|
+
const windows = trimmed.includes("\\") || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\");
|
|
21
|
+
const normalized = (windows ? win32.normalize(trimmed) : posix.normalize(trimmed)).replaceAll("\\", "/");
|
|
22
|
+
const value = normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
23
|
+
return { value: windows ? value.toLowerCase() : value, windows };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pathsOverlap(a: NormalizedWritePath, b: NormalizedWritePath): boolean {
|
|
27
|
+
const left = a.windows || b.windows ? a.value.toLowerCase() : a.value;
|
|
28
|
+
const right = a.windows || b.windows ? b.value.toLowerCase() : b.value;
|
|
29
|
+
const isAncestor = (parent: string, child: string): boolean => {
|
|
30
|
+
if (parent === "/") return child.startsWith("/");
|
|
31
|
+
if (parent === ".") return !child.startsWith("/");
|
|
32
|
+
return child.startsWith(`${parent}/`);
|
|
33
|
+
};
|
|
34
|
+
return left === right || isAncestor(left, right) || isAncestor(right, left);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WriteSetOverlap {
|
|
38
|
+
firstIndex: number;
|
|
39
|
+
secondIndex: number;
|
|
40
|
+
firstPath: string;
|
|
41
|
+
secondPath: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Find equal/ancestor-descendant write-set collisions in deterministic owner/path order. */
|
|
45
|
+
export function findWriteSetOverlaps(tasks: readonly WriteSetOwner[]): WriteSetOverlap[] {
|
|
46
|
+
const overlaps: WriteSetOverlap[] = [];
|
|
47
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
48
|
+
const first = tasks[i];
|
|
49
|
+
if (!first) continue;
|
|
50
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
51
|
+
const second = tasks[j];
|
|
52
|
+
if (!second) continue;
|
|
53
|
+
for (const firstPath of first.writeSet ?? []) {
|
|
54
|
+
if (!firstPath.trim()) continue;
|
|
55
|
+
for (const secondPath of second.writeSet ?? []) {
|
|
56
|
+
if (!secondPath.trim()) continue;
|
|
57
|
+
if (pathsOverlap(normalizeWritePath(firstPath), normalizeWritePath(secondPath))) {
|
|
58
|
+
overlaps.push({ firstIndex: i, secondIndex: j, firstPath, secondPath });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return overlaps;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Undefined when the path is a repository-relative ownership name; otherwise a reason. */
|
|
68
|
+
export function writeSetPathError(path: string): string | undefined {
|
|
69
|
+
if (typeof path !== "string" || !path.trim()) return "empty writeSet path";
|
|
70
|
+
const raw = path.trim();
|
|
71
|
+
const slash = raw.replaceAll("\\", "/");
|
|
72
|
+
const normalized = posix.normalize(slash);
|
|
73
|
+
if (posix.isAbsolute(slash) || win32.isAbsolute(raw) || normalized === ".." || normalized.startsWith("../")) {
|
|
74
|
+
return 'outside the repository-relative ownership namespace; use "." for the repository root';
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Return an actionable error for a parallel write-set collision; undefined means safe to spawn.
|
|
80
|
+
* Lives here (not in `tools/delegate.ts`) so a strategy can call it without importing a tool
|
|
81
|
+
* module — `delegate.ts` re-exports it unchanged for its own call sites. */
|
|
82
|
+
export function validateParallelWriteSets(tasks: readonly WriteSetOwner[]): string | undefined {
|
|
83
|
+
for (const [index, task] of tasks.entries()) {
|
|
84
|
+
for (const path of task.writeSet ?? []) {
|
|
85
|
+
const err = writeSetPathError(path);
|
|
86
|
+
if (!err) continue;
|
|
87
|
+
if (err.startsWith("empty")) {
|
|
88
|
+
return `delegate: task[${index}] ("${task.agent}") has an empty writeSet path; remove it or provide a repository-relative path.`;
|
|
89
|
+
}
|
|
90
|
+
return `delegate: task[${index}] ("${task.agent}") writeSet path "${path}" is ${err}.`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const overlap = findWriteSetOverlaps(tasks)[0];
|
|
94
|
+
if (!overlap) return undefined;
|
|
95
|
+
const first = tasks[overlap.firstIndex];
|
|
96
|
+
const second = tasks[overlap.secondIndex];
|
|
97
|
+
return (
|
|
98
|
+
`delegate: parallel writeSet overlap between task[${overlap.firstIndex}] ("${first?.agent ?? "?"}") ` +
|
|
99
|
+
`path "${overlap.firstPath}" and task[${overlap.secondIndex}] ("${second?.agent ?? "?"}") ` +
|
|
100
|
+
`path "${overlap.secondPath}". Split the paths, serialize these tasks, or remove the overlap before spawning.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-style allow/deny permission resolution over glob patterns.
|
|
3
|
+
*
|
|
4
|
+
* Pure module — no Pi imports. One resolver shared by persona `tools`/`delegate`/
|
|
5
|
+
* `skills` gating, agent tool allowlists, and the `EffectiveCapabilities` builder.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** An allow/deny block of glob patterns (`*` = any run, `?` = exactly one char). */
|
|
9
|
+
export interface Permission {
|
|
10
|
+
allow?: string[];
|
|
11
|
+
deny?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Patterns are fixed (a persona's allow/deny lists), but `isAllowed` runs on every `tool_call`
|
|
15
|
+
// and across the whole tool registry on each persona switch — so compile each glob once.
|
|
16
|
+
const globCache = new Map<string, RegExp>();
|
|
17
|
+
const GLOB_CACHE_MAX = 256;
|
|
18
|
+
|
|
19
|
+
/** Compile a glob (`*`, `?`) into a full-string-anchored RegExp; all other
|
|
20
|
+
* characters are matched literally (regex metacharacters are escaped). Memoised. */
|
|
21
|
+
function globToRegExp(pattern: string): RegExp {
|
|
22
|
+
const cached = globCache.get(pattern);
|
|
23
|
+
if (cached) return cached;
|
|
24
|
+
let body = "";
|
|
25
|
+
for (const ch of pattern) {
|
|
26
|
+
if (ch === "*") body += ".*";
|
|
27
|
+
else if (ch === "?") body += ".";
|
|
28
|
+
else body += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
const re = new RegExp(`^${body}$`);
|
|
31
|
+
if (globCache.size >= GLOB_CACHE_MAX) globCache.clear();
|
|
32
|
+
globCache.set(pattern, re);
|
|
33
|
+
return re;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matchesAny(name: string, patterns: string[]): boolean {
|
|
37
|
+
return patterns.some((p) => globToRegExp(p).test(name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve whether `name` is permitted by an opencode-style block.
|
|
42
|
+
*
|
|
43
|
+
* - **deny wins:** a match in `deny` → denied.
|
|
44
|
+
* - **allowlist mode:** if `allow` is present, only matching names are allowed
|
|
45
|
+
* (`allow: []` ⇒ lockdown; `allow: ["*"]` ⇒ allow all).
|
|
46
|
+
* - **denylist mode:** `deny` only ⇒ allow everything except denied.
|
|
47
|
+
* - **absent block** (no `allow`/`deny`, or `perm` undefined) ⇒ `defaultAllow`.
|
|
48
|
+
*/
|
|
49
|
+
export function isAllowed(name: string, perm: Permission | undefined, defaultAllow = true): boolean {
|
|
50
|
+
const deny = perm?.deny;
|
|
51
|
+
if (deny !== undefined && matchesAny(name, deny)) return false;
|
|
52
|
+
const allow = perm?.allow;
|
|
53
|
+
if (allow !== undefined) return matchesAny(name, allow);
|
|
54
|
+
if (deny !== undefined) return true; // denylist mode: not denied ⇒ allowed
|
|
55
|
+
return defaultAllow;
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/** Oldest Pi this extension is written against. 0.83 made `message_update` delta-only (src/engine/stream.ts);
|
|
7
|
+
* older hosts are feature-detected where cheap but are not supported. Keep in sync with README.md;
|
|
8
|
+
* npm host peers use "*" because Pi supplies them through its extension loader. */
|
|
9
|
+
export const MIN_PI_VERSION = "0.83.0";
|
|
10
|
+
|
|
11
|
+
function parse(v: string): [number, number, number] | undefined {
|
|
12
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
13
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function satisfiesFloor(version: string, floor: string): boolean {
|
|
17
|
+
const a = parse(version), b = parse(floor);
|
|
18
|
+
if (!a || !b) return false;
|
|
19
|
+
for (let i = 0; i < 3; i++) { if (a[i]! !== b[i]!) return a[i]! > b[i]!; }
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveHostEntry(): string | undefined {
|
|
24
|
+
// The host package is ESM-only (no CJS `main`), so `createRequire(...).resolve` throws
|
|
25
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED for it — `import.meta.resolve` is tried first because it
|
|
26
|
+
// follows an `exports` map with no `main`/`require` condition. `createRequire` stays as a
|
|
27
|
+
// fallback for any host build that does publish one.
|
|
28
|
+
try {
|
|
29
|
+
return fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
try {
|
|
32
|
+
return createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent");
|
|
33
|
+
} catch { /* unresolved host */ }
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Best-effort: the host's package.json is not in its `exports` map, so resolve the entry and read the file. */
|
|
38
|
+
export function installedPiVersion(): string | undefined {
|
|
39
|
+
const entry = resolveHostEntry();
|
|
40
|
+
if (!entry) return undefined;
|
|
41
|
+
let dir = dirname(entry);
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string; version?: string };
|
|
45
|
+
if (pkg.name === "@earendil-works/pi-coding-agent" && typeof pkg.version === "string") return pkg.version;
|
|
46
|
+
} catch { /* climb */ }
|
|
47
|
+
dir = dirname(dir);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|