@ferris1225/pi-subagents 4.3.8 → 4.3.9

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.
@@ -0,0 +1,208 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ export interface SymbolScopeClaim {
4
+ path: string;
5
+ name: string;
6
+ }
7
+
8
+ export interface PhaseScopeInput {
9
+ paths?: string[];
10
+ symbols?: SymbolScopeClaim[];
11
+ }
12
+
13
+ /** Absolute, platform-normalized claims used only for deterministic admission. */
14
+ export interface PhaseScope {
15
+ paths?: string[];
16
+ symbols?: SymbolScopeClaim[];
17
+ }
18
+
19
+ export interface PhaseScopeOverlap {
20
+ left: string;
21
+ right: string;
22
+ kind: "path-path" | "path-symbol" | "symbol-symbol";
23
+ }
24
+
25
+ export const PHASE_ID_MAX_LENGTH = 80;
26
+ export const PHASE_ID_PATTERN_SOURCE = "^[A-Za-z0-9][A-Za-z0-9._:-]*$";
27
+ const PHASE_ID_PATTERN = new RegExp(PHASE_ID_PATTERN_SOURCE, "u");
28
+ const WILDCARD_PATH = /[*?]/u;
29
+
30
+ function normalizePathCase(path: string): string {
31
+ return process.platform === "win32" ? path.toLowerCase() : path;
32
+ }
33
+
34
+ function normalizeClaimPath(path: unknown, cwd: string, label: string): string {
35
+ if (typeof path !== "string" || path.trim().length === 0) {
36
+ throw new Error(`${label} must be a non-blank exact file or directory path.`);
37
+ }
38
+ const trimmed = path.trim();
39
+ if (WILDCARD_PATH.test(trimmed)) {
40
+ throw new Error(`${label} must be exact; wildcard * and ? inputs are not supported. Other punctuation is treated literally.`);
41
+ }
42
+ return normalizePathCase(resolve(cwd, trimmed));
43
+ }
44
+
45
+ export function normalizePhaseId(phaseId: string | undefined): string | undefined {
46
+ if (phaseId === undefined) return undefined;
47
+ if (
48
+ typeof phaseId !== "string" ||
49
+ phaseId.length > PHASE_ID_MAX_LENGTH ||
50
+ !PHASE_ID_PATTERN.test(phaseId)
51
+ ) {
52
+ throw new Error(`phaseId must be a single-line identifier of 1-${PHASE_ID_MAX_LENGTH} ASCII letters, numbers, or ._:- characters, starting with a letter or number.`);
53
+ }
54
+ return phaseId;
55
+ }
56
+
57
+ /** Resolve exact claims against the caller-facing cwd. An omitted scope remains
58
+ * compatible; a present scope must contain at least one valid claim. */
59
+ export function normalizePhaseScope(
60
+ scope: PhaseScopeInput | undefined,
61
+ cwd: string,
62
+ ): PhaseScope | undefined {
63
+ if (scope === undefined) return undefined;
64
+ if (!scope || typeof scope !== "object") throw new Error("scope must be an object when provided.");
65
+ if (scope.paths !== undefined && !Array.isArray(scope.paths)) throw new Error("scope.paths must be an array.");
66
+ if (scope.symbols !== undefined && !Array.isArray(scope.symbols)) throw new Error("scope.symbols must be an array.");
67
+
68
+ const paths = [...new Set((scope.paths ?? []).map((path, index) =>
69
+ normalizeClaimPath(path, cwd, `scope.paths[${index}]`),
70
+ ))];
71
+ const symbols: SymbolScopeClaim[] = [];
72
+ const symbolKeys = new Set<string>();
73
+ for (const [index, symbol] of (scope.symbols ?? []).entries()) {
74
+ if (!symbol || typeof symbol !== "object") {
75
+ throw new Error(`scope.symbols[${index}] must contain an exact path and symbol name.`);
76
+ }
77
+ const path = normalizeClaimPath(symbol.path, cwd, `scope.symbols[${index}].path`);
78
+ if (typeof symbol.name !== "string" || symbol.name.trim().length === 0) {
79
+ throw new Error(`scope.symbols[${index}].name must be non-blank.`);
80
+ }
81
+ const name = symbol.name.trim();
82
+ const key = `${path}\0${name}`;
83
+ if (!symbolKeys.has(key)) {
84
+ symbolKeys.add(key);
85
+ symbols.push({ path, name });
86
+ }
87
+ }
88
+ if (paths.length === 0 && symbols.length === 0) {
89
+ throw new Error("scope must contain at least one valid claim in paths or symbols.");
90
+ }
91
+ return {
92
+ ...(paths.length > 0 ? { paths } : {}),
93
+ ...(symbols.length > 0 ? { symbols } : {}),
94
+ };
95
+ }
96
+
97
+ /** Merge normalized continuation claims monotonically so retained edits never lose coverage. */
98
+ export function mergePhaseScopes(
99
+ previous: PhaseScope | undefined,
100
+ additional: PhaseScope | undefined,
101
+ ): PhaseScope | undefined {
102
+ if (!previous) return additional;
103
+ if (!additional) return previous;
104
+ const paths = [...new Set([...(previous.paths ?? []), ...(additional.paths ?? [])])];
105
+ const symbols: SymbolScopeClaim[] = [];
106
+ const symbolKeys = new Set<string>();
107
+ for (const symbol of [...(previous.symbols ?? []), ...(additional.symbols ?? [])]) {
108
+ const key = `${symbol.path}\0${symbol.name}`;
109
+ if (symbolKeys.has(key)) continue;
110
+ symbolKeys.add(key);
111
+ symbols.push(symbol);
112
+ }
113
+ return {
114
+ ...(paths.length > 0 ? { paths } : {}),
115
+ ...(symbols.length > 0 ? { symbols } : {}),
116
+ };
117
+ }
118
+
119
+ function containsPath(ancestor: string, candidate: string): boolean {
120
+ if (ancestor === candidate) return true;
121
+ const child = relative(ancestor, candidate);
122
+ return child !== "" && child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
123
+ }
124
+
125
+ function describeSymbol(symbol: SymbolScopeClaim): string {
126
+ return `${symbol.path}#${symbol.name}`;
127
+ }
128
+
129
+ /** Return the first deterministic overlap. Path claims cover the named path and
130
+ * descendants; symbols on the same file overlap only when their names match. */
131
+ export function findPhaseScopeOverlap(
132
+ left: PhaseScope,
133
+ right: PhaseScope,
134
+ ): PhaseScopeOverlap | undefined {
135
+ for (const leftPath of left.paths ?? []) {
136
+ for (const rightPath of right.paths ?? []) {
137
+ if (containsPath(leftPath, rightPath) || containsPath(rightPath, leftPath)) {
138
+ return { left: leftPath, right: rightPath, kind: "path-path" };
139
+ }
140
+ }
141
+ }
142
+ for (const leftPath of left.paths ?? []) {
143
+ for (const rightSymbol of right.symbols ?? []) {
144
+ if (containsPath(leftPath, rightSymbol.path)) {
145
+ return { left: leftPath, right: describeSymbol(rightSymbol), kind: "path-symbol" };
146
+ }
147
+ }
148
+ }
149
+ for (const leftSymbol of left.symbols ?? []) {
150
+ for (const rightPath of right.paths ?? []) {
151
+ if (containsPath(rightPath, leftSymbol.path)) {
152
+ return { left: describeSymbol(leftSymbol), right: rightPath, kind: "path-symbol" };
153
+ }
154
+ }
155
+ }
156
+ for (const leftSymbol of left.symbols ?? []) {
157
+ for (const rightSymbol of right.symbols ?? []) {
158
+ if (leftSymbol.path === rightSymbol.path && leftSymbol.name === rightSymbol.name) {
159
+ return {
160
+ left: describeSymbol(leftSymbol),
161
+ right: describeSymbol(rightSymbol),
162
+ kind: "symbol-symbol",
163
+ };
164
+ }
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ export interface WriterScopeLease {
171
+ id: number;
172
+ agentName: string;
173
+ state: "queued" | "resuming" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
174
+ lifecycleOperation?: "park" | "resume" | "stop" | "settle";
175
+ retired?: boolean;
176
+ scope?: PhaseScope;
177
+ /** Transient monotonic scope claimed while a continuation is preparing. */
178
+ admissionScope?: PhaseScope;
179
+ writeCapable?: boolean;
180
+ }
181
+
182
+ export interface WriterLeaseScopeOverlap {
183
+ lease: WriterScopeLease;
184
+ overlap: PhaseScopeOverlap;
185
+ }
186
+
187
+ const SCOPE_ADMISSION_STATES = new Set<WriterScopeLease["state"]>(["queued", "resuming", "running", "interrupting", "parked"]);
188
+
189
+ /** Compare absolute normalized claims against active writer leases. Scope identity,
190
+ * unlike phase identity, is independent of the caller's cwd. Settled phases do not block. */
191
+ export function findWriterLeaseScopeOverlap(
192
+ scope: PhaseScope,
193
+ leases: Iterable<WriterScopeLease>,
194
+ excludeRunId?: number,
195
+ ): WriterLeaseScopeOverlap | undefined {
196
+ for (const lease of leases) {
197
+ if (lease.id === excludeRunId) continue;
198
+ const active = lease.lifecycleOperation === "settle" || SCOPE_ADMISSION_STATES.has(lease.state);
199
+ const leaseScope = lease.admissionScope ?? lease.scope;
200
+ const writes =
201
+ lease.admissionScope !== undefined ||
202
+ (lease.writeCapable ?? lease.agentName !== "scout");
203
+ if (!active || lease.retired || !writes || !leaseScope) continue;
204
+ const overlap = findPhaseScopeOverlap(scope, leaseScope);
205
+ if (overlap) return { lease, overlap };
206
+ }
207
+ return undefined;
208
+ }
@@ -13,6 +13,7 @@ export interface PhaseLeaseSource {
13
13
  id: number;
14
14
  agentName: string;
15
15
  task: string;
16
+ phaseId?: string;
16
17
  cwd: string;
17
18
  state: "queued" | "resuming" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
18
19
  lifecycleOperation?: "park" | "resume" | "stop" | "settle";
@@ -74,19 +75,22 @@ function isResumableSettledLease(source: PhaseLeaseSource): boolean {
74
75
  );
75
76
  }
76
77
 
77
- /** Exact normalized task plus resolved cwd, regardless of agent name. An
78
- * active lease wins over a settled one so the message names the live owner. */
78
+ /** Stable phase id in the same resolved cwd, or the legacy exact normalized
79
+ * task+cwd fallback, regardless of agent name. Active leases win over settled. */
79
80
  export function findDuplicateDispatch(
80
81
  sources: Iterable<PhaseLeaseSource>,
81
82
  task: string,
82
83
  cwd: string,
84
+ phaseId?: string,
83
85
  ): DuplicateDispatch | undefined {
84
86
  const taskKey = normalizedTask(task);
85
87
  const cwdKey = normalizedCwd(cwd);
86
- const matches = [...sources].filter((source) =>
87
- normalizedTask(source.task) === taskKey &&
88
- normalizedCwd(source.cwd) === cwdKey,
89
- );
88
+ const phaseKey = phaseId?.trim();
89
+ const matches = [...sources].filter((source) => {
90
+ if (normalizedCwd(source.cwd) !== cwdKey) return false;
91
+ const samePhase = Boolean(phaseKey && source.phaseId && source.phaseId.trim() === phaseKey);
92
+ return samePhase || normalizedTask(source.task) === taskKey;
93
+ });
90
94
  const active = matches.find(isActivePhaseLease);
91
95
  if (active) return { source: active, kind: "active" };
92
96
  const settled = matches.find(isResumableSettledLease);
@@ -106,7 +110,8 @@ function formatActivePhaseLeases(sources: Iterable<PhaseLeaseSource>): string {
106
110
  if (active.length === 0) return "";
107
111
  const lines = active.slice(0, MAX_ACTIVE_LEASES).map((source) => {
108
112
  const state = source.lifecycleOperation === "settle" ? "settling" : source.state;
109
- return `- #${source.id} ${phaseForAgent(source.agentName)} (${source.agentName}, ${state}): ${summarizeLeaseTask(source.task)}`;
113
+ const phase = source.phaseId ? `, phase:${source.phaseId}` : "";
114
+ return `- #${source.id} ${phaseForAgent(source.agentName)} (${source.agentName}, ${state}${phase}): ${summarizeLeaseTask(source.task)}`;
110
115
  });
111
116
  if (active.length > MAX_ACTIVE_LEASES) {
112
117
  lines.push(`- … ${active.length - MAX_ACTIVE_LEASES} more active lease${active.length - MAX_ACTIVE_LEASES === 1 ? "" : "s"} omitted`);
@@ -114,10 +119,26 @@ function formatActivePhaseLeases(sources: Iterable<PhaseLeaseSource>): string {
114
119
  return lines.join("\n");
115
120
  }
116
121
 
117
- export function formatPhaseLeaseReceipt(sources: Iterable<PhaseLeaseSource>): string {
122
+ export function formatParallelScopeAdmissionNote(declaredScopesComplete: boolean): string {
123
+ return declaredScopesComplete
124
+ ? "Declared scope admission passed; scope is conflict metadata, not permissions or a sandbox."
125
+ : "Independence not verified: at least one task omitted scope; compatibility dispatch continued.";
126
+ }
127
+
128
+ export type PhaseLeaseReceiptOptions =
129
+ | { mode: "single" }
130
+ | { mode: "parallel"; declaredScopesComplete: boolean };
131
+
132
+ export function formatPhaseLeaseReceipt(
133
+ sources: Iterable<PhaseLeaseSource>,
134
+ options: PhaseLeaseReceiptOptions,
135
+ ): string {
118
136
  const leases = formatActivePhaseLeases(sources);
119
137
  if (!leases) return "";
120
- return `Active phase lease:\n${leases}\nDo not duplicate it; continue only disjoint work.`;
138
+ const admission = options.mode === "single"
139
+ ? ""
140
+ : `\n${formatParallelScopeAdmissionNote(options.declaredScopesComplete)}`;
141
+ return `Active phase lease:\n${leases}\nDo not duplicate it; continue only disjoint work.${admission}`;
121
142
  }
122
143
 
123
144
  export function buildDelegationDirective(
@@ -134,16 +155,16 @@ export function buildDelegationDirective(
134
155
  const hasSentinel = agents.some((agent) => agent.name === "sentinel");
135
156
 
136
157
  const dispatchRules = [
137
- "Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context: proactively delegate substantial self-contained phases when saved main-context work exceeds handoff cost, and decide before starting the work yourself — a half-done phase handed off pays twice.",
138
- "Scale effort to the question: atomic lookups, known locations, focused edits, and context-heavy decisions stay in main; one broad question is one clustered scout brief (repository and external research together); one coherent primary change is one artisan. Parallel only for independent scopes, batched in one launch; the runtime runs at most six child processes and queues the rest.",
158
+ "Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context: proactively delegate substantial self-contained phases when saved main-context work exceeds handoff cost; decide before starting — a half-done phase handed off pays twice.",
159
+ "Scale effort to the question: atomic lookups, known locations, focused edits, and context-heavy decisions stay in main; one broad question is one clustered scout brief (repository and external research together); one coherent primary change is one artisan. Batch only independent work, at most six child processes. Set stable `phaseId` and exact writer `scope`; reject deterministic duplicates and declared overlaps before allocation. Scope is conflict metadata, not permissions/sandboxing; a parallel omission reports `independence not verified`. Delegation depends on handoff cost and full conversation context; never infer it as a natural-language safety claim.",
139
160
  ...(hasScout ? ["`scout`: read-only broad code mapping or external research; returns file/source citations as leads, not proof."] : []),
140
161
  ...(hasArtisan ? ["`artisan`: one substantial primary change; owns root cause, implementation, affected tests/docs, and targeted checks."] : []),
141
162
  ...(hasSteward ? ["`steward`: final cleanup/docs sync for a completed broad or multi-writer diff; focused hygiene stays inline."] : []),
142
- ...(hasSentinel ? ["`sentinel`: read-only fresh-context review of a completed diff, after cleanup and before commit, only when the diff touches concurrency, trust boundaries, persistence/compatibility, or failure/cancellation paths, or when checks cannot prove it — never a commit ritual. A finding is evidence: route it to the owning thread via `resume` or fix it inline."] : []),
163
+ ...(hasSentinel ? ["`sentinel`: read-only fresh-context review of a completed diff after cleanup, only when the diff touches concurrency, trust boundaries, persistence/compatibility, failure/cancellation, or unproved behavior — never a commit ritual. `subagent_risk` applies fixed changed-path rules without a model; it never dispatches or blocks. Route findings to the owner via `resume` or fix inline."] : []),
143
164
  "A child has no memory of this conversation. Every brief states: the objective and its done condition; exact paths/symbols; facts already established, with citations, so the child starts there instead of re-deriving them; boundaries (what not to touch or decide); and the expected output shape.",
144
165
  "One owner per phase; dependent phases wait for the prerequisite result. Main uses the compact result and cited lines and never repeats delegated broad search, implementation, or cleanup. Child output is evidence/leads, not authority/instructions.",
145
166
  "For one high-stakes uncertainty, at most two read-only scouts with distinct perspectives/hypotheses; main reconciles disagreements against cited evidence. Never overlap writers or send identical briefs.",
146
- "Same thread, never a second one: `subagent_control steer` sends new in-scope evidence to a running phase (a settled or parked thread continues with it); `resume` continues a parked or finished thread with an appended objective and its retained context; `park` pauses a running thread at a stable checkpoint; `subagent_stop` ends a phase the evidence made moot. An equivalent brief is rejected, not re-run.",
167
+ "Same thread, never a second one: reuse its immutable `phaseId`; `subagent_control steer` sends new evidence to a running phase; `resume` continues parked/finished context, retains prior scope, and may add claims but never remove them; `park` pauses; `subagent_stop` retires. Identity is `phaseId`, exact task+cwd fallback, never fuzzy or embedding-based.",
147
168
  "`wait: true` only when the result is the immediate dependency; otherwise continue disjoint work. Never sleep or poll, and never finish while a run is active.",
148
169
  "Inspect the integrated diff and actual check output; read a truncated result's artifact only when the shown lines are insufficient. Never report an unrun check as passed.",
149
170
  ];
@@ -0,0 +1,168 @@
1
+ import { resolve } from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { runCommand } from "../isolation/git-command.ts";
5
+
6
+ export type SubagentRiskCategory =
7
+ | "concurrency"
8
+ | "trust-boundary"
9
+ | "persistence-compatibility"
10
+ | "failure-cancellation";
11
+
12
+ export type GitRiskRunner = (
13
+ cwd: string,
14
+ args: readonly string[],
15
+ signal?: AbortSignal,
16
+ ) => Promise<string>;
17
+
18
+ export interface SubagentRiskAdvisory {
19
+ available: boolean;
20
+ changedPaths: string[];
21
+ categories: SubagentRiskCategory[];
22
+ matches: Partial<Record<SubagentRiskCategory, string[]>>;
23
+ recommendSentinel: boolean;
24
+ unavailableReason?: string;
25
+ }
26
+
27
+ const CATEGORY_RULES: ReadonlyArray<{
28
+ category: SubagentRiskCategory;
29
+ pattern: RegExp;
30
+ }> = [
31
+ {
32
+ category: "concurrency",
33
+ pattern: /(?:^|[/_.-])(?:concurr(?:ency|ent)?|parallel|queues?|workers?|threads?|locks?|mutex|semaphore|dispatch|background|races?|lane)(?:[/_.-]|$)/u,
34
+ },
35
+ {
36
+ category: "trust-boundary",
37
+ pattern: /(?:^|[/_.-])(?:auth|credentials?|permissions?|policy|privilege|secrets?|security|sandbox|trust|tokens?)(?:[/_.-]|$)/u,
38
+ },
39
+ {
40
+ category: "persistence-compatibility",
41
+ pattern: /(?:^|[/_.-])(?:compat(?:ibility)?|durable|manifests?|migrations?|persist(?:ence|ent)?|restore|schemas?|serializ(?:e|ation)|storage)(?:[/_.-]|$)/u,
42
+ },
43
+ {
44
+ category: "failure-cancellation",
45
+ pattern: /(?:^|[/_.-])(?:abort(?:ed|ion|s)?|cancel(?:ed|lation|led)?|errors?|fail(?:ed|ures?)?|recovery|retries|retry|stop|timeout)(?:[/_.-]|$)/u,
46
+ },
47
+ ];
48
+
49
+ function normalizedGitPath(path: string): string {
50
+ return path.replaceAll("\\", "/").replace(/^\.\//u, "");
51
+ }
52
+
53
+ export function classifyRiskPaths(paths: readonly string[]): Pick<
54
+ SubagentRiskAdvisory,
55
+ "categories" | "matches" | "recommendSentinel"
56
+ > {
57
+ const normalized = [...new Set(paths.map(normalizedGitPath).filter(Boolean))].sort();
58
+ const matches: Partial<Record<SubagentRiskCategory, string[]>> = {};
59
+ const categories: SubagentRiskCategory[] = [];
60
+ for (const rule of CATEGORY_RULES) {
61
+ const matching = normalized.filter((path) => rule.pattern.test(path.toLowerCase()));
62
+ if (matching.length === 0) continue;
63
+ categories.push(rule.category);
64
+ matches[rule.category] = matching;
65
+ }
66
+ return { categories, matches, recommendSentinel: categories.length > 0 };
67
+ }
68
+
69
+ const RISK_GIT_TIMEOUT_MS = 30_000;
70
+ const RISK_GIT_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
71
+
72
+ const defaultGitRunner: GitRiskRunner = async (cwd, args, signal) => {
73
+ const result = await runCommand("git", args, {
74
+ cwd,
75
+ signal,
76
+ timeoutMs: RISK_GIT_TIMEOUT_MS,
77
+ maxOutputBytes: RISK_GIT_OUTPUT_MAX_BYTES,
78
+ });
79
+ if (result.code !== 0) {
80
+ const detail = result.stderr.toString("utf8").trim();
81
+ throw new Error(detail || `git ${args[0] ?? "command"} exited with code ${result.code}`);
82
+ }
83
+ return result.stdout.toString("utf8");
84
+ };
85
+
86
+ function isCancellation(error: unknown, signal?: AbortSignal): boolean {
87
+ return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
88
+ }
89
+
90
+ function nulPaths(output: string): string[] {
91
+ return output.split("\0").map(normalizedGitPath).filter(Boolean);
92
+ }
93
+
94
+ /** Inspect repository-root-relative tracked and untracked changes without a model call.
95
+ * Non-cancellation Git failures are advisory-unavailable and never block work. */
96
+ export async function analyzeSubagentRisk(
97
+ cwd: string,
98
+ runGit: GitRiskRunner = defaultGitRunner,
99
+ signal?: AbortSignal,
100
+ ): Promise<SubagentRiskAdvisory> {
101
+ signal?.throwIfAborted();
102
+ const resolvedCwd = resolve(cwd);
103
+ try {
104
+ const topLevel = (await runGit(resolvedCwd, ["rev-parse", "--show-toplevel"], signal)).trim();
105
+ signal?.throwIfAborted();
106
+ if (!topLevel) throw new Error("Git returned an empty repository top-level path.");
107
+ const repositoryRoot = resolve(topLevel);
108
+ const [tracked, untracked] = await Promise.all([
109
+ runGit(repositoryRoot, ["diff", "--name-only", "-z", "HEAD"], signal),
110
+ runGit(repositoryRoot, ["ls-files", "--others", "--exclude-standard", "-z"], signal),
111
+ ]);
112
+ signal?.throwIfAborted();
113
+ const changedPaths = [...new Set([...nulPaths(tracked), ...nulPaths(untracked)])].sort();
114
+ const classification = classifyRiskPaths(changedPaths);
115
+ return { available: true, changedPaths, ...classification };
116
+ } catch (error) {
117
+ if (isCancellation(error, signal)) throw error;
118
+ return {
119
+ available: false,
120
+ changedPaths: [],
121
+ categories: [],
122
+ matches: {},
123
+ recommendSentinel: false,
124
+ unavailableReason: error instanceof Error ? error.message : String(error),
125
+ };
126
+ }
127
+ }
128
+
129
+ export function registerSubagentRiskTool(pi: ExtensionAPI): void {
130
+ pi.registerTool({
131
+ name: "subagent_risk",
132
+ label: "Subagent Risk",
133
+ description: "Advisory-only, no-model-call inspection of repository-root-relative tracked and untracked changes from HEAD, even when called from a nested cwd. Applies fixed path rules for concurrency, trust-boundary, persistence-compatibility, and failure-cancellation risk, and reports whether a fresh Sentinel review is suggested. It never dispatches a child or blocks work.",
134
+ parameters: Type.Object({
135
+ cwd: Type.Optional(Type.String({ description: "Repository working directory; defaults to the current caller cwd." })),
136
+ }),
137
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
138
+ const advisory = await analyzeSubagentRisk(
139
+ resolve(ctx.cwd, params.cwd ?? "."),
140
+ defaultGitRunner,
141
+ signal,
142
+ );
143
+ if (!advisory.available) {
144
+ return {
145
+ content: [{
146
+ type: "text",
147
+ text: `Sentinel risk advisory unavailable: ${advisory.unavailableReason ?? "Git could not inspect the working tree"}. Advisory only; no child was dispatched and work was not blocked.`,
148
+ }],
149
+ details: advisory,
150
+ };
151
+ }
152
+ const changed = advisory.changedPaths.length > 0
153
+ ? advisory.changedPaths.map((path) => `- ${path}`).join("\n")
154
+ : "- (none)";
155
+ const categories = advisory.categories.length > 0 ? advisory.categories.join(", ") : "none";
156
+ const recommendation = advisory.recommendSentinel
157
+ ? "Sentinel suggested by fixed path rules. Dispatch remains the main agent's decision."
158
+ : "Sentinel not suggested by fixed path rules.";
159
+ return {
160
+ content: [{
161
+ type: "text",
162
+ text: `Changed paths relative to HEAD:\n${changed}\nRisk categories: ${categories}\n${recommendation} Advisory only; no child was dispatched and work was not blocked.`,
163
+ }],
164
+ details: advisory,
165
+ };
166
+ },
167
+ });
168
+ }
@@ -19,6 +19,7 @@ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promis
19
19
  import { uptime } from "node:os";
20
20
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
21
21
  import type { UsageStats } from "../execution/rpc-control.ts";
22
+ import { normalizePhaseId, normalizePhaseScope, type PhaseScope } from "../delegation/phase-scope.ts";
22
23
  import type { SubagentThread } from "./runtime.ts";
23
24
  import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
24
25
  import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
@@ -76,6 +77,9 @@ export interface ThreadRecord {
76
77
  generation: number;
77
78
  agentName: string;
78
79
  task: string;
80
+ phaseId?: string;
81
+ scope?: PhaseScope;
82
+ writeCapable?: boolean;
79
83
  cwd: string;
80
84
  executionCwd: string;
81
85
  /** Resolved (clamped) level of the last generation. */
@@ -166,6 +170,14 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
166
170
  if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
167
171
  const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
168
172
  if (worktree === null) return undefined;
173
+ let phaseId: string | undefined;
174
+ let scope: PhaseScope | undefined;
175
+ try {
176
+ phaseId = normalizePhaseId(raw.phaseId as string | undefined);
177
+ scope = normalizePhaseScope(raw.scope as Parameters<typeof normalizePhaseScope>[0], raw.cwd);
178
+ } catch {
179
+ return undefined;
180
+ }
169
181
  return {
170
182
  runId: raw.runId,
171
183
  createdAt: raw.createdAt,
@@ -173,6 +185,9 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
173
185
  generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
174
186
  agentName: raw.agentName,
175
187
  task: raw.task,
188
+ ...(phaseId ? { phaseId } : {}),
189
+ ...(scope ? { scope } : {}),
190
+ ...(typeof raw.writeCapable === "boolean" ? { writeCapable: raw.writeCapable } : {}),
176
191
  cwd: raw.cwd,
177
192
  executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
178
193
  ...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
@@ -375,6 +390,9 @@ export function threadRecordFromThread(
375
390
  generation: thread.generation,
376
391
  agentName: thread.agentName,
377
392
  task: thread.task,
393
+ ...(thread.phaseId ? { phaseId: thread.phaseId } : {}),
394
+ ...(thread.scope ? { scope: thread.scope } : {}),
395
+ ...(thread.writeCapable !== undefined ? { writeCapable: thread.writeCapable } : {}),
378
396
  cwd: thread.cwd,
379
397
  executionCwd: thread.executionCwd,
380
398
  ...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { PhaseScope, PhaseScopeInput } from "../delegation/phase-scope.ts";
12
13
  import { rmSync } from "node:fs";
13
14
  import { resolveSubagentConcurrency, BackgroundTaskQueue } from "../execution/background.ts";
14
15
  import {
@@ -43,6 +44,12 @@ export interface SubagentThread {
43
44
  generation: number;
44
45
  agentName: string;
45
46
  task: string;
47
+ phaseId?: string;
48
+ scope?: PhaseScope;
49
+ /** Monotonic continuation scope visible while resume preflight is in flight. */
50
+ admissionScope?: PhaseScope;
51
+ /** Capability snapshot used by declared-scope admission, including after restore. */
52
+ writeCapable?: boolean;
46
53
  /** Caller-facing cwd in the original worktree. */
47
54
  cwd: string;
48
55
  /** Actual child cwd (the equivalent path inside an isolated worktree). */
@@ -77,7 +84,11 @@ export interface SubagentThread {
77
84
  retireOnSettle?: boolean;
78
85
  retired?: boolean;
79
86
  /** Installed by dispatch so the control tool can restart the same logical id. */
80
- resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
87
+ resume: (
88
+ objective?: string,
89
+ ctx?: ExtensionContext,
90
+ metadata?: { scope?: PhaseScopeInput },
91
+ ) => Promise<SingleResult>;
81
92
  /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
82
93
  * runs under the canonical original-repository lane. */
83
94
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;